Skip to content

Commit 4bb256f

Browse files
0xKarl98zerosnacks
andauthored
fix(invariant): preserve delay semantics during shrinking (foundry-rs#14218)
* fix(invariant): preserve delay semantics during shrinking * fix tests --------- Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
1 parent 7e226c1 commit 4bb256f

6 files changed

Lines changed: 268 additions & 45 deletions

File tree

crates/config/src/invariant.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,9 @@ impl InvariantConfig {
7979
pub fn new(cache_dir: PathBuf) -> Self {
8080
Self { failure_persist_dir: Some(cache_dir), ..Default::default() }
8181
}
82+
83+
/// Returns true if generated invariant calls may advance block time or height.
84+
pub fn has_delay(&self) -> bool {
85+
self.max_block_delay.is_some() || self.max_time_delay.is_some()
86+
}
8287
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -527,9 +527,12 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
527527
invariant_test.test_data.failures.error =
528528
Some(InvariantFuzzError::Revert(case_data));
529529
result::RichInvariantResults::new(false, None)
530-
} else if !invariant_contract.is_optimization() {
531-
// In optimization mode, keep reverted calls to preserve
532-
// warp/roll values for correct replay during shrinking.
530+
} else if !invariant_contract.is_optimization()
531+
&& !self.config.has_delay()
532+
{
533+
// Delay-enabled campaigns keep reverted calls so shrinking can
534+
// preserve their warp/roll contribution when building the final
535+
// counterexample.
533536
current_run.inputs.pop();
534537
result::RichInvariantResults::new(true, None)
535538
} else {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,10 @@ pub(crate) fn can_continue<FEN: FoundryEvmNetwork>(
183183
invariant_data.failures.error = Some(InvariantFuzzError::Revert(case_data));
184184

185185
return Ok(RichInvariantResults::new(false, None));
186-
} else if call_result.reverted && !is_optimization {
187-
// If we don't fail test on revert then remove last reverted call from inputs.
188-
// In optimization mode, we keep reverted calls to preserve warp/roll values
189-
// for correct replay during shrinking.
186+
} else if call_result.reverted && !is_optimization && !invariant_config.has_delay() {
187+
// If we don't fail test on revert then remove the reverted call from inputs.
188+
// Delay-enabled campaigns keep reverted calls so shrinking can preserve their
189+
// warp/roll contribution when building the final counterexample.
190190
invariant_run.inputs.pop();
191191
}
192192
}

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

Lines changed: 152 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,37 @@ fn apply_warp_roll_to_env<FEN: FoundryEvmNetwork>(
8787
}
8888
}
8989

90+
/// Builds the final shrunk sequence from the shrinker state.
91+
///
92+
/// When `accumulate_warp_roll` is enabled, warp/roll from removed calls is folded into the next
93+
/// kept call so the final sequence remains reproducible.
94+
fn build_shrunk_sequence(
95+
calls: &[BasicTxDetails],
96+
shrinker: &CallSequenceShrinker,
97+
accumulate_warp_roll: bool,
98+
) -> Vec<BasicTxDetails> {
99+
if !accumulate_warp_roll {
100+
return shrinker.current().map(|idx| calls[idx].clone()).collect();
101+
}
102+
103+
let mut result = Vec::new();
104+
let mut accumulated_warp = U256::ZERO;
105+
let mut accumulated_roll = U256::ZERO;
106+
107+
for (idx, call) in calls.iter().enumerate() {
108+
accumulated_warp += call.warp.unwrap_or(U256::ZERO);
109+
accumulated_roll += call.roll.unwrap_or(U256::ZERO);
110+
111+
if shrinker.included_calls.test(idx) {
112+
result.push(apply_warp_roll(call, accumulated_warp, accumulated_roll));
113+
accumulated_warp = U256::ZERO;
114+
accumulated_roll = U256::ZERO;
115+
}
116+
}
117+
118+
result
119+
}
120+
90121
pub(crate) fn shrink_sequence<FEN: FoundryEvmNetwork>(
91122
config: &InvariantConfig,
92123
invariant_contract: &InvariantContract<'_>,
@@ -108,6 +139,7 @@ pub(crate) fn shrink_sequence<FEN: FoundryEvmNetwork>(
108139
return Ok(vec![]);
109140
}
110141

142+
let accumulate_warp_roll = config.has_delay();
111143
let mut call_idx = 0;
112144
let mut shrinker = CallSequenceShrinker::new(calls.len());
113145

@@ -125,6 +157,7 @@ pub(crate) fn shrink_sequence<FEN: FoundryEvmNetwork>(
125157
target_address,
126158
calldata.clone(),
127159
CheckSequenceOptions {
160+
accumulate_warp_roll,
128161
fail_on_revert: config.fail_on_revert,
129162
call_after_invariant: invariant_contract.call_after_invariant,
130163
rd: None,
@@ -144,7 +177,7 @@ pub(crate) fn shrink_sequence<FEN: FoundryEvmNetwork>(
144177
call_idx = shrinker.next_index(call_idx);
145178
}
146179

147-
Ok(shrinker.current().map(|idx| &calls[idx]).cloned().collect())
180+
Ok(build_shrunk_sequence(calls, &shrinker, accumulate_warp_roll))
148181
}
149182

150183
/// Checks if the given call sequence breaks the invariant.
@@ -153,7 +186,25 @@ pub(crate) fn shrink_sequence<FEN: FoundryEvmNetwork>(
153186
/// persisted failures.
154187
/// Returns the result of invariant check (and afterInvariant call if needed) and if sequence was
155188
/// entirely applied.
189+
///
190+
/// When `options.accumulate_warp_roll` is enabled, warp/roll from removed calls is folded into the
191+
/// next kept call so the candidate sequence stays representable as a concrete counterexample.
156192
pub fn check_sequence<FEN: FoundryEvmNetwork>(
193+
executor: Executor<FEN>,
194+
calls: &[BasicTxDetails],
195+
sequence: Vec<usize>,
196+
test_address: Address,
197+
calldata: Bytes,
198+
options: CheckSequenceOptions<'_>,
199+
) -> eyre::Result<(bool, bool, Option<String>)> {
200+
if options.accumulate_warp_roll {
201+
check_sequence_with_accumulation(executor, calls, sequence, test_address, calldata, options)
202+
} else {
203+
check_sequence_simple(executor, calls, sequence, test_address, calldata, options)
204+
}
205+
}
206+
207+
fn check_sequence_simple<FEN: FoundryEvmNetwork>(
157208
mut executor: Executor<FEN>,
158209
calls: &[BasicTxDetails],
159210
sequence: Vec<usize>,
@@ -179,9 +230,59 @@ pub fn check_sequence<FEN: FoundryEvmNetwork>(
179230
}
180231
}
181232

182-
// Check the invariant for call sequence.
233+
finish_sequence_check(&executor, test_address, calldata, &options)
234+
}
235+
236+
fn check_sequence_with_accumulation<FEN: FoundryEvmNetwork>(
237+
mut executor: Executor<FEN>,
238+
calls: &[BasicTxDetails],
239+
sequence: Vec<usize>,
240+
test_address: Address,
241+
calldata: Bytes,
242+
options: CheckSequenceOptions<'_>,
243+
) -> eyre::Result<(bool, bool, Option<String>)> {
244+
let mut accumulated_warp = U256::ZERO;
245+
let mut accumulated_roll = U256::ZERO;
246+
let mut seq_iter = sequence.iter().peekable();
247+
248+
for (idx, tx) in calls.iter().enumerate() {
249+
accumulated_warp += tx.warp.unwrap_or(U256::ZERO);
250+
accumulated_roll += tx.roll.unwrap_or(U256::ZERO);
251+
252+
if seq_iter.peek() != Some(&&idx) {
253+
continue;
254+
}
255+
256+
seq_iter.next();
257+
258+
let tx_with_accumulated = apply_warp_roll(tx, accumulated_warp, accumulated_roll);
259+
let mut call_result = execute_tx(&mut executor, &tx_with_accumulated)?;
260+
261+
if call_result.reverted {
262+
if options.fail_on_revert && call_result.result.as_ref() != MAGIC_ASSUME {
263+
return Ok((false, false, call_failure_reason(call_result, options.rd)));
264+
}
265+
} else {
266+
executor.commit(&mut call_result);
267+
}
268+
269+
accumulated_warp = U256::ZERO;
270+
accumulated_roll = U256::ZERO;
271+
}
272+
273+
// Unlike optimization mode we intentionally do not apply trailing warp/roll before the
274+
// invariant call: those delays would not be representable in the final shrunk sequence.
275+
finish_sequence_check(&executor, test_address, calldata, &options)
276+
}
277+
278+
fn finish_sequence_check<FEN: FoundryEvmNetwork>(
279+
executor: &Executor<FEN>,
280+
test_address: Address,
281+
calldata: Bytes,
282+
options: &CheckSequenceOptions<'_>,
283+
) -> eyre::Result<(bool, bool, Option<String>)> {
183284
let (invariant_result, mut success) =
184-
call_invariant_function(&executor, test_address, calldata)?;
285+
call_invariant_function(executor, test_address, calldata)?;
185286
if !success {
186287
return Ok((false, true, call_failure_reason(invariant_result, options.rd)));
187288
}
@@ -190,7 +291,7 @@ pub fn check_sequence<FEN: FoundryEvmNetwork>(
190291
// declared.
191292
if success && options.call_after_invariant {
192293
let (after_invariant_result, after_invariant_success) =
193-
call_after_invariant_function(&executor, test_address)?;
294+
call_after_invariant_function(executor, test_address)?;
194295
success = after_invariant_success;
195296
if !success {
196297
return Ok((false, true, call_failure_reason(after_invariant_result, options.rd)));
@@ -201,6 +302,7 @@ pub fn check_sequence<FEN: FoundryEvmNetwork>(
201302
}
202303

203304
pub struct CheckSequenceOptions<'a> {
305+
pub accumulate_warp_roll: bool,
204306
pub fail_on_revert: bool,
205307
pub call_after_invariant: bool,
206308
pub rd: Option<&'a RevertDecoder>,
@@ -279,23 +381,7 @@ pub(crate) fn shrink_sequence_value<FEN: FoundryEvmNetwork>(
279381
call_idx = shrinker.next_index(call_idx);
280382
}
281383

282-
// Build the final shrunk sequence, accumulating warp/roll from removed calls.
283-
let mut result = Vec::new();
284-
let mut accumulated_warp = U256::ZERO;
285-
let mut accumulated_roll = U256::ZERO;
286-
287-
for (idx, call) in calls.iter().enumerate() {
288-
accumulated_warp += call.warp.unwrap_or(U256::ZERO);
289-
accumulated_roll += call.roll.unwrap_or(U256::ZERO);
290-
291-
if shrinker.included_calls.test(idx) {
292-
result.push(apply_warp_roll(call, accumulated_warp, accumulated_roll));
293-
accumulated_warp = U256::ZERO;
294-
accumulated_roll = U256::ZERO;
295-
}
296-
}
297-
298-
Ok(result)
384+
Ok(build_shrunk_sequence(calls, &shrinker, true))
299385
}
300386

301387
/// Executes a call sequence and returns the optimization value (int256) from the invariant
@@ -347,3 +433,48 @@ pub fn check_sequence_value<FEN: FoundryEvmNetwork>(
347433

348434
Ok(None)
349435
}
436+
437+
#[cfg(test)]
438+
mod tests {
439+
use super::{CallSequenceShrinker, build_shrunk_sequence};
440+
use alloy_primitives::{Address, Bytes, U256};
441+
use foundry_evm_fuzz::{BasicTxDetails, CallDetails};
442+
use proptest::bits::BitSetLike;
443+
444+
fn tx(warp: Option<u64>, roll: Option<u64>) -> BasicTxDetails {
445+
BasicTxDetails {
446+
warp: warp.map(U256::from),
447+
roll: roll.map(U256::from),
448+
sender: Address::ZERO,
449+
call_details: CallDetails { target: Address::ZERO, calldata: Bytes::new() },
450+
}
451+
}
452+
453+
#[test]
454+
fn build_shrunk_sequence_accumulates_removed_delay_into_next_kept_call() {
455+
let calls = vec![tx(Some(3), Some(5)), tx(Some(7), Some(11)), tx(Some(13), Some(17))];
456+
let mut shrinker = CallSequenceShrinker::new(calls.len());
457+
shrinker.included_calls.clear(0);
458+
459+
let shrunk = build_shrunk_sequence(&calls, &shrinker, true);
460+
461+
assert_eq!(shrunk.len(), 2);
462+
assert_eq!(shrunk[0].warp, Some(U256::from(10)));
463+
assert_eq!(shrunk[0].roll, Some(U256::from(16)));
464+
assert_eq!(shrunk[1].warp, Some(U256::from(13)));
465+
assert_eq!(shrunk[1].roll, Some(U256::from(17)));
466+
}
467+
468+
#[test]
469+
fn build_shrunk_sequence_does_not_move_trailing_delay_backward() {
470+
let calls = vec![tx(Some(3), Some(5)), tx(Some(7), Some(11))];
471+
let mut shrinker = CallSequenceShrinker::new(calls.len());
472+
shrinker.included_calls.clear(1);
473+
474+
let shrunk = build_shrunk_sequence(&calls, &shrinker, true);
475+
476+
assert_eq!(shrunk.len(), 1);
477+
assert_eq!(shrunk[0].warp, Some(U256::from(3)));
478+
assert_eq!(shrunk[0].roll, Some(U256::from(5)));
479+
}
480+
}

crates/forge/src/runner.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> {
800800
invariant_contract.address,
801801
invariant_contract.invariant_function.selector().to_vec().into(),
802802
CheckSequenceOptions {
803+
accumulate_warp_roll: invariant_config.has_delay(),
803804
fail_on_revert: invariant_config.fail_on_revert,
804805
call_after_invariant: invariant_contract.call_after_invariant,
805806
rd: Some(self.revert_decoder()),

0 commit comments

Comments
 (0)