diff --git a/crates/cheatcodes/src/evm.rs b/crates/cheatcodes/src/evm.rs index 7b7a61430f507..31922b8194c41 100644 --- a/crates/cheatcodes/src/evm.rs +++ b/crates/cheatcodes/src/evm.rs @@ -6,11 +6,13 @@ use crate::{ inspector::{Ecx, RecordDebugStepInfo}, }; use alloy_consensus::TxEnvelope; +use alloy_dyn_abi::{DynSolType, DynSolValue}; use alloy_genesis::{Genesis, GenesisAccount}; -use alloy_primitives::{Address, B256, U256, map::HashMap}; +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_evm_core::{ ContextExt, backend::{DatabaseExt, RevertStateSnapshotAction}, @@ -30,6 +32,7 @@ use std::{ collections::{BTreeMap, HashSet, btree_map::Entry}, fmt::Display, path::Path, + str::FromStr, }; mod record_debug_step; @@ -103,6 +106,58 @@ 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. + #[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. @@ -170,11 +225,38 @@ impl Display for AccountStateDiffs { if !&self.state_diff.is_empty() { writeln!(f, "- state diff:")?; for (slot, slot_changes) in &self.state_diff { - writeln!( - f, - "@ {slot}: {} → {}", - slot_changes.previous_value, slot_changes.new_value - )?; + 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 + )?; + } + _ => { + // No slot info - show raw hex values + writeln!( + f, + "@ {slot}: {} → {}", + slot_changes.previous_value, slot_changes.new_value + )?; + } + } } } @@ -1257,12 +1339,20 @@ fn get_recorded_state_diffs(ccx: &mut CheatsCtxt) -> BTreeMap 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)); + + // Try to decode values if we have slot info + let decoded = slot_info.as_ref().and_then(|info| { + let prev = decode_storage_value( + 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, }); } Entry::Occupied(mut slot_state_diff) => { - slot_state_diff.get_mut().new_value = storage_access.newValue; + let entry = slot_state_diff.get_mut(); + entry.new_value = storage_access.newValue; + + if let Some(slot_info) = &entry.slot_info + && let Some(ref mut decoded) = entry.decoded + && let Some(new_value) = decode_storage_value( + storage_access.newValue, + &slot_info.dyn_sol_type, + ) + { + decoded.new_value = new_value; + } } } } @@ -1375,6 +1496,158 @@ fn get_contract_name(ccx: &mut CheatsCtxt, address: Address) -> Option { None } +/// Helper function to get the contract data from the deployed code at an address. +fn get_contract_data<'a>( + ccx: &'a mut CheatsCtxt, + address: Address, +) -> Option<(&'a foundry_compilers::ArtifactId, &'a foundry_common::contracts::ContractData)> { + // Check if we have available artifacts to match against + let artifacts = ccx.state.config.available_artifacts.as_ref()?; + + // Try to load the account and get its code + let account = ccx.ecx.journaled_state.load_account(address).ok()?; + let code = account.info.code.as_ref()?; + + // Skip if code is empty + if code.is_empty() { + return None; + } + + // Try to find the artifact by deployed code + let code_bytes = code.original_bytes(); + if let Some(result) = artifacts.find_by_deployed_code_exact(&code_bytes) { + return Some(result); + } + + // Fallback to fuzzy matching if exact match fails + 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/forge/tests/it/cheats.rs b/crates/forge/tests/it/cheats.rs index 5e2764c603fd0..daadd874b9593 100644 --- a/crates/forge/tests/it/cheats.rs +++ b/crates/forge/tests/it/cheats.rs @@ -1,22 +1,23 @@ //! Forge tests for cheatcodes. - use crate::{ config::*, test_helpers::{ - ForgeTestData, RE_PATH_SEPARATOR, TEST_DATA_DEFAULT, TEST_DATA_MULTI_VERSION, - TEST_DATA_PARIS, + ForgeTestData, ForgeTestProfile, RE_PATH_SEPARATOR, TEST_DATA_DEFAULT, + TEST_DATA_MULTI_VERSION, TEST_DATA_PARIS, get_compiled, }, }; use alloy_primitives::U256; +use foundry_cli::utils::install_crypto_provider; +use foundry_compilers::artifacts::output_selection::ContractOutputSelection; use foundry_config::{FsPermissions, fs_permissions::PathPermission}; -use foundry_test_utils::Filter; +use foundry_test_utils::{Filter, init_tracing}; /// Executes all cheat code tests but not fork cheat codes or tests that require isolation mode or /// specific seed. 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)"); + .exclude_contracts("(Isolated|WithSeed|StateDiffStorageLayoutTest)"); // Exclude FFI tests on Windows because no `echo`, and file tests that expect certain file paths if cfg!(windows) { @@ -36,7 +37,8 @@ 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}*")); + let filter = Filter::new(".*", ".*(Isolated)", &format!(".*cheats{RE_PATH_SEPARATOR}*")) + .exclude_contracts("StateDiffStorageLayoutTest"); let runner = test_data.runner_with(|config| { config.isolate = true; @@ -61,6 +63,29 @@ async fn test_cheats_local_default() { test_cheats_local(&TEST_DATA_DEFAULT).await } +#[tokio::test(flavor = "multi_thread")] +async fn test_state_diff_storage_layout() { + let test_data = { + let profile = ForgeTestProfile::Default; + install_crypto_provider(); + init_tracing(); + let mut config = profile.config(); + config.extra_output = vec![ContractOutputSelection::StorageLayout]; + let mut project = config.project().unwrap(); + // Compile with StorageLayout + let output = get_compiled(&mut project); + ForgeTestData { project, output, config: config.into(), profile } + }; + let filter = + Filter::new(".*", "StateDiffStorageLayoutTest", &format!(".*cheats{RE_PATH_SEPARATOR}*")); + + let runner = test_data.runner_with(|config| { + config.fs_permissions = FsPermissions::new(vec![PathPermission::read_write("./")]); + }); + + TestConfig::with_filter(runner, filter).run().await; +} + #[tokio::test(flavor = "multi_thread")] async fn test_cheats_local_default_isolated() { test_cheats_local_isolated(&TEST_DATA_DEFAULT).await diff --git a/testdata/default/cheats/StateDiffStorageLayout.t.sol b/testdata/default/cheats/StateDiffStorageLayout.t.sol new file mode 100644 index 0000000000000..961a2cd4b4363 --- /dev/null +++ b/testdata/default/cheats/StateDiffStorageLayout.t.sol @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import "ds-test/test.sol"; +import "cheats/Vm.sol"; + +contract SimpleStorage { + uint256 public value; // Slot 0 + address public owner; // Slot 1 + uint256[3] public values; // Slots 2, 3, 4 + + constructor() { + owner = msg.sender; + } + + function setValue(uint256 _value) public { + value = _value; + } + + function setOwner(address _owner) public { + owner = _owner; + } + + function setValues(uint256 a, uint256 b, uint256 c) public { + values[0] = a; + values[1] = b; + values[2] = c; + } +} + +contract VariousArrays { + // Different array types to test + uint256[3] public numbers; // Slots 0, 1, 2 + address[2] public addresses; // Slots 3, 4 + bool[5] public flags; // Slot 5 (packed) + bytes32[2] public hashes; // Slots 6, 7 + + function setNumbers(uint256 a, uint256 b, uint256 c) public { + numbers[0] = a; + numbers[1] = b; + numbers[2] = c; + } + + function setAddresses(address a, address b) public { + addresses[0] = a; + addresses[1] = b; + } + + function setFlags(bool a, bool b, bool c, bool d, bool e) public { + flags[0] = a; + flags[1] = b; + flags[2] = c; + flags[3] = d; + flags[4] = e; + } + + function setHashes(bytes32 a, bytes32 b) public { + hashes[0] = a; + hashes[1] = b; + } +} + +contract TwoDArrayStorage { + // 2D array: 2 arrays of 3 uint256 elements each + // Total slots: 6 (slots 0-5) + // [0][0] at slot 0, [0][1] at slot 1, [0][2] at slot 2 + // [1][0] at slot 3, [1][1] at slot 4, [1][2] at slot 5 + uint256[3][2] public matrix; + + // Another 2D array starting at slot 6 + // 3 arrays of 2 addresses each + // Total slots: 6 (slots 6-11) + address[2][3] public addresses2D; + + // Mixed size 2D array starting at slot 12 + // 4 arrays of 2 bytes32 each + // Total slots: 8 (slots 12-19) + bytes32[2][4] public data2D; + + function setMatrix(uint256[3] memory row0, uint256[3] memory row1) public { + matrix[0] = row0; + matrix[1] = row1; + } + + function setMatrixElement(uint256 i, uint256 j, uint256 value) public { + matrix[i][j] = value; + } + + function setAddresses2D(address[2] memory row0, address[2] memory row1, address[2] memory row2) public { + addresses2D[0] = row0; + addresses2D[1] = row1; + addresses2D[2] = row2; + } + + function setData2D(uint256 i, uint256 j, bytes32 value) public { + data2D[i][j] = value; + } +} + +contract StateDiffStorageLayoutTest is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + SimpleStorage simpleStorage; + VariousArrays variousArrays; + TwoDArrayStorage twoDArrayStorage; + + function setUp() public { + simpleStorage = new SimpleStorage(); + variousArrays = new VariousArrays(); + twoDArrayStorage = new TwoDArrayStorage(); + } + + function testSimpleStorageLayout() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Modify storage slots with known positions + simpleStorage.setValue(42); // Modifies slot 0 (value) + simpleStorage.setOwner(address(this)); // Modifies slot 1 (owner) + simpleStorage.setValues(100, 200, 300); // Modifies slots 2, 3, 4 (values array) + + // Get the state diff as string + string memory stateDiff = vm.getStateDiff(); + + // Get the state diff as JSON and verify it contains the expected structure + string memory stateDiffJson = vm.getStateDiffJson(); + + // 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"); + + 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"); + + // Check for decoded values + assertContains(stateDiffJson, '"decoded":', "Should contain decoded values"); + + // 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"); + + // 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"); + + // 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"); + + // Verify storage accesses for SimpleStorage + bool foundValueSlot = false; + bool foundOwnerSlot = false; + bool foundValuesSlot0 = false; + bool foundValuesSlot1 = false; + bool foundValuesSlot2 = false; + + for (uint256 i = 0; i < accesses.length; i++) { + if (accesses[i].account == address(simpleStorage)) { + for (uint256 j = 0; j < accesses[i].storageAccesses.length; j++) { + bytes32 slot = accesses[i].storageAccesses[j].slot; + if (slot == bytes32(uint256(0))) foundValueSlot = true; + if (slot == bytes32(uint256(1))) foundOwnerSlot = true; + if (slot == bytes32(uint256(2))) foundValuesSlot0 = true; + if (slot == bytes32(uint256(3))) foundValuesSlot1 = true; + if (slot == bytes32(uint256(4))) foundValuesSlot2 = true; + } + } + } + + 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])"); + } + + function testVariousArrayTypes() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Modify different array types + variousArrays.setNumbers(100, 200, 300); + variousArrays.setAddresses(address(0x1), address(0x2)); + variousArrays.setFlags(true, false, true, false, true); + variousArrays.setHashes(keccak256("test1"), keccak256("test2")); + + // Get the state diff as JSON + 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"); + + assertContains(stateDiffJson, '"label":"addresses[0]"', "Should contain 'addresses[0]' label"); + assertContains(stateDiffJson, '"label":"addresses[1]"', "Should contain 'addresses[1]' label"); + + assertContains(stateDiffJson, '"label":"flags[0]"', "Should contain 'flags[0]' label"); + + assertContains(stateDiffJson, '"label":"hashes[0]"', "Should contain 'hashes[0]' label"); + assertContains(stateDiffJson, '"label":"hashes[1]"', "Should contain 'hashes[1]' label"); + + // 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"); + + // Check decoded values + assertContains(stateDiffJson, '"decoded":', "Should contain decoded values"); + // Check addresses are decoded as raw hex strings + assertContains( + stateDiffJson, '"newValue":"0x0000000000000000000000000000000000000001"', "Should decode address 1" + ); + assertContains( + stateDiffJson, '"newValue":"0x0000000000000000000000000000000000000002"', "Should decode address 2" + ); + + // Stop recording and verify account accesses + Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); + assertTrue(accesses.length > 0, "Should have account accesses"); + } + + function testStateDiffJsonFormat() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Make a simple change to verify JSON format + simpleStorage.setValue(123); + + // Get the JSON and verify it's properly formatted + 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"); + + vm.stopAndReturnStateDiff(); + } + + function test2DArrayStorageLayout() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Set matrix values + // matrix[0][0] = 100, matrix[0][1] = 101, matrix[0][2] = 102 + // matrix[1][0] = 200, matrix[1][1] = 201, matrix[1][2] = 202 + uint256[3] memory row0 = [uint256(100), 101, 102]; + uint256[3] memory row1 = [uint256(200), 201, 202]; + twoDArrayStorage.setMatrix(row0, row1); + + // Get the state diff and check labels + 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"); + + // Check that we have the right type + assertContains(stateDiffJson, '"type":"uint256[3][2]"', "Should contain 2D array type"); + + // 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"); + + vm.stopAndReturnStateDiff(); + } + + function testMixed2DArrays() public { + vm.startStateDiffRecording(); + + // Test address 2D array + address[2] memory addrRow0 = [address(0x1), address(0x2)]; + address[2] memory addrRow1 = [address(0x3), address(0x4)]; + address[2] memory addrRow2 = [address(0x5), address(0x6)]; + twoDArrayStorage.setAddresses2D(addrRow0, addrRow1, addrRow2); + + // Test bytes32 2D array + twoDArrayStorage.setData2D(0, 0, keccak256("data00")); + twoDArrayStorage.setData2D(0, 1, keccak256("data01")); + twoDArrayStorage.setData2D(1, 0, keccak256("data10")); + twoDArrayStorage.setData2D(1, 1, keccak256("data11")); + + 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"); + + // 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"); + + // 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"); + + vm.stopAndReturnStateDiff(); + } + + function testStateDiffDecodedValues() public { + // Start recording state diffs + vm.startStateDiffRecording(); + + // Make changes to create state diffs with decoded values + simpleStorage.setValue(42); // uint256 value + simpleStorage.setOwner(address(0xBEEF)); // address value + simpleStorage.setValues(100, 200, 300); // array values + + // Get the state diff as string (not JSON) + string memory stateDiff = vm.getStateDiff(); + + // 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"); + + // For addresses, should show decoded address format + assertContains(stateDiff, "0x000000000000000000000000000000000000bEEF", "Should show decoded address"); + + // 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"); + + 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); + } + } +}