Skip to main content

forge/cmd/
fuzz.rs

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