Skip to content

Commit a7e5d64

Browse files
yash-atreyaYash Atreya
andauthored
feat(cheatcodes): include slot type and decode values in state diffs (#11276)
* feat(`forge`): sample typed storage values * arc it * nit * clippy * nit * strip file prefixes * fmt * don't add adjacent values to sample * feat(cheatcodes): add contract identifier to AccountStateDiffs * forge fmt * doc nits * fix tests * feat(`cheatcodes`): include `SlotInfo` in SlotStateDiff * cleanup + identify slots of static arrays * nits * nit * nits * test + nits * docs * handle 2d arrays * use DynSolType * feat: decode storage values * doc nit * skip decoded serialization if none * nit * fmt * fix * fix * fix * fix * while decode * fix: show only decoded in plaintext / display output + test * feat: format slots to only significant bits in vm.getStateDiff output * encode_prefixed * nit * fix * nit * Revert "encode_prefixed" This reverts commit 3cfefdc. * Revert "feat: format slots to only significant bits in vm.getStateDiff output" This reverts commit aa36022. * docs * doc fix --------- Co-authored-by: Yash Atreya <[email protected]>
1 parent 2e4d303 commit a7e5d64

File tree

3 files changed

+691
-14
lines changed

3 files changed

+691
-14
lines changed

crates/cheatcodes/src/evm.rs

Lines changed: 281 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ use crate::{
66
inspector::{Ecx, RecordDebugStepInfo},
77
};
88
use alloy_consensus::TxEnvelope;
9+
use alloy_dyn_abi::{DynSolType, DynSolValue};
910
use alloy_genesis::{Genesis, GenesisAccount};
10-
use alloy_primitives::{Address, B256, U256, map::HashMap};
11+
use alloy_primitives::{Address, B256, U256, hex, map::HashMap};
1112
use alloy_rlp::Decodable;
1213
use alloy_sol_types::SolValue;
1314
use foundry_common::fs::{read_json_file, write_json_file};
15+
use foundry_compilers::artifacts::StorageLayout;
1416
use foundry_evm_core::{
1517
ContextExt,
1618
backend::{DatabaseExt, RevertStateSnapshotAction},
@@ -30,6 +32,7 @@ use std::{
3032
collections::{BTreeMap, HashSet, btree_map::Entry},
3133
fmt::Display,
3234
path::Path,
35+
str::FromStr,
3336
};
3437

3538
mod record_debug_step;
@@ -103,6 +106,58 @@ struct SlotStateDiff {
103106
previous_value: B256,
104107
/// Current storage value.
105108
new_value: B256,
109+
/// Decoded values according to the Solidity type (e.g., uint256, address).
110+
/// Only present when storage layout is available and decoding succeeds.
111+
#[serde(skip_serializing_if = "Option::is_none")]
112+
decoded: Option<DecodedSlotValues>,
113+
114+
/// Storage layout metadata (variable name, type, offset).
115+
/// Only present when contract has storage layout output.
116+
#[serde(skip_serializing_if = "Option::is_none", flatten)]
117+
slot_info: Option<SlotInfo>,
118+
}
119+
120+
/// Storage slot metadata from the contract's storage layout.
121+
#[derive(Serialize, Debug)]
122+
struct SlotInfo {
123+
/// Variable name (e.g., "owner", "values\[0\]", "config.maxSize").
124+
label: String,
125+
/// Solidity type, serialized as string (e.g., "uint256", "address").
126+
#[serde(rename = "type", serialize_with = "serialize_dyn_sol_type")]
127+
dyn_sol_type: DynSolType,
128+
/// Byte offset within the 32-byte slot (0 for full slot, 0-31 for packed).
129+
offset: i64,
130+
/// Storage slot number as decimal string.
131+
slot: String,
132+
}
133+
134+
fn serialize_dyn_sol_type<S>(dyn_type: &DynSolType, serializer: S) -> Result<S::Ok, S::Error>
135+
where
136+
S: serde::Serializer,
137+
{
138+
serializer.serialize_str(&dyn_type.to_string())
139+
}
140+
141+
/// Decoded storage values showing before and after states.
142+
#[derive(Debug)]
143+
struct DecodedSlotValues {
144+
/// Decoded value before the state change.
145+
previous_value: DynSolValue,
146+
/// Decoded value after the state change.
147+
new_value: DynSolValue,
148+
}
149+
150+
impl Serialize for DecodedSlotValues {
151+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
152+
where
153+
S: serde::Serializer,
154+
{
155+
use serde::ser::SerializeStruct;
156+
let mut state = serializer.serialize_struct("DecodedSlotValues", 2)?;
157+
state.serialize_field("previousValue", &format_dyn_sol_value_raw(&self.previous_value))?;
158+
state.serialize_field("newValue", &format_dyn_sol_value_raw(&self.new_value))?;
159+
state.end()
160+
}
106161
}
107162

108163
/// Balance diff info.
@@ -170,11 +225,38 @@ impl Display for AccountStateDiffs {
170225
if !&self.state_diff.is_empty() {
171226
writeln!(f, "- state diff:")?;
172227
for (slot, slot_changes) in &self.state_diff {
173-
writeln!(
174-
f,
175-
"@ {slot}: {} → {}",
176-
slot_changes.previous_value, slot_changes.new_value
177-
)?;
228+
match (&slot_changes.slot_info, &slot_changes.decoded) {
229+
(Some(slot_info), Some(decoded)) => {
230+
// Have both slot info and decoded values - only show decoded values
231+
writeln!(
232+
f,
233+
"@ {slot} ({}, {}): {} → {}",
234+
slot_info.label,
235+
slot_info.dyn_sol_type,
236+
format_dyn_sol_value_raw(&decoded.previous_value),
237+
format_dyn_sol_value_raw(&decoded.new_value)
238+
)?;
239+
}
240+
(Some(slot_info), None) => {
241+
// Have slot info but no decoded values - show raw hex values
242+
writeln!(
243+
f,
244+
"@ {slot} ({}, {}): {} → {}",
245+
slot_info.label,
246+
slot_info.dyn_sol_type,
247+
slot_changes.previous_value,
248+
slot_changes.new_value
249+
)?;
250+
}
251+
_ => {
252+
// No slot info - show raw hex values
253+
writeln!(
254+
f,
255+
"@ {slot}: {} → {}",
256+
slot_changes.previous_value, slot_changes.new_value
257+
)?;
258+
}
259+
}
178260
}
179261
}
180262

@@ -1257,12 +1339,20 @@ fn get_recorded_state_diffs(ccx: &mut CheatsCtxt) -> BTreeMap<Address, AccountSt
12571339
}
12581340
}
12591341

1260-
// Look up contract names for all addresses
1342+
// Look up contract names and storage layouts for all addresses
12611343
let mut contract_names = HashMap::new();
1344+
let mut storage_layouts = HashMap::new();
12621345
for address in addresses_to_lookup {
12631346
if let Some(name) = get_contract_name(ccx, address) {
12641347
contract_names.insert(address, name);
12651348
}
1349+
1350+
// Also get storage layout if available
1351+
if let Some((_artifact_id, contract_data)) = get_contract_data(ccx, address)
1352+
&& let Some(storage_layout) = &contract_data.storage_layout
1353+
{
1354+
storage_layouts.insert(address, storage_layout.clone());
1355+
}
12661356
}
12671357

12681358
// Now process the records
@@ -1331,13 +1421,44 @@ fn get_recorded_state_diffs(ccx: &mut CheatsCtxt) -> BTreeMap<Address, AccountSt
13311421
// Update state diff. Do not overwrite the initial value if already set.
13321422
match account_diff.state_diff.entry(storage_access.slot) {
13331423
Entry::Vacant(slot_state_diff) => {
1424+
// Get storage layout info for this slot
1425+
let slot_info = storage_layouts
1426+
.get(&storage_access.account)
1427+
.and_then(|layout| get_slot_info(layout, &storage_access.slot));
1428+
1429+
// Try to decode values if we have slot info
1430+
let decoded = slot_info.as_ref().and_then(|info| {
1431+
let prev = decode_storage_value(
1432+
storage_access.previousValue,
1433+
&info.dyn_sol_type,
1434+
)?;
1435+
let new = decode_storage_value(
1436+
storage_access.newValue,
1437+
&info.dyn_sol_type,
1438+
)?;
1439+
Some(DecodedSlotValues { previous_value: prev, new_value: new })
1440+
});
1441+
13341442
slot_state_diff.insert(SlotStateDiff {
13351443
previous_value: storage_access.previousValue,
13361444
new_value: storage_access.newValue,
1445+
decoded,
1446+
slot_info,
13371447
});
13381448
}
13391449
Entry::Occupied(mut slot_state_diff) => {
1340-
slot_state_diff.get_mut().new_value = storage_access.newValue;
1450+
let entry = slot_state_diff.get_mut();
1451+
entry.new_value = storage_access.newValue;
1452+
1453+
if let Some(slot_info) = &entry.slot_info
1454+
&& let Some(ref mut decoded) = entry.decoded
1455+
&& let Some(new_value) = decode_storage_value(
1456+
storage_access.newValue,
1457+
&slot_info.dyn_sol_type,
1458+
)
1459+
{
1460+
decoded.new_value = new_value;
1461+
}
13411462
}
13421463
}
13431464
}
@@ -1375,6 +1496,158 @@ fn get_contract_name(ccx: &mut CheatsCtxt, address: Address) -> Option<String> {
13751496
None
13761497
}
13771498

1499+
/// Helper function to get the contract data from the deployed code at an address.
1500+
fn get_contract_data<'a>(
1501+
ccx: &'a mut CheatsCtxt,
1502+
address: Address,
1503+
) -> Option<(&'a foundry_compilers::ArtifactId, &'a foundry_common::contracts::ContractData)> {
1504+
// Check if we have available artifacts to match against
1505+
let artifacts = ccx.state.config.available_artifacts.as_ref()?;
1506+
1507+
// Try to load the account and get its code
1508+
let account = ccx.ecx.journaled_state.load_account(address).ok()?;
1509+
let code = account.info.code.as_ref()?;
1510+
1511+
// Skip if code is empty
1512+
if code.is_empty() {
1513+
return None;
1514+
}
1515+
1516+
// Try to find the artifact by deployed code
1517+
let code_bytes = code.original_bytes();
1518+
if let Some(result) = artifacts.find_by_deployed_code_exact(&code_bytes) {
1519+
return Some(result);
1520+
}
1521+
1522+
// Fallback to fuzzy matching if exact match fails
1523+
artifacts.find_by_deployed_code(&code_bytes)
1524+
}
1525+
1526+
/// Gets storage layout info for a specific slot.
1527+
fn get_slot_info(storage_layout: &StorageLayout, slot: &B256) -> Option<SlotInfo> {
1528+
let slot = U256::from_be_bytes(slot.0);
1529+
let slot_str = slot.to_string();
1530+
1531+
for storage in &storage_layout.storage {
1532+
let base_slot = U256::from_str(&storage.slot).ok()?;
1533+
let storage_type = storage_layout.types.get(&storage.storage_type)?;
1534+
let dyn_type = DynSolType::parse(&storage_type.label).ok()?;
1535+
1536+
// Check for exact slot match
1537+
if storage.slot == slot_str {
1538+
let label = match &dyn_type {
1539+
DynSolType::FixedArray(_, _) => {
1540+
// For arrays, label the base slot with indices
1541+
format!("{}{}", storage.label, get_array_base_indices(&dyn_type))
1542+
}
1543+
_ => storage.label.clone(),
1544+
};
1545+
1546+
return Some(SlotInfo {
1547+
label,
1548+
dyn_sol_type: dyn_type,
1549+
offset: storage.offset,
1550+
slot: storage.slot.clone(),
1551+
});
1552+
}
1553+
1554+
// Check if slot is part of a static array
1555+
if let DynSolType::FixedArray(_, _) = &dyn_type
1556+
&& let Ok(total_bytes) = storage_type.number_of_bytes.parse::<u64>()
1557+
{
1558+
let total_slots = total_bytes.div_ceil(32);
1559+
1560+
// Check if slot is within array range
1561+
if slot > base_slot && slot < base_slot + U256::from(total_slots) {
1562+
let index = (slot - base_slot).to::<u64>();
1563+
let label = format_array_element_label(&storage.label, &dyn_type, index);
1564+
1565+
return Some(SlotInfo {
1566+
label,
1567+
dyn_sol_type: dyn_type,
1568+
offset: 0,
1569+
slot: slot.to_string(),
1570+
});
1571+
}
1572+
}
1573+
}
1574+
1575+
None
1576+
}
1577+
1578+
/// Returns the base index [\0\] or [\0\][\0\] for a fixed array type depending on the dimensions.
1579+
fn get_array_base_indices(dyn_type: &DynSolType) -> String {
1580+
match dyn_type {
1581+
DynSolType::FixedArray(inner, _) => {
1582+
if let DynSolType::FixedArray(_, _) = inner.as_ref() {
1583+
// Nested array (2D or higher)
1584+
format!("[0]{}", get_array_base_indices(inner))
1585+
} else {
1586+
// Simple 1D array
1587+
"[0]".to_string()
1588+
}
1589+
}
1590+
_ => String::new(),
1591+
}
1592+
}
1593+
1594+
/// Helper function to format an array element label given its index
1595+
fn format_array_element_label(base_label: &str, dyn_type: &DynSolType, index: u64) -> String {
1596+
match dyn_type {
1597+
DynSolType::FixedArray(inner, _) => {
1598+
if let DynSolType::FixedArray(_, inner_size) = inner.as_ref() {
1599+
// 2D array: calculate row and column
1600+
let row = index / (*inner_size as u64);
1601+
let col = index % (*inner_size as u64);
1602+
format!("{base_label}[{row}][{col}]")
1603+
} else {
1604+
// 1D array
1605+
format!("{base_label}[{index}]")
1606+
}
1607+
}
1608+
_ => base_label.to_string(),
1609+
}
1610+
}
1611+
1612+
/// Helper function to decode a single storage value using its DynSolType
1613+
fn decode_storage_value(value: B256, dyn_type: &DynSolType) -> Option<DynSolValue> {
1614+
// Storage values are always 32 bytes, stored as a single word
1615+
// For arrays, we need to unwrap to the base element type
1616+
let mut actual_type = dyn_type;
1617+
// Unwrap nested arrays to get to the base element type.
1618+
while let DynSolType::FixedArray(elem_type, _) = actual_type {
1619+
actual_type = elem_type.as_ref();
1620+
}
1621+
1622+
// Use abi_decode to decode the value
1623+
actual_type.abi_decode(&value.0).ok()
1624+
}
1625+
1626+
/// Helper function to format DynSolValue as raw string without type information
1627+
fn format_dyn_sol_value_raw(value: &DynSolValue) -> String {
1628+
match value {
1629+
DynSolValue::Bool(b) => b.to_string(),
1630+
DynSolValue::Int(i, _) => i.to_string(),
1631+
DynSolValue::Uint(u, _) => u.to_string(),
1632+
DynSolValue::FixedBytes(bytes, size) => hex::encode_prefixed(&bytes.0[..*size]),
1633+
DynSolValue::Address(addr) => addr.to_string(),
1634+
DynSolValue::Function(func) => func.as_address_and_selector().1.to_string(),
1635+
DynSolValue::Bytes(bytes) => hex::encode_prefixed(bytes),
1636+
DynSolValue::String(s) => s.clone(),
1637+
DynSolValue::Array(values) | DynSolValue::FixedArray(values) => {
1638+
let formatted: Vec<String> = values.iter().map(format_dyn_sol_value_raw).collect();
1639+
format!("[{}]", formatted.join(", "))
1640+
}
1641+
DynSolValue::Tuple(values) => {
1642+
let formatted: Vec<String> = values.iter().map(format_dyn_sol_value_raw).collect();
1643+
format!("({})", formatted.join(", "))
1644+
}
1645+
DynSolValue::CustomStruct { name: _, prop_names: _, tuple } => {
1646+
format_dyn_sol_value_raw(&DynSolValue::Tuple(tuple.clone()))
1647+
}
1648+
}
1649+
}
1650+
13781651
/// Helper function to set / unset cold storage slot of the target address.
13791652
fn set_cold_slot(ccx: &mut CheatsCtxt, target: Address, slot: U256, cold: bool) {
13801653
if let Some(account) = ccx.ecx.journaled_state.state.get_mut(&target)

0 commit comments

Comments
 (0)