Skip to content

Commit ff732fc

Browse files
feat(invariants): sample typed storage values (#11204)
* feat(`forge`): sample typed storage values * arc it * nit * clippy * nit * strip file prefixes * fmt * don't add adjacent values to sample * correctly match artifact identifier and name * clippy * nit * handle simple mappings while inserting storage values * cleanup + insert only DynSolType's found in mappings * Nit: with_project_contracts --------- Co-authored-by: grandizzy <grandizzy.the.egg@gmail.com>
1 parent 2aa992a commit ff732fc

File tree

5 files changed

+220
-21
lines changed

5 files changed

+220
-21
lines changed

crates/common/src/contracts.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use foundry_compilers::{
99
ArtifactId, Project, ProjectCompileOutput,
1010
artifacts::{
1111
BytecodeObject, CompactBytecode, CompactContractBytecode, CompactDeployedBytecode,
12-
ConfigurableContractArtifact, ContractBytecodeSome, Offsets,
12+
ConfigurableContractArtifact, ContractBytecodeSome, Offsets, StorageLayout,
1313
},
1414
utils::canonicalized,
1515
};
@@ -75,6 +75,8 @@ pub struct ContractData {
7575
pub bytecode: Option<BytecodeData>,
7676
/// Contract runtime code.
7777
pub deployed_bytecode: Option<BytecodeData>,
78+
/// Contract storage layout, if available.
79+
pub storage_layout: Option<Arc<StorageLayout>>,
7880
}
7981

8082
impl ContractData {
@@ -120,6 +122,29 @@ impl ContractsByArtifact {
120122
abi: abi?,
121123
bytecode: bytecode.map(Into::into),
122124
deployed_bytecode: deployed_bytecode.map(Into::into),
125+
storage_layout: None,
126+
},
127+
))
128+
})
129+
.collect();
130+
Self(Arc::new(map))
131+
}
132+
133+
/// Creates a new instance from project compile output, preserving storage layouts.
134+
pub fn with_storage_layout(output: ProjectCompileOutput) -> Self {
135+
let map = output
136+
.into_artifacts()
137+
.filter_map(|(id, artifact)| {
138+
let name = id.name.clone();
139+
let abi = artifact.abi?;
140+
Some((
141+
id,
142+
ContractData {
143+
name,
144+
abi,
145+
bytecode: artifact.bytecode.map(Into::into),
146+
deployed_bytecode: artifact.deployed_bytecode.map(Into::into),
147+
storage_layout: artifact.storage_layout.map(Arc::new),
123148
},
124149
))
125150
})

crates/evm/evm/src/executors/invariant/mod.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,11 @@ impl<'a> InvariantExecutor<'a> {
790790
&& self.artifact_filters.matches(identifier)
791791
})
792792
.map(|(addr, (identifier, abi))| {
793-
(*addr, TargetedContract::new(identifier.clone(), abi.clone()))
793+
(
794+
*addr,
795+
TargetedContract::new(identifier.clone(), abi.clone())
796+
.with_project_contracts(self.project_contracts),
797+
)
794798
})
795799
.collect();
796800
let mut contracts = TargetedContracts { inner: contracts };
@@ -833,8 +837,12 @@ impl<'a> InvariantExecutor<'a> {
833837
// Identifiers are specified as an array, so we loop through them.
834838
for identifier in artifacts {
835839
// Try to find the contract by name or identifier in the project's contracts.
836-
if let Some(abi) = self.project_contracts.find_abi_by_name_or_identifier(identifier)
840+
if let Some((_, contract_data)) =
841+
self.project_contracts.iter().find(|(artifact, _)| {
842+
&artifact.name == identifier || &artifact.identifier() == identifier
843+
})
837844
{
845+
let abi = &contract_data.abi;
838846
combined
839847
// Check if there's an entry for the given key in the 'combined' map.
840848
.entry(*addr)
@@ -844,7 +852,13 @@ impl<'a> InvariantExecutor<'a> {
844852
entry.abi.functions.extend(abi.functions.clone());
845853
})
846854
// Otherwise insert it into the map.
847-
.or_insert_with(|| TargetedContract::new(identifier.to_string(), abi));
855+
.or_insert_with(|| {
856+
let mut contract =
857+
TargetedContract::new(identifier.to_string(), abi.clone());
858+
contract.storage_layout =
859+
contract_data.storage_layout.as_ref().map(Arc::clone);
860+
contract
861+
});
848862
}
849863
}
850864
}
@@ -944,7 +958,10 @@ impl<'a> InvariantExecutor<'a> {
944958
address
945959
)
946960
})?;
947-
entry.insert(TargetedContract::new(identifier.clone(), abi.clone()))
961+
entry.insert(
962+
TargetedContract::new(identifier.clone(), abi.clone())
963+
.with_project_contracts(self.project_contracts),
964+
)
948965
}
949966
};
950967
contract.add_selectors(selectors.iter().copied(), should_exclude)?;

crates/evm/fuzz/src/invariant/mod.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use alloy_json_abi::{Function, JsonAbi};
2-
use alloy_primitives::{Address, Bytes, Selector};
2+
use alloy_primitives::{Address, Bytes, Selector, map::HashMap};
3+
use foundry_compilers::artifacts::StorageLayout;
34
use itertools::Either;
45
use parking_lot::Mutex;
56
use serde::{Deserialize, Serialize};
@@ -75,6 +76,7 @@ impl FuzzRunIdentifiedContracts {
7576
abi: contract.abi.clone(),
7677
targeted_functions: functions,
7778
excluded_functions: Vec::new(),
79+
storage_layout: contract.storage_layout.as_ref().map(Arc::clone),
7880
};
7981
targets.insert(*address, contract);
8082
}
@@ -146,6 +148,16 @@ impl TargetedContracts {
146148
.map(|function| format!("{}.{}", contract.identifier.clone(), function.name))
147149
})
148150
}
151+
152+
/// Returns a map of contract addresses to their storage layouts.
153+
pub fn get_storage_layouts(&self) -> HashMap<Address, Arc<StorageLayout>> {
154+
self.inner
155+
.iter()
156+
.filter_map(|(addr, c)| {
157+
c.storage_layout.as_ref().map(|layout| (*addr, Arc::clone(layout)))
158+
})
159+
.collect()
160+
}
149161
}
150162

151163
impl std::ops::Deref for TargetedContracts {
@@ -173,12 +185,33 @@ pub struct TargetedContract {
173185
pub targeted_functions: Vec<Function>,
174186
/// The excluded functions of the contract.
175187
pub excluded_functions: Vec<Function>,
188+
/// The contract's storage layout, if available.
189+
pub storage_layout: Option<Arc<StorageLayout>>,
176190
}
177191

178192
impl TargetedContract {
179193
/// Returns a new `TargetedContract` instance.
180194
pub fn new(identifier: String, abi: JsonAbi) -> Self {
181-
Self { identifier, abi, targeted_functions: Vec::new(), excluded_functions: Vec::new() }
195+
Self {
196+
identifier,
197+
abi,
198+
targeted_functions: Vec::new(),
199+
excluded_functions: Vec::new(),
200+
storage_layout: None,
201+
}
202+
}
203+
204+
/// Determines contract storage layout from project contracts. Needs `storageLayout` to be
205+
/// enabled as extra output in project configuration.
206+
pub fn with_project_contracts(mut self, project_contracts: &ContractsByArtifact) -> Self {
207+
if let Some((src, name)) = self.identifier.split_once(':')
208+
&& let Some((_, contract_data)) = project_contracts.iter().find(|(artifact, _)| {
209+
artifact.name == name && artifact.source.as_path().ends_with(src)
210+
})
211+
{
212+
self.storage_layout = contract_data.storage_layout.as_ref().map(Arc::clone);
213+
}
214+
self
182215
}
183216

184217
/// Helper to retrieve functions to fuzz for specified abi.

crates/evm/fuzz/src/strategies/state.rs

Lines changed: 134 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use alloy_primitives::{
66
map::{AddressIndexSet, B256IndexSet, HashMap},
77
};
88
use foundry_common::ignore_metadata_hash;
9+
use foundry_compilers::artifacts::StorageLayout;
910
use foundry_config::FuzzDictionaryConfig;
1011
use foundry_evm_core::utils::StateChangeset;
1112
use parking_lot::{RawRwLock, RwLock, lock_api::RwLockReadGuard};
@@ -72,8 +73,10 @@ impl EvmFuzzState {
7273
let (target_abi, target_function) = targets.fuzzed_artifacts(tx);
7374
dict.insert_logs_values(target_abi, logs, run_depth);
7475
dict.insert_result_values(target_function, result, run_depth);
76+
// Get storage layouts for contracts in the state changeset
77+
let storage_layouts = targets.get_storage_layouts();
78+
dict.insert_new_state_values(state_changeset, &storage_layouts);
7579
}
76-
dict.insert_new_state_values(state_changeset);
7780
}
7881

7982
/// Removes all newly added entries from the dictionary.
@@ -151,7 +154,7 @@ impl FuzzDictionary {
151154
// Sort storage values before inserting to ensure deterministic dictionary.
152155
let values = account.storage.iter().collect::<BTreeMap<_, _>>();
153156
for (slot, value) in values {
154-
self.insert_storage_value(slot, value);
157+
self.insert_storage_value(slot, value, None);
155158
}
156159
}
157160
}
@@ -226,16 +229,21 @@ impl FuzzDictionary {
226229

227230
/// Insert values from call state changeset into fuzz dictionary.
228231
/// These values are removed at the end of current run.
229-
fn insert_new_state_values(&mut self, state_changeset: &StateChangeset) {
232+
fn insert_new_state_values(
233+
&mut self,
234+
state_changeset: &StateChangeset,
235+
storage_layouts: &HashMap<Address, Arc<StorageLayout>>,
236+
) {
230237
for (address, account) in state_changeset {
231238
// Insert basic account information.
232239
self.insert_value(address.into_word());
233240
// Insert push bytes.
234241
self.insert_push_bytes_values(address, &account.info);
235242
// Insert storage values.
236243
if self.config.include_storage {
244+
let storage_layout = storage_layouts.get(address).map(|arc| arc.as_ref());
237245
for (slot, value) in &account.storage {
238-
self.insert_storage_value(slot, &value.present_value);
246+
self.insert_storage_value(slot, &value.present_value, storage_layout);
239247
}
240248
}
241249
}
@@ -288,19 +296,132 @@ impl FuzzDictionary {
288296
}
289297
}
290298

299+
/// Resolves storage types from a storage layout for a given slot and all mapping types.
300+
/// Returns a tuple of (slot_type, mapping_types) where slot_type is the specific type
301+
/// for the storage slot and mapping_types are all mapping value types found in the layout.
302+
fn resolve_storage_types(
303+
&self,
304+
storage_layout: Option<&StorageLayout>,
305+
storage_slot: &U256,
306+
) -> (Option<DynSolType>, Vec<DynSolType>) {
307+
let Some(layout) = storage_layout else {
308+
return (None, Vec::new());
309+
};
310+
311+
// Try to determine the type of this specific storage slot
312+
let slot_type =
313+
layout.storage.iter().find(|s| s.slot == storage_slot.to_string()).and_then(
314+
|storage| {
315+
layout
316+
.types
317+
.get(&storage.storage_type)
318+
.and_then(|t| DynSolType::parse(&t.label).ok())
319+
},
320+
);
321+
322+
// Collect all mapping value types from the layout
323+
let mapping_types = layout
324+
.types
325+
.values()
326+
.filter_map(|type_info| {
327+
if type_info.encoding == "mapping"
328+
&& let Some(t_value) = type_info.value.as_ref()
329+
&& let Some(mapping_value) = t_value.strip_prefix("t_")
330+
{
331+
DynSolType::parse(mapping_value).ok()
332+
} else {
333+
None
334+
}
335+
})
336+
.collect();
337+
338+
(slot_type, mapping_types)
339+
}
340+
291341
/// Insert values from single storage slot and storage value into fuzz dictionary.
292342
/// If storage values are newly collected then they are removed at the end of current run.
293-
fn insert_storage_value(&mut self, storage_slot: &U256, storage_value: &U256) {
343+
fn insert_storage_value(
344+
&mut self,
345+
storage_slot: &U256,
346+
storage_value: &U256,
347+
storage_layout: Option<&StorageLayout>,
348+
) {
349+
// Always insert the slot itself
294350
self.insert_value(B256::from(*storage_slot));
295-
self.insert_value(B256::from(*storage_value));
296-
// also add the value below and above the storage value to the dictionary.
297-
if *storage_value != U256::ZERO {
298-
let below_value = storage_value - U256::from(1);
299-
self.insert_value(B256::from(below_value));
351+
352+
let (slot_type, mapping_types) = self.resolve_storage_types(storage_layout, storage_slot);
353+
354+
if let Some(sol_type) = slot_type {
355+
self.insert_decoded_storage_value(sol_type, storage_value);
356+
} else if !mapping_types.is_empty() {
357+
self.insert_mapping_storage_values(mapping_types, storage_value);
358+
} else {
359+
// No type information available, insert as raw values (old behavior)
360+
self.insert_value(B256::from(*storage_value));
361+
// also add the value below and above the storage value to the dictionary.
362+
if *storage_value != U256::ZERO {
363+
let below_value = storage_value - U256::from(1);
364+
self.insert_value(B256::from(below_value));
365+
}
366+
if *storage_value != U256::MAX {
367+
let above_value = storage_value + U256::from(1);
368+
self.insert_value(B256::from(above_value));
369+
}
300370
}
301-
if *storage_value != U256::MAX {
302-
let above_value = storage_value + U256::from(1);
303-
self.insert_value(B256::from(above_value));
371+
}
372+
373+
/// Insert decoded storage values into the fuzz dictionary.
374+
/// Only simple static type values are inserted as sample values.
375+
/// Complex types (dynamic arrays, structs) are inserted as raw values
376+
fn insert_decoded_storage_value(&mut self, sol_type: DynSolType, storage_value: &U256) {
377+
// Only insert values for types that can be represented as a single word
378+
match &sol_type {
379+
DynSolType::Address
380+
| DynSolType::Uint(_)
381+
| DynSolType::Int(_)
382+
| DynSolType::Bool
383+
| DynSolType::FixedBytes(_)
384+
| DynSolType::Bytes => {
385+
// Insert as a typed sample value
386+
self.sample_values.entry(sol_type).or_default().insert(B256::from(*storage_value));
387+
}
388+
_ => {
389+
// For complex types (arrays, mappings, structs), insert as raw value
390+
self.insert_value(B256::from(*storage_value));
391+
}
392+
}
393+
}
394+
395+
/// Insert storage values of mapping value types as sample values in the fuzz dictionary.
396+
///
397+
/// ```solidity
398+
/// mapping(uint256 => address) public myMapping;
399+
/// // `address` is the mapping value type here.
400+
/// // `uint256` is the mapping key type.
401+
/// ```
402+
///
403+
/// A storage value is inserted if and only if it can be decoded into one of the mapping
404+
/// [`DynSolType`] value types found in the [`StorageLayout`].
405+
///
406+
/// If decoding fails, the value is inserted as a raw value.
407+
fn insert_mapping_storage_values(
408+
&mut self,
409+
mapping_types: Vec<DynSolType>,
410+
storage_value: &U256,
411+
) {
412+
for sol_type in mapping_types {
413+
match sol_type.abi_decode(storage_value.as_le_slice()) {
414+
Ok(_) => {
415+
self.sample_values
416+
.entry(sol_type)
417+
.or_default()
418+
.insert(B256::from(*storage_value));
419+
}
420+
Err(_) => {
421+
// If decoding fails, insert as raw value
422+
self.insert_value(B256::from(*storage_value));
423+
}
424+
}
304425
}
305426
}
306427

crates/forge/src/multi_runner.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,10 @@ impl MultiContractRunnerBuilder {
519519
}
520520
}
521521

522-
let known_contracts = ContractsByArtifact::new(linked_contracts);
522+
// Create known contracts with storage layout information
523+
let known_contracts = ContractsByArtifact::with_storage_layout(
524+
output.clone().with_stripped_file_prefixes(root),
525+
);
523526

524527
Ok(MultiContractRunner {
525528
contracts: deployable_contracts,

0 commit comments

Comments
 (0)