Skip to content

Commit bb0761c

Browse files
authored
fix(symbolic): explore stateful invariant paths (#15591)
* fix(symbolic): explore multicall invariants * fix(symbolic): replay setup arbitrary storage Import setup-time vm.setArbitraryStorage targets into symbolic invariant execution and carry model values for concrete storage slots into replay. This lets invariant counterexamples that depend on symbolic storage behind deployed setup dependencies replay against Forge's concrete executor. * fix(symbolic): complete hard arithmetic support models * fix(symbolic): satisfy workspace clippy * fix(symbolic): continue invariant fuzz after safe symex * docs(symbolic): document arbitrary storage targets * test(symbolic): assert invariant fuzz keeps symex metadata * fix(symbolic): import copied arbitrary storage targets * fix(symbolic): cache copied arbitrary storage replay values * fix(symbolic): fall back after invariant symex gaps * fix(symbolic): preserve invariant counterexample origin * fix(symbolic): persist invariant replay metadata * test(symbolic): cover invariant storage rerun * fix(symbolic): satisfy nightly clippy * fix(symbolic): replay handler storage reruns * fix(symbolic): preserve storage key aliases * fix(symbolic): keep sequence minimization metadata * fix(symbolic): simplify replay storage key models * fix(symbolic): tighten invariant replay semantics * fix(symbolic): tighten invariant replay soundness * fix(symbolic): align invariant replay semantics * fix(symbolic): verify replay failure sites * test(symbolic): cover reverted cheatcode effects * fix(symbolic): bind handler storage to sequence Keep symbolic replay storage paired with the exact handler call sequence that produced it. If fuzzing later selects a different reproducer at the same failure site, persist that concrete sequence without stale symbolic storage.
1 parent 83af1e0 commit bb0761c

25 files changed

Lines changed: 3884 additions & 252 deletions

File tree

crates/cheatcodes/src/evm.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,10 @@ impl Cheatcode for loadCall {
288288
if ccx.state.has_arbitrary_storage(&target) {
289289
// If storage slot is untouched and load from a target with arbitrary storage,
290290
// then set random value for current slot.
291-
let rand_value = ccx.state.rng().random();
291+
let rand_value = ccx
292+
.state
293+
.cached_arbitrary_storage_value(target, slot.into())
294+
.unwrap_or_else(|| ccx.state.rng().random());
292295
ccx.state.arbitrary_storage.as_mut().unwrap().save(
293296
ccx.ecx,
294297
target,

crates/cheatcodes/src/inspector.rs

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -403,11 +403,11 @@ pub struct ArbitraryStorage {
403403
/// Mapping of arbitrary storage addresses to generated values (slot, arbitrary value).
404404
/// (SLOADs return random value if storage slot wasn't accessed).
405405
/// Changed values are recorded and used to copy storage to different addresses.
406-
pub values: HashMap<Address, HashMap<U256, U256>>,
406+
values: HashMap<Address, HashMap<U256, U256>>,
407407
/// Mapping of address with storage copied to arbitrary storage address source.
408-
pub copies: HashMap<Address, Address>,
408+
copies: HashMap<Address, Address>,
409409
/// Address with storage slots that should be overwritten even if previously set.
410-
pub overwrites: HashSet<Address>,
410+
overwrites: HashSet<Address>,
411411
}
412412

413413
impl ArbitraryStorage {
@@ -428,6 +428,47 @@ impl ArbitraryStorage {
428428
}
429429
}
430430

431+
/// Returns addresses explicitly marked with arbitrary storage.
432+
fn targets(&self) -> impl Iterator<Item = Address> + '_ {
433+
self.values.keys().copied()
434+
}
435+
436+
/// Returns addresses explicitly marked with arbitrary storage and whether nonzero slots are
437+
/// overwritten.
438+
fn target_overwrite_modes(&self) -> impl Iterator<Item = (Address, bool)> + '_ {
439+
self.values.keys().map(|address| (*address, self.overwrites.contains(address)))
440+
}
441+
442+
/// Returns addresses that copy storage from arbitrary-storage targets.
443+
fn copied_targets(&self) -> impl Iterator<Item = Address> + '_ {
444+
self.copies.keys().copied()
445+
}
446+
447+
/// Returns copied arbitrary-storage targets and their source address.
448+
fn copied_target_sources(&self) -> impl Iterator<Item = (Address, Address)> + '_ {
449+
self.copies.iter().map(|(target, source)| (*target, *source))
450+
}
451+
452+
/// Caches a concrete value for a slot on an arbitrary-storage address or copied target.
453+
fn cache_value(&mut self, address: Address, slot: U256, data: U256) {
454+
if let Some(values) = self.values.get_mut(&address) {
455+
values.insert(slot, data);
456+
return;
457+
}
458+
459+
let Some(source) = self.copies.get(&address).copied() else {
460+
return;
461+
};
462+
if let Some(values) = self.values.get_mut(&source) {
463+
values.insert(slot, data);
464+
}
465+
}
466+
467+
/// Returns a cached arbitrary value for a slot.
468+
fn cached_value(&self, address: Address, slot: U256) -> Option<U256> {
469+
self.values.get(&address).and_then(|values| values.get(&slot)).copied()
470+
}
471+
431472
/// Saves arbitrary storage value for a given address:
432473
/// - store value in changed values cache.
433474
/// - update account's storage with given value.
@@ -1298,6 +1339,49 @@ impl<FEN: FoundryEvmNetwork> Cheatcodes<FEN> {
12981339
self.arbitrary_storage.get_or_insert_with(ArbitraryStorage::default)
12991340
}
13001341

1342+
/// Returns addresses explicitly marked with arbitrary storage.
1343+
pub fn arbitrary_storage_targets(&self) -> impl Iterator<Item = Address> + '_ {
1344+
self.arbitrary_storage.as_ref().into_iter().flat_map(ArbitraryStorage::targets)
1345+
}
1346+
1347+
/// Returns addresses explicitly marked with arbitrary storage and whether nonzero slots are
1348+
/// overwritten.
1349+
pub fn arbitrary_storage_target_overwrite_modes(
1350+
&self,
1351+
) -> impl Iterator<Item = (Address, bool)> + '_ {
1352+
self.arbitrary_storage
1353+
.as_ref()
1354+
.into_iter()
1355+
.flat_map(ArbitraryStorage::target_overwrite_modes)
1356+
}
1357+
1358+
/// Returns addresses that copy storage from arbitrary-storage targets.
1359+
pub fn arbitrary_storage_copied_targets(&self) -> impl Iterator<Item = Address> + '_ {
1360+
self.arbitrary_storage.as_ref().into_iter().flat_map(ArbitraryStorage::copied_targets)
1361+
}
1362+
1363+
/// Returns copied arbitrary-storage targets and their source address.
1364+
pub fn arbitrary_storage_copied_target_sources(
1365+
&self,
1366+
) -> impl Iterator<Item = (Address, Address)> + '_ {
1367+
self.arbitrary_storage
1368+
.as_ref()
1369+
.into_iter()
1370+
.flat_map(ArbitraryStorage::copied_target_sources)
1371+
}
1372+
1373+
/// Caches a concrete replay value for a slot on an arbitrary-storage address or copied target.
1374+
pub fn cache_arbitrary_storage_value(&mut self, address: Address, slot: U256, value: U256) {
1375+
if let Some(storage) = &mut self.arbitrary_storage {
1376+
storage.cache_value(address, slot, value);
1377+
}
1378+
}
1379+
1380+
/// Returns a cached arbitrary-storage replay value for a slot.
1381+
pub fn cached_arbitrary_storage_value(&self, address: Address, slot: U256) -> Option<U256> {
1382+
self.arbitrary_storage.as_ref().and_then(|storage| storage.cached_value(address, slot))
1383+
}
1384+
13011385
/// Whether the given address has arbitrary storage.
13021386
pub fn has_arbitrary_storage(&self, address: &Address) -> bool {
13031387
match &self.arbitrary_storage {
@@ -2535,7 +2619,9 @@ impl<FEN: FoundryEvmNetwork> Cheatcodes<FEN> {
25352619
|| self.should_overwrite_arbitrary_storage(&target_address, key)
25362620
{
25372621
if self.has_arbitrary_storage(&target_address) {
2538-
let arbitrary_value = self.rng().random();
2622+
let arbitrary_value = self
2623+
.cached_arbitrary_storage_value(target_address, key)
2624+
.unwrap_or_else(|| self.rng().random());
25392625
self.arbitrary_storage.as_mut().unwrap().save(
25402626
ecx,
25412627
target_address,
@@ -3218,4 +3304,18 @@ mod tests {
32183304
));
32193305
assert!(cheats.has_log_hooks());
32203306
}
3307+
3308+
#[test]
3309+
fn arbitrary_storage_cache_value_routes_copied_targets_to_source() {
3310+
let mut storage = ArbitraryStorage::default();
3311+
let source = Address::repeat_byte(0x11);
3312+
let copied = Address::repeat_byte(0x22);
3313+
let slot = U256::from(7);
3314+
3315+
storage.mark_arbitrary(&source, false);
3316+
storage.mark_copy(&source, &copied);
3317+
storage.cache_value(copied, slot, U256::ZERO);
3318+
3319+
assert_eq!(storage.cached_value(source, slot), Some(U256::ZERO));
3320+
}
32213321
}

crates/config/src/symbolic.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ pub struct SymbolicConfig {
5959
/// Solver names or exact commands to race in parallel. Ignored when `solver_command` is set.
6060
#[serde(default, skip_serializing_if = "Vec::is_empty")]
6161
pub solver_portfolio: Vec<String>,
62-
/// Optional timeout in seconds for solver-backed symbolic execution.
62+
/// Optional SMT solver timeout in seconds. Also bounds wall-clock symbolic invariant
63+
/// exploration before falling back to invariant fuzzing.
6364
pub timeout: Option<u32>,
6465
/// Halmos-compatible loop bound accepted by config and annotations.
6566
#[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,17 @@ pub struct HandlerAssertionFailure {
3636

3737
impl HandlerAssertionFailure {
3838
/// Builds a failure from a replayed sequence whose last call asserted.
39-
pub fn from_replayed_sequence(
39+
pub const fn from_replayed_sequence(
4040
call_sequence: Vec<BasicTxDetails>,
41+
reverter: Address,
42+
selector: Selector,
4143
edge_fingerprint: B256,
4244
revert_reason: String,
4345
) -> Self {
44-
let last = call_sequence.last().expect("replayed sequence is non-empty");
45-
let reverter = last.call_details.target;
46-
let selector_bytes: [u8; 4] =
47-
last.call_details.calldata.get(..4).and_then(|s| s.try_into().ok()).unwrap_or_default();
4846
let original_sequence_len = call_sequence.len();
4947
Self {
5048
reverter,
51-
selector: Selector::from(selector_bytes),
49+
selector,
5250
call_sequence,
5351
original_sequence_len,
5452
revert_reason,

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,9 @@ pub struct CheckSequenceOutcome {
213213
pub calls_count: usize,
214214
pub reverts: usize,
215215
pub failure_site: Option<CheckSequenceFailureSite>,
216+
/// Whether replay stopped on an assertion in a sequence call rather than a plain revert or
217+
/// terminal invariant check.
218+
pub sequence_assertion_failure: bool,
216219
}
217220

218221
pub struct ShrunkSequence {
@@ -225,6 +228,8 @@ pub struct ShrunkSequence {
225228
#[derive(Debug)]
226229
pub struct HandlerReplayOutcome {
227230
pub anchor_asserted: bool,
231+
pub reverter: Address,
232+
pub selector: Selector,
228233
pub revert_reason: Option<String>,
229234
/// Normalized via `handler_edge_fingerprint` so callers can compare directly.
230235
pub anchor_fingerprint: B256,
@@ -711,6 +716,7 @@ pub fn check_sequence<FEN: FoundryEvmNetwork>(
711716
calls_count: calls_executed,
712717
reverts,
713718
failure_site: Some(site),
719+
sequence_assertion_failure: true,
714720
}));
715721
}
716722
if call_result.reverted && options.fail_on_revert {
@@ -722,6 +728,7 @@ pub fn check_sequence<FEN: FoundryEvmNetwork>(
722728
calls_count: calls_executed,
723729
reverts,
724730
failure_site: None,
731+
sequence_assertion_failure: false,
725732
}));
726733
}
727734
let site = sequence_call_failure_site(&calls[idx], &call_result);
@@ -732,6 +739,7 @@ pub fn check_sequence<FEN: FoundryEvmNetwork>(
732739
calls_count: calls_executed,
733740
reverts,
734741
failure_site: Some(site),
742+
sequence_assertion_failure: false,
735743
}));
736744
}
737745
Ok(ReplayDecision::Continue(call_result))
@@ -752,6 +760,7 @@ pub fn check_sequence<FEN: FoundryEvmNetwork>(
752760
calls_count: calls_executed,
753761
reverts,
754762
failure_site,
763+
sequence_assertion_failure: false,
755764
})
756765
}
757766

@@ -951,6 +960,8 @@ pub fn replay_handler_failure_sequence<FEN: FoundryEvmNetwork>(
951960
let Some(&anchor_idx) = sequence.last() else {
952961
return Ok(HandlerReplayOutcome {
953962
anchor_asserted: false,
963+
reverter: Address::ZERO,
964+
selector: Selector::ZERO,
954965
revert_reason: None,
955966
anchor_fingerprint: B256::ZERO,
956967
});
@@ -966,7 +977,7 @@ pub fn replay_handler_failure_sequence<FEN: FoundryEvmNetwork>(
966977
if idx == anchor_idx {
967978
let snapshot = snapshot_edge_fingerprint(&call_result);
968979
let anchor = &calls[anchor_idx];
969-
let reverter = anchor.call_details.target;
980+
let reverter = call_result.reverter.unwrap_or(anchor.call_details.target);
970981
let selector_bytes: [u8; 4] = anchor
971982
.call_details
972983
.calldata
@@ -979,6 +990,8 @@ pub fn replay_handler_failure_sequence<FEN: FoundryEvmNetwork>(
979990
if asserted { assertion_failure_reason(call_result, rd) } else { None };
980991
return Ok(ReplayDecision::Stop(HandlerReplayOutcome {
981992
anchor_asserted: asserted,
993+
reverter,
994+
selector,
982995
revert_reason: reason,
983996
anchor_fingerprint: fingerprint,
984997
}));
@@ -987,6 +1000,8 @@ pub fn replay_handler_failure_sequence<FEN: FoundryEvmNetwork>(
9871000
// Pre-anchor assertion = different bug; reject.
9881001
return Ok(ReplayDecision::Stop(HandlerReplayOutcome {
9891002
anchor_asserted: false,
1003+
reverter: Address::ZERO,
1004+
selector: Selector::ZERO,
9901005
revert_reason: None,
9911006
anchor_fingerprint: B256::ZERO,
9921007
}));
@@ -997,6 +1012,8 @@ pub fn replay_handler_failure_sequence<FEN: FoundryEvmNetwork>(
9971012

9981013
Ok(outcome.unwrap_or(HandlerReplayOutcome {
9991014
anchor_asserted: false,
1015+
reverter: Address::ZERO,
1016+
selector: Selector::ZERO,
10001017
revert_reason: None,
10011018
anchor_fingerprint: B256::ZERO,
10021019
}))

crates/evm/symbolic/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,45 @@ set, generates symbolic arguments with the same ABI model used for stateless
315315
tests, preserves symbolic world state between calls, and replays a concrete
316316
sequence before reporting a counterexample.
317317

318+
Some invariant harnesses deploy dependency contracts in `setUp`, then rely on
319+
those dependencies having satisfiable environment state during the campaign. For
320+
example, a lending invariant may call an ERC20 mock for balances and allowances,
321+
or an oracle mock for a price, without exposing target functions that write
322+
those values first. In that case the dependency storage remains the concrete
323+
state produced by `setUp`, and symbolic execution can only explore paths
324+
reachable from that concrete dependency state.
325+
326+
Use Foundry's existing `vm.setArbitraryStorage(address)` cheatcode to mark those
327+
environment dependency contracts as symbolic storage targets:
328+
329+
```solidity
330+
function setUp() public {
331+
loanToken = new ERC20Mock();
332+
collateralToken = new ERC20Mock();
333+
oracle = new OracleMock();
334+
335+
vm.setArbitraryStorage(address(loanToken));
336+
vm.setArbitraryStorage(address(collateralToken));
337+
vm.setArbitraryStorage(address(oracle));
338+
339+
targetContract(address(this));
340+
}
341+
```
342+
343+
If the harness imports a smaller Hevm interface that does not expose this
344+
cheatcode, declare a local interface with `setArbitraryStorage(address)` and
345+
cast it to `address(vm)`.
346+
347+
Keep this scoped to external dependencies that model the environment, such as
348+
token balances, token allowances, and oracle prices. Do not blanket-mark the
349+
invariant harness or protocol state as arbitrary; that can create unreachable
350+
states and counterexamples that concrete replay rejects.
351+
352+
Replay currently materializes only concrete storage slots observed during
353+
symbolic execution. Dependency storage keyed by symbolic calldata is still
354+
explored symbolically, but it can replay-filter to `Incomplete` or `mismatch`
355+
instead of a confirmed counterexample if the required slot is not concrete.
356+
318357
## Configuration
319358

320359
The primary configuration path is native Foundry config.

0 commit comments

Comments
 (0)