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