diff --git a/crates/cheatcodes/src/evm.rs b/crates/cheatcodes/src/evm.rs
index c7056d5e79945..b56e34dd41126 100644
--- a/crates/cheatcodes/src/evm.rs
+++ b/crates/cheatcodes/src/evm.rs
@@ -8,7 +8,10 @@ use crate::{
use alloy_consensus::TxEnvelope;
use alloy_genesis::{Genesis, GenesisAccount};
use alloy_network::eip2718::EIP4844_TX_TYPE_ID;
-use alloy_primitives::{Address, B256, U256, hex, map::HashMap};
+use alloy_primitives::{
+ Address, B256, U256, hex,
+ map::{B256Map, HashMap},
+};
use alloy_rlp::Decodable;
use alloy_sol_types::SolValue;
use foundry_common::{
@@ -1376,6 +1379,16 @@ fn get_recorded_state_diffs(ccx: &mut CheatsCtxt) -> BTreeMap
>();
+
// Record account state diffs.
for storage_access in &account_access.storageAccesses {
if storage_access.isWrite && !storage_access.reverted {
@@ -1398,19 +1411,43 @@ fn get_recorded_state_diffs(ccx: &mut CheatsCtxt) -> BTreeMap>();
+ decoder.identify_bytes_or_string(
+ &storage_access.slot,
+ ¤t_base_slot_values,
+ )
+ })
+ .map(|mut info| {
+ // Always decode values first
+ info.decode_values(
+ storage_access.previousValue,
+ storage_access.newValue,
+ );
+
+ // Then handle long bytes/strings if applicable
+ if info.is_bytes_or_string() {
+ info.decode_bytes_or_string_values(
+ &storage_access.slot,
+ &raw_changes_by_slot,
+ );
+ }
+
+ info
+ })
});
- // Decode values if we have slot info
- if let Some(ref mut info) = slot_info {
- info.decode_values(
- storage_access.previousValue,
- storage_access.newValue,
- );
- }
-
slot_state_diff.insert(SlotStateDiff {
previous_value: storage_access.previousValue,
new_value: storage_access.newValue,
diff --git a/crates/common/src/slot_identifier.rs b/crates/common/src/slot_identifier.rs
index a1b82e9ca3228..350ec5c6cb0da 100644
--- a/crates/common/src/slot_identifier.rs
+++ b/crates/common/src/slot_identifier.rs
@@ -5,16 +5,17 @@
use crate::mapping_slots::MappingSlots;
use alloy_dyn_abi::{DynSolType, DynSolValue};
-use alloy_primitives::{B256, U256, hex};
+use alloy_primitives::{B256, U256, hex, keccak256, map::B256Map};
use foundry_common_fmt::format_token_raw;
use foundry_compilers::artifacts::{Storage, StorageLayout, StorageType};
use serde::Serialize;
-use std::{str::FromStr, sync::Arc};
+use std::{collections::BTreeMap, str::FromStr, sync::Arc};
use tracing::trace;
// Constants for storage type encodings
const ENCODING_INPLACE: &str = "inplace";
const ENCODING_MAPPING: &str = "mapping";
+const ENCODING_BYTES: &str = "bytes";
/// Information about a storage slot including its label, type, and decoded values.
#[derive(Serialize, Debug)]
@@ -67,6 +68,9 @@ pub struct StorageTypeInfo {
impl SlotInfo {
/// Decodes a single storage value based on the slot's type information.
+ ///
+ /// Note: For decoding [`DynSolType::Bytes`] or [`DynSolType::String`] that span multiple slots,
+ /// use [`SlotInfo::decode_bytes_or_string`].
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;
@@ -75,8 +79,184 @@ impl SlotInfo {
actual_type = elem_type.as_ref();
}
- // Decode based on the actual type
- actual_type.abi_decode(&value.0).ok()
+ // Special handling for bytes and string types
+ match actual_type {
+ DynSolType::Bytes | DynSolType::String => {
+ // Decode bytes/string from storage
+ // The last byte contains the length * 2 for short strings/bytes
+ // or length * 2 + 1 for long strings/bytes
+ let length_byte = value.0[31];
+
+ if length_byte & 1 == 0 {
+ // Short string/bytes (less than 32 bytes)
+ let length = (length_byte >> 1) as usize;
+ // Extract data
+ let data = if length == 0 { Vec::new() } else { value.0[0..length].to_vec() };
+
+ // Create the appropriate value based on type
+ if matches!(actual_type, DynSolType::String) {
+ let str_val = if data.is_empty() {
+ String::new()
+ } else {
+ String::from_utf8(data).unwrap_or_default()
+ };
+ Some(DynSolValue::String(str_val))
+ } else {
+ Some(DynSolValue::Bytes(data))
+ }
+ } else {
+ // Long string/bytes (32 bytes or more)
+ // The actual data is stored at keccak256(slot)
+ // Return None for long values - they need decode_bytes_or_string()
+ None
+ }
+ }
+ _ => {
+ // Decode based on the actual type
+ actual_type.abi_decode(&value.0).ok()
+ }
+ }
+ }
+
+ /// Slot is of type [`DynSolType::Bytes`] or [`DynSolType::String`]
+ pub fn is_bytes_or_string(&self) -> bool {
+ matches!(self.slot_type.dyn_sol_type, DynSolType::Bytes | DynSolType::String)
+ }
+
+ /// Decodes a [`DynSolType::Bytes`] or [`DynSolType::String`] value
+ /// that spans across multiple slots.
+ pub fn decode_bytes_or_string(
+ &mut self,
+ base_slot: &B256,
+ storage_values: &B256Map,
+ ) -> Option {
+ // Only process bytes/string types
+ if !self.is_bytes_or_string() {
+ return None;
+ }
+
+ // Try to handle as long bytes/string
+ self.aggregate_bytes_or_strings(base_slot, storage_values).map(|data| {
+ match self.slot_type.dyn_sol_type {
+ DynSolType::String => {
+ DynSolValue::String(String::from_utf8(data).unwrap_or_default())
+ }
+ DynSolType::Bytes => DynSolValue::Bytes(data),
+ _ => unreachable!(),
+ }
+ })
+ }
+
+ /// Decodes both previous and new [`DynSolType::Bytes`] or [`DynSolType::String`] values
+ /// that span across multiple slots using state diff data.
+ ///
+ /// Accepts a mapping of storage_slot to (previous_value, new_value).
+ pub fn decode_bytes_or_string_values(
+ &mut self,
+ base_slot: &B256,
+ storage_accesses: &BTreeMap,
+ ) {
+ // Only process bytes/string types
+ if !self.is_bytes_or_string() {
+ return;
+ }
+
+ // Get both previous and new values from the storage accesses
+ if let Some((prev_base_value, new_base_value)) = storage_accesses.get(base_slot) {
+ // Reusable closure to decode bytes/string based on length encoding
+ let mut decode_value = |base_value: B256, is_new: bool| {
+ let length_byte = base_value.0[31];
+ if length_byte & 1 == 1 {
+ // Long bytes/string - aggregate from multiple slots
+ let value_map = storage_accesses
+ .iter()
+ .map(|(slot, (prev, new))| (*slot, if is_new { *new } else { *prev }))
+ .collect::>();
+ self.decode_bytes_or_string(base_slot, &value_map)
+ } else {
+ // Short bytes/string - decode directly from base slot
+ self.decode(base_value)
+ }
+ };
+
+ // Decode previous value
+ let prev_decoded = decode_value(*prev_base_value, false);
+
+ // Decode new value
+ let new_decoded = decode_value(*new_base_value, true);
+
+ // Set decoded values if both were successfully decoded
+ if let (Some(prev), Some(new)) = (prev_decoded, new_decoded) {
+ self.decoded = Some(DecodedSlotValues { previous_value: prev, new_value: new });
+ }
+ }
+ }
+
+ /// Aggregates a [`DynSolType::Bytes`] or [`DynSolType::String`] value that spans across
+ /// multiple slots by looking up the length in the base_slot.
+ ///
+ /// Returns the aggregated raw bytes.
+ fn aggregate_bytes_or_strings(
+ &mut self,
+ base_slot: &B256,
+ storage_values: &B256Map,
+ ) -> Option> {
+ if !self.is_bytes_or_string() {
+ return None;
+ }
+
+ // Check if it's a long bytes/string by looking at the base value
+ if let Some(base_value) = storage_values.get(base_slot) {
+ let length_byte = base_value.0[31];
+
+ // Check if value is long
+ if length_byte & 1 == 1 {
+ // Long bytes/string - populate members
+ let length: U256 = U256::from_be_bytes(base_value.0) >> 1;
+ let num_slots = length.to::().div_ceil(32).min(256);
+ let data_start = U256::from_be_bytes(keccak256(base_slot.0).0);
+
+ let mut members = Vec::new();
+ let mut full_data = Vec::with_capacity(length.to::());
+
+ for i in 0..num_slots {
+ let data_slot = B256::from(data_start + U256::from(i));
+ let data_slot_u256 = data_start + U256::from(i);
+
+ // Create member info for this data slot with indexed label
+ let member_info = Self {
+ label: format!("{}[{}]", self.label, i),
+ slot_type: StorageTypeInfo {
+ label: self.slot_type.label.clone(),
+ dyn_sol_type: DynSolType::FixedBytes(32),
+ },
+ offset: 0,
+ slot: data_slot_u256.to_string(),
+ members: None,
+ decoded: None,
+ keys: None,
+ };
+
+ if let Some(value) = storage_values.get(&data_slot) {
+ // Collect data
+ let bytes_to_take =
+ std::cmp::min(32, length.to::() - full_data.len());
+ full_data.extend_from_slice(&value.0[..bytes_to_take]);
+ }
+
+ members.push(member_info);
+ }
+
+ // Set the members field
+ if !members.is_empty() {
+ self.members = Some(members);
+ }
+
+ return Some(full_data);
+ }
+ }
+
+ None
}
/// Decodes storage values (previous and new) and populates the decoded field.
@@ -124,6 +304,8 @@ impl SlotInfo {
// For structs with members, we don't need a top-level decoded value
} else {
// For non-struct types, decode directly
+ // Note: decode() returns None for long bytes/strings, which will be handled by
+ // decode_bytes_or_string()
if let (Some(prev), Some(new)) = (self.decode(previous_value), self.decode(new_value)) {
self.decoded = Some(DecodedSlotValues { previous_value: prev, new_value: new });
}
@@ -267,10 +449,44 @@ impl SlotIdentifier {
}
} else if storage_type.encoding == ENCODING_MAPPING
&& let Some(mapping_slots) = mapping_slots
- && let Some(slot_info) =
+ && let Some(info) =
self.handle_mapping(storage, storage_type, slot, &slot_str, mapping_slots)
{
- return Some(slot_info);
+ return Some(info);
+ }
+ }
+
+ None
+ }
+
+ /// Identifies a bytes or string storage slot by checking all bytes/string variables
+ /// in the storage layout and using their base slot values from the provided storage changes.
+ ///
+ /// # Arguments
+ /// * `slot` - The slot being identified
+ /// * `storage_values` - Map of storage slots to their current values
+ pub fn identify_bytes_or_string(
+ &self,
+ slot: &B256,
+ storage_values: &B256Map,
+ ) -> Option {
+ let slot_u256 = U256::from_be_bytes(slot.0);
+ let slot_str = slot_u256.to_string();
+
+ // Search through all bytes/string variables in the storage layout
+ for storage in &self.storage_layout.storage {
+ if let Some(storage_type) = self.storage_layout.types.get(&storage.storage_type)
+ && storage_type.encoding == ENCODING_BYTES
+ {
+ let Some(base_slot) = U256::from_str(&storage.slot).map(B256::from).ok() else {
+ continue;
+ };
+ // Get the base slot value from storage_values
+ if let Some(base_value) = storage_values.get(&base_slot)
+ && let Some(info) = self.handle_bytes_string(slot_u256, &slot_str, base_value)
+ {
+ return Some(info);
+ }
}
}
@@ -612,6 +828,102 @@ impl SlotIdentifier {
})
}
+ /// Handles identification of bytes/string storage slots.
+ ///
+ /// Bytes and strings in Solidity use a special storage layout:
+ /// - Short values (<32 bytes): stored in the same slot with length * 2
+ /// - Long values (>=32 bytes): length * 2 + 1 in main slot, data at keccak256(slot)
+ ///
+ /// This function checks if the given slot is:
+ /// 1. A main slot for a bytes/string variable
+ /// 2. A data slot for any long bytes/string variable in the storage layout
+ ///
+ /// # Arguments
+ /// * `slot` - The accessed slot being identified
+ /// * `slot_str` - String representation of the slot for output
+ /// * `base_slot_value` - The value at the base slot (used to determine length for long
+ /// bytes/strings)
+ fn handle_bytes_string(
+ &self,
+ slot: U256,
+ slot_str: &str,
+ base_slot_value: &B256,
+ ) -> Option {
+ for storage in &self.storage_layout.storage {
+ // Get the type information and base slot
+ let Some(storage_type) = self.storage_layout.types.get(&storage.storage_type) else {
+ continue;
+ };
+
+ // Skip if not bytes or string encoding
+ if storage_type.encoding != ENCODING_BYTES {
+ continue;
+ }
+
+ // Check if this is the main slot
+ let base_slot = U256::from_str(&storage.slot).ok()?;
+ if slot == base_slot {
+ // Parse the type to get the correct DynSolType
+ let dyn_type = if storage_type.label == "string" {
+ DynSolType::String
+ } else if storage_type.label == "bytes" {
+ DynSolType::Bytes
+ } else {
+ continue;
+ };
+
+ return Some(SlotInfo {
+ label: storage.label.clone(),
+ slot_type: StorageTypeInfo {
+ label: storage_type.label.clone(),
+ dyn_sol_type: dyn_type,
+ },
+ offset: storage.offset,
+ slot: slot_str.to_string(),
+ members: None,
+ decoded: None,
+ keys: None,
+ });
+ }
+
+ // Check if it could be a data slot for this long bytes/string
+ // Calculate where data slots would start for this variable
+ let data_start =
+ U256::from_be_bytes(alloy_primitives::keccak256(base_slot.to_be_bytes::<32>()).0);
+
+ // Get the length from the base slot value to calculate exact number of slots
+ // For long bytes/strings, the length is stored as (length * 2 + 1) in the base slot
+ let length_byte = base_slot_value.0[31];
+ if length_byte & 1 == 1 {
+ // It's a long bytes/string
+ let length = U256::from_be_bytes(base_slot_value.0) >> 1;
+ // Calculate number of slots needed (round up)
+ let num_slots = (length + U256::from(31)) / U256::from(32);
+
+ // Check if our slot is within the data region
+ if slot >= data_start && slot < data_start + num_slots {
+ let slot_index = (slot - data_start).to::();
+
+ return Some(SlotInfo {
+ label: format!("{}[{}]", storage.label, slot_index),
+ slot_type: StorageTypeInfo {
+ label: storage_type.label.clone(),
+ // Type is assigned as FixedBytes(32) for data slots
+ dyn_sol_type: DynSolType::FixedBytes(32),
+ },
+ offset: 0,
+ slot: slot_str.to_string(),
+ members: None,
+ decoded: None,
+ keys: None,
+ });
+ }
+ }
+ }
+
+ None
+ }
+
fn resolve_mapping_type(&self, type_ref: &str) -> Option<(Vec, String, String)> {
let storage_type = self.storage_layout.types.get(type_ref)?;
diff --git a/testdata/default/cheats/StateDiffBytesString.t.sol b/testdata/default/cheats/StateDiffBytesString.t.sol
new file mode 100644
index 0000000000000..31d37e9bd5b74
--- /dev/null
+++ b/testdata/default/cheats/StateDiffBytesString.t.sol
@@ -0,0 +1,219 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import "ds-test/test.sol";
+import "cheats/Vm.sol";
+
+contract BytesStringStorage {
+ // Short string (less than 32 bytes)
+ string public shortString; // Slot 0
+
+ // Long string (32 bytes or more)
+ string public longString; // Slot 1
+
+ // Short bytes
+ bytes public shortBytes; // Slot 2
+
+ // Long bytes
+ bytes public longBytes; // Slot 3
+
+ // Fixed size bytes
+ bytes32 public fixedBytes; // Slot 4
+
+ // Mapping with string values
+ mapping(address => string) public userNames; // Slot 5
+
+ function setShortString(string memory _value) public {
+ shortString = _value;
+ }
+
+ function setLongString(string memory _value) public {
+ longString = _value;
+ }
+
+ function setShortBytes(bytes memory _value) public {
+ shortBytes = _value;
+ }
+
+ function setLongBytes(bytes memory _value) public {
+ longBytes = _value;
+ }
+
+ function setFixedBytes(bytes32 _value) public {
+ fixedBytes = _value;
+ }
+
+ function setUserName(address user, string memory name) public {
+ userNames[user] = name;
+ }
+}
+
+contract StateDiffBytesStringTest is DSTest {
+ Vm constant vm = Vm(HEVM_ADDRESS);
+ BytesStringStorage bytesStringStorage;
+
+ function setUp() public {
+ bytesStringStorage = new BytesStringStorage();
+ }
+
+ function testLongStringStorage() public {
+ // Start recording state diffs
+ vm.startStateDiffRecording();
+
+ // Set a long string (32 bytes or more)
+ string memory longStr =
+ "This is a very long string that exceeds 32 bytes and will be stored differently in Solidity storage";
+ bytesStringStorage.setLongString(longStr);
+
+ // Get the state diff as string
+ string memory stateDiff = vm.getStateDiff();
+ emit log_string("State diff for long string:");
+ emit log_string(stateDiff);
+
+ // Get the state diff as JSON
+ string memory stateDiffJson = vm.getStateDiffJson();
+ emit log_string("State diff JSON for long string:");
+ emit log_string(stateDiffJson);
+
+ // Verify the JSON contains expected fields
+ assertTrue(vm.contains(stateDiffJson, '"label":"longString"'));
+ assertTrue(vm.contains(stateDiffJson, '"type":"string"'));
+
+ // For long strings, we should see multiple slots being accessed
+ // The main slot (slot 1) contains the length
+ // The data slots start at keccak256(1)
+
+ // Stop recording
+ Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff();
+ assertTrue(accesses.length > 0);
+
+ // Verify storage accesses
+ uint256 writeCount = 0;
+ for (uint256 i = 0; i < accesses.length; i++) {
+ if (accesses[i].account == address(bytesStringStorage)) {
+ for (uint256 j = 0; j < accesses[i].storageAccesses.length; j++) {
+ if (accesses[i].storageAccesses[j].isWrite) {
+ writeCount++;
+ }
+ }
+ }
+ }
+ // Long string should write to multiple slots (main slot + data slots)
+ assertTrue(writeCount >= 2);
+ }
+
+ function testShortBytesStorage() public {
+ // Start recording state diffs
+ vm.startStateDiffRecording();
+
+ // Set short bytes (less than 32 bytes)
+ bytes memory shortData = hex"deadbeef";
+ bytesStringStorage.setShortBytes(shortData);
+
+ // Get the state diff as JSON
+ string memory stateDiffJson = vm.getStateDiffJson();
+ emit log_string("State diff JSON for short bytes:");
+ emit log_string(stateDiffJson);
+
+ // Verify the JSON contains expected fields
+ assertTrue(vm.contains(stateDiffJson, '"label":"shortBytes"'));
+ assertTrue(vm.contains(stateDiffJson, '"type":"bytes"'));
+ assertTrue(vm.contains(stateDiffJson, '"decoded":'));
+
+ // Check the decoded bytes value
+ assertTrue(vm.contains(stateDiffJson, '"newValue":"0xdeadbeef"'));
+
+ // Stop recording
+ vm.stopAndReturnStateDiff();
+ }
+
+ function testLongBytesStorage() public {
+ // Start recording state diffs
+ vm.startStateDiffRecording();
+
+ // Set long bytes (32 bytes or more)
+ bytes memory longData = new bytes(100);
+ for (uint256 i = 0; i < 100; i++) {
+ longData[i] = bytes1(uint8(i));
+ }
+ bytesStringStorage.setLongBytes(longData);
+
+ // Get the state diff as JSON
+ string memory stateDiffJson = vm.getStateDiffJson();
+ emit log_string("State diff JSON for long bytes:");
+ emit log_string(stateDiffJson);
+
+ // Verify the JSON contains expected fields
+ assertTrue(vm.contains(stateDiffJson, '"label":"longBytes"'));
+ assertTrue(vm.contains(stateDiffJson, '"type":"bytes"'));
+
+ // Stop recording
+ Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff();
+
+ // Verify multiple slots were written (main slot + data slots)
+ uint256 writeCount = 0;
+ for (uint256 i = 0; i < accesses.length; i++) {
+ if (accesses[i].account == address(bytesStringStorage)) {
+ writeCount += accesses[i].storageAccesses.length;
+ }
+ }
+ assertTrue(writeCount >= 2);
+ }
+
+ function testFixedBytesStorage() public {
+ // Start recording state diffs
+ vm.startStateDiffRecording();
+
+ // Set fixed bytes32
+ bytes32 fixedData = keccak256("test data");
+ bytesStringStorage.setFixedBytes(fixedData);
+
+ // Get the state diff as JSON
+ string memory stateDiffJson = vm.getStateDiffJson();
+ emit log_string("State diff JSON for fixed bytes:");
+ emit log_string(stateDiffJson);
+
+ // Verify the JSON contains expected fields
+ assertTrue(vm.contains(stateDiffJson, '"label":"fixedBytes"'));
+ assertTrue(vm.contains(stateDiffJson, '"type":"bytes32"'));
+ assertTrue(vm.contains(stateDiffJson, '"decoded":'));
+
+ // Stop recording
+ vm.stopAndReturnStateDiff();
+ }
+
+ function testMultipleBytesStringChanges() public {
+ // Start recording state diffs
+ vm.startStateDiffRecording();
+
+ // Make multiple changes
+ bytesStringStorage.setShortString("Short");
+ bytesStringStorage.setLongString("This is a long string that will use multiple storage slots for data");
+ bytesStringStorage.setShortBytes(hex"1234");
+ bytesStringStorage.setFixedBytes(bytes32(uint256(0xdeadbeef)));
+
+ // Get the state diff as string
+ string memory stateDiff = vm.getStateDiff();
+ emit log_string("State diff for multiple changes:");
+ emit log_string(stateDiff);
+
+ // Get the state diff as JSON
+ string memory stateDiffJson = vm.getStateDiffJson();
+ emit log_string("State diff JSON for multiple changes:");
+ emit log_string(stateDiffJson);
+
+ // Verify all fields are properly labeled
+ assertTrue(vm.contains(stateDiffJson, '"label":"shortString"'));
+ assertTrue(vm.contains(stateDiffJson, '"label":"longString"'));
+ assertTrue(vm.contains(stateDiffJson, '"label":"shortBytes"'));
+ assertTrue(vm.contains(stateDiffJson, '"label":"fixedBytes"'));
+
+ // Verify types are correct
+ assertTrue(vm.contains(stateDiffJson, '"type":"string"'));
+ assertTrue(vm.contains(stateDiffJson, '"type":"bytes"'));
+ assertTrue(vm.contains(stateDiffJson, '"type":"bytes32"'));
+
+ // Stop recording
+ vm.stopAndReturnStateDiff();
+ }
+}