1use super::{
2 FailureKey, InvariantFailureMetrics, InvariantFailures, InvariantFuzzError,
3 InvariantFuzzTestResult, InvariantMetrics,
4};
5use crate::executors::{EarlyExit, EvmExecutionCancellation};
6use alloy_primitives::{Address, I256, Selector};
7use eyre::{Result, ensure};
8use foundry_evm_coverage::HitMaps;
9use foundry_evm_fuzz::BasicTxDetails;
10use std::{
11 collections::{HashMap, HashSet},
12 sync::{
13 Arc, Mutex,
14 atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering},
15 },
16 time::{Duration, Instant},
17};
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct InvariantCampaignSpec {
25 pub total_runs: u32,
27}
28
29impl InvariantCampaignSpec {
30 pub const fn new(total_runs: u32) -> Self {
31 Self { total_runs }
32 }
33
34 pub fn worker_plans(self, workers: usize) -> Result<Vec<InvariantWorkerPlan>> {
39 ensure!(workers > 0, "invariant campaign requires at least one worker");
40
41 if self.total_runs == 0 {
42 return Ok(vec![InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 0 }]);
43 }
44
45 let worker_count = workers.min(self.total_runs as usize) as u32;
46 let base_runs = self.total_runs / worker_count;
47 let extra_runs = self.total_runs % worker_count;
48
49 let mut first_global_run = 0;
50 let mut plans = Vec::with_capacity(worker_count as usize);
51 for worker_id in 0..worker_count {
52 let runs = base_runs + u32::from(worker_id < extra_runs);
53 plans.push(InvariantWorkerPlan { worker_id, first_global_run, runs });
54 first_global_run += runs;
55 }
56
57 debug_assert_eq!(first_global_run, self.total_runs);
58 Ok(plans)
59 }
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub struct InvariantWorkerPlan {
69 pub worker_id: u32,
70 pub first_global_run: u32,
71 pub runs: u32,
72}
73
74pub struct InvariantCampaignState {
76 started_at: Instant,
77 timed: bool,
78 total_runs: AtomicU32,
79 total_txs: AtomicU64,
80 total_gas: AtomicU64,
81 cancellation: EvmExecutionCancellation,
82 last_metrics_report: Mutex<Instant>,
83 failure_metrics: Mutex<CampaignFailureMetrics>,
84}
85
86#[derive(Default)]
87struct CampaignFailureMetrics {
88 metrics: InvariantFailureMetrics,
89 handler_sites: HashSet<(Address, Selector)>,
90}
91
92impl InvariantCampaignState {
93 pub fn new(early_exit: EarlyExit, timeout: Option<u32>) -> Self {
94 let started_at = Instant::now();
95 let deadline = timeout
96 .map(|timeout| Duration::from_secs(timeout.into()))
97 .and_then(|timeout| started_at.checked_add(timeout));
98 Self {
99 started_at,
100 timed: timeout.is_some(),
101 total_runs: AtomicU32::new(0),
102 total_txs: AtomicU64::new(0),
103 total_gas: AtomicU64::new(0),
104 cancellation: EvmExecutionCancellation::campaign(
105 early_exit,
106 Arc::new(AtomicBool::new(false)),
107 deadline,
108 ),
109 last_metrics_report: Mutex::new(started_at),
110 failure_metrics: Mutex::new(CampaignFailureMetrics::default()),
111 }
112 }
113
114 pub fn increment_runs(&self) -> u32 {
115 self.total_runs.fetch_add(1, Ordering::Relaxed) + 1
116 }
117
118 #[cfg(test)]
119 pub fn total_runs(&self) -> u32 {
120 self.total_runs.load(Ordering::Relaxed)
121 }
122
123 pub fn record_call(&self, gas_used: u64) {
124 self.total_txs.fetch_add(1, Ordering::Relaxed);
125 self.total_gas.fetch_add(gas_used, Ordering::Relaxed);
126 }
127
128 pub fn throughput_totals(&self) -> (u64, u64) {
129 (self.total_txs.load(Ordering::Relaxed), self.total_gas.load(Ordering::Relaxed))
130 }
131
132 pub fn elapsed(&self) -> Duration {
133 self.started_at.elapsed()
134 }
135
136 pub const fn is_timed_campaign(&self) -> bool {
137 self.timed
138 }
139
140 pub fn should_stop(&self) -> bool {
141 self.cancellation.should_stop(true)
142 }
143
144 pub fn request_terminal_stop(&self) {
145 self.cancellation.request_stop();
146 }
147
148 pub fn interrupted(&self, runs: usize, planned_runs: u32) -> bool {
154 self.early_exit().should_stop()
155 && if self.timed {
156 !self.cancellation.stop_requested()
157 } else {
158 runs < planned_runs as usize
159 }
160 }
161
162 pub fn should_emit_metrics_report(&self, interval: Duration) -> bool {
163 let mut last_report =
164 self.last_metrics_report.lock().expect("metrics report lock poisoned");
165 if last_report.elapsed() <= interval {
166 return false;
167 }
168
169 *last_report = Instant::now();
170 true
171 }
172
173 pub(super) fn record_invariant_failure(
174 &self,
175 invariant_name: &str,
176 target: &str,
177 reason: &str,
178 ) {
179 let mut failure_metrics =
180 self.failure_metrics.lock().expect("failure metrics lock poisoned");
181 if !failure_metrics.metrics.unique_failures.contains(invariant_name) {
182 failure_metrics.metrics.record_failure(invariant_name, target, reason);
183 }
184 }
185
186 pub(super) fn sync_handler_failures(&self, failures: &InvariantFailures) {
187 let mut failure_metrics =
188 self.failure_metrics.lock().expect("failure metrics lock poisoned");
189 for (key, error) in &failures.failures {
190 let FailureKey::Handler(addr, selector) = key else { continue };
191 if failure_metrics.handler_sites.insert((*addr, *selector)) {
192 let reason = error.revert_reason().unwrap_or_default();
193 failure_metrics.metrics.record_handler_failure(*addr, *selector, &reason);
194 }
195 }
196 debug_assert_eq!(
197 failure_metrics.metrics.broken_handlers,
198 failure_metrics.handler_sites.len()
199 );
200 }
201
202 pub(super) fn failure_metrics(&self) -> InvariantFailureMetrics {
203 self.failure_metrics.lock().expect("failure metrics lock poisoned").metrics.clone()
204 }
205
206 pub const fn early_exit(&self) -> &EarlyExit {
207 self.cancellation.early_exit_ref()
208 }
209
210 pub const fn cancellation(&self) -> &EvmExecutionCancellation {
211 &self.cancellation
212 }
213}
214
215#[derive(Debug)]
220pub struct InvariantWorkerOutput {
221 pub plan: InvariantWorkerPlan,
222 pub result: InvariantFuzzTestResult,
223}
224
225impl InvariantWorkerOutput {
226 #[cfg(test)]
227 pub const fn new(plan: InvariantWorkerPlan, result: InvariantFuzzTestResult) -> Self {
228 Self { plan, result }
229 }
230}
231
232#[derive(Debug)]
244pub struct InvariantCampaignAggregator {
245 spec: InvariantCampaignSpec,
246 outputs: Vec<InvariantWorkerOutput>,
247}
248
249impl InvariantCampaignAggregator {
250 pub const fn new(spec: InvariantCampaignSpec) -> Self {
251 Self { spec, outputs: Vec::new() }
252 }
253
254 pub fn push(&mut self, output: InvariantWorkerOutput) {
255 self.outputs.push(output);
256 }
257
258 #[cfg(test)]
260 pub fn finish(self) -> Result<InvariantFuzzTestResult> {
261 self.finish_campaign()
262 }
263
264 pub fn finish_campaign(mut self) -> Result<InvariantFuzzTestResult> {
265 ensure!(!self.outputs.is_empty(), "missing invariant worker output");
266
267 self.outputs.sort_by_key(|output| output.plan.first_global_run);
268 ensure_outputs_cover_campaign(self.spec, &self.outputs)?;
269 fold_outputs(self.outputs)
270 }
271
272 pub fn finish_partial(mut self) -> Result<InvariantFuzzTestResult> {
279 ensure!(!self.outputs.is_empty(), "missing invariant worker output");
280
281 self.outputs.sort_by_key(|output| output.plan.first_global_run);
282 ensure_worker_ids_are_valid(&self.outputs)?;
283 fold_outputs(self.outputs)
284 }
285}
286
287fn fold_outputs(outputs: Vec<InvariantWorkerOutput>) -> Result<InvariantFuzzTestResult> {
288 let workers = outputs.len();
289 let mut errors = HashMap::default();
290 let mut handler_errors = HashMap::default();
291 let mut runs = 0;
292 let mut calls = 0;
293 let mut reverts = 0;
294 let mut last_run_inputs = Vec::new();
295 let mut gas_report_traces = Vec::new();
296 let mut line_coverage = None;
297 let mut metrics = HashMap::default();
298 let mut failed_corpus_replays = 0;
299 let mut optimization_best = None;
300
301 for InvariantWorkerOutput { plan, result } in outputs {
302 if plan.worker_id == 0 {
303 failed_corpus_replays = result.failed_corpus_replays;
304 }
305 for (invariant, error) in result.errors {
306 errors.entry(invariant).or_insert(error);
307 }
308 merge_handler_errors(&mut handler_errors, result.handler_errors);
309 runs += result.runs;
310 calls += result.calls;
311 reverts += result.reverts;
312 if !result.last_run_inputs.is_empty() {
313 last_run_inputs = result.last_run_inputs;
314 }
315 gas_report_traces.extend(result.gas_report_traces);
316 HitMaps::merge_opt(&mut line_coverage, result.line_coverage);
317 merge_metrics(&mut metrics, result.metrics);
318 merge_optimization(
319 &mut optimization_best,
320 result.optimization_best_value,
321 result.optimization_best_sequence,
322 );
323 }
324 let (optimization_best_value, optimization_best_sequence) =
325 optimization_best.map(|(value, sequence)| (Some(value), sequence)).unwrap_or_default();
326 Ok(InvariantFuzzTestResult::new(
327 errors,
328 handler_errors,
329 runs,
330 calls,
331 reverts,
332 last_run_inputs,
333 gas_report_traces,
334 line_coverage,
335 metrics,
336 failed_corpus_replays,
337 workers,
338 optimization_best_value,
339 optimization_best_sequence,
340 ))
341}
342
343fn ensure_outputs_cover_campaign(
344 spec: InvariantCampaignSpec,
345 outputs: &[InvariantWorkerOutput],
346) -> Result<()> {
347 ensure_worker_ids_are_valid(outputs)?;
348
349 if spec.total_runs == 0 {
350 ensure!(
351 outputs.len() == 1
352 && outputs[0].plan.first_global_run == 0
353 && outputs[0].plan.runs == 0,
354 "invariant worker outputs do not cover the logical campaign"
355 );
356 return Ok(());
357 }
358
359 let mut next_global_run = 0;
360 for output in outputs {
361 ensure!(output.plan.runs > 0, "invariant worker outputs do not cover the logical campaign");
362 ensure!(
363 output.plan.first_global_run == next_global_run,
364 "invariant worker outputs do not cover the logical campaign"
365 );
366 next_global_run = next_global_run
367 .checked_add(output.plan.runs)
368 .ok_or_else(|| eyre::eyre!("invariant worker output range overflows"))?;
369 }
370
371 ensure!(
372 next_global_run == spec.total_runs,
373 "invariant worker outputs do not cover the logical campaign"
374 );
375 Ok(())
376}
377
378fn ensure_worker_ids_are_valid(outputs: &[InvariantWorkerOutput]) -> Result<()> {
379 let mut seen = HashSet::with_capacity(outputs.len());
380 for output in outputs {
381 ensure!(
382 seen.insert(output.plan.worker_id),
383 "duplicate invariant worker output for worker {}",
384 output.plan.worker_id
385 );
386 }
387
388 ensure!(seen.contains(&0), "missing invariant master worker output");
389 Ok(())
390}
391
392fn merge_handler_errors(
396 merged: &mut HashMap<(Address, Selector), InvariantFuzzError>,
397 worker_errors: HashMap<(Address, Selector), InvariantFuzzError>,
398) {
399 for (site, error) in worker_errors {
400 let candidate_len = handler_error_sequence_len(&error);
401 if merged
402 .get(&site)
403 .is_none_or(|existing| handler_error_sequence_len(existing) > candidate_len)
404 {
405 merged.insert(site, error);
406 }
407 }
408}
409
410fn merge_metrics(
412 merged: &mut HashMap<String, InvariantMetrics>,
413 worker_metrics: HashMap<String, InvariantMetrics>,
414) {
415 for (selector, metrics) in worker_metrics {
416 let entry = merged.entry(selector).or_default();
417 entry.calls += metrics.calls;
418 entry.reverts += metrics.reverts;
419 entry.discards += metrics.discards;
420 }
421}
422
423fn merge_optimization(
425 best: &mut Option<(I256, Vec<BasicTxDetails>)>,
426 candidate_value: Option<I256>,
427 candidate_sequence: Vec<BasicTxDetails>,
428) {
429 let Some(candidate_value) = candidate_value else {
430 return;
431 };
432
433 if best.as_ref().is_none_or(|(best, _)| candidate_value > *best) {
434 *best = Some((candidate_value, candidate_sequence));
435 }
436}
437
438fn handler_error_sequence_len(error: &InvariantFuzzError) -> usize {
439 error.as_handler_assertion().map_or(usize::MAX, |failure| failure.call_sequence.len())
440}
441
442#[cfg(test)]
443mod tests {
444 use super::{
445 super::error::{FailedInvariantCaseData, HandlerAssertionFailure},
446 *,
447 };
448 use alloy_primitives::{B256, Bytes};
449 use foundry_evm_coverage::HitMap;
450 use foundry_evm_fuzz::CallDetails;
451 use foundry_evm_traces::CallTraceArena;
452 use proptest::test_runner::TestError;
453
454 fn empty_result(reverts: usize, failed_corpus_replays: usize) -> InvariantFuzzTestResult {
455 InvariantFuzzTestResult::new(
456 HashMap::default(),
457 HashMap::default(),
458 0,
459 0,
460 reverts,
461 Vec::new(),
462 Vec::new(),
463 None,
464 HashMap::default(),
465 failed_corpus_replays,
466 1,
467 None,
468 Vec::new(),
469 )
470 }
471
472 fn basic_tx(sender: u8) -> BasicTxDetails {
473 BasicTxDetails {
474 warp: None,
475 roll: None,
476 sender: Address::repeat_byte(sender),
477 call_details: CallDetails {
478 target: Address::repeat_byte(sender.wrapping_add(1)),
479 calldata: Bytes::from(vec![0, 0, 0, sender]),
480 value: None,
481 },
482 }
483 }
484
485 fn hit_maps(pc: u32, hits: u32) -> HitMaps {
486 let mut hit_map = HitMap::new(Bytes::from_static(&[0]));
487 hit_map.hits(pc, hits);
488
489 let mut maps = HitMaps::default();
490 maps.insert(B256::ZERO, hit_map);
491 maps
492 }
493
494 fn worker_result(
496 reverts: usize,
497 last_input_sender: u8,
498 metric_name: &str,
499 metrics: InvariantMetrics,
500 coverage_hits: u32,
501 failed_corpus_replays: usize,
502 ) -> InvariantFuzzTestResult {
503 let mut result = empty_result(reverts, failed_corpus_replays);
504 result.runs = 1;
505 result.calls = metrics.calls;
506 result.last_run_inputs = vec![basic_tx(last_input_sender)];
507 result.gas_report_traces.push(vec![CallTraceArena::default()]);
508 result.line_coverage = Some(hit_maps(7, coverage_hits));
509 result.metrics.insert(metric_name.to_string(), metrics);
510 result
511 }
512
513 fn sequence(len: usize, first_sender: u8) -> Vec<BasicTxDetails> {
514 (0..len).map(|idx| basic_tx(first_sender.wrapping_add(idx as u8))).collect()
515 }
516
517 fn predicate_error(reason: &str, sequence_len: usize) -> InvariantFuzzError {
519 InvariantFuzzError::BrokenInvariant(FailedInvariantCaseData {
520 test_error: TestError::Fail(reason.to_string().into(), sequence(sequence_len, 0x80)),
521 return_reason: reason.to_string().into(),
522 revert_reason: reason.to_string(),
523 addr: Address::repeat_byte(0x70),
524 calldata: Bytes::new(),
525 inner_sequence: Vec::new(),
526 shrink_run_limit: 0,
527 fail_on_revert: false,
528 assertion_failure: false,
529 fork_block_number: None,
530 })
531 }
532
533 fn handler_error(
535 reverter: Address,
536 selector: Selector,
537 sequence_len: usize,
538 reason: &str,
539 ) -> InvariantFuzzError {
540 InvariantFuzzError::HandlerAssertion(HandlerAssertionFailure {
541 reverter,
542 selector,
543 call_sequence: sequence(sequence_len, 0x90),
544 original_sequence_len: sequence_len,
545 revert_reason: reason.to_string(),
546 fork_block_number: None,
547 edge_fingerprint: B256::ZERO,
548 })
549 }
550
551 fn one_worker_plan(total_runs: u32) -> InvariantWorkerPlan {
552 let mut plans = InvariantCampaignSpec::new(total_runs).worker_plans(1).unwrap();
553 assert_eq!(plans.len(), 1);
554 plans.pop().unwrap()
555 }
556
557 #[test]
558 fn worker_plans_cover_logical_campaign_with_one_worker() {
559 let plan = one_worker_plan(3);
560
561 assert_eq!(plan.worker_id, 0);
562 assert_eq!(plan.first_global_run, 0);
563 assert_eq!(plan.runs, 3);
564 }
565
566 #[test]
567 fn worker_plans_split_runs_evenly() {
568 let plans = InvariantCampaignSpec::new(100).worker_plans(4).unwrap();
569
570 assert_eq!(
571 plans,
572 vec![
573 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 25 },
574 InvariantWorkerPlan { worker_id: 1, first_global_run: 25, runs: 25 },
575 InvariantWorkerPlan { worker_id: 2, first_global_run: 50, runs: 25 },
576 InvariantWorkerPlan { worker_id: 3, first_global_run: 75, runs: 25 },
577 ]
578 );
579 }
580
581 #[test]
582 fn worker_plans_distribute_remainder_to_earlier_workers() {
583 let plans = InvariantCampaignSpec::new(10).worker_plans(3).unwrap();
584
585 assert_eq!(
586 plans,
587 vec![
588 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 4 },
589 InvariantWorkerPlan { worker_id: 1, first_global_run: 4, runs: 3 },
590 InvariantWorkerPlan { worker_id: 2, first_global_run: 7, runs: 3 },
591 ]
592 );
593 }
594
595 #[test]
596 fn worker_plans_do_not_create_empty_workers_when_runs_are_available() {
597 let plans = InvariantCampaignSpec::new(2).worker_plans(8).unwrap();
598
599 assert_eq!(
600 plans,
601 vec![
602 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
603 InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 },
604 ]
605 );
606 }
607
608 #[test]
609 fn worker_plans_keep_zero_run_campaign_as_single_empty_plan() {
610 let plans = InvariantCampaignSpec::new(0).worker_plans(4).unwrap();
611
612 assert_eq!(plans, vec![InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 0 }]);
613 }
614
615 #[test]
616 fn worker_plans_reject_zero_workers() {
617 let err = InvariantCampaignSpec::new(1).worker_plans(0).unwrap_err();
618 assert!(err.to_string().contains("requires at least one worker"));
619
620 let err = InvariantCampaignSpec::new(0).worker_plans(0).unwrap_err();
621 assert!(err.to_string().contains("requires at least one worker"));
622 }
623
624 #[test]
625 fn campaign_state_stops_after_terminal_request() {
626 let state = InvariantCampaignState::new(EarlyExit::new(false), None);
627 assert!(!state.should_stop());
628
629 state.request_terminal_stop();
630
631 assert!(state.should_stop());
632 }
633
634 #[test]
635 fn campaign_state_uses_shared_timeout_and_global_throughput() {
636 let state = InvariantCampaignState::new(EarlyExit::new(false), Some(0));
637 std::thread::sleep(Duration::from_millis(1));
638
639 assert!(state.is_timed_campaign());
640 assert!(state.should_stop());
641
642 state.record_call(20);
643 state.record_call(30);
644 assert_eq!(state.throughput_totals(), (2, 50));
645 assert_eq!(state.increment_runs(), 1);
646 assert_eq!(state.total_runs(), 1);
647 }
648
649 #[test]
650 fn campaign_state_reports_early_exit_before_completion_as_interrupted() {
651 let early_exit = EarlyExit::new(true);
652 let untimed = InvariantCampaignState::new(early_exit.clone(), None);
653 let timed = InvariantCampaignState::new(early_exit.clone(), Some(3600));
654 let expired = InvariantCampaignState::new(early_exit.clone(), Some(0));
655 std::thread::sleep(Duration::from_millis(1));
656 assert!(expired.should_stop());
657 assert!(!untimed.interrupted(4, 100));
658 assert!(!timed.interrupted(4, 100));
659
660 early_exit.record_failure();
661 assert!(untimed.interrupted(4, 100));
662 assert!(!untimed.interrupted(100, 100));
663 assert!(timed.interrupted(100, 100));
664 assert!(!expired.interrupted(4, 100));
665 }
666
667 #[test]
668 fn campaign_state_deduplicates_handler_failure_events_across_workers() {
669 let state = InvariantCampaignState::new(EarlyExit::new(false), None);
670 let target = Address::repeat_byte(0x11);
671 let selector = Selector::from([0xde, 0xad, 0xbe, 0xef]);
672 let mut first_worker = InvariantFailures::new();
673 first_worker.seed_handler_failure(
674 target,
675 selector,
676 handler_error(target, selector, 2, "assertion failed"),
677 );
678 let mut second_worker = InvariantFailures::new();
679 second_worker.seed_handler_failure(
680 target,
681 selector,
682 handler_error(target, selector, 1, "assertion failed"),
683 );
684
685 state.sync_handler_failures(&first_worker);
686 state.sync_handler_failures(&second_worker);
687
688 assert_eq!(state.failure_metrics().broken_handlers, 1);
689 }
690
691 #[test]
692 fn aggregator_returns_single_worker_result_without_rewriting() {
693 let spec = InvariantCampaignSpec::new(1);
694 let worker = InvariantWorkerOutput::new(one_worker_plan(1), empty_result(2, 3));
695
696 let mut aggregator = InvariantCampaignAggregator::new(spec);
697 aggregator.push(worker);
698 let result = aggregator.finish().unwrap();
699
700 assert_eq!(result.reverts, 2);
701 assert_eq!(result.failed_corpus_replays, 3);
702 }
703
704 #[test]
705 fn aggregator_accepts_single_worker_output_for_zero_run_campaign() {
706 let spec = InvariantCampaignSpec::new(0);
707 let worker = InvariantWorkerOutput::new(
708 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 0 },
709 empty_result(0, 0),
710 );
711
712 let mut aggregator = InvariantCampaignAggregator::new(spec);
713 aggregator.push(worker);
714 let result = aggregator.finish().unwrap();
715
716 assert_eq!(result.reverts, 0);
717 }
718
719 #[test]
720 fn aggregator_merges_multiple_worker_outputs_in_logical_run_order() {
721 let spec = InvariantCampaignSpec::new(3);
722 let plans = [
723 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
724 InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 },
725 InvariantWorkerPlan { worker_id: 2, first_global_run: 2, runs: 1 },
726 ];
727
728 let mut aggregator = InvariantCampaignAggregator::new(spec);
729 aggregator.push(InvariantWorkerOutput::new(
730 plans[2],
731 worker_result(
732 3,
733 0x30,
734 "transfer(address)",
735 InvariantMetrics { calls: 3, reverts: 1, discards: 0 },
736 3,
737 0,
738 ),
739 ));
740 aggregator.push(InvariantWorkerOutput::new(
741 plans[0],
742 worker_result(
743 1,
744 0x10,
745 "transfer(address)",
746 InvariantMetrics { calls: 1, reverts: 0, discards: 2 },
747 1,
748 4,
749 ),
750 ));
751 aggregator.push(InvariantWorkerOutput::new(
752 plans[1],
753 worker_result(
754 2,
755 0x20,
756 "approve(address)",
757 InvariantMetrics { calls: 2, reverts: 1, discards: 1 },
758 2,
759 0,
760 ),
761 ));
762
763 let result = aggregator.finish().unwrap();
764
765 assert_eq!(result.runs, 3);
766 assert_eq!(result.calls, 6);
767 assert_eq!(result.reverts, 6);
768 assert_eq!(result.gas_report_traces.len(), 3);
769 assert_eq!(result.last_run_inputs[0].sender, Address::repeat_byte(0x30));
770
771 let transfer_metrics = result.metrics.get("transfer(address)").unwrap();
772 assert_eq!(transfer_metrics, &InvariantMetrics { calls: 4, reverts: 1, discards: 2 });
773 let approve_metrics = result.metrics.get("approve(address)").unwrap();
774 assert_eq!(approve_metrics, &InvariantMetrics { calls: 2, reverts: 1, discards: 1 });
775
776 let coverage = result.line_coverage.unwrap();
777 assert_eq!(coverage.get(&B256::ZERO).unwrap().get(7).unwrap().get(), 6);
778 assert_eq!(result.failed_corpus_replays, 4);
779 }
780
781 #[test]
782 fn aggregator_preserves_run_and_call_counts() {
783 let spec = InvariantCampaignSpec::new(3);
784 let plans = [
785 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
786 InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 2 },
787 ];
788 let mut first = empty_result(0, 0);
789 first.runs = 1;
790 first.calls = 1000;
791 let mut second = empty_result(0, 0);
792 second.runs = 2;
793 second.calls = 2000;
794
795 let mut aggregator = InvariantCampaignAggregator::new(spec);
796 aggregator.push(InvariantWorkerOutput::new(plans[1], second));
797 aggregator.push(InvariantWorkerOutput::new(plans[0], first));
798 let result = aggregator.finish().unwrap();
799
800 assert_eq!(result.runs, 3);
801 assert_eq!(result.calls, 3000);
802 }
803
804 #[test]
805 fn timeout_aggregator_accepts_partial_outputs_with_range_gaps() {
806 fn result_with_counts(
807 runs: usize,
808 calls: usize,
809 has_last_run: bool,
810 failed_corpus_replays: usize,
811 ) -> InvariantFuzzTestResult {
812 let mut result = empty_result(0, failed_corpus_replays);
813 result.runs = runs;
814 result.calls = calls;
815 result.last_run_inputs = if has_last_run { vec![basic_tx(0x44)] } else { Vec::new() };
816 result
817 }
818
819 let spec = InvariantCampaignSpec::new(10);
820 let outputs = [
821 InvariantWorkerOutput::new(
822 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 2 },
823 result_with_counts(2, 20, true, 5),
824 ),
825 InvariantWorkerOutput::new(
826 InvariantWorkerPlan { worker_id: 1, first_global_run: 4, runs: 0 },
827 result_with_counts(0, 0, false, 0),
828 ),
829 InvariantWorkerOutput::new(
830 InvariantWorkerPlan { worker_id: 2, first_global_run: 7, runs: 1 },
831 result_with_counts(1, 10, true, 0),
832 ),
833 ];
834
835 let mut strict = InvariantCampaignAggregator::new(spec);
836 for output in outputs {
837 strict.push(output);
838 }
839 let err = strict.finish().unwrap_err();
840 assert!(err.to_string().contains("do not cover the logical campaign"));
841
842 let mut partial = InvariantCampaignAggregator::new(spec);
843 partial.push(InvariantWorkerOutput::new(
844 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 2 },
845 result_with_counts(2, 20, true, 5),
846 ));
847 partial.push(InvariantWorkerOutput::new(
848 InvariantWorkerPlan { worker_id: 1, first_global_run: 4, runs: 0 },
849 result_with_counts(0, 0, false, 0),
850 ));
851 partial.push(InvariantWorkerOutput::new(
852 InvariantWorkerPlan { worker_id: 2, first_global_run: 7, runs: 1 },
853 result_with_counts(1, 10, true, 0),
854 ));
855
856 let result = partial.finish_partial().unwrap();
857
858 assert_eq!(result.runs, 3);
859 assert_eq!(result.calls, 30);
860 assert_eq!(result.failed_corpus_replays, 5);
861 }
862
863 #[test]
864 fn aggregator_keeps_earlier_predicate_failure_for_each_invariant() {
865 let spec = InvariantCampaignSpec::new(2);
866 let plans = [
867 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
868 InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 },
869 ];
870 let mut earlier = empty_result(0, 0);
871 earlier.errors.insert("invariant_balance".to_string(), predicate_error("earlier", 3));
872 let mut later = empty_result(0, 0);
873 later.errors.insert("invariant_balance".to_string(), predicate_error("later", 1));
874
875 let mut aggregator = InvariantCampaignAggregator::new(spec);
876 aggregator.push(InvariantWorkerOutput::new(plans[1], later));
877 aggregator.push(InvariantWorkerOutput::new(plans[0], earlier));
878 let result = aggregator.finish().unwrap();
879
880 assert_eq!(result.errors.len(), 1);
881 assert_eq!(result.errors["invariant_balance"].revert_reason().as_deref(), Some("earlier"));
882 }
883
884 #[test]
885 fn aggregator_dedupes_handler_assertions_by_site_and_keeps_shorter_sequence() {
886 let spec = InvariantCampaignSpec::new(2);
887 let plans = [
888 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
889 InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 },
890 ];
891 let site = (Address::repeat_byte(0xaa), Selector::from([1, 2, 3, 4]));
892 let mut longer = empty_result(0, 0);
893 longer.handler_errors.insert(site, handler_error(site.0, site.1, 4, "longer"));
894 let mut shorter = empty_result(0, 0);
895 shorter.handler_errors.insert(site, handler_error(site.0, site.1, 2, "shorter"));
896
897 let mut aggregator = InvariantCampaignAggregator::new(spec);
898 aggregator.push(InvariantWorkerOutput::new(plans[1], shorter));
899 aggregator.push(InvariantWorkerOutput::new(plans[0], longer));
900 let result = aggregator.finish().unwrap();
901
902 let failure = result.handler_errors[&site].as_handler_assertion().unwrap();
903 assert_eq!(result.handler_errors.len(), 1);
904 assert_eq!(failure.call_sequence.len(), 2);
905 assert_eq!(failure.revert_reason, "shorter");
906 }
907
908 #[test]
909 fn aggregator_keeps_earlier_handler_assertion_when_lengths_tie() {
910 let spec = InvariantCampaignSpec::new(2);
911 let plans = [
912 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
913 InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 },
914 ];
915 let site = (Address::repeat_byte(0xaa), Selector::from([1, 2, 3, 4]));
916 let mut earlier = empty_result(0, 0);
917 earlier.handler_errors.insert(site, handler_error(site.0, site.1, 2, "earlier"));
918 let mut later = empty_result(0, 0);
919 later.handler_errors.insert(site, handler_error(site.0, site.1, 2, "later"));
920
921 let mut aggregator = InvariantCampaignAggregator::new(spec);
922 aggregator.push(InvariantWorkerOutput::new(plans[1], later));
923 aggregator.push(InvariantWorkerOutput::new(plans[0], earlier));
924 let result = aggregator.finish().unwrap();
925
926 let failure = result.handler_errors[&site].as_handler_assertion().unwrap();
927 assert_eq!(result.handler_errors.len(), 1);
928 assert_eq!(failure.call_sequence.len(), 2);
929 assert_eq!(failure.revert_reason, "earlier");
930 }
931
932 #[test]
933 fn aggregator_keeps_distinct_predicate_failures() {
934 let spec = InvariantCampaignSpec::new(2);
935 let plans = [
936 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
937 InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 },
938 ];
939 let mut earlier = empty_result(0, 0);
940 earlier.errors.insert("invariant_a".to_string(), predicate_error("a", 3));
941 let mut later = empty_result(0, 0);
942 later.errors.insert("invariant_b".to_string(), predicate_error("b", 2));
943
944 let mut aggregator = InvariantCampaignAggregator::new(spec);
945 aggregator.push(InvariantWorkerOutput::new(plans[1], later));
946 aggregator.push(InvariantWorkerOutput::new(plans[0], earlier));
947 let result = aggregator.finish().unwrap();
948
949 assert_eq!(result.errors.len(), 2);
950 assert_eq!(result.errors["invariant_a"].revert_reason().as_deref(), Some("a"));
951 assert_eq!(result.errors["invariant_b"].revert_reason().as_deref(), Some("b"));
952 }
953
954 #[test]
955 fn aggregator_keeps_first_max_optimization_value_on_tie() {
956 let spec = InvariantCampaignSpec::new(3);
957 let plans = [
958 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
959 InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 },
960 InvariantWorkerPlan { worker_id: 2, first_global_run: 2, runs: 1 },
961 ];
962 let mut first = empty_result(0, 0);
963 first.optimization_best_value = Some(I256::try_from(7).unwrap());
964 first.optimization_best_sequence = sequence(1, 0x10);
965 let mut earlier_best = empty_result(0, 0);
966 earlier_best.optimization_best_value = Some(I256::try_from(9).unwrap());
967 earlier_best.optimization_best_sequence = sequence(1, 0x20);
968 let mut later_tie = empty_result(0, 0);
969 later_tie.optimization_best_value = Some(I256::try_from(9).unwrap());
970 later_tie.optimization_best_sequence = sequence(1, 0x30);
971
972 let mut aggregator = InvariantCampaignAggregator::new(spec);
973 aggregator.push(InvariantWorkerOutput::new(plans[2], later_tie));
974 aggregator.push(InvariantWorkerOutput::new(plans[0], first));
975 aggregator.push(InvariantWorkerOutput::new(plans[1], earlier_best));
976 let result = aggregator.finish().unwrap();
977
978 assert_eq!(result.optimization_best_value, Some(I256::try_from(9).unwrap()));
979 assert_eq!(result.optimization_best_sequence[0].sender, Address::repeat_byte(0x20));
980 }
981
982 #[test]
983 fn aggregator_rejects_overlapping_outputs() {
984 let spec = InvariantCampaignSpec::new(1);
985 let mut aggregator = InvariantCampaignAggregator::new(spec);
986
987 aggregator.push(InvariantWorkerOutput::new(
988 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
989 empty_result(0, 0),
990 ));
991 aggregator.push(InvariantWorkerOutput::new(
992 InvariantWorkerPlan { worker_id: 1, first_global_run: 0, runs: 1 },
993 empty_result(0, 0),
994 ));
995 let err = aggregator.finish().unwrap_err();
996
997 assert!(err.to_string().contains("do not cover the logical campaign"));
998 }
999
1000 #[test]
1001 fn aggregator_rejects_duplicate_worker_ids() {
1002 let spec = InvariantCampaignSpec::new(2);
1003 let mut aggregator = InvariantCampaignAggregator::new(spec);
1004
1005 aggregator.push(InvariantWorkerOutput::new(
1006 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
1007 empty_result(0, 0),
1008 ));
1009 aggregator.push(InvariantWorkerOutput::new(
1010 InvariantWorkerPlan { worker_id: 0, first_global_run: 1, runs: 1 },
1011 empty_result(0, 0),
1012 ));
1013 let err = aggregator.finish().unwrap_err();
1014
1015 assert!(err.to_string().contains("duplicate invariant worker output"));
1016 }
1017
1018 #[test]
1019 fn aggregator_allows_non_dense_worker_ids_with_contiguous_ranges() {
1020 let spec = InvariantCampaignSpec::new(2);
1021 let mut aggregator = InvariantCampaignAggregator::new(spec);
1022
1023 aggregator.push(InvariantWorkerOutput::new(
1024 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
1025 empty_result(0, 0),
1026 ));
1027 aggregator.push(InvariantWorkerOutput::new(
1028 InvariantWorkerPlan { worker_id: 2, first_global_run: 1, runs: 1 },
1029 empty_result(2, 0),
1030 ));
1031 let result = aggregator.finish().unwrap();
1032
1033 assert_eq!(result.reverts, 2);
1034 }
1035
1036 #[test]
1037 fn aggregator_rejects_missing_master_worker() {
1038 let spec = InvariantCampaignSpec::new(2);
1039 let mut aggregator = InvariantCampaignAggregator::new(spec);
1040
1041 aggregator.push(InvariantWorkerOutput::new(
1042 InvariantWorkerPlan { worker_id: 1, first_global_run: 0, runs: 1 },
1043 empty_result(0, 0),
1044 ));
1045 aggregator.push(InvariantWorkerOutput::new(
1046 InvariantWorkerPlan { worker_id: 2, first_global_run: 1, runs: 1 },
1047 empty_result(0, 0),
1048 ));
1049 let err = aggregator.finish().unwrap_err();
1050
1051 assert!(err.to_string().contains("missing invariant master worker output"));
1052 }
1053
1054 #[test]
1055 fn aggregator_uses_master_failed_corpus_replays() {
1056 let spec = InvariantCampaignSpec::new(2);
1057 let plans = [
1058 InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 },
1059 InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 },
1060 ];
1061
1062 let mut aggregator = InvariantCampaignAggregator::new(spec);
1063 aggregator.push(InvariantWorkerOutput::new(plans[0], empty_result(0, 7)));
1064 aggregator.push(InvariantWorkerOutput::new(plans[1], empty_result(0, 1)));
1065 let result = aggregator.finish().unwrap();
1066
1067 assert_eq!(result.failed_corpus_replays, 7);
1068 }
1069
1070 #[test]
1071 fn aggregator_uses_master_failed_corpus_replays_independent_of_output_order() {
1072 let spec = InvariantCampaignSpec::new(2);
1073 let plans = [
1074 InvariantWorkerPlan { worker_id: 1, first_global_run: 0, runs: 1 },
1075 InvariantWorkerPlan { worker_id: 0, first_global_run: 1, runs: 1 },
1076 ];
1077
1078 let mut aggregator = InvariantCampaignAggregator::new(spec);
1079 aggregator.push(InvariantWorkerOutput::new(plans[0], empty_result(0, 0)));
1080 aggregator.push(InvariantWorkerOutput::new(plans[1], empty_result(0, 7)));
1081 let result = aggregator.finish().unwrap();
1082
1083 assert_eq!(result.failed_corpus_replays, 7);
1084 }
1085
1086 #[test]
1087 fn aggregator_rejects_plan_that_does_not_cover_campaign() {
1088 let spec = InvariantCampaignSpec::new(2);
1089 let plan = InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 };
1090 let worker = InvariantWorkerOutput::new(plan, empty_result(0, 0));
1091
1092 let mut aggregator = InvariantCampaignAggregator::new(spec);
1093 aggregator.push(worker);
1094 let err = aggregator.finish().unwrap_err();
1095
1096 assert!(err.to_string().contains("do not cover the logical campaign"));
1097 }
1098
1099 #[test]
1100 fn aggregator_rejects_missing_output() {
1101 let aggregator = InvariantCampaignAggregator::new(InvariantCampaignSpec::new(1));
1102 let err = aggregator.finish().unwrap_err();
1103
1104 assert!(err.to_string().contains("missing invariant worker output"));
1105 }
1106}