Skip to main content

forge/cmd/
fuzz.rs

1use crate::{
2    cmd::test::{CampaignArgs, FilterArgs, FuzzMinimizeReplaySession, ShowmapDomainArg, TestArgs},
3    multi_runner::{FuzzMinimizeEdgeIndices, FuzzMinimizeObservation, ShowmapConfig},
4    result::TestOutcome,
5};
6use alloy_dyn_abi::{DynSolValue, JsonAbiExt};
7use alloy_json_abi::{Function, JsonAbi};
8use alloy_primitives::{Address, B256, Function as SolFunction, I256, Selector, U256};
9use clap::{Parser, Subcommand, ValueEnum, ValueHint};
10use eyre::{Context, Result, bail};
11use flate2::{Compression, write::GzEncoder};
12use foundry_cli::{
13    opts::{BuildOpts, EvmArgs, GlobalArgs},
14    utils::LoadConfig,
15};
16use foundry_common::{
17    fmt::format_tokens_raw,
18    fs, sh_println, sh_status,
19    shell::{OutputMode, Shell},
20};
21use foundry_config::{Config, filter::GlobMatcher};
22use foundry_evm::{
23    executors::{CorpusDirEntry, ReplayObservation, ShowmapDomain, read_corpus_tree},
24    fuzz::BasicTxDetails,
25};
26use serde::Serialize;
27use std::{
28    collections::BTreeMap,
29    fs::OpenOptions,
30    io::{BufWriter, Write},
31    path::{Path, PathBuf},
32    pin::Pin,
33    time::{SystemTime, UNIX_EPOCH},
34};
35use tempfile::{Builder as TempDirBuilder, TempDir};
36
37type FuzzOutcomeFuture = Pin<Box<dyn Future<Output = Result<TestOutcome>>>>;
38
39/// Run and manage Forge fuzzing corpora.
40#[derive(Clone, Debug, Parser)]
41pub struct FuzzArgs {
42    #[command(subcommand)]
43    pub command: FuzzSubcommands,
44}
45
46impl FuzzArgs {
47    pub fn run(self) -> FuzzOutcomeFuture {
48        match self.command {
49            FuzzSubcommands::Run(args) => {
50                let mut test = TestArgs::from_fuzz_run(args);
51                test.enable_fuzz_only_with_auto_fuzz_corpus();
52                Box::pin(test.run())
53            }
54            FuzzSubcommands::Replay(args) => Box::pin(args.run()),
55            FuzzSubcommands::Show(args) => Box::pin(async move {
56                args.run()?;
57                Ok(TestOutcome::empty(None, true))
58            }),
59            FuzzSubcommands::Cmin(args) => Box::pin(async move {
60                args.run().await?;
61                Ok(TestOutcome::empty(None, true))
62            }),
63            FuzzSubcommands::Tmin(args) => Box::pin(async move {
64                args.run().await?;
65                Ok(TestOutcome::empty(None, true))
66            }),
67        }
68    }
69
70    pub const fn is_junit(&self) -> bool {
71        match &self.command {
72            FuzzSubcommands::Run(args) => args.junit,
73            FuzzSubcommands::Replay(args) => args.is_junit(),
74            FuzzSubcommands::Show(_) | FuzzSubcommands::Cmin(_) | FuzzSubcommands::Tmin(_) => false,
75        }
76    }
77}
78
79#[derive(Clone, Debug, Subcommand)]
80#[allow(clippy::large_enum_variant)]
81pub enum FuzzSubcommands {
82    /// Run only fuzz and invariant tests.
83    Run(FuzzRunArgs),
84    /// Replay persisted fuzz failures, or corpus entries with `--corpus-dir`.
85    Replay(FuzzReplayArgs),
86    /// Print persisted corpus entries.
87    Show(FuzzShowArgs),
88    /// Minimize a corpus by keeping entries that contribute new coverage.
89    Cmin(FuzzCminArgs),
90    /// Minimize one corpus entry while preserving its failure or coverage.
91    Tmin(FuzzTminArgs),
92}
93
94/// Run only fuzz and invariant tests.
95#[derive(Clone, Debug, Parser)]
96pub struct FuzzRunArgs {
97    #[command(flatten)]
98    pub(crate) global: GlobalArgs,
99
100    /// The contract file you want to test, it's a shortcut for --match-path.
101    #[arg(value_hint = ValueHint::FilePath)]
102    pub(crate) path: Option<GlobMatcher>,
103
104    #[command(flatten)]
105    pub(crate) filter: FilterArgs,
106
107    #[command(flatten)]
108    pub(crate) campaign: CampaignArgs,
109
110    #[command(flatten)]
111    pub(crate) evm: EvmArgs,
112
113    #[command(flatten)]
114    pub(crate) build: BuildOpts,
115
116    /// Output test results as JUnit XML report.
117    #[arg(long, conflicts_with_all = ["quiet", "json", "gas_report", "list", "show_progress"], help_heading = "Display options")]
118    pub(crate) junit: bool,
119
120    /// Exit with code 0 even if a test fails.
121    #[arg(long, env = "FORGE_ALLOW_FAILURE")]
122    pub(crate) allow_failure: bool,
123
124    /// Stop running tests after the first failure.
125    #[arg(long)]
126    pub(crate) fail_fast: bool,
127
128    /// Re-run recorded test failures from last run.
129    /// If no failure recorded then regular test run is performed.
130    #[arg(long)]
131    pub(crate) rerun: bool,
132
133    /// Show test execution progress.
134    #[arg(long, conflicts_with_all = ["quiet", "json"], help_heading = "Display options")]
135    pub(crate) show_progress: bool,
136
137    /// The Etherscan (or equivalent) API key.
138    #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")]
139    pub(crate) etherscan_api_key: Option<String>,
140
141    /// List fuzz and invariant tests instead of running them.
142    #[arg(long, short, conflicts_with_all = ["show_progress"], help_heading = "Display options")]
143    pub(crate) list: bool,
144
145    /// Print a gas report.
146    #[arg(long, env = "FORGE_GAS_REPORT")]
147    pub(crate) gas_report: bool,
148
149    /// Replay the persisted corpus and emit AFL-`afl-showmap`-style coverage
150    /// files at the given output directory.
151    #[arg(
152        long,
153        value_name = "DIR",
154        value_hint = ValueHint::DirPath,
155        help_heading = "Showmap replay",
156        conflicts_with_all = ["rerun", "fuzz_input_file", "gas_report"],
157    )]
158    pub(crate) showmap_out: Option<PathBuf>,
159
160    /// Emit one showmap file per corpus entry (default: one aggregated file per test).
161    #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")]
162    pub(crate) showmap_per_input: bool,
163
164    /// Coverage domain(s) to dump.
165    #[arg(
166        long,
167        value_enum,
168        default_value_t = ShowmapDomainArg::Evm,
169        help_heading = "Showmap replay",
170        requires = "showmap_out",
171    )]
172    pub(crate) showmap_domain: ShowmapDomainArg,
173
174    /// Approach name (used as a subdirectory of `--showmap-out`).
175    #[arg(
176        long,
177        default_value = "replay",
178        help_heading = "Showmap replay",
179        requires = "showmap_out"
180    )]
181    pub(crate) showmap_approach: String,
182
183    /// Trial identifier embedded in each showmap filename.
184    #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")]
185    pub(crate) showmap_trial: Option<String>,
186
187    /// Override the corpus directory to replay.
188    #[arg(
189        long,
190        value_name = "PATH",
191        value_hint = ValueHint::DirPath,
192        help_heading = "Showmap replay",
193        requires = "showmap_out",
194    )]
195    pub(crate) showmap_corpus_dir: Option<PathBuf>,
196
197    /// File to rerun fuzz failures from.
198    #[arg(long)]
199    pub(crate) fuzz_input_file: Option<String>,
200}
201
202/// Replay persisted fuzz failures, or corpus entries with `--corpus-dir`.
203#[derive(Clone, Debug, Parser)]
204pub struct FuzzReplayArgs {
205    #[command(flatten)]
206    run: FuzzRunArgs,
207}
208
209impl FuzzReplayArgs {
210    async fn run(self) -> Result<TestOutcome> {
211        let corpus_dir = self.run.campaign.corpus_dir.clone();
212        let mut test = TestArgs::from_fuzz_run(self.run);
213        if corpus_dir.is_none() {
214            test.enable_fuzz_failure_replay();
215            return test.run().await;
216        }
217
218        let replay_id =
219            SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
220        test.set_showmap_override(ShowmapConfig {
221            out_dir: std::env::temp_dir().join(format!("forge-fuzz-replay-{replay_id}")),
222            approach: "replay".to_string(),
223            trial: "replay".to_string(),
224            per_input: false,
225            domain: ShowmapDomain::Evm,
226            corpus_dir,
227            emit_files: false,
228        });
229        test.run().await
230    }
231
232    const fn is_junit(&self) -> bool {
233        self.run.junit
234    }
235}
236
237#[derive(Clone, Copy, Debug, Default, ValueEnum)]
238#[clap(rename_all = "lowercase")]
239pub enum CorpusShowFormat {
240    #[default]
241    Human,
242    Json,
243}
244
245/// Print persisted corpus entries.
246#[derive(Clone, Debug, Parser)]
247pub struct FuzzShowArgs {
248    /// Corpus directory or a single corpus file.
249    #[arg(value_name = "PATH", value_hint = ValueHint::AnyPath)]
250    corpus: PathBuf,
251    /// Output format.
252    #[arg(long, value_enum, default_value_t)]
253    format: CorpusShowFormat,
254    /// Maximum number of entries to print.
255    #[arg(long, value_name = "N")]
256    limit: Option<usize>,
257}
258
259impl FuzzShowArgs {
260    fn run(&self) -> Result<()> {
261        let decoder = CorpusDecoder::load();
262        let entries = read_entries(&self.corpus, self.limit, &decoder)?;
263        match self.format {
264            CorpusShowFormat::Human => {
265                for entry in entries {
266                    sh_println!("{} ({} txs)", entry.path.display(), entry.sequence.len())?;
267                    for (idx, tx) in entry.sequence.iter().enumerate() {
268                        if let Some(decoded) = &tx.decoded {
269                            let ambiguity = if decoded.ambiguous_contracts.is_empty() {
270                                String::new()
271                            } else {
272                                format!(" ambiguous=[{}]", decoded.ambiguous_contracts.join(","))
273                            };
274                            sh_println!(
275                                "  {idx}: {} sender={} target={} value={}{}",
276                                decoded.call,
277                                tx.raw.sender,
278                                tx.raw.call_details.target,
279                                tx.raw
280                                    .call_details
281                                    .value
282                                    .map(|v| v.to_string())
283                                    .unwrap_or_else(|| "0".to_string()),
284                                ambiguity
285                            )?;
286                        } else {
287                            sh_println!(
288                                "  {idx}: target={} sender={} calldata={} value={}",
289                                tx.raw.call_details.target,
290                                tx.raw.sender,
291                                tx.raw.call_details.calldata,
292                                tx.raw
293                                    .call_details
294                                    .value
295                                    .map(|v| v.to_string())
296                                    .unwrap_or_else(|| "0".to_string())
297                            )?;
298                        }
299                    }
300                }
301            }
302            CorpusShowFormat::Json => sh_println!("{}", serde_json::to_string_pretty(&entries)?)?,
303        }
304        Ok(())
305    }
306}
307
308/// Minimize a corpus by keeping entries that contribute new coverage.
309#[derive(Clone, Debug, Parser)]
310pub struct FuzzCminArgs {
311    #[command(flatten)]
312    test: FuzzMinimizeTestArgs,
313    /// Input corpus directory.
314    #[arg(value_name = "CORPUS_DIR", value_hint = ValueHint::DirPath)]
315    corpus_dir: PathBuf,
316    /// Output corpus directory.
317    #[arg(long = "corpus-out", value_name = "DIR", value_hint = ValueHint::DirPath)]
318    out: PathBuf,
319}
320
321impl FuzzCminArgs {
322    async fn run(self) -> Result<()> {
323        if cmin_out_exists(&self.out) {
324            bail!("output corpus directory already exists: {}", self.out.display());
325        }
326
327        let staging_out = temporary_cmin_out(&self.out)?;
328        let summary = self.run_to(staging_out.path()).await?;
329        let staging_path = staging_out.keep();
330
331        if cmin_out_exists(&self.out) {
332            bail!(
333                "output corpus directory already exists: {}; minimized corpus remains staged at {}",
334                self.out.display(),
335                staging_path.display()
336            );
337        }
338
339        std::fs::rename(&staging_path, &self.out).with_context(|| {
340            format!(
341                "failed to rename minimized corpus {} to {}",
342                staging_path.display(),
343                self.out.display()
344            )
345        })?;
346
347        sh_println!(
348            "minimized corpus: kept {}/{} entries in {}",
349            summary.kept,
350            summary.total,
351            self.out.display()
352        )?;
353        if summary.skipped > 0 {
354            sh_status!(
355                "skipped {} entries or txs that could not be read or replayed",
356                summary.skipped
357            )?;
358        }
359        Ok(())
360    }
361
362    async fn run_to(&self, out_dir: &Path) -> Result<CminSummary> {
363        let session = self.test.clone().prepare_session(&self.corpus_dir).await?;
364        let mut kept = 0usize;
365        let mut total = 0usize;
366        let mut skipped_entries = 0usize;
367        let mut unreadable = 0usize;
368        let mut empty = 0usize;
369        let mut unmatched_txs = 0usize;
370        let mut rejected_txs = 0usize;
371        let mut failed_entries = 0usize;
372        let mut failed_replays = 0usize;
373        let mut replayed = 0usize;
374        let mut cumulative = BTreeMap::<String, ReplayObservation>::new();
375        let evm_edge_indices = FuzzMinimizeEdgeIndices::default();
376
377        for entry in read_corpus_entries(&self.corpus_dir)? {
378            total += 1;
379            let sequence = entry
380                .read_tx_seq()
381                .with_context(|| format!("failed to read corpus entry {}", entry.path.display()));
382            let Ok(sequence) = sequence else {
383                skipped_entries += 1;
384                unreadable += 1;
385                continue;
386            };
387            if sequence.is_empty() {
388                skipped_entries += 1;
389                empty += 1;
390                continue;
391            }
392            let observations = replay_candidate(&session, evm_edge_indices.clone(), sequence)?;
393            let mut entry_improved = false;
394            let mut entry_failed = false;
395            let mut entry_failed_replays = 0usize;
396            let mut entry_replayed = 0usize;
397            let mut entry_unmatched_txs = 0usize;
398            let mut entry_rejected_txs = 0usize;
399            for FuzzMinimizeObservation { target, observation } in observations {
400                if observation.failure.is_some() {
401                    entry_failed = true;
402                    entry_failed_replays += observation.replayed;
403                    continue;
404                }
405                entry_replayed += observation.replayed;
406                entry_unmatched_txs = entry_unmatched_txs.max(observation.unmatched);
407                entry_rejected_txs = entry_rejected_txs.max(observation.skipped);
408                let cumulative = cumulative.entry(target).or_default();
409                entry_improved |= merge_new_edges(cumulative, &observation);
410            }
411            if entry_replayed > 0 {
412                replayed += entry_replayed;
413            } else if entry_failed {
414                skipped_entries += 1;
415                failed_entries += 1;
416                failed_replays += entry_failed_replays;
417            } else {
418                unmatched_txs += entry_unmatched_txs;
419                rejected_txs += entry_rejected_txs;
420            }
421            if !entry_improved {
422                continue;
423            }
424
425            let out = if self.corpus_dir.is_file() {
426                out_dir.join(entry.path.file_name().unwrap_or_default())
427            } else {
428                let relative = entry.path.strip_prefix(&self.corpus_dir).with_context(|| {
429                    format!(
430                        "corpus entry {} is not under {}",
431                        entry.path.display(),
432                        self.corpus_dir.display()
433                    )
434                })?;
435                out_dir.join(relative)
436            };
437            if let Some(parent) = out.parent() {
438                fs::create_dir_all(parent)?;
439            }
440            std::fs::copy(&entry.path, &out).with_context(|| {
441                format!("failed to copy {} to {}", entry.path.display(), out.display())
442            })?;
443            kept += 1;
444        }
445
446        if total > 0 && replayed == 0 {
447            let corpus = self.corpus_dir.display();
448            if unreadable == total {
449                bail!(
450                    "replayed 0 transactions from {corpus}; all {unreadable} corpus entries could not be read"
451                );
452            }
453            if failed_entries > 0 {
454                bail!(
455                    "replayed 0 successful transactions from {corpus}; {failed_entries} corpus \
456                     entries failed during replay after {failed_replays} replayed transactions"
457                );
458            }
459            if unmatched_txs > 0 {
460                bail!(
461                    "replayed 0 transactions from {corpus}; {unmatched_txs} transactions did not match \
462                     the test; check that --mc/--mt and replay-critical options match the corpus \
463                     entries"
464                );
465            }
466            if rejected_txs > 0 {
467                bail!(
468                    "replayed 0 transactions from {corpus}; {rejected_txs} transactions were rejected \
469                     by vm.assume or vm.skip"
470                );
471            }
472            if empty == total.saturating_sub(unreadable) {
473                bail!(
474                    "replayed 0 transactions from {corpus}; corpus entries were empty{}",
475                    if unreadable > 0 {
476                        format!(" or unreadable ({unreadable} unreadable)")
477                    } else {
478                        String::new()
479                    }
480                );
481            }
482            bail!(
483                "replayed 0 transactions from {corpus}; {unreadable} unreadable entries, {empty} \
484                 empty entries"
485            );
486        }
487
488        Ok(CminSummary { kept, total, skipped: skipped_entries + unmatched_txs + rejected_txs })
489    }
490}
491
492fn cmin_out_exists(out: &Path) -> bool {
493    std::fs::symlink_metadata(out).is_ok()
494}
495
496struct CminSummary {
497    kept: usize,
498    total: usize,
499    skipped: usize,
500}
501
502/// Minimize one corpus entry while preserving its failure or coverage.
503#[derive(Clone, Debug, Parser)]
504pub struct FuzzTminArgs {
505    #[command(flatten)]
506    test: FuzzMinimizeTestArgs,
507    /// Input corpus file or directory.
508    #[arg(value_name = "INPUT", value_hint = ValueHint::AnyPath)]
509    input: PathBuf,
510    /// Output corpus file or directory.
511    #[arg(long = "corpus-out", value_name = "PATH", value_hint = ValueHint::AnyPath)]
512    out: PathBuf,
513    /// Maximum candidate replays to attempt per corpus entry.
514    #[arg(long, default_value_t = 5000, value_name = "N")]
515    max_attempts: usize,
516}
517
518impl FuzzTminArgs {
519    async fn run(self) -> Result<()> {
520        if self.max_attempts == 0 {
521            bail!("--max-attempts must be greater than 0");
522        }
523        if self.input.is_dir() { self.run_dir().await } else { self.run_file().await }
524    }
525
526    async fn run_file(self) -> Result<()> {
527        validate_tmin_output_path(&self.out)?;
528
529        let mut sequence = read_single_sequence(&self.input)?;
530        if sequence.is_empty() {
531            bail!("corpus entry {} is empty", self.input.display());
532        }
533
534        let before_txs = sequence.len();
535        let decoder_args = self.test.clone();
536        let session = self.test.prepare_session(input_corpus_root(&self.input)).await?;
537        let decoder = decoder_args.decoder();
538        let attempts =
539            minimize_entry(&session, &decoder, &self.input, &mut sequence, self.max_attempts)?;
540        write_sequence_create_new(&self.out, &sequence)?;
541
542        sh_println!(
543            "minimized entry: {before_txs} txs -> {} txs in {}",
544            sequence.len(),
545            self.out.display()
546        )?;
547        sh_status!("attempted {attempts} candidate replays")?;
548        Ok(())
549    }
550
551    async fn run_dir(self) -> Result<()> {
552        if cmin_out_exists(&self.out) {
553            bail!("output corpus directory already exists: {}", self.out.display());
554        }
555
556        let entries = read_corpus_entries(&self.input)?;
557        let staging_out = temporary_cmin_out(&self.out)?;
558        let decoder_args = self.test.clone();
559        let session = self.test.prepare_session(&self.input).await?;
560        let decoder = decoder_args.decoder();
561        let mut total_entries = 0usize;
562        let mut before_txs = 0usize;
563        let mut after_txs = 0usize;
564        let mut attempts = 0usize;
565        let mut skipped_entries = 0usize;
566
567        for entry in entries {
568            let sequence = entry
569                .read_tx_seq()
570                .with_context(|| format!("failed to read corpus entry {}", entry.path.display()));
571            let Ok(mut sequence) = sequence else {
572                skipped_entries += 1;
573                continue;
574            };
575            if sequence.is_empty() {
576                skipped_entries += 1;
577                continue;
578            }
579            before_txs += sequence.len();
580            attempts +=
581                minimize_entry(&session, &decoder, &entry.path, &mut sequence, self.max_attempts)?;
582            after_txs += sequence.len();
583
584            let relative = entry.path.strip_prefix(&self.input).with_context(|| {
585                format!(
586                    "corpus entry {} is not under {}",
587                    entry.path.display(),
588                    self.input.display()
589                )
590            })?;
591            write_sequence_create_new(&staging_out.path().join(relative), &sequence)?;
592            total_entries += 1;
593        }
594        if total_entries == 0 {
595            bail!("no readable non-empty corpus entries found under {}", self.input.display());
596        }
597
598        let staging_path = staging_out.keep();
599        if cmin_out_exists(&self.out) {
600            bail!(
601                "output corpus directory already exists: {}; minimized corpus remains staged at {}",
602                self.out.display(),
603                staging_path.display()
604            );
605        }
606        std::fs::rename(&staging_path, &self.out).with_context(|| {
607            format!(
608                "failed to rename minimized corpus {} to {}",
609                staging_path.display(),
610                self.out.display()
611            )
612        })?;
613
614        sh_println!(
615            "minimized corpus: {total_entries} entries, {before_txs} txs -> {after_txs} txs in {}",
616            self.out.display()
617        )?;
618        sh_status!("attempted {attempts} candidate replays")?;
619        if skipped_entries > 0 {
620            sh_status!("skipped {skipped_entries} entries that could not be read or were empty")?;
621        }
622        Ok(())
623    }
624}
625
626fn minimize_entry(
627    session: &FuzzMinimizeReplaySession,
628    decoder: &CorpusDecoder,
629    input: &Path,
630    sequence: &mut Vec<BasicTxDetails>,
631    max_attempts: usize,
632) -> Result<usize> {
633    let evm_edge_indices = FuzzMinimizeEdgeIndices::default();
634    let baseline = replay_baseline(session, evm_edge_indices.clone(), sequence.clone())
635        .with_context(|| format!("failed to replay baseline corpus entry {}", input.display()))?;
636    if baseline.requirements.is_empty() {
637        bail!(
638            "replayed 0 transactions from {}; check that --mc/--mt and replay-critical options match the corpus entry",
639            input.display()
640        );
641    }
642    if !baseline.has_failure && !baseline.has_coverage {
643        bail!("baseline replay for {} produced no failure or edge coverage", input.display());
644    }
645
646    let mut ctx = MinimizeContext::new(session, evm_edge_indices, baseline, max_attempts);
647    minimize_sequence(&mut ctx, sequence, decoder)?;
648    Ok(ctx.attempts)
649}
650
651struct ReplayBaseline {
652    requirements: BTreeMap<String, ReplayObservation>,
653    has_failure: bool,
654    has_coverage: bool,
655}
656
657fn replay_baseline(
658    session: &FuzzMinimizeReplaySession,
659    evm_edge_indices: FuzzMinimizeEdgeIndices,
660    sequence: Vec<BasicTxDetails>,
661) -> Result<ReplayBaseline> {
662    let observations = replay_candidate(session, evm_edge_indices, sequence)?;
663    let mut requirements = BTreeMap::new();
664    let mut has_failure = false;
665    let mut has_coverage = false;
666    for FuzzMinimizeObservation { target, observation } in observations {
667        let observation_has_failure = observation.failure.is_some();
668        let observation_has_coverage = has_edges(&observation);
669        if observation.replayed == 0 && !observation_has_failure && !observation_has_coverage {
670            continue;
671        }
672        has_failure |= observation_has_failure;
673        has_coverage |= observation_has_coverage;
674        requirements.insert(target, observation);
675    }
676    Ok(ReplayBaseline { requirements, has_failure, has_coverage })
677}
678
679struct MinimizeContext<'a> {
680    session: &'a FuzzMinimizeReplaySession,
681    evm_edge_indices: FuzzMinimizeEdgeIndices,
682    baseline: ReplayBaseline,
683    max_attempts: usize,
684    attempts: usize,
685}
686
687impl<'a> MinimizeContext<'a> {
688    const fn new(
689        session: &'a FuzzMinimizeReplaySession,
690        evm_edge_indices: FuzzMinimizeEdgeIndices,
691        baseline: ReplayBaseline,
692        max_attempts: usize,
693    ) -> Self {
694        Self { session, evm_edge_indices, baseline, max_attempts, attempts: 0 }
695    }
696
697    const fn at_budget(&self) -> bool {
698        self.attempts >= self.max_attempts
699    }
700
701    const fn remaining_attempts(&self) -> usize {
702        self.max_attempts.saturating_sub(self.attempts)
703    }
704
705    fn accepts(&mut self, candidate: &[BasicTxDetails]) -> Result<bool> {
706        if self.at_budget() {
707            return Ok(false);
708        }
709        self.attempts += 1;
710        let observations =
711            replay_candidate(self.session, self.evm_edge_indices.clone(), candidate.to_vec())?;
712        let observations = observations
713            .into_iter()
714            .map(|obs| (obs.target, obs.observation))
715            .collect::<BTreeMap<_, _>>();
716
717        if has_new_active_targets(&observations, &self.baseline.requirements) {
718            return Ok(false);
719        }
720
721        for (target, baseline) in &self.baseline.requirements {
722            let Some(candidate) = observations.get(target) else {
723                return Ok(false);
724            };
725            if let Some(failure) = &baseline.failure {
726                if candidate.failure.as_ref() != Some(failure) {
727                    return Ok(false);
728                }
729            } else if candidate.failure.is_some() || !same_edge_hit_sets(candidate, baseline) {
730                return Ok(false);
731            }
732        }
733
734        Ok(true)
735    }
736}
737
738fn has_new_active_targets(
739    observations: &BTreeMap<String, ReplayObservation>,
740    baseline: &BTreeMap<String, ReplayObservation>,
741) -> bool {
742    observations.iter().any(|(target, observation)| {
743        !baseline.contains_key(target)
744            && (observation.failure.is_some() || observation.replayed > 0 || has_edges(observation))
745    })
746}
747
748fn minimize_sequence(
749    ctx: &mut MinimizeContext<'_>,
750    sequence: &mut Vec<BasicTxDetails>,
751    decoder: &CorpusDecoder,
752) -> Result<()> {
753    let mut idx = 0;
754    while idx < sequence.len() && !ctx.at_budget() {
755        let removed = sequence.remove(idx);
756        if ctx.accepts(sequence)? {
757            continue;
758        }
759        sequence.insert(idx, removed);
760        idx += 1;
761    }
762
763    let mut idx = 0;
764    while idx < sequence.len() && !ctx.at_budget() {
765        let restore = sequence[idx].clone();
766        cleanup_metadata(&mut sequence[idx]);
767        if !ctx.accepts(sequence)? {
768            sequence[idx] = restore;
769        }
770        idx += 1;
771    }
772
773    let mut tx_idx = 0;
774    while tx_idx < sequence.len() && !ctx.at_budget() {
775        loop {
776            let candidates = abi_calldata_candidates(
777                sequence[tx_idx].call_details.calldata.as_ref(),
778                decoder,
779                ctx.remaining_attempts(),
780            );
781            if candidates.is_empty() {
782                break;
783            };
784
785            let mut accepted = false;
786            for calldata in candidates {
787                if calldata.len() > sequence[tx_idx].call_details.calldata.len() {
788                    continue;
789                }
790                let restore =
791                    std::mem::replace(&mut sequence[tx_idx].call_details.calldata, calldata.into());
792                if ctx.accepts(sequence)? {
793                    accepted = true;
794                    break;
795                }
796                sequence[tx_idx].call_details.calldata = restore;
797                if ctx.at_budget() {
798                    break;
799                }
800            }
801            if !accepted || ctx.at_budget() {
802                break;
803            }
804        }
805        tx_idx += 1;
806    }
807
808    Ok(())
809}
810
811fn temporary_cmin_out(out: &Path) -> Result<TempDir> {
812    let parent =
813        out.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or(Path::new("."));
814    let filename =
815        out.file_name().ok_or_else(|| eyre::eyre!("missing output corpus directory name"))?;
816    let prefix = format!(".{}.tmp-", filename.to_string_lossy());
817    TempDirBuilder::new().prefix(&prefix).tempdir_in(parent).with_context(|| {
818        format!("failed to create temporary output directory for {}", out.display())
819    })
820}
821
822fn input_corpus_root(input: &Path) -> &Path {
823    input.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or(Path::new("."))
824}
825
826fn read_single_sequence(path: &Path) -> Result<Vec<BasicTxDetails>> {
827    let entries = read_corpus_tree(path)?;
828    let [entry] = entries.as_slice() else {
829        bail!("expected one corpus entry at {}, found {}", path.display(), entries.len());
830    };
831    entry
832        .read_tx_seq()
833        .with_context(|| format!("failed to read corpus entry {}", entry.path.display()))
834}
835
836fn validate_tmin_output_path(path: &Path) -> Result<()> {
837    if std::fs::symlink_metadata(path).is_ok() {
838        bail!("output corpus file already exists: {}", path.display());
839    }
840    Ok(())
841}
842
843fn write_sequence_create_new(path: &Path, sequence: &[BasicTxDetails]) -> Result<()> {
844    if let Some(parent) = path.parent()
845        && !parent.as_os_str().is_empty()
846    {
847        fs::create_dir_all(parent)?;
848    }
849
850    let file = OpenOptions::new()
851        .write(true)
852        .create_new(true)
853        .open(path)
854        .with_context(|| format!("failed to create output corpus file {}", path.display()))?;
855    if is_gzip_path(path) {
856        let writer = BufWriter::new(file);
857        let mut encoder = GzEncoder::new(writer, Compression::default());
858        serde_json::to_writer(&mut encoder, &sequence)
859            .with_context(|| format!("failed to write output corpus file {}", path.display()))?;
860        let mut writer = encoder
861            .finish()
862            .with_context(|| format!("failed to finish output corpus file {}", path.display()))?;
863        writer
864            .flush()
865            .with_context(|| format!("failed to flush output corpus file {}", path.display()))?;
866    } else {
867        let mut writer = BufWriter::new(file);
868        serde_json::to_writer(&mut writer, &sequence)
869            .with_context(|| format!("failed to write output corpus file {}", path.display()))?;
870        writer
871            .flush()
872            .with_context(|| format!("failed to flush output corpus file {}", path.display()))?;
873    }
874    Ok(())
875}
876
877fn is_gzip_path(path: &Path) -> bool {
878    path.extension()
879        .and_then(|extension| extension.to_str())
880        .is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
881}
882
883fn has_edges(observation: &ReplayObservation) -> bool {
884    observation.evm_edges.iter().any(|&edge| edge != 0)
885        || observation.sancov_edges.iter().any(|&edge| edge != 0)
886}
887
888fn same_edge_hit_sets(candidate: &ReplayObservation, baseline: &ReplayObservation) -> bool {
889    same_edge_hit_set(&candidate.evm_edges, &baseline.evm_edges)
890        && same_edge_hit_set(&candidate.sancov_edges, &baseline.sancov_edges)
891}
892
893fn same_edge_hit_set(candidate: &[u8], baseline: &[u8]) -> bool {
894    let len = candidate.len().max(baseline.len());
895    (0..len).all(|idx| {
896        let candidate_hit = candidate.get(idx).copied().unwrap_or_default() != 0;
897        let baseline_hit = baseline.get(idx).copied().unwrap_or_default() != 0;
898        candidate_hit == baseline_hit
899    })
900}
901
902fn cleanup_metadata(tx: &mut BasicTxDetails) {
903    if tx.warp == Some(U256::ZERO) {
904        tx.warp = None;
905    }
906    if tx.roll == Some(U256::ZERO) {
907        tx.roll = None;
908    }
909    if tx.call_details.value == Some(U256::ZERO) {
910        tx.call_details.value = None;
911    }
912}
913
914fn abi_calldata_candidates(calldata: &[u8], decoder: &CorpusDecoder, limit: usize) -> Vec<Vec<u8>> {
915    if limit == 0 {
916        return Vec::new();
917    }
918    let Some((function, args)) = decoder.unique_decodable_function(calldata) else {
919        return Vec::new();
920    };
921
922    let mut candidates = Vec::new();
923    for arg_idx in 0..args.len() {
924        for value in value_candidates(&args[arg_idx], limit.saturating_sub(candidates.len())) {
925            let mut candidate_args = args.clone();
926            candidate_args[arg_idx] = value;
927            let Ok(encoded) = function.abi_encode_input(&candidate_args) else {
928                continue;
929            };
930            if encoded.as_slice() != calldata && !candidates.contains(&encoded) {
931                candidates.push(encoded);
932                if candidates.len() >= limit {
933                    return candidates;
934                }
935            }
936        }
937    }
938    candidates
939}
940
941fn value_candidates(value: &DynSolValue, limit: usize) -> Vec<DynSolValue> {
942    let mut candidates = Vec::new();
943    push_scalar_value_candidates(value, &mut candidates, limit);
944    push_compound_value_candidates(value, &mut candidates, limit);
945    candidates.into_iter().filter(|candidate| candidate != value).collect()
946}
947
948fn push_candidate(candidates: &mut Vec<DynSolValue>, limit: usize, candidate: DynSolValue) -> bool {
949    if candidates.len() >= limit {
950        return false;
951    }
952    candidates.push(candidate);
953    true
954}
955
956fn push_scalar_value_candidates(
957    value: &DynSolValue,
958    candidates: &mut Vec<DynSolValue>,
959    limit: usize,
960) {
961    match value {
962        DynSolValue::Bool(_) => {
963            push_candidate(candidates, limit, DynSolValue::Bool(false));
964        }
965        DynSolValue::Uint(value, bits) => {
966            if *value != U256::ZERO {
967                push_candidate(candidates, limit, DynSolValue::Uint(U256::ZERO, *bits));
968            }
969            if *value > U256::from(1) {
970                push_candidate(candidates, limit, DynSolValue::Uint(U256::from(1), *bits));
971            }
972        }
973        DynSolValue::Int(value, bits) => {
974            if *value != I256::ZERO {
975                push_candidate(candidates, limit, DynSolValue::Int(I256::ZERO, *bits));
976            }
977            if *value != I256::ZERO
978                && *value != I256::from_raw(U256::from(1))
979                && *value != I256::MINUS_ONE
980            {
981                push_candidate(
982                    candidates,
983                    limit,
984                    DynSolValue::Int(I256::from_raw(U256::from(1)), *bits),
985                );
986            }
987            if *value != I256::ZERO
988                && *value != I256::from_raw(U256::from(1))
989                && *value != I256::MINUS_ONE
990            {
991                push_candidate(candidates, limit, DynSolValue::Int(I256::MINUS_ONE, *bits));
992            }
993        }
994        DynSolValue::Address(_) => {
995            push_candidate(candidates, limit, DynSolValue::Address(Address::ZERO));
996        }
997        DynSolValue::FixedBytes(_, size) => {
998            push_candidate(candidates, limit, DynSolValue::FixedBytes(B256::ZERO, *size));
999        }
1000        DynSolValue::Function(_) => {
1001            push_candidate(candidates, limit, DynSolValue::Function(SolFunction::ZERO));
1002        }
1003        DynSolValue::Bytes(bytes) => {
1004            push_candidate(candidates, limit, DynSolValue::Bytes(Vec::new()));
1005            if bytes.len() > 1 {
1006                push_candidate(
1007                    candidates,
1008                    limit,
1009                    DynSolValue::Bytes(bytes[..bytes.len() / 2].to_vec()),
1010                );
1011            }
1012        }
1013        DynSolValue::String(string) => {
1014            push_candidate(candidates, limit, DynSolValue::String(String::new()));
1015            if string.len() > 1 {
1016                let mut half = string.len() / 2;
1017                while half > 0 && !string.is_char_boundary(half) {
1018                    half -= 1;
1019                }
1020                push_candidate(candidates, limit, DynSolValue::String(string[..half].to_string()));
1021            }
1022        }
1023        DynSolValue::Array(_)
1024        | DynSolValue::FixedArray(_)
1025        | DynSolValue::Tuple(_)
1026        | DynSolValue::CustomStruct { .. } => {}
1027    }
1028}
1029
1030fn push_compound_value_candidates(
1031    value: &DynSolValue,
1032    candidates: &mut Vec<DynSolValue>,
1033    limit: usize,
1034) {
1035    match value {
1036        DynSolValue::Array(values) => {
1037            push_candidate(candidates, limit, DynSolValue::Array(Vec::new()));
1038            if values.len() > 1 {
1039                push_candidate(
1040                    candidates,
1041                    limit,
1042                    DynSolValue::Array(values[..values.len() / 2].to_vec()),
1043                );
1044            }
1045            push_child_value_candidates(values, candidates, limit, |values| {
1046                DynSolValue::Array(values.to_vec())
1047            });
1048        }
1049        DynSolValue::FixedArray(values) => {
1050            push_child_value_candidates(values, candidates, limit, |values| {
1051                DynSolValue::FixedArray(values.to_vec())
1052            });
1053        }
1054        DynSolValue::Tuple(values) => {
1055            push_child_value_candidates(values, candidates, limit, |values| {
1056                DynSolValue::Tuple(values.to_vec())
1057            });
1058        }
1059        DynSolValue::CustomStruct { name, prop_names, tuple } => {
1060            push_child_value_candidates(tuple, candidates, limit, |values| {
1061                DynSolValue::CustomStruct {
1062                    name: name.clone(),
1063                    prop_names: prop_names.clone(),
1064                    tuple: values.to_vec(),
1065                }
1066            });
1067        }
1068        DynSolValue::Bool(_)
1069        | DynSolValue::Uint(_, _)
1070        | DynSolValue::Int(_, _)
1071        | DynSolValue::Address(_)
1072        | DynSolValue::FixedBytes(_, _)
1073        | DynSolValue::Function(_)
1074        | DynSolValue::Bytes(_)
1075        | DynSolValue::String(_) => {}
1076    }
1077}
1078
1079fn push_child_value_candidates(
1080    values: &[DynSolValue],
1081    candidates: &mut Vec<DynSolValue>,
1082    limit: usize,
1083    rebuild: impl Fn(&[DynSolValue]) -> DynSolValue,
1084) {
1085    for idx in 0..values.len() {
1086        if candidates.len() >= limit {
1087            return;
1088        }
1089        for child in value_candidates(&values[idx], limit.saturating_sub(candidates.len())) {
1090            let mut values = values.to_vec();
1091            values[idx] = child;
1092            candidates.push(rebuild(&values));
1093            if candidates.len() >= limit {
1094                return;
1095            }
1096        }
1097    }
1098}
1099
1100#[derive(Serialize)]
1101pub struct DisplayCorpusEntry {
1102    path: PathBuf,
1103    sequence: Vec<DisplayTxDetails>,
1104}
1105
1106#[derive(Serialize)]
1107struct DisplayTxDetails {
1108    #[serde(flatten)]
1109    raw: BasicTxDetails,
1110    #[serde(skip_serializing_if = "Option::is_none")]
1111    decoded: Option<DecodedCall>,
1112}
1113
1114#[derive(Serialize)]
1115struct DecodedCall {
1116    #[serde(skip_serializing_if = "Option::is_none")]
1117    contract: Option<String>,
1118    #[serde(skip_serializing_if = "Vec::is_empty")]
1119    ambiguous_contracts: Vec<String>,
1120    signature: String,
1121    args: Vec<String>,
1122    call: String,
1123}
1124
1125struct IndexedFunction {
1126    contract: String,
1127    function: Function,
1128}
1129
1130#[derive(Default)]
1131struct CorpusDecoder {
1132    functions: BTreeMap<Selector, Vec<IndexedFunction>>,
1133}
1134
1135impl CorpusDecoder {
1136    fn load() -> Self {
1137        Config::load().ok().map(|config| Self::from_artifacts(&config.out)).unwrap_or_default()
1138    }
1139
1140    fn from_artifacts(out: &Path) -> Self {
1141        let mut this = Self::default();
1142        if !out.is_dir() {
1143            return this;
1144        }
1145
1146        for path in fs::json_files(out) {
1147            let Ok(artifact) = fs::read_json_file::<serde_json::Value>(&path) else {
1148                continue;
1149            };
1150            let Some(abi_value) = artifact.get("abi").cloned() else {
1151                continue;
1152            };
1153            let Ok(abi) = serde_json::from_value::<JsonAbi>(abi_value) else {
1154                continue;
1155            };
1156            let contract =
1157                path.file_stem().and_then(|name| name.to_str()).unwrap_or("<unknown>").to_string();
1158
1159            for function in abi.functions().cloned() {
1160                this.functions
1161                    .entry(function.selector())
1162                    .or_default()
1163                    .push(IndexedFunction { contract: contract.clone(), function });
1164            }
1165        }
1166
1167        this
1168    }
1169
1170    fn decode(&self, tx: &BasicTxDetails) -> Option<DecodedCall> {
1171        let calldata = tx.call_details.calldata.as_ref();
1172        if calldata.len() < 4 {
1173            return None;
1174        }
1175
1176        let selector = Selector::from_slice(&calldata[..4]);
1177        let functions = self.functions.get(&selector)?;
1178        let (function, decoded_args) = self.unique_decodable_function(calldata)?;
1179        let args = format_tokens_raw(&decoded_args).collect::<Vec<_>>();
1180        let signature = function.signature();
1181
1182        let mut contracts = functions
1183            .iter()
1184            .filter(|indexed| indexed.function.signature() == signature.as_str())
1185            .map(|indexed| indexed.contract.clone())
1186            .collect::<Vec<_>>();
1187        contracts.sort();
1188        contracts.dedup();
1189
1190        let function_call = format!("{}({})", function.name, args.join(", "));
1191        if contracts.len() == 1 {
1192            let contract = contracts.pop()?;
1193            Some(DecodedCall {
1194                call: format!("{contract}.{function_call}"),
1195                contract: Some(contract),
1196                ambiguous_contracts: Vec::new(),
1197                signature,
1198                args,
1199            })
1200        } else {
1201            Some(DecodedCall {
1202                call: function_call,
1203                contract: None,
1204                ambiguous_contracts: contracts,
1205                signature,
1206                args,
1207            })
1208        }
1209    }
1210
1211    fn unique_decodable_function(&self, calldata: &[u8]) -> Option<(&Function, Vec<DynSolValue>)> {
1212        if calldata.len() < 4 {
1213            return None;
1214        }
1215
1216        let selector = Selector::from_slice(&calldata[..4]);
1217        let mut unique = None;
1218        for (function, decoded_args) in
1219            self.functions.get(&selector)?.iter().filter_map(|indexed| {
1220                let decoded_args = indexed.function.abi_decode_input(&calldata[4..]).ok()?;
1221                Some((&indexed.function, decoded_args))
1222            })
1223        {
1224            let signature = function.signature();
1225            match &unique {
1226                Some((existing, _, _)) if existing == &signature => {}
1227                Some(_) => return None,
1228                None => unique = Some((signature, function, decoded_args)),
1229            }
1230        }
1231        unique.map(|(_, function, decoded_args)| (function, decoded_args))
1232    }
1233}
1234
1235fn read_entries(
1236    path: &Path,
1237    limit: Option<usize>,
1238    decoder: &CorpusDecoder,
1239) -> Result<Vec<DisplayCorpusEntry>> {
1240    let iter = read_corpus_entries(path)?.into_iter().take(limit.unwrap_or(usize::MAX));
1241    iter.map(|entry| {
1242        let sequence = entry
1243            .read_tx_seq()
1244            .with_context(|| format!("failed to read corpus entry {}", entry.path.display()))?;
1245        let sequence = sequence
1246            .into_iter()
1247            .map(|raw| {
1248                let decoded = decoder.decode(&raw);
1249                DisplayTxDetails { raw, decoded }
1250            })
1251            .collect();
1252        Ok(DisplayCorpusEntry { path: entry.path, sequence })
1253    })
1254    .collect()
1255}
1256
1257fn read_corpus_entries(path: &Path) -> Result<Vec<CorpusDirEntry>> {
1258    let entries = read_corpus_tree(path)?;
1259    if entries.is_empty() {
1260        bail!("no corpus entries found under {}", path.display());
1261    }
1262    Ok(entries)
1263}
1264
1265#[derive(Clone, Debug, Parser)]
1266struct FuzzMinimizeTestArgs {
1267    #[command(flatten)]
1268    global: GlobalArgs,
1269    #[command(flatten)]
1270    filter: FilterArgs,
1271    #[command(flatten)]
1272    evm: EvmArgs,
1273    #[command(flatten)]
1274    build: BuildOpts,
1275}
1276
1277impl FuzzMinimizeTestArgs {
1278    async fn prepare_session(self, corpus_dir: &Path) -> Result<FuzzMinimizeReplaySession> {
1279        let mut test = TestArgs::parse_from(["test", "-q"]);
1280        test.set_fuzz_minimize_replay_options(self.global, self.evm, self.build, self.filter);
1281        test.enable_fuzz_only();
1282        prepare_minimize_session(&mut test, corpus_dir).await
1283    }
1284
1285    fn decoder(&self) -> CorpusDecoder {
1286        self.build
1287            .load_config_no_warnings()
1288            .ok()
1289            .map(|config| CorpusDecoder::from_artifacts(&config.out))
1290            .unwrap_or_default()
1291    }
1292}
1293
1294struct QuietShellGuard {
1295    previous: OutputMode,
1296}
1297
1298impl QuietShellGuard {
1299    fn new() -> Self {
1300        let mut shell = Shell::get();
1301        let previous = shell.output_mode();
1302        shell.set_output_mode(OutputMode::Quiet);
1303        Self { previous }
1304    }
1305}
1306
1307impl Drop for QuietShellGuard {
1308    fn drop(&mut self) {
1309        Shell::get().set_output_mode(self.previous);
1310    }
1311}
1312
1313async fn prepare_minimize_session(
1314    test: &mut TestArgs,
1315    corpus_dir: &Path,
1316) -> Result<FuzzMinimizeReplaySession> {
1317    let _quiet = QuietShellGuard::new();
1318    test.prepare_fuzz_minimize_replay(corpus_dir).await
1319}
1320
1321fn replay_candidate(
1322    session: &FuzzMinimizeReplaySession,
1323    evm_edge_indices: FuzzMinimizeEdgeIndices,
1324    sequence: Vec<BasicTxDetails>,
1325) -> Result<Vec<FuzzMinimizeObservation>> {
1326    let _quiet = QuietShellGuard::new();
1327    session.replay(sequence, evm_edge_indices)
1328}
1329
1330fn merge_new_edges(cumulative: &mut ReplayObservation, observation: &ReplayObservation) -> bool {
1331    merge_new_edge_vec(&mut cumulative.evm_edges, &observation.evm_edges)
1332        | merge_new_edge_vec(&mut cumulative.sancov_edges, &observation.sancov_edges)
1333}
1334
1335fn merge_new_edge_vec(cumulative: &mut Vec<u8>, candidate: &[u8]) -> bool {
1336    if cumulative.len() < candidate.len() {
1337        cumulative.resize(candidate.len(), 0);
1338    }
1339    let mut improved = false;
1340    for (cumulative, &candidate) in cumulative.iter_mut().zip(candidate) {
1341        if *cumulative < candidate {
1342            *cumulative = candidate;
1343            improved = true;
1344        }
1345    }
1346    improved
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351    use super::*;
1352    use clap::CommandFactory;
1353    use foundry_evm::executors::ReplayFailure;
1354
1355    #[test]
1356    fn fuzz_args_clap_shape_is_valid() {
1357        FuzzArgs::command().debug_assert();
1358    }
1359
1360    #[test]
1361    fn fuzz_run_rejects_fuzz_worker() {
1362        assert!(FuzzArgs::try_parse_from(["foundry-cli", "run", "--fuzz-worker", "1"]).is_err());
1363    }
1364
1365    #[test]
1366    fn fuzz_run_rejects_fuzz_run() {
1367        assert!(FuzzArgs::try_parse_from(["foundry-cli", "run", "--fuzz-run", "1"]).is_err());
1368    }
1369
1370    fn decoder_with_functions(functions: Vec<Function>) -> CorpusDecoder {
1371        let mut decoder = CorpusDecoder::default();
1372        for function in functions {
1373            decoder
1374                .functions
1375                .entry(function.selector())
1376                .or_default()
1377                .push(IndexedFunction { contract: "Target".to_string(), function });
1378        }
1379        decoder
1380    }
1381
1382    fn candidate_args(function: &Function, candidates: Vec<Vec<u8>>) -> Vec<Vec<DynSolValue>> {
1383        candidates
1384            .into_iter()
1385            .map(|calldata| function.abi_decode_input(&calldata[4..]).unwrap())
1386            .collect()
1387    }
1388
1389    #[test]
1390    fn merge_new_edges_keeps_sancov_hit_count_bucket_increases() {
1391        let mut cumulative = ReplayObservation { sancov_edges: vec![0, 1], ..Default::default() };
1392        let candidate = ReplayObservation { sancov_edges: vec![0, 8], ..Default::default() };
1393
1394        assert!(merge_new_edges(&mut cumulative, &candidate));
1395        assert_eq!(cumulative.sancov_edges, vec![0, 8]);
1396    }
1397
1398    #[test]
1399    fn same_edge_hit_sets_allow_hit_count_bucket_changes() {
1400        let baseline = ReplayObservation { evm_edges: vec![0, 8], ..Default::default() };
1401        let candidate = ReplayObservation { evm_edges: vec![0, 1], ..Default::default() };
1402
1403        assert!(same_edge_hit_sets(&candidate, &baseline));
1404    }
1405
1406    #[test]
1407    fn same_edge_hit_sets_treat_missing_trailing_buckets_as_zero() {
1408        let baseline = ReplayObservation { sancov_edges: vec![0, 0], ..Default::default() };
1409        let candidate = ReplayObservation { sancov_edges: vec![0], ..Default::default() };
1410
1411        assert!(same_edge_hit_sets(&candidate, &baseline));
1412    }
1413
1414    #[test]
1415    fn has_new_active_targets_rejects_candidate_only_activity() {
1416        let baseline = BTreeMap::from([(
1417            "A".to_string(),
1418            ReplayObservation { evm_edges: vec![1], ..Default::default() },
1419        )]);
1420        let observations = BTreeMap::from([
1421            ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1422            ("B".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1423        ]);
1424
1425        assert!(has_new_active_targets(&observations, &baseline));
1426    }
1427
1428    #[test]
1429    fn has_new_active_targets_rejects_candidate_only_failures() {
1430        let baseline = BTreeMap::from([(
1431            "A".to_string(),
1432            ReplayObservation { evm_edges: vec![1], ..Default::default() },
1433        )]);
1434        let observations = BTreeMap::from([
1435            ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1436            (
1437                "B".to_string(),
1438                ReplayObservation {
1439                    failure: Some(ReplayFailure::AfterInvariant),
1440                    ..Default::default()
1441                },
1442            ),
1443        ]);
1444
1445        assert!(has_new_active_targets(&observations, &baseline));
1446    }
1447
1448    #[test]
1449    fn has_new_active_targets_rejects_candidate_only_replayed_transactions() {
1450        let baseline = BTreeMap::from([(
1451            "A".to_string(),
1452            ReplayObservation { evm_edges: vec![1], ..Default::default() },
1453        )]);
1454        let observations = BTreeMap::from([
1455            ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1456            ("B".to_string(), ReplayObservation { replayed: 1, ..Default::default() }),
1457        ]);
1458
1459        assert!(has_new_active_targets(&observations, &baseline));
1460    }
1461
1462    #[test]
1463    fn has_new_active_targets_allows_inactive_candidate_only_targets() {
1464        let baseline = BTreeMap::from([(
1465            "A".to_string(),
1466            ReplayObservation { evm_edges: vec![1], ..Default::default() },
1467        )]);
1468        let observations = BTreeMap::from([
1469            ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1470            ("B".to_string(), ReplayObservation::default()),
1471        ]);
1472
1473        assert!(!has_new_active_targets(&observations, &baseline));
1474    }
1475
1476    #[test]
1477    fn abi_calldata_candidates_simplify_scalar_values() {
1478        let function = Function::parse("target(uint256,int256,bool,address)").unwrap();
1479        let decoder = decoder_with_functions(vec![function.clone()]);
1480        let calldata = function
1481            .abi_encode_input(&[
1482                DynSolValue::Uint(U256::from(42), 256),
1483                DynSolValue::Int(I256::from_raw(U256::from(42)), 256),
1484                DynSolValue::Bool(true),
1485                DynSolValue::Address(Address::from([0x11; 20])),
1486            ])
1487            .unwrap();
1488
1489        let candidates =
1490            candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX));
1491
1492        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Uint(U256::ZERO, 256)));
1493        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Uint(U256::from(1), 256)));
1494        assert!(candidates.iter().any(|args| args[1] == DynSolValue::Int(I256::ZERO, 256)));
1495        assert!(
1496            candidates
1497                .iter()
1498                .any(|args| { args[1] == DynSolValue::Int(I256::from_raw(U256::from(1)), 256) })
1499        );
1500        assert!(candidates.iter().any(|args| args[1] == DynSolValue::Int(I256::MINUS_ONE, 256)));
1501        assert!(candidates.iter().any(|args| args[2] == DynSolValue::Bool(false)));
1502        assert!(candidates.iter().any(|args| args[3] == DynSolValue::Address(Address::ZERO)));
1503    }
1504
1505    #[test]
1506    fn abi_calldata_candidates_do_not_oscillate_signed_one_values() {
1507        let function = Function::parse("target(int256)").unwrap();
1508        let decoder = decoder_with_functions(vec![function.clone()]);
1509        let one = DynSolValue::Int(I256::from_raw(U256::from(1)), 256);
1510        let minus_one = DynSolValue::Int(I256::MINUS_ONE, 256);
1511
1512        let one_calldata = function.abi_encode_input(&[one]).unwrap();
1513        let one_candidates =
1514            candidate_args(&function, abi_calldata_candidates(&one_calldata, &decoder, usize::MAX));
1515        assert_eq!(one_candidates, vec![vec![DynSolValue::Int(I256::ZERO, 256)]]);
1516
1517        let minus_one_calldata = function.abi_encode_input(&[minus_one]).unwrap();
1518        let minus_one_candidates = candidate_args(
1519            &function,
1520            abi_calldata_candidates(&minus_one_calldata, &decoder, usize::MAX),
1521        );
1522        assert_eq!(minus_one_candidates, vec![vec![DynSolValue::Int(I256::ZERO, 256)]]);
1523    }
1524
1525    #[test]
1526    fn abi_calldata_candidates_shrink_dynamic_values() {
1527        let function = Function::parse("target(bytes,string,uint256[])").unwrap();
1528        let decoder = decoder_with_functions(vec![function.clone()]);
1529        let calldata = function
1530            .abi_encode_input(&[
1531                DynSolValue::Bytes(vec![1, 2, 3, 4]),
1532                DynSolValue::String("abcdef".to_string()),
1533                DynSolValue::Array(vec![
1534                    DynSolValue::Uint(U256::from(10), 256),
1535                    DynSolValue::Uint(U256::from(11), 256),
1536                    DynSolValue::Uint(U256::from(12), 256),
1537                    DynSolValue::Uint(U256::from(13), 256),
1538                ]),
1539            ])
1540            .unwrap();
1541
1542        let candidates =
1543            candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX));
1544
1545        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Bytes(Vec::new())));
1546        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Bytes(vec![1, 2])));
1547        assert!(candidates.iter().any(|args| args[1] == DynSolValue::String(String::new())));
1548        assert!(candidates.iter().any(|args| args[1] == DynSolValue::String("abc".to_string())));
1549        assert!(candidates.iter().any(|args| args[2] == DynSolValue::Array(Vec::new())));
1550        assert!(candidates.iter().any(|args| {
1551            args[2]
1552                == DynSolValue::Array(vec![
1553                    DynSolValue::Uint(U256::from(10), 256),
1554                    DynSolValue::Uint(U256::from(11), 256),
1555                ])
1556        }));
1557    }
1558
1559    #[test]
1560    fn abi_calldata_candidates_simplify_tuple_children() {
1561        let function = Function::parse("target((uint256,bool,address))").unwrap();
1562        let decoder = decoder_with_functions(vec![function.clone()]);
1563        let calldata = function
1564            .abi_encode_input(&[DynSolValue::Tuple(vec![
1565                DynSolValue::Uint(U256::from(42), 256),
1566                DynSolValue::Bool(true),
1567                DynSolValue::Address(Address::from([0x11; 20])),
1568            ])])
1569            .unwrap();
1570
1571        let candidates =
1572            candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX));
1573
1574        assert!(candidates.iter().any(|args| {
1575            args[0]
1576                == DynSolValue::Tuple(vec![
1577                    DynSolValue::Uint(U256::ZERO, 256),
1578                    DynSolValue::Bool(true),
1579                    DynSolValue::Address(Address::from([0x11; 20])),
1580                ])
1581        }));
1582        assert!(candidates.iter().any(|args| {
1583            args[0]
1584                == DynSolValue::Tuple(vec![
1585                    DynSolValue::Uint(U256::from(42), 256),
1586                    DynSolValue::Bool(false),
1587                    DynSolValue::Address(Address::from([0x11; 20])),
1588                ])
1589        }));
1590        assert!(candidates.iter().any(|args| {
1591            args[0]
1592                == DynSolValue::Tuple(vec![
1593                    DynSolValue::Uint(U256::from(42), 256),
1594                    DynSolValue::Bool(true),
1595                    DynSolValue::Address(Address::ZERO),
1596                ])
1597        }));
1598    }
1599
1600    #[test]
1601    fn abi_calldata_candidates_skip_ambiguous_or_undecodable_calldata() {
1602        let function = Function::parse("target(uint256)").unwrap();
1603        let other = Function::parse("other(uint256)").unwrap();
1604        let calldata =
1605            function.abi_encode_input(&[DynSolValue::Uint(U256::from(42), 256)]).unwrap();
1606
1607        let mut ambiguous = CorpusDecoder::default();
1608        ambiguous.functions.entry(function.selector()).or_default().extend([
1609            IndexedFunction { contract: "Target".to_string(), function: function.clone() },
1610            IndexedFunction { contract: "Other".to_string(), function: other },
1611        ]);
1612        assert!(abi_calldata_candidates(&calldata, &ambiguous, usize::MAX).is_empty());
1613
1614        let decoder = decoder_with_functions(vec![function]);
1615        assert!(
1616            abi_calldata_candidates(&calldata[..calldata.len() - 1], &decoder, usize::MAX)
1617                .is_empty()
1618        );
1619    }
1620
1621    #[test]
1622    fn abi_calldata_candidates_accept_same_signature_with_metadata_differences() {
1623        let function =
1624            Function::parse("function target(uint256 value) external returns (uint256)").unwrap();
1625        let same_signature = Function::parse("function target(uint256 value) view").unwrap();
1626        let calldata =
1627            function.abi_encode_input(&[DynSolValue::Uint(U256::from(42), 256)]).unwrap();
1628
1629        let mut decoder = CorpusDecoder::default();
1630        decoder.functions.entry(function.selector()).or_default().extend([
1631            IndexedFunction { contract: "WithReturn".to_string(), function: function.clone() },
1632            IndexedFunction { contract: "NoReturn".to_string(), function: same_signature },
1633        ]);
1634
1635        let candidates =
1636            candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX));
1637
1638        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Uint(U256::ZERO, 256)));
1639    }
1640}