Skip to content

Commit 5dc5891

Browse files
feat(cheatcodes): vm.getStorageSlots (#11537)
* feat(common): identify bytes and string slots using storageLayout * fmt * clippy * aggregate and decode bytes and strings * cleanup * nits * clippy * fmt * nit * cleanup * identify the slots using the length of data in base slot value * nit * fix * fix mapping identification * feat(cheatcodes): vm.getStorageSlots * fix tests * nit * fmt * nit * nit * fmt * Update crates/common/src/slot_identifier.rs * Update crates/common/src/slot_identifier.rs * add mapping in test * fmt * error mapping and dyn arrays * nit * nit --------- Co-authored-by: zerosnacks <[email protected]>
1 parent 83b904f commit 5dc5891

File tree

7 files changed

+217
-9
lines changed

7 files changed

+217
-9
lines changed

crates/cheatcodes/assets/cheatcodes.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cheatcodes/spec/src/vm.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,10 @@ interface Vm {
432432
#[cheatcode(group = Evm, safety = Safe)]
433433
function getStateDiffJson() external view returns (string memory diff);
434434

435+
/// Returns an array of storage slots occupied by the specified variable.
436+
#[cheatcode(group = Evm, safety = Safe)]
437+
function getStorageSlots(address target, string calldata variableName) external view returns (uint256[] memory slots);
438+
435439
/// Returns an array of `StorageAccess` from current `vm.stateStateDiffRecording` session
436440
#[cheatcode(group = Evm, safety = Safe)]
437441
function getStorageAccesses() external view returns (StorageAccess[] memory storageAccesses);

crates/cheatcodes/src/evm.rs

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ use alloy_consensus::TxEnvelope;
99
use alloy_genesis::{Genesis, GenesisAccount};
1010
use alloy_network::eip2718::EIP4844_TX_TYPE_ID;
1111
use alloy_primitives::{
12-
Address, B256, U256, hex,
12+
Address, B256, U256, hex, keccak256,
1313
map::{B256Map, HashMap},
1414
};
1515
use alloy_rlp::Decodable;
1616
use alloy_sol_types::SolValue;
1717
use foundry_common::{
1818
fs::{read_json_file, write_json_file},
19-
slot_identifier::{SlotIdentifier, SlotInfo},
19+
slot_identifier::{
20+
ENCODING_BYTES, ENCODING_DYN_ARRAY, ENCODING_INPLACE, ENCODING_MAPPING, SlotIdentifier,
21+
SlotInfo,
22+
},
2023
};
2124
use foundry_evm_core::{
2225
ContextExt,
@@ -37,6 +40,7 @@ use std::{
3740
collections::{BTreeMap, HashSet, btree_map::Entry},
3841
fmt::Display,
3942
path::Path,
43+
str::FromStr,
4044
};
4145

4246
mod record_debug_step;
@@ -908,6 +912,83 @@ impl Cheatcode for getStateDiffJsonCall {
908912
}
909913
}
910914

915+
impl Cheatcode for getStorageSlotsCall {
916+
fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result {
917+
let Self { target, variableName } = self;
918+
919+
let storage_layout = get_contract_data(ccx, *target)
920+
.and_then(|(_, data)| data.storage_layout.as_ref().map(|layout| layout.clone()))
921+
.ok_or_else(|| fmt_err!("Storage layout not available for contract at {target}. Try compiling contracts with `--extra-output storageLayout`"))?;
922+
923+
trace!(storage = ?storage_layout.storage, "fetched storage");
924+
925+
let storage = storage_layout
926+
.storage
927+
.iter()
928+
.find(|s| s.label.to_lowercase() == *variableName.to_lowercase())
929+
.ok_or_else(|| fmt_err!("variable '{variableName}' not found in storage layout"))?;
930+
931+
let storage_type = storage_layout
932+
.types
933+
.get(&storage.storage_type)
934+
.ok_or_else(|| fmt_err!("storage type not found for variable {variableName}"))?;
935+
936+
if storage_type.encoding == ENCODING_MAPPING || storage_type.encoding == ENCODING_DYN_ARRAY
937+
{
938+
return Err(fmt_err!(
939+
"cannot get storage slots for variables with mapping or dynamic array types"
940+
));
941+
}
942+
943+
let slot = U256::from_str(&storage.slot).map_err(|_| {
944+
fmt_err!("invalid slot {} format for variable {variableName}", storage.slot)
945+
})?;
946+
947+
let mut slots = Vec::new();
948+
949+
// Always push the base slot
950+
slots.push(slot);
951+
952+
if storage_type.encoding == ENCODING_INPLACE {
953+
// For inplace encoding, calculate the number of slots needed
954+
let num_bytes = U256::from_str(&storage_type.number_of_bytes).map_err(|_| {
955+
fmt_err!(
956+
"invalid number_of_bytes {} for variable {variableName}",
957+
storage_type.number_of_bytes
958+
)
959+
})?;
960+
let num_slots = num_bytes.div_ceil(U256::from(32));
961+
962+
// Start from 1 since base slot is already added
963+
for i in 1..num_slots.to::<usize>() {
964+
slots.push(slot + U256::from(i));
965+
}
966+
}
967+
968+
if storage_type.encoding == ENCODING_BYTES {
969+
// Try to check if it's a long bytes/string by reading the current storage
970+
// value
971+
if let Ok(value) = ccx.ecx.journaled_state.sload(*target, slot) {
972+
let value_bytes = value.data.to_be_bytes::<32>();
973+
let length_byte = value_bytes[31];
974+
// Check if it's a long bytes/string (LSB is 1)
975+
if length_byte & 1 == 1 {
976+
// Calculate data slots for long bytes/string
977+
let length: U256 = value.data >> 1;
978+
let num_data_slots = length.to::<usize>().div_ceil(32);
979+
let data_start = U256::from_be_bytes(keccak256(B256::from(slot).0).0);
980+
981+
for i in 0..num_data_slots {
982+
slots.push(data_start + U256::from(i));
983+
}
984+
}
985+
}
986+
}
987+
988+
Ok(slots.abi_encode())
989+
}
990+
}
991+
911992
impl Cheatcode for getStorageAccessesCall {
912993
fn apply(&self, state: &mut Cheatcodes) -> Result {
913994
let mut storage_accesses = Vec::new();

crates/common/src/slot_identifier.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@ use serde::Serialize;
1212
use std::{collections::BTreeMap, str::FromStr, sync::Arc};
1313
use tracing::trace;
1414

15-
// Constants for storage type encodings
16-
const ENCODING_INPLACE: &str = "inplace";
17-
const ENCODING_MAPPING: &str = "mapping";
18-
const ENCODING_BYTES: &str = "bytes";
15+
/// "inplace" encoding type for variables that fit in one storage slot i.e 32 bytes
16+
pub const ENCODING_INPLACE: &str = "inplace";
17+
/// "mapping" encoding type for Solidity mappings, which use keccak256 hash-based storage
18+
pub const ENCODING_MAPPING: &str = "mapping";
19+
/// "bytes" encoding type for bytes and string types, which use either inplace or keccak256
20+
/// hash-based storage depending on length
21+
pub const ENCODING_BYTES: &str = "bytes";
22+
/// "dynamic_array" encoding type for dynamic arrays, which uses keccak256 hash-based storage
23+
pub const ENCODING_DYN_ARRAY: &str = "dynamic_array";
1924

2025
/// Information about a storage slot including its label, type, and decoded values.
2126
#[derive(Serialize, Debug)]

crates/forge/tests/it/cheats.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use foundry_test_utils::{Filter, init_tracing};
1717
async fn test_cheats_local(test_data: &ForgeTestData) {
1818
let mut filter = Filter::new(".*", ".*", &format!(".*cheats{RE_PATH_SEPARATOR}*"))
1919
.exclude_paths("Fork")
20-
.exclude_contracts("(Isolated|WithSeed|StateDiff)");
20+
.exclude_contracts("(Isolated|WithSeed|StateDiff|GetStorageSlotsTest)");
2121

2222
// Exclude FFI tests on Windows because no `echo`, and file tests that expect certain file paths
2323
if cfg!(windows) {
@@ -40,7 +40,7 @@ async fn test_cheats_local(test_data: &ForgeTestData) {
4040
/// Executes subset of all cheat code tests in isolation mode.
4141
async fn test_cheats_local_isolated(test_data: &ForgeTestData) {
4242
let filter = Filter::new(".*", ".*(Isolated)", &format!(".*cheats{RE_PATH_SEPARATOR}*"))
43-
.exclude_contracts("StateDiff");
43+
.exclude_contracts("(StateDiff|GetStorageSlotsTest)");
4444

4545
let runner = test_data.runner_with(|config| {
4646
config.isolate = true;
@@ -78,7 +78,11 @@ async fn test_state_diff_storage_layout() {
7878
let output = get_compiled(&mut project);
7979
ForgeTestData { project, output, config: config.into(), profile }
8080
};
81-
let filter = Filter::new(".*", "StateDiff", &format!(".*cheats{RE_PATH_SEPARATOR}*"));
81+
let filter = Filter::new(
82+
".*",
83+
"(StateDiff|GetStorageSlotsTest)",
84+
&format!(".*cheats{RE_PATH_SEPARATOR}*"),
85+
);
8286

8387
let runner = test_data.runner_with(|config| {
8488
config.fs_permissions = FsPermissions::new(vec![PathPermission::read_write("./")]);

testdata/cheats/Vm.sol

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.18;
3+
4+
import "ds-test/test.sol";
5+
import "cheats/Vm.sol";
6+
7+
contract StorageContract {
8+
// Simple variables - 1 slot each
9+
uint256 public value; // Slot 0
10+
address public owner; // Slot 1
11+
12+
// Fixed array - 3 consecutive slots
13+
uint256[3] public numbers; // Slots 2, 3, 4
14+
15+
// Bytes variables
16+
bytes public shortBytes; // Slot 5 (less than 32 bytes)
17+
mapping(address => uint256) public balances; // Slot 6 Inserted in between to make sure we can still properly identify the bytes
18+
bytes public longBytes; // Slot 7 (32+ bytes, will use multiple slots)
19+
20+
// String variables
21+
string public shortString; // Slot 8 (less than 32 bytes)
22+
string public longString; // Slot 9 (32+ bytes, will use multiple slots)
23+
24+
function setShortBytes(bytes memory _data) public {
25+
shortBytes = _data;
26+
}
27+
28+
function setLongBytes(bytes memory _data) public {
29+
longBytes = _data;
30+
}
31+
32+
function setShortString(string memory _str) public {
33+
shortString = _str;
34+
}
35+
36+
function setLongString(string memory _str) public {
37+
longString = _str;
38+
}
39+
40+
function setNumbers(uint256 a, uint256 b, uint256 c) public {
41+
numbers[0] = a;
42+
numbers[1] = b;
43+
numbers[2] = c;
44+
}
45+
}
46+
47+
contract GetStorageSlotsTest is DSTest {
48+
Vm constant vm = Vm(HEVM_ADDRESS);
49+
StorageContract storageContract;
50+
51+
function setUp() public {
52+
storageContract = new StorageContract();
53+
}
54+
55+
function testGetStorageSlots() public {
56+
// Test 1: Simple variable
57+
uint256[] memory slots = vm.getStorageSlots(address(storageContract), "value");
58+
assertEq(slots.length, 1);
59+
assertEq(slots[0], 0);
60+
61+
// Test 2: Fixed array (should return 3 consecutive slots)
62+
slots = vm.getStorageSlots(address(storageContract), "numbers");
63+
assertEq(slots.length, 3);
64+
assertEq(slots[0], 2);
65+
assertEq(slots[1], 3);
66+
assertEq(slots[2], 4);
67+
68+
// Test 3: Short bytes (less than 32 bytes)
69+
storageContract.setShortBytes(hex"deadbeef");
70+
slots = vm.getStorageSlots(address(storageContract), "shortBytes");
71+
assertEq(slots.length, 1);
72+
assertEq(slots[0], 5);
73+
74+
// Test 4: Long bytes (100 bytes = 4 slots needed)
75+
bytes memory longData = new bytes(100);
76+
for (uint256 i = 0; i < 100; i++) {
77+
longData[i] = bytes1(uint8(i));
78+
}
79+
storageContract.setLongBytes(longData);
80+
81+
slots = vm.getStorageSlots(address(storageContract), "longBytes");
82+
// Should return 5 slots: 1 base slot + 4 data slots
83+
assertEq(slots.length, 5);
84+
assertEq(slots[0], 7); // Base slot
85+
86+
// Data slots start at keccak256(base_slot)
87+
uint256 dataStart = uint256(keccak256(abi.encode(uint256(7))));
88+
assertEq(slots[1], dataStart);
89+
assertEq(slots[2], dataStart + 1);
90+
assertEq(slots[3], dataStart + 2);
91+
assertEq(slots[4], dataStart + 3);
92+
}
93+
}

0 commit comments

Comments
 (0)