Skip to content
Closed
21 changes: 21 additions & 0 deletions crates/config/src/fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ pub struct FuzzCorpusConfig {
/// Whether to capture comparison operands from sancov-instrumented crates
/// and inject them into the fuzz dictionary. Independent of `sancov_edges`.
pub sancov_trace_cmp: bool,
/// Whether to enable the symbolic-assist worker. When set, the master
/// fuzz worker periodically replays a corpus seed under a branch-trace
/// inspector, proposes ABI-aware calldata mutations that flip an
/// uncovered branch, validates them through the normal coverage gate,
/// and writes accepted candidates into its `sync/` directory so the
/// existing corpus protocol distributes them.
/// Requires `corpus_dir` to be set and EVM edge coverage to be enabled
/// (i.e. `sancov_edges` off).
pub symexec_assist: bool,
/// Number of fuzz runs between symbolic-assist cycles on the master
/// worker. Ignored when `symexec_assist` is `false`.
pub symexec_assist_interval: u32,
}

impl FuzzCorpusConfig {
Expand Down Expand Up @@ -178,6 +190,13 @@ impl FuzzCorpusConfig {
pub const fn is_coverage_guided(&self) -> bool {
self.corpus_dir.is_some()
}

/// Whether the symbolic-assist worker should run. v1 only supports the
/// EVM edge-coverage signal, so we require coverage-guided mode and
/// disable assist when sancov edges are taking over.
pub const fn symexec_assist_active(&self) -> bool {
self.symexec_assist && self.is_coverage_guided() && !self.sancov_edges
}
}

impl Default for FuzzCorpusConfig {
Expand All @@ -190,6 +209,8 @@ impl Default for FuzzCorpusConfig {
show_edge_coverage: false,
sancov_edges: false,
sancov_trace_cmp: false,
symexec_assist: false,
symexec_assist_interval: 200,
}
}
}
156 changes: 149 additions & 7 deletions crates/evm/evm/src/executors/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
//! - This all happens periodically, there is no clear order in which workers export or import
//! entries since it doesn't matter as long as the corpus eventually syncs across all workers

use crate::executors::{Executor, RawCallResult, invariant::execute_tx};
use crate::executors::{Executor, RawCallResult, invariant::execute_tx, symexec::SeedSnapshot};
use alloy_dyn_abi::JsonAbiExt;
use alloy_json_abi::Function;
use alloy_primitives::{Bytes, I256};
Expand Down Expand Up @@ -119,6 +119,14 @@ struct CorpusEntry {
/// Timestamp of when this entry was written to disk in seconds.
#[serde(skip_serializing)]
timestamp: u64,
/// Transient flag: when `true`, `new_input` returns this entry's
/// calldata verbatim on its next selection and then clears the flag.
/// Only set by `insert_symexec_candidate` so that a candidate whose
/// scalar argument is the precise magic the worker discovered gets
/// one un-mutated shot at the contract before `abi_mutate` scrambles
/// it. Not serialized — restarts lose the hint, which is fine.
#[serde(skip_serializing, default)]
try_verbatim_once: bool,
}

impl CorpusEntry {
Expand Down Expand Up @@ -149,6 +157,7 @@ impl CorpusEntry {
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs(),
try_verbatim_once: false,
}
}

Expand Down Expand Up @@ -716,12 +725,30 @@ impl WorkerCorpus {
let tx = if self.in_memory_corpus.is_empty() {
self.new_tx(test_runner)?
} else {
let corpus = &self.in_memory_corpus
[test_runner.rng().random_range(0..self.in_memory_corpus.len())];
self.current_mutated = Some(corpus.uuid);
let mut tx = corpus.tx_seq.first().unwrap().clone();
self.abi_mutate(&mut tx, function, test_runner, fuzz_state)?;
tx
let idx = test_runner.rng().random_range(0..self.in_memory_corpus.len());
// If a corpus entry was inserted with `try_verbatim_once`
// (i.e. via `insert_symexec_candidate`), run it as-is once so
// the precise scalar the worker discovered (e.g. a 32-byte EQ
// magic copied from a runtime compare) isn't scrambled by
// `abi_mutate` on its very first selection. Clear the flag so
// subsequent selections of the same entry follow the normal
// mutate-then-credit path.
if self.in_memory_corpus[idx].try_verbatim_once {
self.in_memory_corpus[idx].try_verbatim_once = false;
let corpus = &self.in_memory_corpus[idx];
// Set `current_mutated` so `process_inputs` still credits
// this entry if the verbatim run produces new coverage —
// a productive verbatim hit should affect favorability,
// just like a productive mutated hit would.
self.current_mutated = Some(corpus.uuid);
corpus.tx_seq.first().unwrap().clone()
} else {
let corpus = &self.in_memory_corpus[idx];
self.current_mutated = Some(corpus.uuid);
let mut tx = corpus.tx_seq.first().unwrap().clone();
self.abi_mutate(&mut tx, function, test_runner, fuzz_state)?;
tx
}
};

Ok(tx.call_details.calldata)
Expand Down Expand Up @@ -1123,6 +1150,121 @@ impl WorkerCorpus {
Ok(())
}

// -- Symbolic-assist helpers ------------------------------------------
//
// These accessors are used by `crate::executors::symexec::run_symexec_assist`
// to drive the master worker's symbolic-assist cycle without exposing
// `CorpusEntry` internals to the rest of the crate.

/// Whether this is the master worker (the only one allowed to drive
/// symbolic assist in v1).
pub const fn is_master(&self) -> bool {
self.id == 0
}

/// Snapshot a small candidate pool of seeds for symbolic-assist seed
/// scoring. Returns at most `MAX` entries (kept tiny so scoring stays
/// cheap).
pub fn symexec_seed_pool(&self) -> Vec<SeedSnapshot> {
const MAX: usize = 16;
self.in_memory_corpus
.iter()
.rev()
.take(MAX)
.map(|e| SeedSnapshot {
uuid: e.uuid,
tx_seq: e.tx_seq.clone(),
is_favored: e.is_favored,
new_finds_produced: e.new_finds_produced,
})
.collect()
}

/// Returns a copy of the current EVM history map. Used by the symbolic
/// worker to detect frontier (currently-unseen) edges without holding
/// the corpus mutably for the duration of replay.
pub fn history_map_snapshot(&self) -> Vec<u8> {
self.history_map.clone()
}

/// Directly insert a validated symbolic-assist candidate into the
/// master's in-memory corpus *and* persist it to disk.
///
/// Going through the usual `sync/` → `calibrate()` path is not
/// reliable for short campaigns: `last_sync_timestamp` is in seconds
/// resolution, so any candidate the worker produces within the same
/// second as the last sync would be filtered out by `load_sync_corpus`
/// and never imported. Inserting here avoids that race entirely.
pub fn insert_symexec_candidate(&mut self, tx_seq: Vec<BasicTxDetails>) -> Result<()> {
let Some(worker_dir) = &self.worker_dir else { return Ok(()) };
let corpus_dir = worker_dir.join(CORPUS_DIR);
foundry_common::fs::create_dir_all(&corpus_dir)?;

let mut corpus = CorpusEntry::new(tx_seq);
// Ask `new_input` to return this entry as-is on its very first
// selection so the magic value the worker spent effort discovering
// gets one un-mutated shot.
corpus.try_verbatim_once = true;
if let Err(err) = corpus.write_to_disk_in(&corpus_dir, self.config.corpus_gzip) {
debug!(target: "corpus", %err, "failed to persist symexec corpus entry");
}

// Track so the next master `export_to_workers` distributes this entry.
let new_index = self.in_memory_corpus.len();
self.new_entry_indices.push(new_index);
self.metrics.corpus_count += 1;
self.in_memory_corpus.push(corpus);
Ok(())
}

/// Validate a candidate sequence produced by the symbolic-assist
/// worker: re-run it through a clone of `executor` and report whether
/// it produced at least one previously-unseen EVM edge against a
/// snapshot of the history map. Does *not* mutate `self.history_map`
/// — the next `calibrate()` call will fold in the persisted file's
/// coverage in the normal way.
///
/// `stateful` mirrors `WorkerCorpus::new`'s replay behavior: when
/// `true`, intermediate txs are committed so later txs see the prefix
/// state (invariant tests). When `false`, each tx runs against the
/// post-`setUp` baseline (stateless fuzz).
pub fn symexec_validate<FEN: FoundryEvmNetwork>(
&self,
executor: &Executor<FEN>,
candidate: &[BasicTxDetails],
stateful: bool,
) -> Result<bool> {
if !self.config.collect_evm_edge_coverage() {
return Ok(false);
}
// DO NOT call `collect_edge_coverage(true)` here: it replaces the
// `EdgeCovInspector` with a fresh one whose `DefaultHashBuilder`
// is a *different* random seed, so the resulting
// `result.edge_coverage` would bucket edges under indices that do
// not correspond to anything in `self.history_map`. If the live
// executor truly has no `EdgeCovInspector` we have no way to
// produce comparable buckets, so bail out conservatively.
if executor.inspector().inner.edge_coverage.is_none() {
return Ok(false);
}
let mut executor = executor.clone();
let mut history = self.history_map.clone();
let mut sancov_history = self.sancov_history_map.clone();
let mut produced_new = false;
for tx in candidate {
let mut result = execute_tx(&mut executor, tx)?;
let (new_coverage, _is_edge) =
result.merge_all_coverage(&mut history, &mut sancov_history);
if new_coverage {
produced_new = true;
}
if stateful {
executor.commit(&mut result);
}
}
Ok(produced_new)
}

/// Helper to check if a tx can be replayed.
fn can_replay_tx(
tx: &BasicTxDetails,
Expand Down
44 changes: 44 additions & 0 deletions crates/evm/evm/src/executors/fuzz/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::executors::{
DURATION_BETWEEN_METRICS_REPORT, EarlyExit, Executor, FuzzTestTimer, RawCallResult,
corpus::{GlobalCorpusMetrics, WorkerCorpus},
symexec::{self, SymExecState},
};
use alloy_dyn_abi::JsonAbiExt;
use alloy_json_abi::Function;
Expand Down Expand Up @@ -503,6 +504,17 @@ impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
let sync_threshold = SYNC_INTERVAL + sync_offset;
let mut runs_since_sync = sync_threshold; // Always sync at the start.
let mut last_metrics_report = Instant::now();

// Symbolic-assist state — only initialised on the master worker
// (`worker_id == 0`). The master periodically replays a corpus seed
// under a branch-trace inspector, proposes ABI-aware mutations
// that flip an unseen branch, validates them via normal coverage,
// and writes accepted candidates into its own `sync/` directory so
// the existing corpus protocol distributes them.
let symexec_active = worker_id == 0 && self.config.corpus.symexec_assist_active();
let symexec_interval = self.config.corpus.symexec_assist_interval.max(1);
let mut runs_since_symexec: u32 = 0;
let mut symexec_state = SymExecState::default();
// Continue while:
// 1. Global state allows (not timed out, not at global limit, no failure found)
// 2. Worker hasn't reached its specific run limit
Expand Down Expand Up @@ -544,6 +556,38 @@ impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
runs_since_sync = 0;
}

if symexec_active {
runs_since_symexec += 1;
if runs_since_symexec >= symexec_interval {
let timer = Instant::now();
match symexec::run_symexec_assist(
&mut corpus,
&executor,
Some(func),
None,
&mut symexec_state,
/* stateful */ false,
) {
Ok(n) if n > 0 => {
trace!(
target: "corpus",
"symexec assist accepted {n} candidates in {:?}",
timer.elapsed()
);
}
Ok(_) => {}
Err(err) => {
debug!(
target: "corpus",
%err,
"symexec assist cycle errored"
);
}
}
runs_since_symexec = 0;
}
}

let fuzz_run = self.config.run.unwrap_or(worker.runs + 1);
if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut()
&& let Some(seed) = self.config.seed
Expand Down
44 changes: 43 additions & 1 deletion crates/evm/evm/src/executors/invariant/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use crate::{
executors::{
DURATION_BETWEEN_METRICS_REPORT, EarlyExit, EvmError, Executor, FuzzTestTimer,
RawCallResult, corpus::WorkerCorpus,
RawCallResult,
corpus::WorkerCorpus,
symexec::{self, SymExecState},
},
inspectors::Fuzzer,
};
Expand Down Expand Up @@ -507,7 +509,47 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
// Invariant runs with edge coverage if corpus dir is set or showing edge coverage.
let edge_coverage_enabled = self.config.corpus.collect_edge_coverage();

// Symbolic-assist state for invariant tests. The invariant runner
// is single-worker, so the assist always runs (when enabled) on
// the master corpus.
let symexec_active = self.config.corpus.symexec_assist_active();
let symexec_interval = self.config.corpus.symexec_assist_interval.max(1);
let mut runs_since_symexec: u32 = 0;
let mut symexec_state = SymExecState::default();

'stop: while continue_campaign(runs) {
if symexec_active {
runs_since_symexec += 1;
if runs_since_symexec >= symexec_interval {
let timer = Instant::now();
match symexec::run_symexec_assist(
&mut corpus_manager,
&self.executor,
None,
Some(&invariant_test.targeted_contracts),
&mut symexec_state,
/* stateful */ true,
) {
Ok(n) if n > 0 => {
trace!(
target: "corpus",
"symexec assist accepted {n} candidates in {:?}",
timer.elapsed()
);
}
Ok(_) => {}
Err(err) => {
debug!(
target: "corpus",
%err,
"symexec assist cycle errored"
);
}
}
runs_since_symexec = 0;
}
}

// Per-run failure count snapshot used to gate `afterInvariant` below.
let failures_before_run = invariant_test.test_data.failures.invariant_count();

Expand Down
Loading
Loading