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    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/// Output produced by one invariant worker.
202///
203/// This is a data envelope for aggregation only. It does not imply that this module executed the
204/// worker, shrank failures, or wrote any persisted corpus/failure files.
205#[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/// Merges worker outputs back into one logical invariant campaign result.
219///
220/// Merge policy:
221/// - outputs are folded in `first_global_run` order;
222/// - predicate failures keep the first failure in logical run order;
223/// - handler assertion failures keep the shorter reproducer, with equal lengths preserving the
224///   earlier logical worker;
225/// - optimization mode keeps the maximum value, with ties preserving the earlier logical worker;
226/// - `failed_corpus_replays` is a master-worker-only value from worker `0`;
227/// - run/call counts, reverts, gas traces, selector metrics, and line coverage accumulate into the
228///   logical campaign result.
229#[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    /// Validates the collected worker ranges and folds them into one logical campaign result.
245    #[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    /// Folds timeout worker outputs without requiring full logical campaign coverage.
259    ///
260    /// Timeout campaigns share a wall-clock deadline across workers. When the deadline hits, any
261    /// worker may have completed fewer than its assigned runs, so the original static ranges can
262    /// contain gaps. The merge still validates worker identity and preserves deterministic worker
263    /// order, but final run count is derived from the completed worker counters.
264    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
378/// Deduplicates handler assertion failures by site, keeping the shorter reproducer.
379/// Equal-length reproducers keep the one already inserted, which is the earlier logical worker
380/// because the caller folds worker outputs in `first_global_run` order.
381fn 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
396/// Adds worker-local selector metrics into the logical campaign totals.
397fn 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
409/// Keeps the best optimization value, using logical run order to break ties.
410fn 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    /// Builds a worker-local result fixture with the fields merged by the aggregator.
481    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    /// Builds a predicate failure fixture with a reproducible call sequence.
504    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    /// Builds a handler assertion fixture with a reproducible call sequence.
520    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}