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)]
201 pub(crate) fuzz_input_file: Option<String>,
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 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#[derive(Clone, Debug, Parser)]
249pub struct FuzzShowArgs {
250 #[arg(value_name = "PATH", value_hint = ValueHint::AnyPath)]
252 corpus: PathBuf,
253 #[arg(long, value_enum, default_value_t)]
255 format: CorpusShowFormat,
256 #[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#[derive(Clone, Debug, Parser)]
312pub struct FuzzCminArgs {
313 #[command(flatten)]
314 test: FuzzMinimizeTestArgs,
315 #[arg(value_name = "CORPUS_DIR", value_hint = ValueHint::DirPath)]
317 corpus_dir: PathBuf,
318 #[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#[derive(Clone, Debug, Parser)]
511pub struct FuzzTminArgs {
512 #[command(flatten)]
513 test: FuzzMinimizeTestArgs,
514 #[arg(value_name = "INPUT", value_hint = ValueHint::AnyPath)]
516 input: PathBuf,
517 #[arg(long = "corpus-out", value_name = "PATH", value_hint = ValueHint::AnyPath)]
519 out: PathBuf,
520 #[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}