Skip to main content

foundry_evm/executors/invariant/
campaign.rs

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/// Immutable plan-level description for an invariant campaign.
20///
21/// This is only a planning contract for splitting one logical campaign into worker ranges. It does
22/// not start workers, choose worker counts, or decide corpus/failure persistence.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct InvariantCampaignSpec {
25    /// Total logical runs configured for the campaign.
26    pub total_runs: u32,
27}
28
29impl InvariantCampaignSpec {
30    pub const fn new(total_runs: u32) -> Self {
31        Self { total_runs }
32    }
33
34    /// Partitions the logical campaign into contiguous worker run ranges.
35    ///
36    /// This only describes work assignment. It does not start worker execution and does not
37    /// attribute failures to worker/run origins.
38    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/// Static assignment of a contiguous logical run range to one worker.
63///
64/// The assigned range is `[first_global_run, first_global_run + runs)`.
65/// Worker `0` is the master worker for master-only artifacts such as persisted corpus replay
66/// counts.
67#[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
74/// Shared state used only to coordinate invariant worker execution.
75pub 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    /// Returns whether fail-fast or Ctrl-C stopped the campaign before its timeout or
149    /// `planned_runs`.
150    ///
151    /// The deadline and terminal failures latch the campaign stop flag, while early exit does not,
152    /// so a timed campaign without a latched stop was interrupted.
153    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/// Output produced by one invariant worker.
216///
217/// This is a data envelope for aggregation only. It does not imply that this module executed the
218/// worker, shrank failures, or wrote any persisted corpus/failure files.
219#[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/// Merges worker outputs back into one logical invariant campaign result.
233///
234/// Merge policy:
235/// - outputs are folded in `first_global_run` order;
236/// - predicate failures keep the first failure in logical run order;
237/// - handler assertion failures keep the shorter reproducer, with equal lengths preserving the
238///   earlier logical worker;
239/// - optimization mode keeps the maximum value, with ties preserving the earlier logical worker;
240/// - `failed_corpus_replays` is a master-worker-only value from worker `0`;
241/// - run/call counts, reverts, gas traces, selector metrics, and line coverage accumulate into the
242///   logical campaign result.
243#[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    /// Validates the collected worker ranges and folds them into one logical campaign result.
259    #[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    /// Folds timeout worker outputs without requiring full logical campaign coverage.
273    ///
274    /// Timeout campaigns share a wall-clock deadline across workers. When the deadline hits, any
275    /// worker may have completed fewer than its assigned runs, so the original static ranges can
276    /// contain gaps. The merge still validates worker identity and preserves deterministic worker
277    /// order, but final run count is derived from the completed worker counters.
278    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
392/// Deduplicates handler assertion failures by site, keeping the shorter reproducer.
393/// Equal-length reproducers keep the one already inserted, which is the earlier logical worker
394/// because the caller folds worker outputs in `first_global_run` order.
395fn 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
410/// Adds worker-local selector metrics into the logical campaign totals.
411fn 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
423/// Keeps the best optimization value, using logical run order to break ties.
424fn 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    /// Builds a worker-local result fixture with the fields merged by the aggregator.
495    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    /// Builds a predicate failure fixture with a reproducible call sequence.
518    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    /// Builds a handler assertion fixture with a reproducible call sequence.
534    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}