diff --git a/crates/cheatcodes/src/evm.rs b/crates/cheatcodes/src/evm.rs index 8e21191f4e3b8..9b1bb4c9efe28 100644 --- a/crates/cheatcodes/src/evm.rs +++ b/crates/cheatcodes/src/evm.rs @@ -6,14 +6,15 @@ use crate::{ inspector::{Ecx, RecordDebugStepInfo}, }; use alloy_consensus::TxEnvelope; -use alloy_dyn_abi::{DynSolType, DynSolValue}; use alloy_genesis::{Genesis, GenesisAccount}; use alloy_network::eip2718::EIP4844_TX_TYPE_ID; use alloy_primitives::{Address, B256, U256, hex, map::HashMap}; use alloy_rlp::Decodable; use alloy_sol_types::SolValue; -use foundry_common::fs::{read_json_file, write_json_file}; -use foundry_compilers::artifacts::StorageLayout; +use foundry_common::{ + fs::{read_json_file, write_json_file}, + slot_identifier::{SlotIdentifier, SlotInfo}, +}; use foundry_evm_core::{ ContextExt, backend::{DatabaseExt, RevertStateSnapshotAction}, @@ -33,10 +34,10 @@ use std::{ collections::{BTreeMap, HashSet, btree_map::Entry}, fmt::Display, path::Path, - str::FromStr, }; mod record_debug_step; +use foundry_common::fmt::format_token_raw; use record_debug_step::{convert_call_trace_to_debug_step, flatten_call_trace}; use serde::Serialize; @@ -107,60 +108,13 @@ struct SlotStateDiff { previous_value: B256, /// Current storage value. new_value: B256, - /// Decoded values according to the Solidity type (e.g., uint256, address). - /// Only present when storage layout is available and decoding succeeds. - #[serde(skip_serializing_if = "Option::is_none")] - decoded: Option, - /// Storage layout metadata (variable name, type, offset). /// Only present when contract has storage layout output. + /// This includes decoded values when available. #[serde(skip_serializing_if = "Option::is_none", flatten)] slot_info: Option, } -/// Storage slot metadata from the contract's storage layout. -#[derive(Serialize, Debug)] -struct SlotInfo { - /// Variable name (e.g., "owner", "values\[0\]", "config.maxSize"). - label: String, - /// Solidity type, serialized as string (e.g., "uint256", "address"). - #[serde(rename = "type", serialize_with = "serialize_dyn_sol_type")] - dyn_sol_type: DynSolType, - /// Byte offset within the 32-byte slot (0 for full slot, 0-31 for packed). - offset: i64, - /// Storage slot number as decimal string. - slot: String, -} - -fn serialize_dyn_sol_type(dyn_type: &DynSolType, serializer: S) -> Result -where - S: serde::Serializer, -{ - serializer.serialize_str(&dyn_type.to_string()) -} - -/// Decoded storage values showing before and after states. -#[derive(Debug)] -struct DecodedSlotValues { - /// Decoded value before the state change. - previous_value: DynSolValue, - /// Decoded value after the state change. - new_value: DynSolValue, -} - -impl Serialize for DecodedSlotValues { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeStruct; - let mut state = serializer.serialize_struct("DecodedSlotValues", 2)?; - state.serialize_field("previousValue", &format_dyn_sol_value_raw(&self.previous_value))?; - state.serialize_field("newValue", &format_dyn_sol_value_raw(&self.new_value))?; - state.end() - } -} - /// Balance diff info. #[derive(Serialize, Default)] #[serde(rename_all = "camelCase")] @@ -226,30 +180,32 @@ impl Display for AccountStateDiffs { if !&self.state_diff.is_empty() { writeln!(f, "- state diff:")?; for (slot, slot_changes) in &self.state_diff { - match (&slot_changes.slot_info, &slot_changes.decoded) { - (Some(slot_info), Some(decoded)) => { - // Have both slot info and decoded values - only show decoded values - writeln!( - f, - "@ {slot} ({}, {}): {} → {}", - slot_info.label, - slot_info.dyn_sol_type, - format_dyn_sol_value_raw(&decoded.previous_value), - format_dyn_sol_value_raw(&decoded.new_value) - )?; - } - (Some(slot_info), None) => { - // Have slot info but no decoded values - show raw hex values - writeln!( - f, - "@ {slot} ({}, {}): {} → {}", - slot_info.label, - slot_info.dyn_sol_type, - slot_changes.previous_value, - slot_changes.new_value - )?; + match &slot_changes.slot_info { + Some(slot_info) => { + if slot_info.decoded.is_some() { + // Have slot info with decoded values - show decoded values + let decoded = slot_info.decoded.as_ref().unwrap(); + writeln!( + f, + "@ {slot} ({}, {}): {} → {}", + slot_info.label, + slot_info.slot_type.dyn_sol_type, + format_token_raw(&decoded.previous_value), + format_token_raw(&decoded.new_value) + )?; + } else { + // Have slot info but no decoded values - show raw hex values + writeln!( + f, + "@ {slot} ({}, {}): {} → {}", + slot_info.label, + slot_info.slot_type.dyn_sol_type, + slot_changes.previous_value, + slot_changes.new_value + )?; + } } - _ => { + None => { // No slot info - show raw hex values writeln!( f, @@ -910,6 +866,8 @@ impl Cheatcode for startStateDiffRecordingCall { fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.recorded_account_diffs_stack = Some(Default::default()); + // Enable mapping recording to track mapping slot accesses + state.mapping_slots.get_or_insert_default(); Ok(Default::default()) } } @@ -1421,31 +1379,34 @@ fn get_recorded_state_diffs(ccx: &mut CheatsCtxt) -> BTreeMap { // Get storage layout info for this slot - let slot_info = storage_layouts - .get(&storage_access.account) - .and_then(|layout| get_slot_info(layout, &storage_access.slot)); + // Include mapping slots if available for the account + let mapping_slots = ccx + .state + .mapping_slots + .as_ref() + .and_then(|slots| slots.get(&storage_access.account)); + + let mut slot_info = layout.and_then(|layout| { + let decoder = SlotIdentifier::new(layout.clone()); + decoder.identify(&storage_access.slot, mapping_slots) + }); - // Try to decode values if we have slot info - let decoded = slot_info.as_ref().and_then(|info| { - let prev = decode_storage_value( + // Decode values if we have slot info + if let Some(ref mut info) = slot_info { + info.decode_values( storage_access.previousValue, - &info.dyn_sol_type, - )?; - let new = decode_storage_value( storage_access.newValue, - &info.dyn_sol_type, - )?; - Some(DecodedSlotValues { previous_value: prev, new_value: new }) - }); + ); + } slot_state_diff.insert(SlotStateDiff { previous_value: storage_access.previousValue, new_value: storage_access.newValue, - decoded, slot_info, }); } @@ -1453,14 +1414,12 @@ fn get_recorded_state_diffs(ccx: &mut CheatsCtxt) -> BTreeMap( artifacts.find_by_deployed_code(&code_bytes) } -/// Gets storage layout info for a specific slot. -fn get_slot_info(storage_layout: &StorageLayout, slot: &B256) -> Option { - let slot = U256::from_be_bytes(slot.0); - let slot_str = slot.to_string(); - - for storage in &storage_layout.storage { - let base_slot = U256::from_str(&storage.slot).ok()?; - let storage_type = storage_layout.types.get(&storage.storage_type)?; - let dyn_type = DynSolType::parse(&storage_type.label).ok()?; - - // Check for exact slot match - if storage.slot == slot_str { - let label = match &dyn_type { - DynSolType::FixedArray(_, _) => { - // For arrays, label the base slot with indices - format!("{}{}", storage.label, get_array_base_indices(&dyn_type)) - } - _ => storage.label.clone(), - }; - - return Some(SlotInfo { - label, - dyn_sol_type: dyn_type, - offset: storage.offset, - slot: storage.slot.clone(), - }); - } - - // Check if slot is part of a static array - if let DynSolType::FixedArray(_, _) = &dyn_type - && let Ok(total_bytes) = storage_type.number_of_bytes.parse::() - { - let total_slots = total_bytes.div_ceil(32); - - // Check if slot is within array range - if slot > base_slot && slot < base_slot + U256::from(total_slots) { - let index = (slot - base_slot).to::(); - let label = format_array_element_label(&storage.label, &dyn_type, index); - - return Some(SlotInfo { - label, - dyn_sol_type: dyn_type, - offset: 0, - slot: slot.to_string(), - }); - } - } - } - - None -} - -/// Returns the base index [\0\] or [\0\][\0\] for a fixed array type depending on the dimensions. -fn get_array_base_indices(dyn_type: &DynSolType) -> String { - match dyn_type { - DynSolType::FixedArray(inner, _) => { - if let DynSolType::FixedArray(_, _) = inner.as_ref() { - // Nested array (2D or higher) - format!("[0]{}", get_array_base_indices(inner)) - } else { - // Simple 1D array - "[0]".to_string() - } - } - _ => String::new(), - } -} - -/// Helper function to format an array element label given its index -fn format_array_element_label(base_label: &str, dyn_type: &DynSolType, index: u64) -> String { - match dyn_type { - DynSolType::FixedArray(inner, _) => { - if let DynSolType::FixedArray(_, inner_size) = inner.as_ref() { - // 2D array: calculate row and column - let row = index / (*inner_size as u64); - let col = index % (*inner_size as u64); - format!("{base_label}[{row}][{col}]") - } else { - // 1D array - format!("{base_label}[{index}]") - } - } - _ => base_label.to_string(), - } -} - -/// Helper function to decode a single storage value using its DynSolType -fn decode_storage_value(value: B256, dyn_type: &DynSolType) -> Option { - // Storage values are always 32 bytes, stored as a single word - // For arrays, we need to unwrap to the base element type - let mut actual_type = dyn_type; - // Unwrap nested arrays to get to the base element type. - while let DynSolType::FixedArray(elem_type, _) = actual_type { - actual_type = elem_type.as_ref(); - } - - // Use abi_decode to decode the value - actual_type.abi_decode(&value.0).ok() -} - -/// Helper function to format DynSolValue as raw string without type information -fn format_dyn_sol_value_raw(value: &DynSolValue) -> String { - match value { - DynSolValue::Bool(b) => b.to_string(), - DynSolValue::Int(i, _) => i.to_string(), - DynSolValue::Uint(u, _) => u.to_string(), - DynSolValue::FixedBytes(bytes, size) => hex::encode_prefixed(&bytes.0[..*size]), - DynSolValue::Address(addr) => addr.to_string(), - DynSolValue::Function(func) => func.as_address_and_selector().1.to_string(), - DynSolValue::Bytes(bytes) => hex::encode_prefixed(bytes), - DynSolValue::String(s) => s.clone(), - DynSolValue::Array(values) | DynSolValue::FixedArray(values) => { - let formatted: Vec = values.iter().map(format_dyn_sol_value_raw).collect(); - format!("[{}]", formatted.join(", ")) - } - DynSolValue::Tuple(values) => { - let formatted: Vec = values.iter().map(format_dyn_sol_value_raw).collect(); - format!("({})", formatted.join(", ")) - } - DynSolValue::CustomStruct { name: _, prop_names: _, tuple } => { - format_dyn_sol_value_raw(&DynSolValue::Tuple(tuple.clone())) - } - } -} - /// Helper function to set / unset cold storage slot of the target address. fn set_cold_slot(ccx: &mut CheatsCtxt, target: Address, slot: U256, cold: bool) { if let Some(account) = ccx.ecx.journaled_state.state.get_mut(&target) diff --git a/crates/cheatcodes/src/evm/mapping.rs b/crates/cheatcodes/src/evm/mapping.rs index 1c4dfe6086129..8b09457c21e9f 100644 --- a/crates/cheatcodes/src/evm/mapping.rs +++ b/crates/cheatcodes/src/evm/mapping.rs @@ -1,58 +1,16 @@ use crate::{Cheatcode, Cheatcodes, Result, Vm::*}; -use alloy_primitives::{ - Address, B256, U256, keccak256, - map::{AddressHashMap, B256HashMap}, -}; +use alloy_primitives::{Address, B256, U256, keccak256, map::AddressHashMap}; use alloy_sol_types::SolValue; +use foundry_common::mapping_slots::MappingSlots; use revm::{ bytecode::opcode, interpreter::{Interpreter, interpreter_types::Jumps}, }; -/// Recorded mapping slots. -#[derive(Clone, Debug, Default)] -pub struct MappingSlots { - /// Holds mapping parent (slots => slots) - pub parent_slots: B256HashMap, - - /// Holds mapping key (slots => key) - pub keys: B256HashMap, - - /// Holds mapping child (slots => slots[]) - pub children: B256HashMap>, - - /// Holds the last sha3 result `sha3_result => (data_low, data_high)`, this would only record - /// when sha3 is called with `size == 0x40`, and the lower 256 bits would be stored in - /// `data_low`, higher 256 bits in `data_high`. - /// This is needed for mapping_key detect if the slot is for some mapping and record that. - pub seen_sha3: B256HashMap<(B256, B256)>, -} - -impl MappingSlots { - /// Tries to insert a mapping slot. Returns true if it was inserted. - pub fn insert(&mut self, slot: B256) -> bool { - match self.seen_sha3.get(&slot).copied() { - Some((key, parent)) => { - if self.keys.contains_key(&slot) { - return false; - } - self.keys.insert(slot, key); - self.parent_slots.insert(slot, parent); - self.children.entry(parent).or_default().push(slot); - self.insert(parent); - true - } - None => false, - } - } -} - impl Cheatcode for startMappingRecordingCall { fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; - if state.mapping_slots.is_none() { - state.mapping_slots = Some(Default::default()); - } + state.mapping_slots.get_or_insert_default(); Ok(Default::default()) } } diff --git a/crates/cheatcodes/src/inspector.rs b/crates/cheatcodes/src/inspector.rs index 5dece34564d3a..bf8a31d11cc33 100644 --- a/crates/cheatcodes/src/inspector.rs +++ b/crates/cheatcodes/src/inspector.rs @@ -4,8 +4,7 @@ use crate::{ CheatsConfig, CheatsCtxt, DynCheatcode, Error, Result, Vm::{self, AccountAccess}, evm::{ - DealRecord, GasRecord, RecordAccess, - mapping::{self, MappingSlots}, + DealRecord, GasRecord, RecordAccess, mapping, mock::{MockCallDataContext, MockCallReturnData}, prank::Prank, }, @@ -33,7 +32,9 @@ use alloy_rpc_types::{ request::{TransactionInput, TransactionRequest}, }; use alloy_sol_types::{SolCall, SolInterface, SolValue}; -use foundry_common::{SELECTOR_LEN, TransactionMaybeSigned, evm::Breakpoints}; +use foundry_common::{ + SELECTOR_LEN, TransactionMaybeSigned, evm::Breakpoints, mapping_slots::MappingSlots, +}; use foundry_evm_core::{ InspectorExt, abi::Vm::stopExpectSafeMemoryCall, diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index fc3b4884c2d5e..c571403f0d2ed 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -26,12 +26,14 @@ pub mod contracts; pub mod errors; pub mod evm; pub mod fs; +pub mod mapping_slots; mod preprocessor; pub mod provider; pub mod reports; pub mod retry; pub mod selectors; pub mod serde_helpers; +pub mod slot_identifier; pub mod term; pub mod traits; pub mod transactions; diff --git a/crates/common/src/mapping_slots.rs b/crates/common/src/mapping_slots.rs new file mode 100644 index 0000000000000..7e5a1932b842a --- /dev/null +++ b/crates/common/src/mapping_slots.rs @@ -0,0 +1,38 @@ +use alloy_primitives::{B256, map::B256HashMap}; + +/// Recorded mapping slots. +#[derive(Clone, Debug, Default)] +pub struct MappingSlots { + /// Holds mapping parent (slots => slots) + pub parent_slots: B256HashMap, + + /// Holds mapping key (slots => key) + pub keys: B256HashMap, + + /// Holds mapping child (slots => slots[]) + pub children: B256HashMap>, + + /// Holds the last sha3 result `sha3_result => (data_low, data_high)`, this would only record + /// when sha3 is called with `size == 0x40`, and the lower 256 bits would be stored in + /// `data_low`, higher 256 bits in `data_high`. + /// This is needed for mapping_key detect if the slot is for some mapping and record that. + pub seen_sha3: B256HashMap<(B256, B256)>, +} + +impl MappingSlots { + /// Tries to insert a mapping slot. Returns true if it was inserted. + pub fn insert(&mut self, slot: B256) -> bool { + match self.seen_sha3.get(&slot).copied() { + Some((key, parent)) => { + if self.keys.insert(slot, key).is_some() { + return false; + } + self.parent_slots.insert(slot, parent); + self.children.entry(parent).or_default().push(slot); + self.insert(parent); + true + } + None => false, + } + } +} diff --git a/crates/common/src/slot_identifier.rs b/crates/common/src/slot_identifier.rs new file mode 100644 index 0000000000000..55fc1b814b297 --- /dev/null +++ b/crates/common/src/slot_identifier.rs @@ -0,0 +1,670 @@ +//! Storage slot identification and decoding utilities for Solidity storage layouts. +//! +//! This module provides functionality to identify and decode storage slots based on +//! Solidity storage layout information from the compiler. + +use crate::mapping_slots::MappingSlots; +use alloy_dyn_abi::{DynSolType, DynSolValue}; +use alloy_primitives::{B256, U256, hex}; +use foundry_common_fmt::format_token_raw; +use foundry_compilers::artifacts::{Storage, StorageLayout, StorageType}; +use serde::Serialize; +use std::{str::FromStr, sync::Arc}; +use tracing::trace; + +// Constants for storage type encodings +const ENCODING_INPLACE: &str = "inplace"; +const ENCODING_MAPPING: &str = "mapping"; + +/// Information about a storage slot including its label, type, and decoded values. +#[derive(Serialize, Debug)] +pub struct SlotInfo { + /// The variable name from the storage layout. + /// + /// For top-level variables: just the variable name (e.g., "myVariable") + /// For struct members: dotted path (e.g., "myStruct.memberName") + /// For array elements: name with indices (e.g., "myArray\[0\]", "matrix\[1\]\[2\]") + /// For nested structures: full path (e.g., "outer.inner.field") + /// For mappings: base name with keys (e.g., "balances\[0x1234...\]")/ex + pub label: String, + /// The Solidity type information + #[serde(rename = "type", serialize_with = "serialize_slot_type")] + pub slot_type: StorageTypeInfo, + /// Offset within the storage slot (for packed storage) + pub offset: i64, + /// The storage slot number as a string + pub slot: String, + /// For struct members, contains nested SlotInfo for each member + /// + /// This is populated when a struct's members / fields are packed in a single slot. + #[serde(skip_serializing_if = "Option::is_none")] + pub members: Option>, + /// Decoded values (if available) - used for struct members + #[serde(skip_serializing_if = "Option::is_none")] + pub decoded: Option, + /// Decoded mapping keys (serialized as "key" for single, "keys" for multiple) + #[serde( + skip_serializing_if = "Option::is_none", + flatten, + serialize_with = "serialize_mapping_keys" + )] + pub keys: Option>, +} + +/// Wrapper type that holds both the original type label and the parsed DynSolType. +/// +/// We need both because: +/// - `label`: Used for serialization to ensure output matches user expectations +/// - `dyn_sol_type`: The parsed type used for actual value decoding +#[derive(Debug)] +pub struct StorageTypeInfo { + /// The original type label from storage layout (e.g., "uint256", "address", "mapping(address + /// => uint256)") + pub label: String, + /// The parsed dynamic Solidity type used for decoding + pub dyn_sol_type: DynSolType, +} + +impl SlotInfo { + /// Decodes a single storage value based on the slot's type information. + pub fn decode(&self, value: B256) -> Option { + // Storage values are always 32 bytes, stored as a single word + let mut actual_type = &self.slot_type.dyn_sol_type; + // Unwrap nested arrays to get to the base element type. + while let DynSolType::FixedArray(elem_type, _) = actual_type { + actual_type = elem_type.as_ref(); + } + + // Decode based on the actual type + actual_type.abi_decode(&value.0).ok() + } + + /// Decodes storage values (previous and new) and populates the decoded field. + /// For structs with members, it decodes each member individually. + pub fn decode_values(&mut self, previous_value: B256, new_value: B256) { + // If this is a struct with members, decode each member individually + if let Some(members) = &mut self.members { + for member in members.iter_mut() { + let offset = member.offset as usize; + let size = match &member.slot_type.dyn_sol_type { + DynSolType::Uint(bits) | DynSolType::Int(bits) => bits / 8, + DynSolType::Address => 20, + DynSolType::Bool => 1, + DynSolType::FixedBytes(size) => *size, + _ => 32, // Default to full word + }; + + // Extract and decode member values + let mut prev_bytes = [0u8; 32]; + let mut new_bytes = [0u8; 32]; + + if offset + size <= 32 { + // In Solidity storage, values are right-aligned + // For offset 0, we want the rightmost bytes + // For offset 16 (for a uint128), we want bytes 0-16 + // For packed storage: offset 0 is at the rightmost position + // offset 0, size 16 -> read bytes 16-32 (rightmost) + // offset 16, size 16 -> read bytes 0-16 (leftmost) + let byte_start = 32 - offset - size; + prev_bytes[32 - size..] + .copy_from_slice(&previous_value.0[byte_start..byte_start + size]); + new_bytes[32 - size..] + .copy_from_slice(&new_value.0[byte_start..byte_start + size]); + } + + // Decode the member values + if let (Ok(prev_val), Ok(new_val)) = ( + member.slot_type.dyn_sol_type.abi_decode(&prev_bytes), + member.slot_type.dyn_sol_type.abi_decode(&new_bytes), + ) { + member.decoded = + Some(DecodedSlotValues { previous_value: prev_val, new_value: new_val }); + } + } + // For structs with members, we don't need a top-level decoded value + } else { + // For non-struct types, decode directly + if let (Some(prev), Some(new)) = (self.decode(previous_value), self.decode(new_value)) { + self.decoded = Some(DecodedSlotValues { previous_value: prev, new_value: new }); + } + } + } +} + +/// Custom serializer for StorageTypeInfo that only outputs the label +fn serialize_slot_type(info: &StorageTypeInfo, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&info.label) +} + +/// Custom serializer for mapping keys +fn serialize_mapping_keys(keys: &Option>, serializer: S) -> Result +where + S: serde::Serializer, +{ + use serde::ser::SerializeMap; + + if let Some(keys) = keys { + let mut map = serializer.serialize_map(Some(1))?; + if keys.len() == 1 { + map.serialize_entry("key", &keys[0])?; + } else if keys.len() > 1 { + map.serialize_entry("keys", keys)?; + } + map.end() + } else { + serializer.serialize_none() + } +} + +/// Decoded storage slot values +#[derive(Debug)] +pub struct DecodedSlotValues { + /// Initial decoded storage value + pub previous_value: DynSolValue, + /// Current decoded storage value + pub new_value: DynSolValue, +} + +impl Serialize for DecodedSlotValues { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let mut state = serializer.serialize_struct("DecodedSlotValues", 2)?; + state.serialize_field("previousValue", &format_token_raw(&self.previous_value))?; + state.serialize_field("newValue", &format_token_raw(&self.new_value))?; + state.end() + } +} + +/// Storage slot identifier that uses Solidity [`StorageLayout`] to identify storage slots. +pub struct SlotIdentifier { + storage_layout: Arc, +} + +impl SlotIdentifier { + /// Creates a new SlotIdentifier with the given storage layout. + pub fn new(storage_layout: Arc) -> Self { + Self { storage_layout } + } + + /// Identifies a storage slots type using the [`StorageLayout`]. + /// + /// It can also identify whether a slot belongs to a mapping if provided with [`MappingSlots`]. + pub fn identify(&self, slot: &B256, mapping_slots: Option<&MappingSlots>) -> Option { + let slot_u256 = U256::from_be_bytes(slot.0); + let slot_str = slot_u256.to_string(); + + for storage in &self.storage_layout.storage { + let storage_type = self.storage_layout.types.get(&storage.storage_type)?; + let dyn_type = DynSolType::parse(&storage_type.label).ok(); + + // Check if we're able to match on a slot from the layout i.e any of the base slots. + // This will always be the case for primitive types that fit in a single slot. + if storage.slot == slot_str + && let Some(parsed_type) = dyn_type + { + // Successfully parsed - handle arrays or simple types + let label = if let DynSolType::FixedArray(_, _) = &parsed_type { + format!("{}{}", storage.label, get_array_base_indices(&parsed_type)) + } else { + storage.label.clone() + }; + + return Some(SlotInfo { + label, + slot_type: StorageTypeInfo { + label: storage_type.label.clone(), + dyn_sol_type: parsed_type, + }, + offset: storage.offset, + slot: storage.slot.clone(), + members: None, + decoded: None, + keys: None, + }); + } + + // Encoding types: + if storage_type.encoding == ENCODING_INPLACE { + // Can be of type FixedArrays or Structs + // Handles the case where the accessed `slot` is maybe different from the base slot. + let array_start_slot = U256::from_str(&storage.slot).ok()?; + + if let Some(parsed_type) = dyn_type + && let DynSolType::FixedArray(_, _) = parsed_type + && let Some(slot_info) = self.handle_array_slot( + storage, + storage_type, + slot_u256, + array_start_slot, + &slot_str, + ) + { + return Some(slot_info); + } + + // If type parsing fails and the label is a struct + if is_struct(&storage_type.label) { + let struct_start_slot = U256::from_str(&storage.slot).ok()?; + if let Some(slot_info) = self.handle_struct( + &storage.label, + storage_type, + slot_u256, + struct_start_slot, + storage.offset, + &slot_str, + 0, + ) { + return Some(slot_info); + } + } + } else if storage_type.encoding == ENCODING_MAPPING + && let Some(mapping_slots) = mapping_slots + && let Some(slot_info) = + self.handle_mapping(storage, storage_type, slot, &slot_str, mapping_slots) + { + return Some(slot_info); + } + } + + None + } + + /// Handles identification of array slots. + /// + /// # Arguments + /// * `storage` - The storage metadata from the layout + /// * `storage_type` - Type information for the storage slot + /// * `slot` - The target slot being identified + /// * `array_start_slot` - The starting slot of the array in storage i.e base_slot + /// * `slot_str` - String representation of the slot for output + fn handle_array_slot( + &self, + storage: &Storage, + storage_type: &StorageType, + slot: U256, + array_start_slot: U256, + slot_str: &str, + ) -> Option { + // Check if slot is within array bounds + let total_bytes = storage_type.number_of_bytes.parse::().ok()?; + let total_slots = total_bytes.div_ceil(32); + + if slot >= array_start_slot && slot < array_start_slot + U256::from(total_slots) { + let parsed_type = DynSolType::parse(&storage_type.label).ok()?; + let index = (slot - array_start_slot).to::(); + // Format the array element label based on array dimensions + let label = match &parsed_type { + DynSolType::FixedArray(inner, _) => { + if let DynSolType::FixedArray(_, inner_size) = inner.as_ref() { + // 2D array: calculate row and column + let row = index / (*inner_size as u64); + let col = index % (*inner_size as u64); + format!("{}[{row}][{col}]", storage.label) + } else { + // 1D array + format!("{}[{index}]", storage.label) + } + } + _ => storage.label.clone(), + }; + + return Some(SlotInfo { + label, + slot_type: StorageTypeInfo { + label: storage_type.label.clone(), + dyn_sol_type: parsed_type, + }, + offset: 0, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: None, + }); + } + + None + } + + /// Handles identification of struct slots. + /// + /// Recursively resolves struct members to find the exact member corresponding + /// to the target slot. Handles both single-slot (packed) and multi-slot structs. + /// + /// # Arguments + /// * `base_label` - The label/name for this struct or member + /// * `storage_type` - Type information for the storage + /// * `target_slot` - The target slot being identified + /// * `struct_start_slot` - The starting slot of this struct + /// * `offset` - Offset within the slot (for packed storage) + /// * `slot_str` - String representation of the slot for output + /// * `depth` - Current recursion depth + #[allow(clippy::too_many_arguments)] + fn handle_struct( + &self, + base_label: &str, + storage_type: &StorageType, + target_slot: U256, + struct_start_slot: U256, + offset: i64, + slot_str: &str, + depth: usize, + ) -> Option { + // Limit recursion depth to prevent stack overflow + const MAX_DEPTH: usize = 10; + if depth > MAX_DEPTH { + return None; + } + + let members = storage_type + .other + .get("members") + .and_then(|v| serde_json::from_value::>(v.clone()).ok())?; + + // If this is the exact slot we're looking for (struct's base slot) + if struct_start_slot == target_slot + // Find the member at slot offset 0 (the member that starts at this slot) + && let Some(first_member) = members.iter().find(|m| m.slot == "0") + { + let member_type_info = self.storage_layout.types.get(&first_member.storage_type)?; + + // Check if we have a single-slot struct (all members have slot "0") + let is_single_slot = members.iter().all(|m| m.slot == "0"); + + if is_single_slot { + // Build member info for single-slot struct + let mut member_infos = Vec::new(); + for member in &members { + if let Some(member_type_info) = + self.storage_layout.types.get(&member.storage_type) + && let Some(member_type) = DynSolType::parse(&member_type_info.label).ok() + { + member_infos.push(SlotInfo { + label: member.label.clone(), + slot_type: StorageTypeInfo { + label: member_type_info.label.clone(), + dyn_sol_type: member_type, + }, + offset: member.offset, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: None, + }); + } + } + + // Build the CustomStruct type + let struct_name = + storage_type.label.strip_prefix("struct ").unwrap_or(&storage_type.label); + let prop_names: Vec = members.iter().map(|m| m.label.clone()).collect(); + let member_types: Vec = + member_infos.iter().map(|info| info.slot_type.dyn_sol_type.clone()).collect(); + + let parsed_type = DynSolType::CustomStruct { + name: struct_name.to_string(), + prop_names, + tuple: member_types, + }; + + return Some(SlotInfo { + label: base_label.to_string(), + slot_type: StorageTypeInfo { + label: storage_type.label.clone(), + dyn_sol_type: parsed_type, + }, + offset, + slot: slot_str.to_string(), + decoded: None, + members: if member_infos.is_empty() { None } else { Some(member_infos) }, + keys: None, + }); + } else { + // Multi-slot struct - return the first member. + let member_label = format!("{}.{}", base_label, first_member.label); + + // If the first member is itself a struct, recurse + if is_struct(&member_type_info.label) { + return self.handle_struct( + &member_label, + member_type_info, + target_slot, + struct_start_slot, + first_member.offset, + slot_str, + depth + 1, + ); + } + + // Return the first member as a primitive + return Some(SlotInfo { + label: member_label, + slot_type: StorageTypeInfo { + label: member_type_info.label.clone(), + dyn_sol_type: DynSolType::parse(&member_type_info.label).ok()?, + }, + offset: first_member.offset, + slot: slot_str.to_string(), + decoded: None, + members: None, + keys: None, + }); + } + } + + // Not the base slot - search through members + for member in &members { + let member_slot_offset = U256::from_str(&member.slot).ok()?; + let member_slot = struct_start_slot + member_slot_offset; + let member_type_info = self.storage_layout.types.get(&member.storage_type)?; + let member_label = format!("{}.{}", base_label, member.label); + + // If this member is a struct, recurse into it + if is_struct(&member_type_info.label) { + let slot_info = self.handle_struct( + &member_label, + member_type_info, + target_slot, + member_slot, + member.offset, + slot_str, + depth + 1, + ); + + if member_slot == target_slot || slot_info.is_some() { + return slot_info; + } + } + + if member_slot == target_slot { + // Found the exact member slot + + // Regular member + let member_type = DynSolType::parse(&member_type_info.label).ok()?; + return Some(SlotInfo { + label: member_label, + slot_type: StorageTypeInfo { + label: member_type_info.label.clone(), + dyn_sol_type: member_type, + }, + offset: member.offset, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: None, + }); + } + } + + None + } + + /// Handles identification of mapping slots. + /// + /// Identifies mapping entries by walking up the parent chain to find the base slot, + /// then decodes the keys and builds the appropriate label. + /// + /// # Arguments + /// * `storage` - The storage metadata from the layout + /// * `storage_type` - Type information for the storage + /// * `slot` - The accessed slot being identified + /// * `slot_str` - String representation of the slot for output + /// * `mapping_slots` - Tracked mapping slot accesses for key resolution + fn handle_mapping( + &self, + storage: &Storage, + storage_type: &StorageType, + slot: &B256, + slot_str: &str, + mapping_slots: &MappingSlots, + ) -> Option { + trace!( + "handle_mapping: storage.slot={}, slot={:?}, has_keys={}, has_parents={}", + storage.slot, + slot, + mapping_slots.keys.contains_key(slot), + mapping_slots.parent_slots.contains_key(slot) + ); + + // Verify it's actually a mapping type + if storage_type.encoding != ENCODING_MAPPING { + return None; + } + + // Check if this slot is a known mapping entry + if !mapping_slots.keys.contains_key(slot) { + return None; + } + + // Convert storage.slot to B256 for comparison + let storage_slot_b256 = B256::from(U256::from_str(&storage.slot).ok()?); + + // Walk up the parent chain to collect keys and validate the base slot + let mut current_slot = *slot; + let mut keys_to_decode = Vec::new(); + let mut found_base = false; + + while let Some((key, parent)) = + mapping_slots.keys.get(¤t_slot).zip(mapping_slots.parent_slots.get(¤t_slot)) + { + keys_to_decode.push(*key); + + // Check if the parent is our base storage slot + if *parent == storage_slot_b256 { + found_base = true; + break; + } + + // Move up to the parent for the next iteration + current_slot = *parent; + } + + if !found_base { + trace!("Mapping slot {} does not match any parent in chain", storage.slot); + return None; + } + + // Resolve the mapping type to get all key types and the final value type + let (key_types, value_type_label, full_type_label) = + self.resolve_mapping_type(&storage.storage_type)?; + + // Reverse keys to process from outermost to innermost + keys_to_decode.reverse(); + + // Build the label with decoded keys and collect decoded key values + let mut label = storage.label.clone(); + let mut decoded_keys = Vec::new(); + + // Decode each key using the corresponding type + for (i, key) in keys_to_decode.iter().enumerate() { + if let Some(key_type_label) = key_types.get(i) + && let Ok(sol_type) = DynSolType::parse(key_type_label) + && let Ok(decoded) = sol_type.abi_decode(&key.0) + { + let decoded_key_str = format_token_raw(&decoded); + decoded_keys.push(decoded_key_str.clone()); + label = format!("{label}[{decoded_key_str}]"); + } else { + let hex_key = hex::encode_prefixed(key.0); + decoded_keys.push(hex_key.clone()); + label = format!("{label}[{hex_key}]"); + } + } + + // Parse the final value type for decoding + let dyn_sol_type = DynSolType::parse(&value_type_label).unwrap_or(DynSolType::Bytes); + + Some(SlotInfo { + label, + slot_type: StorageTypeInfo { label: full_type_label, dyn_sol_type }, + offset: storage.offset, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: Some(decoded_keys), + }) + } + + fn resolve_mapping_type(&self, type_ref: &str) -> Option<(Vec, String, String)> { + let storage_type = self.storage_layout.types.get(type_ref)?; + + if storage_type.encoding != ENCODING_MAPPING { + // Not a mapping, return the type as-is + return Some((vec![], storage_type.label.clone(), storage_type.label.clone())); + } + + // Get key and value type references + let key_type_ref = storage_type.key.as_ref()?; + let value_type_ref = storage_type.value.as_ref()?; + + // Resolve the key type + let key_type = self.storage_layout.types.get(key_type_ref)?; + let mut key_types = vec![key_type.label.clone()]; + + // Check if the value is another mapping (nested case) + if let Some(value_storage_type) = self.storage_layout.types.get(value_type_ref) { + if value_storage_type.encoding == "mapping" { + // Recursively resolve the nested mapping + let (nested_keys, final_value, _) = self.resolve_mapping_type(value_type_ref)?; + key_types.extend(nested_keys); + return Some((key_types, final_value, storage_type.label.clone())); + } else { + // Value is not a mapping, we're done + return Some(( + key_types, + value_storage_type.label.clone(), + storage_type.label.clone(), + )); + } + } + + None + } +} + +/// Returns the base indices for array types, e.g. "\[0\]\[0\]" for 2D arrays. +fn get_array_base_indices(dyn_type: &DynSolType) -> String { + match dyn_type { + DynSolType::FixedArray(inner, _) => { + if let DynSolType::FixedArray(_, _) = inner.as_ref() { + // Nested array (2D or higher) + format!("[0]{}", get_array_base_indices(inner)) + } else { + // Simple 1D array + "[0]".to_string() + } + } + _ => String::new(), + } +} + +/// Checks if a given type label represents a struct type. +pub fn is_struct(s: &str) -> bool { + s.starts_with("struct ") +} diff --git a/crates/forge/tests/it/cheats.rs b/crates/forge/tests/it/cheats.rs index daadd874b9593..2ad27e513871c 100644 --- a/crates/forge/tests/it/cheats.rs +++ b/crates/forge/tests/it/cheats.rs @@ -17,7 +17,7 @@ use foundry_test_utils::{Filter, init_tracing}; async fn test_cheats_local(test_data: &ForgeTestData) { let mut filter = Filter::new(".*", ".*", &format!(".*cheats{RE_PATH_SEPARATOR}*")) .exclude_paths("Fork") - .exclude_contracts("(Isolated|WithSeed|StateDiffStorageLayoutTest)"); + .exclude_contracts("(Isolated|WithSeed|StateDiff)"); // Exclude FFI tests on Windows because no `echo`, and file tests that expect certain file paths if cfg!(windows) { @@ -38,7 +38,7 @@ async fn test_cheats_local(test_data: &ForgeTestData) { /// Executes subset of all cheat code tests in isolation mode. async fn test_cheats_local_isolated(test_data: &ForgeTestData) { let filter = Filter::new(".*", ".*(Isolated)", &format!(".*cheats{RE_PATH_SEPARATOR}*")) - .exclude_contracts("StateDiffStorageLayoutTest"); + .exclude_contracts("StateDiff"); let runner = test_data.runner_with(|config| { config.isolate = true; @@ -76,8 +76,7 @@ async fn test_state_diff_storage_layout() { let output = get_compiled(&mut project); ForgeTestData { project, output, config: config.into(), profile } }; - let filter = - Filter::new(".*", "StateDiffStorageLayoutTest", &format!(".*cheats{RE_PATH_SEPARATOR}*")); + let filter = Filter::new(".*", "StateDiff", &format!(".*cheats{RE_PATH_SEPARATOR}*")); let runner = test_data.runner_with(|config| { config.fs_permissions = FsPermissions::new(vec![PathPermission::read_write("./")]); diff --git a/testdata/default/cheats/StateDiffMappings.t.sol b/testdata/default/cheats/StateDiffMappings.t.sol new file mode 100644 index 0000000000000..b4ed985791cde --- /dev/null +++ b/testdata/default/cheats/StateDiffMappings.t.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import "ds-test/test.sol"; +import "cheats/Vm.sol"; + +contract MappingStorage { + // Simple mappings only + mapping(address => uint256) public balances; // Slot 0 + mapping(uint256 => address) public owners; // Slot 1 + mapping(bytes32 => bool) public flags; // Slot 2 + // Nested mapping + mapping(address => mapping(address => uint256)) public allowances; // Slot 3 + + function setBalance(address account, uint256 amount) public { + balances[account] = amount; + } + + function setOwner(uint256 tokenId, address owner) public { + owners[tokenId] = owner; + } + + function setFlag(bytes32 key, bool value) public { + flags[key] = value; + } + + function setAllowance(address owner, address spender, uint256 amount) public { + allowances[owner][spender] = amount; + } +} + +contract StateDiffMappingsTest is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + MappingStorage public mappingStorage; + + function setUp() public { + mappingStorage = new MappingStorage(); + } + + function testSimpleMappingStateDiff() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Modify a simple mapping + address testAccount = address(0x1234); + mappingStorage.setBalance(testAccount, 1000 ether); + + // Test the text format output + string memory stateDiffText = vm.getStateDiff(); + emit log_string("State diff text format:"); + emit log_string(stateDiffText); + + // Verify text format contains the mapping label + assertTrue(vm.contains(stateDiffText, "balances[0x0000000000000000000000000000000000001234]")); + + // Verify text format contains the value type + assertTrue(vm.contains(stateDiffText, "uint256")); + + // Verify text format contains decoded values (shown with arrow) + assertTrue(vm.contains(stateDiffText, ": 0")); + assertTrue(vm.contains(stateDiffText, "1000000000000000000000")); + + // Test JSON format output + string memory json = vm.getStateDiffJson(); + emit log_string("State diff JSON (simple mapping):"); + emit log_string(json); + + // The JSON should contain the decoded mapping slot with proper label + assertTrue(vm.contains(json, '"label":"balances[0x0000000000000000000000000000000000001234]"')); + + // Check the type is correctly identified + assertTrue(vm.contains(json, '"type":"mapping(address => uint256)"')); + + // Check decoded values + assertTrue(vm.contains(json, '"decoded":{"previousValue":"0","newValue":"1000000000000000000000"}')); + + // Check that the key field is present for simple mapping + assertTrue(vm.contains(json, '"key":"0x0000000000000000000000000000000000001234"')); + + // Stop recording and verify we have account accesses + Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); + assertTrue(accesses.length > 0); + + // The AccountAccess structure contains information about storage changes + // but the label and decoded values are only available in the string/JSON outputs + // We've already verified those above + } + + function testMappingWithDifferentKeyTypes() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Test uint256 key + mappingStorage.setOwner(12345, address(0x7777)); + + // Test bytes32 key + bytes32 flagKey = keccak256("test_flag"); + mappingStorage.setFlag(flagKey, true); + + // Test text format output first + string memory stateDiffText = vm.getStateDiff(); + emit log_string("State diff text format (different key types):"); + emit log_string(stateDiffText); + + // Verify text format contains decoded values for uint256 key + assertTrue(vm.contains(stateDiffText, "owners[12345]")); + assertTrue(vm.contains(stateDiffText, "address): 0x0000000000000000000000000000000000000000")); + assertTrue(vm.contains(stateDiffText, "0x0000000000000000000000000000000000007777")); + + // Verify text format contains decoded values for bytes32 key + assertTrue(vm.contains(stateDiffText, "bool): false")); + assertTrue(vm.contains(stateDiffText, "true")); + + // Get state diff JSON + string memory json = vm.getStateDiffJson(); + + // Debug: log the JSON for inspection + emit log_string("State diff JSON (different key types):"); + emit log_string(json); + + // Check uint256 key mapping + assertTrue(vm.contains(json, '"label":"owners[12345]"')); + assertTrue(vm.contains(json, '"type":"mapping(uint256 => address)"')); + assertTrue( + vm.contains( + json, + '"decoded":{"previousValue":"0x0000000000000000000000000000000000000000","newValue":"0x0000000000000000000000000000000000007777"}' + ) + ); + + // Check bytes32 key mapping - the key will be shown as hex + assertTrue(vm.contains(json, '"label":"flags[')); + assertTrue(vm.contains(json, '"type":"mapping(bytes32 => bool)"')); + assertTrue(vm.contains(json, '"decoded":{"previousValue":"false","newValue":"true"}')); + + // Check that the key field is present for uint256 key mapping + assertTrue(vm.contains(json, '"key":"12345"')); + + // Stop recording + vm.stopAndReturnStateDiff(); + } + + function testNestedMappingStateDiff() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Test case 1: owner1 -> spender1 + address owner1 = address(0x1111); + address spender1 = address(0x2222); + mappingStorage.setAllowance(owner1, spender1, 500 ether); + + // Test case 2: same owner (owner1) -> different spender (spender2) + address spender2 = address(0x3333); + mappingStorage.setAllowance(owner1, spender2, 750 ether); + + // Test case 3: different owner (owner2) -> different spender (spender3) + address owner2 = address(0x4444); + address spender3 = address(0x5555); + mappingStorage.setAllowance(owner2, spender3, 1000 ether); + + // Test text format output + string memory stateDiffText = vm.getStateDiff(); + emit log_string("State diff text format (nested mappings):"); + emit log_string(stateDiffText); + + // Verify text format contains nested mapping labels + assertTrue( + vm.contains( + stateDiffText, + "allowances[0x0000000000000000000000000000000000001111][0x0000000000000000000000000000000000002222]" + ) + ); + assertTrue( + vm.contains( + stateDiffText, + "allowances[0x0000000000000000000000000000000000001111][0x0000000000000000000000000000000000003333]" + ) + ); + // The text format shows the value type (uint256) not the full mapping type + assertTrue(vm.contains(stateDiffText, "uint256): 0")); + + // Verify text format contains decoded values for nested mappings + assertTrue(vm.contains(stateDiffText, "500000000000000000000")); + assertTrue(vm.contains(stateDiffText, "750000000000000000000")); + assertTrue(vm.contains(stateDiffText, "1000000000000000000000")); + + // Test JSON format output + string memory json = vm.getStateDiffJson(); + emit log_string("State diff JSON (nested mapping - multiple entries):"); + emit log_string(json); + + // Check that all three nested mapping entries are correctly decoded + + // Entry 1: owner1 -> spender1 + assertTrue( + vm.contains( + json, + '"label":"allowances[0x0000000000000000000000000000000000001111][0x0000000000000000000000000000000000002222]"' + ) + ); + assertTrue(vm.contains(json, '"newValue":"500000000000000000000"')); + + // Entry 2: owner1 -> spender2 (same owner, different spender) + assertTrue( + vm.contains( + json, + '"label":"allowances[0x0000000000000000000000000000000000001111][0x0000000000000000000000000000000000003333]"' + ) + ); + assertTrue(vm.contains(json, '"newValue":"750000000000000000000"')); + + // Entry 3: owner2 -> spender3 (different owner) + assertTrue( + vm.contains( + json, + '"label":"allowances[0x0000000000000000000000000000000000004444][0x0000000000000000000000000000000000005555]"' + ) + ); + assertTrue(vm.contains(json, '"newValue":"1000000000000000000000"')); + + // Check the type is correctly identified for all entries + assertTrue(vm.contains(json, '"type":"mapping(address => mapping(address => uint256))"')); + + // Check that the keys field is present for nested mappings + assertTrue( + vm.contains( + json, + '"keys":["0x0000000000000000000000000000000000001111","0x0000000000000000000000000000000000002222"]' + ) + ); + + assertTrue( + vm.contains( + json, + '"keys":["0x0000000000000000000000000000000000001111","0x0000000000000000000000000000000000003333"]' + ) + ); + + assertTrue( + vm.contains( + json, + '"keys":["0x0000000000000000000000000000000000004444","0x0000000000000000000000000000000000005555"]' + ) + ); + + // Stop recording + vm.stopAndReturnStateDiff(); + } +} diff --git a/testdata/default/cheats/StateDiffStorageLayout.t.sol b/testdata/default/cheats/StateDiffStorageLayout.t.sol index 961a2cd4b4363..027e3d8567098 100644 --- a/testdata/default/cheats/StateDiffStorageLayout.t.sol +++ b/testdata/default/cheats/StateDiffStorageLayout.t.sol @@ -126,31 +126,31 @@ contract StateDiffStorageLayoutTest is DSTest { // The JSON should contain storage layout info for all slots // We check the JSON contains expected substrings for the labels and types - assertContains(stateDiffJson, '"label":"value"', "Should contain 'value' label"); - assertContains(stateDiffJson, '"label":"owner"', "Should contain 'owner' label"); - assertContains(stateDiffJson, '"label":"values[0]"', "Should contain 'values[0]' label"); - assertContains(stateDiffJson, '"label":"values[1]"', "Should contain 'values[1]' label"); - assertContains(stateDiffJson, '"label":"values[2]"', "Should contain 'values[2]' label"); + assertTrue(vm.contains(stateDiffJson, '"label":"value"')); + assertTrue(vm.contains(stateDiffJson, '"label":"owner"')); + assertTrue(vm.contains(stateDiffJson, '"label":"values[0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"values[1]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"values[2]"')); - assertContains(stateDiffJson, '"type":"uint256"', "Should contain uint256 type"); - assertContains(stateDiffJson, '"type":"address"', "Should contain address type"); - assertContains(stateDiffJson, '"type":"uint256[3]"', "Should contain uint256[3] type"); + assertTrue(vm.contains(stateDiffJson, '"type":"uint256"')); + assertTrue(vm.contains(stateDiffJson, '"type":"address"')); + assertTrue(vm.contains(stateDiffJson, '"type":"uint256[3]"')); // Check for decoded values - assertContains(stateDiffJson, '"decoded":', "Should contain decoded values"); + assertTrue(vm.contains(stateDiffJson, '"decoded":')); // Check specific decoded values within the decoded object // The value 42 should be decoded in the first slot - assertContains(stateDiffJson, '"decoded":{"previousValue":"0","newValue":"42"}', "Should decode value 42"); + assertTrue(vm.contains(stateDiffJson, '"decoded":{"previousValue":"0","newValue":"42"}')); // Check that array values are decoded properly (they will have separate decoded objects) - assertContains(stateDiffJson, '"newValue":"100"', "Should decode array value 100"); - assertContains(stateDiffJson, '"newValue":"200"', "Should decode array value 200"); - assertContains(stateDiffJson, '"newValue":"300"', "Should decode array value 300"); + assertTrue(vm.contains(stateDiffJson, '"newValue":"100"')); + assertTrue(vm.contains(stateDiffJson, '"newValue":"200"')); + assertTrue(vm.contains(stateDiffJson, '"newValue":"300"')); // Stop recording and verify we get the expected account accesses Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); - assertTrue(accesses.length >= 3, "Should have at least 3 account accesses for the calls"); + assertTrue(accesses.length >= 3); // Verify storage accesses for SimpleStorage bool foundValueSlot = false; @@ -172,11 +172,11 @@ contract StateDiffStorageLayoutTest is DSTest { } } - assertTrue(foundValueSlot, "Should have accessed slot 0 (value)"); - assertTrue(foundOwnerSlot, "Should have accessed slot 1 (owner)"); - assertTrue(foundValuesSlot0, "Should have accessed slot 2 (values[0])"); - assertTrue(foundValuesSlot1, "Should have accessed slot 3 (values[1])"); - assertTrue(foundValuesSlot2, "Should have accessed slot 4 (values[2])"); + assertTrue(foundValueSlot); + assertTrue(foundOwnerSlot); + assertTrue(foundValuesSlot0); + assertTrue(foundValuesSlot1); + assertTrue(foundValuesSlot2); } function testVariousArrayTypes() public { @@ -193,37 +193,33 @@ contract StateDiffStorageLayoutTest is DSTest { string memory stateDiffJson = vm.getStateDiffJson(); // Verify all array types are properly labeled with indices - assertContains(stateDiffJson, '"label":"numbers[0]"', "Should contain 'numbers[0]' label"); - assertContains(stateDiffJson, '"label":"numbers[1]"', "Should contain 'numbers[1]' label"); - assertContains(stateDiffJson, '"label":"numbers[2]"', "Should contain 'numbers[2]' label"); + assertTrue(vm.contains(stateDiffJson, '"label":"numbers[0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"numbers[1]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"numbers[2]"')); - assertContains(stateDiffJson, '"label":"addresses[0]"', "Should contain 'addresses[0]' label"); - assertContains(stateDiffJson, '"label":"addresses[1]"', "Should contain 'addresses[1]' label"); + assertTrue(vm.contains(stateDiffJson, '"label":"addresses[0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"addresses[1]"')); - assertContains(stateDiffJson, '"label":"flags[0]"', "Should contain 'flags[0]' label"); + assertTrue(vm.contains(stateDiffJson, '"label":"flags[0]"')); - assertContains(stateDiffJson, '"label":"hashes[0]"', "Should contain 'hashes[0]' label"); - assertContains(stateDiffJson, '"label":"hashes[1]"', "Should contain 'hashes[1]' label"); + assertTrue(vm.contains(stateDiffJson, '"label":"hashes[0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"hashes[1]"')); // Verify types are correctly identified - assertContains(stateDiffJson, '"type":"uint256[3]"', "Should contain uint256[3] type"); - assertContains(stateDiffJson, '"type":"address[2]"', "Should contain address[2] type"); - assertContains(stateDiffJson, '"type":"bool[5]"', "Should contain bool[5] type"); - assertContains(stateDiffJson, '"type":"bytes32[2]"', "Should contain bytes32[2] type"); + assertTrue(vm.contains(stateDiffJson, '"type":"uint256[3]"')); + assertTrue(vm.contains(stateDiffJson, '"type":"address[2]"')); + assertTrue(vm.contains(stateDiffJson, '"type":"bool[5]"')); + assertTrue(vm.contains(stateDiffJson, '"type":"bytes32[2]"')); // Check decoded values - assertContains(stateDiffJson, '"decoded":', "Should contain decoded values"); + assertTrue(vm.contains(stateDiffJson, '"decoded":')); // Check addresses are decoded as raw hex strings - assertContains( - stateDiffJson, '"newValue":"0x0000000000000000000000000000000000000001"', "Should decode address 1" - ); - assertContains( - stateDiffJson, '"newValue":"0x0000000000000000000000000000000000000002"', "Should decode address 2" - ); + assertTrue(vm.contains(stateDiffJson, '"newValue":"0x0000000000000000000000000000000000000001"')); + assertTrue(vm.contains(stateDiffJson, '"newValue":"0x0000000000000000000000000000000000000002"')); // Stop recording and verify account accesses Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); - assertTrue(accesses.length > 0, "Should have account accesses"); + assertTrue(accesses.length > 0); } function testStateDiffJsonFormat() public { @@ -237,12 +233,12 @@ contract StateDiffStorageLayoutTest is DSTest { string memory stateDiffJson = vm.getStateDiffJson(); // Check JSON structure contains expected fields - assertContains(stateDiffJson, '"previousValue":', "JSON should contain previousValue field"); - assertContains(stateDiffJson, '"newValue":', "JSON should contain newValue field"); - assertContains(stateDiffJson, '"label":', "JSON should contain label field"); - assertContains(stateDiffJson, '"type":', "JSON should contain type field"); - assertContains(stateDiffJson, '"offset":', "JSON should contain offset field"); - assertContains(stateDiffJson, '"slot":', "JSON should contain slot field"); + assertTrue(vm.contains(stateDiffJson, '"previousValue":')); + assertTrue(vm.contains(stateDiffJson, '"newValue":')); + assertTrue(vm.contains(stateDiffJson, '"label":')); + assertTrue(vm.contains(stateDiffJson, '"type":')); + assertTrue(vm.contains(stateDiffJson, '"offset":')); + assertTrue(vm.contains(stateDiffJson, '"slot":')); vm.stopAndReturnStateDiff(); } @@ -262,24 +258,24 @@ contract StateDiffStorageLayoutTest is DSTest { string memory stateDiffJson = vm.getStateDiffJson(); // Verify the labels for 2D array elements - assertContains(stateDiffJson, '"label":"matrix[0][0]"', "Should contain matrix[0][0] label"); - assertContains(stateDiffJson, '"label":"matrix[0][1]"', "Should contain matrix[0][1] label"); - assertContains(stateDiffJson, '"label":"matrix[0][2]"', "Should contain matrix[0][2] label"); - assertContains(stateDiffJson, '"label":"matrix[1][0]"', "Should contain matrix[1][0] label"); - assertContains(stateDiffJson, '"label":"matrix[1][1]"', "Should contain matrix[1][1] label"); - assertContains(stateDiffJson, '"label":"matrix[1][2]"', "Should contain matrix[1][2] label"); + assertTrue(vm.contains(stateDiffJson, '"label":"matrix[0][0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"matrix[0][1]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"matrix[0][2]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"matrix[1][0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"matrix[1][1]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"matrix[1][2]"')); // Check that we have the right type - assertContains(stateDiffJson, '"type":"uint256[3][2]"', "Should contain 2D array type"); + assertTrue(vm.contains(stateDiffJson, '"type":"uint256[3][2]"')); // Check decoded values for 2D arrays - assertContains(stateDiffJson, '"decoded":', "Should contain decoded values"); - assertContains(stateDiffJson, '"newValue":"100"', "Should decode matrix[0][0] = 100"); - assertContains(stateDiffJson, '"newValue":"101"', "Should decode matrix[0][1] = 101"); - assertContains(stateDiffJson, '"newValue":"102"', "Should decode matrix[0][2] = 102"); - assertContains(stateDiffJson, '"newValue":"200"', "Should decode matrix[1][0] = 200"); - assertContains(stateDiffJson, '"newValue":"201"', "Should decode matrix[1][1] = 201"); - assertContains(stateDiffJson, '"newValue":"202"', "Should decode matrix[1][2] = 202"); + assertTrue(vm.contains(stateDiffJson, '"decoded":')); + assertTrue(vm.contains(stateDiffJson, '"newValue":"100"')); + assertTrue(vm.contains(stateDiffJson, '"newValue":"101"')); + assertTrue(vm.contains(stateDiffJson, '"newValue":"102"')); + assertTrue(vm.contains(stateDiffJson, '"newValue":"200"')); + assertTrue(vm.contains(stateDiffJson, '"newValue":"201"')); + assertTrue(vm.contains(stateDiffJson, '"newValue":"202"')); vm.stopAndReturnStateDiff(); } @@ -302,20 +298,20 @@ contract StateDiffStorageLayoutTest is DSTest { string memory stateDiffJson = vm.getStateDiffJson(); // Check for proper types - assertContains(stateDiffJson, '"type":"address[2][3]"', "Should contain address 2D array type"); - assertContains(stateDiffJson, '"type":"bytes32[2][4]"', "Should contain bytes32 2D array type"); + assertTrue(vm.contains(stateDiffJson, '"type":"address[2][3]"')); + assertTrue(vm.contains(stateDiffJson, '"type":"bytes32[2][4]"')); // Verify address 2D array labels - assertContains(stateDiffJson, '"label":"addresses2D[0][0]"', "Should contain addresses2D[0][0] label"); - assertContains(stateDiffJson, '"label":"addresses2D[0][1]"', "Should contain addresses2D[0][1] label"); - assertContains(stateDiffJson, '"label":"addresses2D[1][0]"', "Should contain addresses2D[1][0] label"); - assertContains(stateDiffJson, '"label":"addresses2D[2][1]"', "Should contain addresses2D[2][1] label"); + assertTrue(vm.contains(stateDiffJson, '"label":"addresses2D[0][0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"addresses2D[0][1]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"addresses2D[1][0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"addresses2D[2][1]"')); // Verify data 2D array labels - assertContains(stateDiffJson, '"label":"data2D[0][0]"', "Should contain data2D[0][0] label"); - assertContains(stateDiffJson, '"label":"data2D[0][1]"', "Should contain data2D[0][1] label"); - assertContains(stateDiffJson, '"label":"data2D[1][0]"', "Should contain data2D[1][0] label"); - assertContains(stateDiffJson, '"label":"data2D[1][1]"', "Should contain data2D[1][1] label"); + assertTrue(vm.contains(stateDiffJson, '"label":"data2D[0][0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"data2D[0][1]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"data2D[1][0]"')); + assertTrue(vm.contains(stateDiffJson, '"label":"data2D[1][1]"')); vm.stopAndReturnStateDiff(); } @@ -335,45 +331,16 @@ contract StateDiffStorageLayoutTest is DSTest { // Test that decoded values are shown in the string format // The output uses Unicode arrow → // For uint256 values, should show decoded value "42" - assertContains(stateDiff, unicode"0 → 42", "Should show decoded uint256 value"); + assertTrue(vm.contains(stateDiff, unicode"0 → 42")); // For addresses, should show decoded address format - assertContains(stateDiff, "0x000000000000000000000000000000000000bEEF", "Should show decoded address"); + assertTrue(vm.contains(stateDiff, "0x000000000000000000000000000000000000bEEF")); // For array elements, should show decoded values - assertContains(stateDiff, unicode"0 → 100", "Should show decoded array value 100"); - assertContains(stateDiff, unicode"0 → 200", "Should show decoded array value 200"); - assertContains(stateDiff, unicode"0 → 300", "Should show decoded array value 300"); + assertTrue(vm.contains(stateDiff, unicode"0 → 100")); + assertTrue(vm.contains(stateDiff, unicode"0 → 200")); + assertTrue(vm.contains(stateDiff, unicode"0 → 300")); vm.stopAndReturnStateDiff(); } - - // Helper function to check if a string contains a substring - function assertContains(string memory haystack, string memory needle, string memory message) internal pure { - bytes memory haystackBytes = bytes(haystack); - bytes memory needleBytes = bytes(needle); - - if (needleBytes.length > haystackBytes.length) { - revert(message); - } - - bool found = false; - for (uint256 i = 0; i <= haystackBytes.length - needleBytes.length; i++) { - bool isMatch = true; - for (uint256 j = 0; j < needleBytes.length; j++) { - if (haystackBytes[i + j] != needleBytes[j]) { - isMatch = false; - break; - } - } - if (isMatch) { - found = true; - break; - } - } - - if (!found) { - revert(message); - } - } } diff --git a/testdata/default/cheats/StateDiffStructTest.t.sol b/testdata/default/cheats/StateDiffStructTest.t.sol new file mode 100644 index 0000000000000..a0b29f156c673 --- /dev/null +++ b/testdata/default/cheats/StateDiffStructTest.t.sol @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity 0.8.18; + +import "ds-test/test.sol"; +import "cheats/Vm.sol"; + +contract DiffTest { + // slot 0 + struct TestStruct { + uint128 a; + uint128 b; + } + + // Multi-slot struct (spans 3 slots) + struct MultiSlotStruct { + uint256 value1; // slot 1 + address addr; // slot 2 (takes 20 bytes, but uses full slot) + uint256 value2; // slot 3 + } + + // Nested struct with MultiSlotStruct as inner + struct NestedStruct { + MultiSlotStruct inner; // slots 4-6 (spans 3 slots) + uint256 value; // slot 7 + address owner; // slot 8 + } + + TestStruct internal testStruct; + MultiSlotStruct internal multiSlotStruct; + NestedStruct internal nestedStruct; + + function setStruct(uint128 a, uint128 b) public { + testStruct.a = a; + testStruct.b = b; + } + + function setMultiSlotStruct(uint256 v1, address a, uint256 v2) public { + multiSlotStruct.value1 = v1; + multiSlotStruct.addr = a; + multiSlotStruct.value2 = v2; + } + + function setNestedStruct(uint256 v1, address a, uint256 v2, uint256 v, address o) public { + nestedStruct.inner.value1 = v1; + nestedStruct.inner.addr = a; + nestedStruct.inner.value2 = v2; + nestedStruct.value = v; + nestedStruct.owner = o; + } +} + +contract StateDiffStructTest is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + DiffTest internal test; + + function setUp() public { + test = new DiffTest(); + } + + function testStruct() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Set struct values: a=1, b=2 + test.setStruct(1, 2); + + // Get the state diff as JSON + string memory stateDiffJson = vm.getStateDiffJson(); + + // Debug: log the JSON for inspection + emit log_string("State diff JSON (testdata):"); + emit log_string(stateDiffJson); + + // Check that the struct is properly labeled + assertTrue(vm.contains(stateDiffJson, '"label":"testStruct"')); + + // Check that the type is correctly identified as a struct + assertTrue(vm.contains(stateDiffJson, '"type":"struct DiffTest.TestStruct"')); + + // Check for members field - structs have members with individual decoded values + assertTrue(vm.contains(stateDiffJson, '"members":')); + + // Check that member 'a' is properly decoded + assertTrue(vm.contains(stateDiffJson, '"label":"a"')); + assertTrue(vm.contains(stateDiffJson, '"type":"uint128"')); + + // Check that member 'b' is properly decoded + assertTrue(vm.contains(stateDiffJson, '"label":"b"')); + + // The members should have decoded values + // Check specific decoded values for each member in the members array + // Member 'a' at offset 0 should have previous value 0 and new value 1 + assertTrue( + vm.contains( + stateDiffJson, + '{"label":"a","type":"uint128","offset":0,"slot":"0","decoded":{"previousValue":"0","newValue":"1"}}' + ) + ); + + // Member 'b' at offset 16 should have previous value 0 and new value 2 + assertTrue( + vm.contains( + stateDiffJson, + '{"label":"b","type":"uint128","offset":16,"slot":"0","decoded":{"previousValue":"0","newValue":"2"}}' + ) + ); + + // Verify the raw storage values are correct + // The storage layout packs uint128 a at offset 0 and uint128 b at offset 16 + // So the value 0x0000000000000000000200000000000000000000000000000001 represents: + // - First 16 bytes (a): 0x0000000000000000000000000000000001 = 1 + // - Last 16 bytes (b): 0x0000000000000000000000000000000002 = 2 + assertTrue(vm.contains(stateDiffJson, '"0x0000000000000000000000000000000200000000000000000000000000000001"')); + + // Stop recording and verify we get the expected account accesses + Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); + assertTrue(accesses.length > 0); + + // Find the storage access for our struct + bool foundStructAccess = false; + for (uint256 i = 0; i < accesses.length; i++) { + if (accesses[i].account == address(test)) { + for (uint256 j = 0; j < accesses[i].storageAccesses.length; j++) { + Vm.StorageAccess memory access = accesses[i].storageAccesses[j]; + if (access.slot == bytes32(uint256(0)) && access.isWrite) { + foundStructAccess = true; + // Verify the storage values + assertEq(access.previousValue, bytes32(uint256(0))); + assertEq( + access.newValue, bytes32(uint256(0x0000000000000000000200000000000000000000000000000001)) + ); + } + } + } + } + + assertTrue(foundStructAccess); + } + + function testMultiSlotStruct() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Set multi-slot struct values + test.setMultiSlotStruct( + 123456789, // value1 + address(0xdEaDbEeF), // addr + 987654321 // value2 + ); + + // Get the state diff as JSON + string memory stateDiffJson = vm.getStateDiffJson(); + + // Debug: log the JSON for inspection + emit log_string("State diff JSON:"); + emit log_string(stateDiffJson); + + // Check that the struct's first member is properly labeled + assertTrue(vm.contains(stateDiffJson, '"label":"multiSlotStruct.value1"')); + + // For multi-slot structs, the base slot now shows the first member's type + // The struct type itself is not shown since we decode the first member directly + + // Multi-slot structs don't have members field in the base slot + // Instead, each member appears as a separate slot entry with dotted labels + + // Check that each member slot is properly labeled + // Note: slot 1 now shows multiSlotStruct.value1 since it's the first member + assertTrue(vm.contains(stateDiffJson, '"label":"multiSlotStruct.value1"')); + assertTrue(vm.contains(stateDiffJson, '"label":"multiSlotStruct.addr"')); + assertTrue(vm.contains(stateDiffJson, '"label":"multiSlotStruct.value2"')); + + // Check member types + assertTrue(vm.contains(stateDiffJson, '"type":"uint256"')); + assertTrue(vm.contains(stateDiffJson, '"type":"address"')); + + // Check that value1 is properly decoded from slot 1 + assertTrue(vm.contains(stateDiffJson, '"decoded":{"previousValue":"0","newValue":"123456789"}')); + + // Also verify the raw hex value + assertTrue(vm.contains(stateDiffJson, "0x00000000000000000000000000000000000000000000000000000000075bcd15")); + + // Slot 2 should have the address decoded + assertTrue( + vm.contains( + stateDiffJson, + '"decoded":{"previousValue":"0x0000000000000000000000000000000000000000","newValue":"0x00000000000000000000000000000000DeaDBeef"}' + ) + ); + + // Slot 3 should have value2 decoded + assertTrue(vm.contains(stateDiffJson, '"decoded":{"previousValue":"0","newValue":"987654321"}')); + + // Stop recording + vm.stopAndReturnStateDiff(); + } + + function testNestedStruct() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Set nested struct values - now with MultiSlotStruct as inner + test.setNestedStruct( + 111111111, // inner.value1 + address(0xCAFE), // inner.addr + 222222222, // inner.value2 + 333333333, // value + address(0xBEEF) // owner + ); + + // Get the state diff as JSON + string memory stateDiffJson = vm.getStateDiffJson(); + + // Debug: log the JSON for inspection + emit log_string("State diff JSON (testdata):"); + emit log_string(stateDiffJson); + + assertTrue( + vm.contains( + stateDiffJson, + '"label":"nestedStruct.inner.value1","type":"uint256","offset":0,"slot":"4","decoded":{"previousValue":"0","newValue":"111111111"}' + ) + ); + + assertTrue( + vm.contains( + stateDiffJson, + '"label":"nestedStruct.inner.addr","type":"address","offset":0,"slot":"5","decoded":{"previousValue":"0x0000000000000000000000000000000000000000","newValue":"0x000000000000000000000000000000000000cafE"}' + ) + ); + + assertTrue( + vm.contains( + stateDiffJson, + '"label":"nestedStruct.inner.value2","type":"uint256","offset":0,"slot":"6","decoded":{"previousValue":"0","newValue":"222222222"}' + ) + ); + + assertTrue(vm.contains(stateDiffJson, "0x00000000000000000000000000000000000000000000000000000000069f6bc7")); + + assertTrue( + vm.contains( + stateDiffJson, + '"label":"nestedStruct.value","type":"uint256","offset":0,"slot":"7","decoded":{"previousValue":"0","newValue":"333333333"}' + ) + ); + + assertTrue( + vm.contains( + stateDiffJson, + '"label":"nestedStruct.owner","type":"address","offset":0,"slot":"8","decoded":{"previousValue":"0x0000000000000000000000000000000000000000","newValue":"0x000000000000000000000000000000000000bEEF"}' + ) + ); + + // Stop recording + vm.stopAndReturnStateDiff(); + } +}