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