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