Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ members = [
"crates/evm/coverage/",
"crates/evm/evm/",
"crates/evm/fuzz/",
"crates/evm/tempo-coverage/",
"crates/evm/traces/",
"crates/fmt/",
"crates/forge/",
Expand Down Expand Up @@ -98,6 +99,16 @@ inherits = "release"
lto = "fat"
codegen-units = 1

# Coverage-guided fuzzing of Tempo precompiles.
# Only tempo-precompiles is compiled with sancov (via RUSTC_WRAPPER), so thin
# LTO is safe — it won't inline away the sancov callbacks from that crate.
# Build with: ./scripts/build-fuzz.sh
[profile.fuzz]
inherits = "release"
lto = "thin"
strip = "none"
debug = "line-tables-only"

# Speed up tests and dev build.
[profile.dev.package]
# Solc and artifacts.
Expand Down
Binary file not shown.
Binary file not shown.
30 changes: 29 additions & 1 deletion crates/config/src/fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ pub struct FuzzCorpusConfig {
pub corpus_min_size: usize,
/// Whether to collect and display edge coverage metrics.
pub show_edge_coverage: bool,
/// Whether to collect Tempo Rust precompile edge coverage via SanitizerCoverage.
/// When enabled, coverage from Tempo precompile execution is fed into the
/// same hitcount map used by the EVM edge coverage, making the fuzzer
/// coverage-guided for precompile code paths.
/// Requires building with sancov RUSTFLAGS (see docs/tempo-coverage.md).
pub tempo_precompile_edges: bool,
/// Whether to capture comparison operands from Tempo precompile execution
/// via SanitizerCoverage trace-cmp callbacks and inject them into the fuzz
/// dictionary. Independent of edge coverage — can be enabled alone.
/// Requires building with sancov RUSTFLAGS (see docs/tempo-coverage.md).
pub tempo_precompile_trace_cmp: bool,
}

impl FuzzCorpusConfig {
Expand All @@ -124,7 +135,22 @@ impl FuzzCorpusConfig {

/// Whether edge coverage should be collected and displayed.
pub fn collect_edge_coverage(&self) -> bool {
self.corpus_dir.is_some() || self.show_edge_coverage
self.corpus_dir.is_some() || self.show_edge_coverage || self.tempo_precompile_edges
}

/// Whether Tempo precompile edge coverage collection is enabled.
pub fn collect_tempo_precompile_edges(&self) -> bool {
self.tempo_precompile_edges && self.collect_edge_coverage()
}

/// Whether Tempo precompile trace-cmp capture is enabled.
pub fn collect_tempo_precompile_trace_cmp(&self) -> bool {
self.tempo_precompile_trace_cmp
}

/// Whether either Tempo precompile coverage mode is enabled (needs the guard).
pub fn tempo_precompile_active(&self) -> bool {
self.tempo_precompile_edges || self.tempo_precompile_trace_cmp
}

/// Whether coverage guided fuzzing is enabled.
Expand All @@ -141,6 +167,8 @@ impl Default for FuzzCorpusConfig {
corpus_min_mutations: 5,
corpus_min_size: 0,
show_edge_coverage: false,
tempo_precompile_edges: false,
tempo_precompile_trace_cmp: false,
}
}
}
1 change: 1 addition & 0 deletions crates/evm/evm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ foundry-compilers.workspace = true
foundry-config.workspace = true
foundry-evm-core.workspace = true
foundry-evm-coverage.workspace = true
foundry-tempo-coverage = { path = "../tempo-coverage" }
foundry-evm-fuzz.workspace = true
foundry-evm-hardforks.workspace = true
foundry-evm-networks.workspace = true
Expand Down
87 changes: 81 additions & 6 deletions crates/evm/evm/src/executors/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ const SYNC_DIR: &str = "sync";
const FAVORABILITY_THRESHOLD: f64 = 0.3;
const COVERAGE_MAP_SIZE: usize = 65536;

const PRODUCTIVITY_SMOOTHING_ALPHA: f64 = 1.0;
const PRODUCTIVITY_SMOOTHING_BETA: f64 = 10.0;
const WEIGHT_EPSILON: f64 = 0.01;
const EXPLORE_PROBABILITY: u32 = 10;

/// Threshold for compressing corpus entries.
/// 4KiB is usually the minimum file size on popular file systems.
const GZIP_THRESHOLD: usize = 4 * 1024;
Expand Down Expand Up @@ -494,17 +499,25 @@ impl WorkerCorpus {
if !self.in_memory_corpus.is_empty() {
self.evict_oldest_corpus()?;

// 10% of the time, generate a fresh random sequence instead of mutating corpus.
// This prevents corpus modes from missing paths that pure random exploration finds.
if test_runner.rng().random_ratio(1, 10) {
new_seq.push(self.new_tx(test_runner)?);
return Ok(new_seq);
}

let mutation_type = self
.mutation_generator
.new_tree(test_runner)
.map_err(|err| eyre!("Could not generate mutation type {err}"))?
.current();

let rng = test_runner.rng();
let corpus_len = self.in_memory_corpus.len();
let primary = &self.in_memory_corpus[rng.random_range(0..corpus_len)];
let secondary = &self.in_memory_corpus[rng.random_range(0..corpus_len)];
let primary_idx = self.select_weighted(test_runner.rng());
let secondary_idx = self.select_weighted(test_runner.rng());
let primary = &self.in_memory_corpus[primary_idx];
let secondary = &self.in_memory_corpus[secondary_idx];

let rng = test_runner.rng();
match mutation_type {
MutationType::Splice => {
trace!(target: "corpus", "splice {} and {}", primary.uuid, secondary.uuid);
Expand Down Expand Up @@ -591,6 +604,24 @@ impl WorkerCorpus {
}
}
}

// Havoc post-pass: after structural mutations, also mutate args of
// random calls (~30% per call) to inject dictionary values.
if !matches!(mutation_type, MutationType::Abi) && !new_seq.is_empty() {
let havoc_indices: Vec<usize> =
(0..new_seq.len()).filter(|_| test_runner.rng().random_ratio(3, 10)).collect();
for idx in havoc_indices {
let tx = &mut new_seq[idx];
let targets = targeted_contracts.targets.lock();
if let (_, Some(function)) = targets.fuzzed_artifacts(tx)
&& !function.inputs.is_empty()
{
let function = function.clone();
drop(targets);
let _ = self.abi_mutate(tx, &function, test_runner, fuzz_state);
}
}
}
}

// Make sure the new sequence contains at least one tx to start fuzzing from.
Expand Down Expand Up @@ -619,8 +650,8 @@ impl WorkerCorpus {
self.evict_oldest_corpus()?;

let tx = if !self.in_memory_corpus.is_empty() {
let corpus = &self.in_memory_corpus
[test_runner.rng().random_range(0..self.in_memory_corpus.len())];
let idx = self.select_weighted(test_runner.rng());
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)?;
Expand Down Expand Up @@ -670,6 +701,50 @@ impl WorkerCorpus {
Ok(sequence[depth].clone())
}

/// Select a corpus entry index using weighted sampling based on smoothed productivity.
///
/// Weight = epsilon + (new_finds + alpha) / (total_mutations + beta)
///
/// With `EXPLORE_PROBABILITY`% chance of uniform random selection (anti-starvation).
/// Entries with `total_mutations == 0` are always prioritized (unseen seeds).
fn select_weighted(&self, rng: &mut impl Rng) -> usize {
let corpus = &self.in_memory_corpus;
debug_assert!(!corpus.is_empty());

let unseen: Vec<usize> = corpus
.iter()
.enumerate()
.filter(|(_, e)| e.total_mutations == 0)
.map(|(i, _)| i)
.collect();
if !unseen.is_empty() {
return unseen[rng.random_range(0..unseen.len())];
}

if rng.random_ratio(EXPLORE_PROBABILITY, 100) {
return rng.random_range(0..corpus.len());
}

let weights: Vec<f64> = corpus
.iter()
.map(|e| {
let productivity = (e.new_finds_produced as f64 + PRODUCTIVITY_SMOOTHING_ALPHA)
/ (e.total_mutations as f64 + PRODUCTIVITY_SMOOTHING_BETA);
WEIGHT_EPSILON + productivity
})
.collect();

let total: f64 = weights.iter().sum();
let mut r = rng.random_range(0.0..total);
for (i, w) in weights.iter().enumerate() {
r -= w;
if r <= 0.0 {
return i;
}
}
corpus.len() - 1
}

/// Flush the oldest corpus mutated more than configured max mutations unless they are
/// favored.
fn evict_oldest_corpus(&mut self) -> Result<()> {
Expand Down
8 changes: 8 additions & 0 deletions crates/evm/evm/src/executors/invariant/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,14 @@ fn collect_data(
run_depth,
);

// Inject typed Tempo precompile comparison operands into the fuzz dictionary.
// These are persisted across runs and inserted into typed sample buckets.
if let Some(cmp_values) = &call_result.tempo_cmp_values {
invariant_test.fuzz_state.collect_typed_cmp_values(
cmp_values.iter().map(|s| (s.width, alloy_primitives::B256::from(s.value))),
);
}

// Re-add changes
if let Some(changed) = sender_changeset {
state_changeset.insert(tx.sender, changed);
Expand Down
42 changes: 39 additions & 3 deletions crates/evm/evm/src/executors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ mod trace;

pub use trace::TracingExecutor;

mod tempo_cov;
use tempo_cov::TempoCoverageGuard;

const DURATION_BETWEEN_METRICS_REPORT: Duration = Duration::from_secs(5);

sol! {
Expand Down Expand Up @@ -562,19 +565,47 @@ impl Executor {
#[instrument(name = "call", level = "debug", skip_all)]
pub fn call_with_env(&self, mut env: Env) -> eyre::Result<RawCallResult> {
let mut stack = self.inspector().clone();
let tempo_edges = stack.inner.tempo_precompile_edges;
let tempo_trace_cmp = stack.inner.tempo_precompile_trace_cmp;
let tempo_active = tempo_edges || tempo_trace_cmp;
let mut backend = CowBackend::new_borrowed(self.backend());
let result = backend.inspect(&mut env, stack.as_inspector())?;
convert_executed_result(env, stack, result, backend.has_state_snapshot_failure())
let result = {
let _guard =
tempo_active.then(|| TempoCoverageGuard::new(tempo_edges, tempo_trace_cmp));
backend.inspect(&mut env, stack.as_inspector())?
};
let mut result =
convert_executed_result(env, stack, result, backend.has_state_snapshot_failure())?;
if tempo_edges {
TempoCoverageGuard::merge_edges_into(&mut result);
}
if tempo_trace_cmp {
TempoCoverageGuard::drain_cmp_into(&mut result);
}
Ok(result)
}

/// Execute the transaction configured in `env.tx`.
#[instrument(name = "transact", level = "debug", skip_all)]
pub fn transact_with_env(&mut self, mut env: Env) -> eyre::Result<RawCallResult> {
let mut stack = self.inspector().clone();
let tempo_edges = stack.inner.tempo_precompile_edges;
let tempo_trace_cmp = stack.inner.tempo_precompile_trace_cmp;
let tempo_active = tempo_edges || tempo_trace_cmp;
let backend = self.backend_mut();
let result = backend.inspect(&mut env, stack.as_inspector())?;
let result = {
let _guard =
tempo_active.then(|| TempoCoverageGuard::new(tempo_edges, tempo_trace_cmp));
backend.inspect(&mut env, stack.as_inspector())?
};
let mut result =
convert_executed_result(env, stack, result, backend.has_state_snapshot_failure())?;
if tempo_edges {
TempoCoverageGuard::merge_edges_into(&mut result);
}
if tempo_trace_cmp {
TempoCoverageGuard::drain_cmp_into(&mut result);
}
self.commit(&mut result);
Ok(result)
}
Expand Down Expand Up @@ -915,6 +946,9 @@ pub struct RawCallResult {
pub line_coverage: Option<HitMaps>,
/// The edge coverage info collected during the call
pub edge_coverage: Option<Vec<u8>>,
/// Comparison operands captured from Tempo precompile trace-cmp callbacks.
/// Each entry contains a width hint and a 32-byte big-endian value.
pub tempo_cmp_values: Option<Vec<foundry_tempo_coverage::CmpSample>>,
/// Scripted transactions generated from this call
pub transactions: Option<BroadcastableTransactions>,
/// The changeset of the state.
Expand Down Expand Up @@ -945,6 +979,7 @@ impl Default for RawCallResult {
traces: None,
line_coverage: None,
edge_coverage: None,
tempo_cmp_values: None,
transactions: None,
state_changeset: HashMap::default(),
env: Env::default(),
Expand Down Expand Up @@ -1150,6 +1185,7 @@ fn convert_executed_result(
traces,
line_coverage,
edge_coverage,
tempo_cmp_values: None,
transactions,
state_changeset,
env,
Expand Down
Loading
Loading