From 3af21c9a9a777c4435155e719609f6afc9d09fb7 Mon Sep 17 00:00:00 2001 From: Gustavo Figueiredo Date: Fri, 15 May 2026 16:16:30 +0100 Subject: [PATCH 1/6] feat(evm): symbolic-assist worker (concolic-lite v1) for coverage-guided fuzzing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a directed-mutation worker that periodically replays a corpus seed under a branch-trace inspector, finds the deepest unseen opposite-side branch ("frontier"), and proposes ABI-aware calldata rewrites that flip the branch — without invoking an SMT solver. Inspired by Echidna's Echidna.SymExec.Exploration and the SaferMaker writeup (https://hackmd.io/@SaferMaker/EVM-Sym-Exec). Architecture: - BranchTraceInspector (crates/evm/evm/src/inspectors/branch_trace.rs): observation-only REVM Inspector that records every JUMPI together with the immediately-preceding compare opcode (EQ/LT/GT/SLT/SGT/ISZERO) and its concrete operands. Uses the same edge-id hashing as EdgeCovInspector so frontier ids can be looked up directly in the corpus history map. - crates/evm/evm/src/executors/symexec/: - types.rs: FrontierKey, FrontierStats, SymExecState, Candidate, hard caps. - select.rs: seed scoring (favored > productive > shorter), deepest unseen-frontier picker, attempt bookkeeping. - mutate.rs: ABI-aware Redqueen-style calldata rewrites for scalar args (uintN/intN/bool/address/bytesN). Skips dynamic types in v1. - mod.rs: SymBackend trait + HeuristicAbiRewrite v1 backend + run_symexec_assist top-level loop. Trait stays in place so v2 can swap in z3/hevm/halmos without changing the loop. - WorkerCorpus helpers: is_master, symexec_seed_pool, history_map_snapshot, master_sync_dir, symexec_validate. The validator clones the executor and history map and only reports new edges; it never mutates the live history map, so the next calibrate() picks up the persisted file in the normal way without poisoning the corpus. - Config: FuzzCorpusConfig.symexec_assist (bool, default false) + symexec_assist_interval (u32, default 200) + symexec_assist_active() guard (requires coverage-guided + EVM edge coverage; sancov-edges disables it). - Wiring: - executors/fuzz/mod.rs: master worker only, periodic call after each sync, stateful = false. - executors/invariant/mod.rs: single worker, periodic call at the top of the run loop, stateful = true (commits prefix txs during replay/validation, resolves the final tx's ABI dynamically via FuzzRunIdentifiedContracts. - Inspector plumbing: branch_trace propagates through InspectorStack -> InspectorData -> RawCallResult.branch_trace (mirrors the existing edge_coverage path). Validation gate ensures only candidates that produce a real new EVM edge are persisted to /worker0/sync/, where the existing corpus protocol distributes them to other workers. Hard CPU budget per cycle: 1 seed, 1 frontier, <=8 candidates, max 3 attempts per frontier per campaign. Tests: - forge cli: symexec_assist_solves_equality_guard (stateless fuzz) — handler with require(x == MAGIC) where MAGIC is a 32-byte distinctive value; asserts the corpus directory contains the magic bytes after the run. - forge cli: symexec_assist_solves_equality_guard_invariant (stateful invariant) — same handler exposed via targetContract; trivial invariant; same structural corpus check. All existing tests still pass: forge cli fuzz tests: 16 passed (incl. new regression) forge cli invariant tests: 68 passed (incl. new regression) foundry-evm lib tests: 21 passed foundry-config lib tests: 154 passed Future extensions kept open by the SymBackend trait: - Z3PathFlip backend (real SMT) - ExternalHevm / ExternalHalmos backends - Mutating the final-tx sender for access-control branches EOF ) Amp-Thread-ID: https://ampcode.com/threads/T-019e2bc9-ece8-718f-9279-971e3e982bec Co-authored-by: Amp --- crates/config/src/fuzz.rs | 21 ++ crates/evm/evm/src/executors/corpus.rs | 82 ++++++ crates/evm/evm/src/executors/fuzz/mod.rs | 45 +++ crates/evm/evm/src/executors/invariant/mod.rs | 44 ++- crates/evm/evm/src/executors/mod.rs | 6 + crates/evm/evm/src/executors/symexec/mod.rs | 261 ++++++++++++++++++ .../evm/evm/src/executors/symexec/mutate.rs | 136 +++++++++ .../evm/evm/src/executors/symexec/select.rs | 101 +++++++ crates/evm/evm/src/executors/symexec/types.rs | 89 ++++++ crates/evm/evm/src/inspectors/branch_trace.rs | 238 ++++++++++++++++ crates/evm/evm/src/inspectors/mod.rs | 5 + crates/evm/evm/src/inspectors/stack.rs | 22 +- crates/forge/tests/cli/config.rs | 8 + crates/forge/tests/cli/test_cmd/fuzz.rs | 98 +++++++ .../forge/tests/cli/test_cmd/invariant/mod.rs | 99 +++++++ 15 files changed, 1251 insertions(+), 4 deletions(-) create mode 100644 crates/evm/evm/src/executors/symexec/mod.rs create mode 100644 crates/evm/evm/src/executors/symexec/mutate.rs create mode 100644 crates/evm/evm/src/executors/symexec/select.rs create mode 100644 crates/evm/evm/src/executors/symexec/types.rs create mode 100644 crates/evm/evm/src/inspectors/branch_trace.rs diff --git a/crates/config/src/fuzz.rs b/crates/config/src/fuzz.rs index 8f63718e086cd..ae9cae5845f9e 100644 --- a/crates/config/src/fuzz.rs +++ b/crates/config/src/fuzz.rs @@ -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 { @@ -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 { @@ -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, } } } diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index afd2d0d6a854f..74bad4024273e 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -1123,6 +1123,88 @@ 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 { + const MAX: usize = 16; + self.in_memory_corpus + .iter() + .rev() + .take(MAX) + .map(|e| crate::executors::symexec::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 { + self.history_map.clone() + } + + /// Path to the master worker's `sync/` directory. The symbolic-assist + /// worker writes accepted candidates here so the next `sync()` cycle + /// imports them. + pub fn master_sync_dir(&self) -> Option { + self.config.corpus_dir.as_ref().map(|d| d.join(format!("{WORKER}0")).join(SYNC_DIR)) + } + + /// 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( + &self, + executor: &Executor, + candidate: &[BasicTxDetails], + stateful: bool, + ) -> Result { + if !self.config.collect_evm_edge_coverage() { + 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, diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index def9b5e4eda02..c2fa60ab9961d 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -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; @@ -503,6 +504,18 @@ impl FuzzedExecutor { 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 @@ -544,6 +557,38 @@ impl FuzzedExecutor { 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 diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 9151b14094dbf..de84accde5a81 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -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, }; @@ -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(); diff --git a/crates/evm/evm/src/executors/mod.rs b/crates/evm/evm/src/executors/mod.rs index d689f723c9bff..ca6280a98fd29 100644 --- a/crates/evm/evm/src/executors/mod.rs +++ b/crates/evm/evm/src/executors/mod.rs @@ -63,6 +63,7 @@ pub use invariant::InvariantExecutor; mod corpus; mod sancov; +pub mod symexec; mod trace; pub use trace::TracingExecutor; @@ -983,6 +984,8 @@ pub struct RawCallResult { pub line_coverage: Option, /// The edge coverage info collected during the call pub edge_coverage: Option>, + /// Branch observations collected by the symbolic-assist worker. + pub branch_trace: Option, /// Sancov edge coverage from instrumented native Rust crates (e.g. precompiles). /// Tracked separately from EVM edge coverage to avoid ID-space collisions. pub sancov_coverage: Option>, @@ -1020,6 +1023,7 @@ impl Default for RawCallResult { traces: None, line_coverage: None, edge_coverage: None, + branch_trace: None, sancov_coverage: None, sancov_cmp_values: None, transactions: None, @@ -1239,6 +1243,7 @@ fn convert_executed_result( traces, line_coverage, edge_coverage, + branch_trace, cheatcodes, chisel_state, reverter, @@ -1266,6 +1271,7 @@ fn convert_executed_result( traces, line_coverage, edge_coverage, + branch_trace, sancov_coverage: None, sancov_cmp_values: None, transactions, diff --git a/crates/evm/evm/src/executors/symexec/mod.rs b/crates/evm/evm/src/executors/symexec/mod.rs new file mode 100644 index 0000000000000..bb0b263340326 --- /dev/null +++ b/crates/evm/evm/src/executors/symexec/mod.rs @@ -0,0 +1,261 @@ +//! Symbolic-assist worker — concolic-lite v1. +//! +//! Implements the *worker-side* of the architectural plan inspired by +//! Echidna's `Echidna.SymExec.Exploration` and the SaferMaker writeup at +//! https://hackmd.io/@SaferMaker/EVM-Sym-Exec. +//! +//! It is *not* a full symbolic-execution engine; it is a directed mutation +//! assistant that: +//! +//! 1. takes a corpus seed (`Vec`), +//! 2. replays it concretely under a [`crate::inspectors::BranchTraceInspector`], +//! 3. picks the deepest *unseen* opposite-side branch as the "frontier", +//! 4. proposes ABI-aware calldata rewrites that — given the recovered +//! compare operands — would flip the frontier branch, +//! 5. validates each candidate through the normal executor (requiring a real +//! new edge in coverage), and +//! 6. writes accepted candidates to the master worker's `sync/` directory so +//! the existing corpus protocol distributes them. +//! +//! v1 is intentionally minimal: +//! - master worker only, +//! - EVM `EdgeCovInspector`-based coverage only (no sancov), +//! - mutates only the final tx of the seed sequence, +//! - skips dynamic ABI types, +//! - hard CPU budget per cycle. + +use crate::{ + executors::{Executor, corpus::WorkerCorpus}, + inspectors::BranchTrace, +}; +use alloy_json_abi::Function; +use alloy_primitives::{B256, keccak256, map::DefaultHashBuilder}; +use eyre::Result; +use foundry_evm_core::evm::FoundryEvmNetwork; +use foundry_evm_fuzz::{BasicTxDetails, invariant::FuzzRunIdentifiedContracts}; +use std::path::Path; + +mod mutate; +mod select; +mod types; + +pub use mutate::propose_calldata_rewrites; +pub use select::{SeedSnapshot, pick_frontier, pick_seed, score_seed, unseen_in_history}; +pub use types::{ + Candidate, FrontierKey, FrontierStats, MAX_CANDIDATES_PER_FRONTIER, MAX_FRONTIER_ATTEMPTS, + MAX_SEEDS_PER_CYCLE, SymExecState, +}; + +/// Backend abstraction so v2 can swap the heuristic engine for a real SMT +/// solver (or external `hevm`) without touching the assist loop. +pub trait SymBackend { + /// Generate candidate sequences from a seed and its branch trace. + /// `tx_index` identifies the call to mutate (the last one, in v1). + fn propose( + &self, + seed: &[BasicTxDetails], + trace: &BranchTrace, + tx_index: usize, + function: Option<&Function>, + state: &SymExecState, + history_map: &[u8], + hash_builder: &DefaultHashBuilder, + ) -> Vec; +} + +/// v1 backend: ABI-aware Redqueen-style rewrites; no SMT. +#[derive(Clone, Debug, Default)] +pub struct HeuristicAbiRewrite; + +impl SymBackend for HeuristicAbiRewrite { + fn propose( + &self, + seed: &[BasicTxDetails], + trace: &BranchTrace, + tx_index: usize, + function: Option<&Function>, + state: &SymExecState, + history_map: &[u8], + hash_builder: &DefaultHashBuilder, + ) -> Vec { + let Some(tx) = seed.get(tx_index) else { return Vec::new() }; + let calldata = &tx.call_details.calldata; + if calldata.len() < 4 { + return Vec::new(); + } + let mut selector = [0u8; 4]; + selector.copy_from_slice(&calldata[..4]); + + let Some((frontier, obs)) = select::pick_frontier( + trace, + tx_index as u32, + selector, + state, + history_map, + hash_builder, + select::unseen_in_history, + ) else { + return Vec::new(); + }; + + let rewrites = propose_calldata_rewrites(tx, function, &obs); + + // The mutation only changes the final tx; build the full sequence. + let source_uuid = uuid::Uuid::nil(); + rewrites + .into_iter() + .map(|new_tx| { + let mut tx_seq = seed.to_vec(); + tx_seq[tx_index] = new_tx; + Candidate { tx_seq, frontier, source_uuid } + }) + .collect() + } +} + +/// Run a single symbolic-assist cycle on the master worker. +/// +/// `function` is the ABI of the call slot the worker is allowed to mutate +/// for stateless fuzz (v1 always mutates the *last* tx of the seed). +/// `targeted_contracts` is used by stateful invariant tests to resolve +/// the final tx's ABI dynamically — pass `None` for stateless fuzz. +/// Exactly one of `function` / `targeted_contracts` must be `Some`. +/// +/// `stateful` controls whether prefix txs are committed during replay and +/// validation — `true` for invariant tests, `false` for stateless fuzz. +/// +/// Returns the number of candidates accepted into the corpus. +#[tracing::instrument(skip_all)] +pub fn run_symexec_assist( + corpus: &mut WorkerCorpus, + executor: &Executor, + function: Option<&Function>, + targeted_contracts: Option<&FuzzRunIdentifiedContracts>, + state: &mut SymExecState, + stateful: bool, +) -> Result { + if !corpus.is_master() { + return Ok(0); + } + + // Snapshot a small candidate pool for seed scoring (avoid scoring the + // whole corpus on every cycle). + let pool = corpus.symexec_seed_pool(); + let Some(seed) = pick_seed(&pool).cloned() else { return Ok(0) }; + + // 1. Replay the seed with the branch-trace inspector enabled. + let mut replay_executor = executor.clone(); + replay_executor.inspector_mut().collect_branch_trace(true); + // Disable edge-coverage on the *replay* executor — branch trace alone + // is what we need, and we don't want replay to mutate the global edge + // map. + replay_executor.inspector_mut().collect_edge_coverage(false); + + // v1: only the final tx's branches are eligible for mutation, so we + // only need to *trace* that final tx — the prefix is replayed purely + // to set up state for stateful tests. + let tx_index = seed.tx_seq.len().saturating_sub(1); + let mut trace = BranchTrace::default(); + for (i, tx) in seed.tx_seq.iter().enumerate() { + if i == tx_index { + replay_executor.inspector_mut().collect_branch_trace(true); + } else { + replay_executor.inspector_mut().collect_branch_trace(false); + } + let mut result = replay_executor.call_raw( + tx.sender, + tx.call_details.target, + tx.call_details.calldata.clone(), + alloy_primitives::U256::ZERO, + )?; + if i == tx_index + && let Some(t) = result.branch_trace.take() + { + trace.branches.extend(t.branches); + } + if stateful && i < tx_index { + replay_executor.commit(&mut result); + } + } + if trace.is_empty() { + return Ok(0); + } + + // 2. Resolve the ABI of the call slot we're allowed to mutate. For + // invariant tests this is looked up from the targeted contracts; + // for stateless fuzz the caller already supplied it. + let resolved_function: Option = match (function, targeted_contracts) { + (Some(f), _) => Some(f.clone()), + (None, Some(targets)) => seed + .tx_seq + .get(tx_index) + .and_then(|tx| targets.targets.lock().fuzzed_artifacts(tx).1.cloned()), + (None, None) => None, + }; + let Some(resolved_function) = resolved_function else { + return Ok(0); + }; + + // 3. Pick a frontier + propose candidates. + let backend = HeuristicAbiRewrite; + let history = corpus.history_map_snapshot(); + let hash_builder = DefaultHashBuilder::default(); + let candidates = backend.propose( + &seed.tx_seq, + &trace, + tx_index, + Some(&resolved_function), + state, + &history, + &hash_builder, + ); + + // 4. Validate each candidate and persist accepted ones. + let mut accepted = 0; + for mut candidate in candidates { + candidate.source_uuid = seed.uuid; + let hash = candidate_hash(&candidate.tx_seq); + if !state.seen_candidate_hashes.insert(hash) { + continue; + } + + let new_edge = corpus.symexec_validate(executor, &candidate.tx_seq, stateful)?; + state.record_attempt(candidate.frontier, candidate.source_uuid, new_edge); + if !new_edge { + continue; + } + + if let Some(sync_dir) = corpus.master_sync_dir() { + write_sync_entry(&sync_dir, &candidate.tx_seq)?; + accepted += 1; + } + } + + Ok(accepted) +} + +/// Stable hash of a candidate sequence — used to skip duplicates we've +/// already validated. In-process state only, so the encoding can change +/// without breaking correctness. +fn candidate_hash(seq: &[BasicTxDetails]) -> B256 { + let mut bytes = Vec::with_capacity(64); + for tx in seq { + bytes.extend_from_slice(tx.sender.as_slice()); + bytes.extend_from_slice(tx.call_details.target.as_slice()); + bytes.extend_from_slice(&tx.call_details.calldata); + } + keccak256(&bytes) +} + +/// Helper used by [`run_symexec_assist`] to write a raw `Vec` +/// JSON file into a worker's `sync/` directory. +pub fn write_sync_entry(sync_dir: &Path, seq: &[BasicTxDetails]) -> Result<()> { + let uuid = uuid::Uuid::new_v4(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let path = sync_dir.join(format!("{uuid}-{ts}.json")); + foundry_common::fs::write_json_file(&path, &seq)?; + Ok(()) +} diff --git a/crates/evm/evm/src/executors/symexec/mutate.rs b/crates/evm/evm/src/executors/symexec/mutate.rs new file mode 100644 index 0000000000000..ded208ea58450 --- /dev/null +++ b/crates/evm/evm/src/executors/symexec/mutate.rs @@ -0,0 +1,136 @@ +//! ABI-aware targeted mutations for flipping a frontier branch. +//! +//! Given a [`BranchObservation`] with a recovered compare and the source +//! `BasicTxDetails`, produce a small bounded set of new calldatas that — if +//! the branch's left-hand side is calldata-derived — would flip the branch. +//! +//! v1 only handles scalar ABI args (`uintN`, `intN`, `bool`, `address`, +//! `bytes32`). Dynamic types (`bytes`, `string`, arrays) are skipped. + +use super::types::MAX_CANDIDATES_PER_FRONTIER; +use crate::inspectors::{BranchObservation, CmpKind}; +use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; +use alloy_json_abi::Function; +use alloy_primitives::{Address, B256, Bytes, I256, U256}; +use foundry_evm_fuzz::{BasicTxDetails, CallDetails}; + +/// Generate the set of "interesting" RHS values for a compare. These are the +/// classic Redqueen tries: the operand itself plus boundary values. +fn target_values(obs: &BranchObservation) -> Vec { + let Some(cmp) = obs.cmp else { return Vec::new() }; + + // We want LHS to satisfy `LHS OP RHS` to land on the *other* side. + // If the branch was taken, the predicate currently holds; we want to + // negate it. If the branch was not taken, we want to satisfy it. + // Both can be achieved by trying values near `rhs`. + let r = cmp.rhs; + let candidates: Vec = match cmp.kind { + CmpKind::Eq => { + if obs.took_branch { + // Currently `lhs == rhs`. Flip by trying `rhs ± 1`. + vec![r.wrapping_add(U256::from(1)), r.wrapping_sub(U256::from(1))] + } else { + vec![r] + } + } + CmpKind::Lt | CmpKind::Slt => { + vec![r, r.wrapping_sub(U256::from(1)), r.wrapping_add(U256::from(1))] + } + CmpKind::Gt | CmpKind::Sgt => { + vec![r, r.wrapping_sub(U256::from(1)), r.wrapping_add(U256::from(1))] + } + CmpKind::IsZero => { + // Try both 0 and 1 — caller has the responsibility of mapping + // these to the correct underlying scalar. + vec![U256::ZERO, U256::from(1)] + } + }; + + candidates +} + +/// Try to rewrite `tx.call_details.calldata` so that one of its decoded +/// scalar arguments equals each target value, returning the resulting +/// candidates. +/// +/// Only scalar arguments are mutated; if `function` is `None` or decoding +/// fails, no candidates are produced. +pub fn propose_calldata_rewrites( + tx: &BasicTxDetails, + function: Option<&Function>, + obs: &BranchObservation, +) -> Vec { + let Some(function) = function else { return Vec::new() }; + let calldata = &tx.call_details.calldata; + if calldata.len() < 4 { + return Vec::new(); + } + + let Ok(decoded) = function.abi_decode_input(&calldata[4..]) else { + return Vec::new(); + }; + + let targets = target_values(obs); + if targets.is_empty() { + return Vec::new(); + } + + let mut out = Vec::new(); + 'targets: for target in targets { + for arg_idx in 0..decoded.len() { + let Some(new_value) = rewrite_scalar(&decoded[arg_idx], target) else { + continue; + }; + let mut new_args = decoded.clone(); + new_args[arg_idx] = new_value; + let Ok(encoded) = function.abi_encode_input(&new_args) else { + continue; + }; + let mut new_calldata = Vec::with_capacity(4 + encoded.len()); + new_calldata.extend_from_slice(&calldata[..4]); + new_calldata.extend_from_slice(&encoded); + + out.push(BasicTxDetails { + warp: tx.warp, + roll: tx.roll, + sender: tx.sender, + call_details: CallDetails { + target: tx.call_details.target, + calldata: Bytes::from(new_calldata), + }, + }); + + if out.len() >= MAX_CANDIDATES_PER_FRONTIER { + break 'targets; + } + } + } + out +} + +/// Try to coerce `target` into the same `DynSolValue` shape as `current`. +/// Returns `None` for types we don't yet handle. +fn rewrite_scalar(current: &DynSolValue, target: U256) -> Option { + match current { + DynSolValue::Uint(_, size) => Some(DynSolValue::Uint(target, *size)), + DynSolValue::Int(_, size) => { + // `target` is a U256; reinterpret bits as I256. + let v = I256::from_raw(target); + Some(DynSolValue::Int(v, *size)) + } + DynSolValue::Bool(_) => Some(DynSolValue::Bool(!target.is_zero())), + DynSolValue::Address(_) => { + // Take the low 20 bytes. + let bytes: [u8; 32] = target.to_be_bytes(); + let mut addr = [0u8; 20]; + addr.copy_from_slice(&bytes[12..]); + Some(DynSolValue::Address(Address::from(addr))) + } + DynSolValue::FixedBytes(_, size) => { + let bytes: [u8; 32] = target.to_be_bytes(); + Some(DynSolValue::FixedBytes(B256::from(bytes), *size)) + } + // Dynamic types and tuples skipped in v1. + _ => None, + } +} diff --git a/crates/evm/evm/src/executors/symexec/select.rs b/crates/evm/evm/src/executors/symexec/select.rs new file mode 100644 index 0000000000000..50bd28981a7f3 --- /dev/null +++ b/crates/evm/evm/src/executors/symexec/select.rs @@ -0,0 +1,101 @@ +//! Seed and frontier selection for the symbolic-assist worker. +//! +//! Both helpers are intentionally cheap and *advisory*: they read snapshots +//! of corpus state and never mutate it. Heavy work (replay, validation) is +//! done by `mod.rs`. + +use super::types::{FrontierKey, SymExecState}; +use crate::inspectors::{BranchObservation, BranchTrace}; +use alloy_primitives::map::DefaultHashBuilder; +use foundry_evm_fuzz::BasicTxDetails; + +/// A read-only snapshot of a corpus entry, just enough to pick a seed. +#[derive(Clone, Debug)] +pub struct SeedSnapshot { + pub uuid: uuid::Uuid, + pub tx_seq: Vec, + pub is_favored: bool, + pub new_finds_produced: usize, +} + +/// Score a seed for symbolic exploration. Higher is better. +/// +/// v1 scoring (intentionally simple): +/// 1. favored seeds first +/// 2. seeds with more `new_finds_produced` (proven productive) +/// 3. shorter sequences (cheaper to replay & easier to mutate) +/// +/// We do *not* yet penalize seeds whose frontiers are exhausted — that +/// happens at frontier-selection time. +pub fn score_seed(seed: &SeedSnapshot) -> i64 { + let len_penalty = seed.tx_seq.len().min(64) as i64; + let mut score: i64 = 0; + if seed.is_favored { + score += 1_000; + } + score += (seed.new_finds_produced.min(1_000) as i64) * 10; + score -= len_penalty; + score +} + +/// Pick the highest-scoring seed from a small candidate pool. +/// +/// Caller is expected to pre-filter / sample so we don't score the entire +/// corpus on every cycle. +pub fn pick_seed(candidates: &[SeedSnapshot]) -> Option<&SeedSnapshot> { + candidates.iter().max_by_key(|s| score_seed(s)) +} + +/// Pick a frontier from a replay trace. +/// +/// Strategy: +/// - prefer the *deepest* observation whose opposite edge is currently unseen +/// (the seed already satisfies all earlier guards), +/// - skip frontiers that have hit `MAX_FRONTIER_ATTEMPTS`, +/// - skip frontiers without a recoverable compare (v1 has no symbolic +/// reasoning beyond ABI rewrites of compare operands). +pub fn pick_frontier( + trace: &BranchTrace, + tx_index: u32, + selector: [u8; 4], + state: &SymExecState, + history_map: &[u8], + hash_builder: &DefaultHashBuilder, + is_unseen: F, +) -> Option<(FrontierKey, BranchObservation)> +where + F: Fn(&[u8], usize) -> bool, +{ + for obs in trace.branches.iter().rev() { + // No compare → v1 has no targeted mutation to offer. + if obs.cmp.is_none() { + continue; + } + + let frontier_id = obs.frontier_edge_id(hash_builder); + if !is_unseen(history_map, frontier_id) { + continue; + } + + let other_dest = if obs.took_branch { obs.other_dest } else { obs.taken_dest }; + let key = FrontierKey { + address: obs.address, + pc: obs.pc, + other_dest_lo: other_dest.as_limbs()[0], + tx_index, + selector, + }; + + if state.should_try(&key) { + return Some((key, obs.clone())); + } + } + None +} + +/// Default predicate: an edge is "unseen" if its hitcount in the history map +/// is zero. The corpus binning logic uses non-linear bins for *new coverage* +/// detection, but for "have we ever taken this edge" plain `== 0` is correct. +pub fn unseen_in_history(history_map: &[u8], edge_id: usize) -> bool { + history_map.get(edge_id).copied().unwrap_or(0) == 0 +} diff --git a/crates/evm/evm/src/executors/symexec/types.rs b/crates/evm/evm/src/executors/symexec/types.rs new file mode 100644 index 0000000000000..353cc5a18a27e --- /dev/null +++ b/crates/evm/evm/src/executors/symexec/types.rs @@ -0,0 +1,89 @@ +//! Shared data types for the symbolic-assist worker. + +use alloy_primitives::{Address, B256}; +use foundry_evm_fuzz::BasicTxDetails; +use std::collections::{HashMap, HashSet}; +use uuid::Uuid; + +/// A specific branch in a specific contract that the symbolic worker can try +/// to flip. Computed from a [`BranchObservation`](crate::inspectors::BranchObservation) +/// and the index of the transaction in the seed sequence that exercised it. +/// +/// Two observations with the same `FrontierKey` represent "the same branch in +/// the same call slot" and should share attempt bookkeeping so the worker +/// doesn't repeatedly hammer an unflippable guard. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FrontierKey { + /// Contract whose bytecode contains the branch. + pub address: Address, + /// Program counter of the `JUMPI`. + pub pc: usize, + /// Destination of the side that the symbolic worker is trying to reach + /// (i.e. the side currently *not* covered). + pub other_dest_lo: u64, + /// Index of the call in the seed `Vec` that produced this + /// observation. For single-call (stateless) fuzz this is always 0; for + /// invariant tests it identifies which tx in the sequence to mutate. + pub tx_index: u32, + /// First 4 bytes of the calldata of that call (selector). Lets us + /// distinguish frontiers reached through different entrypoints. + pub selector: [u8; 4], +} + +/// Per-frontier attempt bookkeeping, kept separately from [`super::corpus`] +/// stats so the symbolic worker doesn't pollute the fuzzer's counters. +#[derive(Clone, Debug, Default)] +pub struct FrontierStats { + pub attempts: u16, + pub successes: u16, + /// UUID of the corpus entry the last attempt was generated from. + pub last_source_uuid: Option, +} + +/// Hard cap on attempts per frontier per campaign. +pub const MAX_FRONTIER_ATTEMPTS: u16 = 3; + +/// Hard cap on candidates evaluated per frontier per cycle. +pub const MAX_CANDIDATES_PER_FRONTIER: usize = 8; + +/// Hard cap on seeds processed per assist cycle. +pub const MAX_SEEDS_PER_CYCLE: usize = 1; + +/// In-process state for the symbolic worker. Owned by the master worker; not +/// persisted to disk (regenerated fresh each campaign). +#[derive(Clone, Debug, Default)] +pub struct SymExecState { + /// Bookkeeping per frontier we have already tried to flip. + pub frontiers: HashMap, + /// Hashes (e.g. `keccak256(serialize(seq))`) of candidate sequences we + /// have already proposed, to avoid re-validating duplicates. + pub seen_candidate_hashes: HashSet, +} + +impl SymExecState { + /// Whether we should attempt this frontier again. + pub fn should_try(&self, key: &FrontierKey) -> bool { + self.frontiers.get(key).map(|s| s.attempts < MAX_FRONTIER_ATTEMPTS).unwrap_or(true) + } + + pub fn record_attempt(&mut self, key: FrontierKey, source: Uuid, success: bool) { + let entry = self.frontiers.entry(key).or_default(); + entry.attempts = entry.attempts.saturating_add(1); + if success { + entry.successes = entry.successes.saturating_add(1); + } + entry.last_source_uuid = Some(source); + } +} + +/// One generated candidate to validate. +#[derive(Clone, Debug)] +pub struct Candidate { + /// The mutated transaction sequence (same length as the source seed). + pub tx_seq: Vec, + /// The frontier the candidate is trying to flip; used for bookkeeping + /// after validation. + pub frontier: FrontierKey, + /// UUID of the corpus entry the candidate was derived from. + pub source_uuid: Uuid, +} diff --git a/crates/evm/evm/src/inspectors/branch_trace.rs b/crates/evm/evm/src/inspectors/branch_trace.rs new file mode 100644 index 0000000000000..39974d67d3cff --- /dev/null +++ b/crates/evm/evm/src/inspectors/branch_trace.rs @@ -0,0 +1,238 @@ +//! Branch-trace inspector for the symbolic-assist worker. +//! +//! Records every conditional jump (`JUMPI`) the EVM executes, together with the +//! comparator operands of the immediately preceding compare opcode +//! (`EQ` / `LT` / `GT` / `SLT` / `SGT` / `ISZERO`) when present. The resulting +//! [`BranchTrace`] is consumed by the symbolic-assist worker +//! (`crate::executors::symexec`) to: +//! +//! 1. find "frontier" branches whose opposite edge has never been covered, and +//! 2. propose ABI-aware mutations of the calldata that would flip the branch. +//! +//! This is the first piece of the concolic-lite engine described in the +//! architectural plan: it captures the same information AFL/Redqueen and +//! libFuzzer's `trace_cmp` use, but at the EVM level. +//! +//! NOTE: this inspector is intentionally *observational* only. It must not +//! mutate any EVM state and must be safe to run alongside the normal +//! `EdgeCovInspector`. + +use alloy_primitives::{Address, U256, map::DefaultHashBuilder}; +use core::hash::{BuildHasher, Hash, Hasher}; +use revm::{ + Inspector, + bytecode::opcode, + interpreter::{ + Interpreter, + interpreter_types::{InputsTr, Jumps}, + }, +}; + +/// Must match `MAX_EDGE_COUNT` in `crate::inspectors::edge_cov`. +/// Kept here as a private constant so the symbolic worker can compare its +/// frontier ids against the corpus `history_map` without depending on +/// `EdgeCovInspector` directly. (Refactor target: extract a shared +/// `edge_id(address, pc, jump_dest)` helper.) +const MAX_EDGE_COUNT: usize = 65536; + +/// A compare opcode observed immediately before a `JUMPI`. +/// +/// We capture the concrete operands so the symbolic worker can derive a +/// targeted mutation (e.g. "set this calldata word to `rhs`") without invoking +/// an SMT solver. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CmpKind { + Eq, + Lt, + Gt, + Slt, + Sgt, + /// `ISZERO` operates on a single operand; `rhs` is unused. + IsZero, +} + +/// A single compare observation. +#[derive(Clone, Copy, Debug)] +pub struct CmpObservation { + pub kind: CmpKind, + pub lhs: U256, + /// `U256::ZERO` for `ISZERO`. + pub rhs: U256, +} + +/// One observed `JUMPI` along the executed path. +#[derive(Clone, Debug)] +pub struct BranchObservation { + /// Contract whose bytecode contains the `JUMPI`. + pub address: Address, + /// Program counter of the `JUMPI` instruction. + pub pc: usize, + /// Destination if the branch was taken. + pub taken_dest: U256, + /// Destination if the branch was *not* taken (always `pc + 1`). + pub other_dest: U256, + /// Whether the branch was taken on this trace. + pub took_branch: bool, + /// Concrete compare that produced the branch condition, when the + /// instruction immediately before the `JUMPI` was a recognised compare. + pub cmp: Option, +} + +impl BranchObservation { + /// Hashed edge id of the destination *not* taken on this trace, computed + /// the same way as `EdgeCovInspector::store_hit`. The symbolic worker + /// uses this to look up whether the opposite side is unseen in the + /// corpus' `history_map`. + pub fn frontier_edge_id(&self, hash_builder: &DefaultHashBuilder) -> usize { + let dest = if self.took_branch { self.other_dest } else { self.taken_dest }; + edge_id(hash_builder, self.address, self.pc, dest) + } +} + +/// Helper mirroring the hashing in `EdgeCovInspector::store_hit`. +/// +/// Kept private here for now; should be hoisted to a shared module once we +/// decide on the canonical location. +fn edge_id( + hash_builder: &DefaultHashBuilder, + address: Address, + pc: usize, + jump_dest: U256, +) -> usize { + let mut hasher = hash_builder.build_hasher(); + address.hash(&mut hasher); + pc.hash(&mut hasher); + jump_dest.hash(&mut hasher); + (hasher.finish() % MAX_EDGE_COUNT as u64) as usize +} + +/// All branch observations recorded for a single execution. +#[derive(Clone, Debug, Default)] +pub struct BranchTrace { + pub branches: Vec, +} + +impl BranchTrace { + pub const fn new() -> Self { + Self { branches: Vec::new() } + } + + pub fn clear(&mut self) { + self.branches.clear(); + } + + pub fn len(&self) -> usize { + self.branches.len() + } + + pub fn is_empty(&self) -> bool { + self.branches.is_empty() + } +} + +/// `Inspector` that records [`BranchObservation`]s for the symbolic worker. +#[derive(Clone, Debug, Default)] +pub struct BranchTraceInspector { + trace: BranchTrace, + /// Last compare opcode observed. Cleared after the next instruction. + /// Tracking only the immediately-preceding compare keeps this cheap and + /// matches Solidity's typical `cmp; iszero?; PUSH dest; JUMPI` pattern. + pending_cmp: Option, + /// If the instruction before `JUMPI` is `ISZERO`, the underlying compare + /// (if any) was two opcodes back — we keep both. + prev_pending_cmp: Option, +} + +impl BranchTraceInspector { + pub const fn new() -> Self { + Self { trace: BranchTrace::new(), pending_cmp: None, prev_pending_cmp: None } + } + + pub const fn trace(&self) -> &BranchTrace { + &self.trace + } + + pub fn take_trace(&mut self) -> BranchTrace { + core::mem::take(&mut self.trace) + } + + pub fn reset(&mut self) { + self.trace.clear(); + self.pending_cmp = None; + self.prev_pending_cmp = None; + } + + fn record_cmp(&mut self, kind: CmpKind, interp: &Interpreter) { + // Operand order on the EVM stack: top = a, next = b. + // Comparisons compute `a OP b`; `ISZERO` just consumes `a`. + let lhs = interp.stack.peek(0).unwrap_or(U256::ZERO); + let rhs = if matches!(kind, CmpKind::IsZero) { + U256::ZERO + } else { + interp.stack.peek(1).unwrap_or(U256::ZERO) + }; + // Shift the previous pending compare down so we can recover it past an + // intervening `ISZERO`. + self.prev_pending_cmp = self.pending_cmp.take(); + self.pending_cmp = Some(CmpObservation { kind, lhs, rhs }); + } + + fn record_jumpi(&mut self, interp: &Interpreter) { + let address = interp.input.target_address(); + let pc = interp.bytecode.pc(); + + // Stack layout for JUMPI: [0]=dest, [1]=condition. + let Ok(dest) = interp.stack.peek(0) else { return }; + let Ok(cond) = interp.stack.peek(1) else { return }; + + let took_branch = !cond.is_zero(); + let taken_dest = if took_branch { dest } else { U256::from(pc + 1) }; + let other_dest = if took_branch { U256::from(pc + 1) } else { dest }; + + // Prefer the underlying compare if the immediate predecessor was + // `ISZERO` (Solidity's `if (x == y)` becomes `EQ; ISZERO; PUSH; JUMPI` + // and its negation `EQ; PUSH; JUMPI`). + let cmp = match self.pending_cmp { + Some(c) if c.kind == CmpKind::IsZero => self.prev_pending_cmp, + other => other, + }; + + self.trace.branches.push(BranchObservation { + address, + pc, + taken_dest, + other_dest, + took_branch, + cmp, + }); + } +} + +impl Inspector for BranchTraceInspector { + #[inline] + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + let op = interp.bytecode.opcode(); + match op { + opcode::EQ => self.record_cmp(CmpKind::Eq, interp), + opcode::LT => self.record_cmp(CmpKind::Lt, interp), + opcode::GT => self.record_cmp(CmpKind::Gt, interp), + opcode::SLT => self.record_cmp(CmpKind::Slt, interp), + opcode::SGT => self.record_cmp(CmpKind::Sgt, interp), + opcode::ISZERO => self.record_cmp(CmpKind::IsZero, interp), + opcode::JUMPI => { + self.record_jumpi(interp); + // Reset pending compares — we only care about the cmp that + // produced *this* branch's condition. + self.pending_cmp = None; + self.prev_pending_cmp = None; + } + _ => { + // Any other opcode invalidates the pending-compare window. + if self.pending_cmp.is_some() { + self.prev_pending_cmp = None; + self.pending_cmp = None; + } + } + } + } +} diff --git a/crates/evm/evm/src/inspectors/mod.rs b/crates/evm/evm/src/inspectors/mod.rs index 233a253b3861a..9751b2f1fd03e 100644 --- a/crates/evm/evm/src/inspectors/mod.rs +++ b/crates/evm/evm/src/inspectors/mod.rs @@ -25,6 +25,11 @@ pub use stack::{InspectorData, InspectorStack, InspectorStackBuilder}; mod edge_cov; pub use edge_cov::EdgeCovInspector; +mod branch_trace; +pub use branch_trace::{ + BranchObservation, BranchTrace, BranchTraceInspector, CmpKind, CmpObservation, +}; + mod revert_diagnostic; pub use revert_diagnostic::RevertDiagnostic; diff --git a/crates/evm/evm/src/inspectors/stack.rs b/crates/evm/evm/src/inspectors/stack.rs index 41c16794c3cce..52a338f5fc0cf 100644 --- a/crates/evm/evm/src/inspectors/stack.rs +++ b/crates/evm/evm/src/inspectors/stack.rs @@ -1,7 +1,7 @@ use super::{ - Cheatcodes, CheatsConfig, ChiselState, CustomPrintTracer, EdgeCovInspector, Fuzzer, - LineCoverageCollector, LogCollector, RevertDiagnostic, ScriptExecutionInspector, TempoLabels, - TracingInspector, + BranchTrace, BranchTraceInspector, Cheatcodes, CheatsConfig, ChiselState, CustomPrintTracer, + EdgeCovInspector, Fuzzer, LineCoverageCollector, LogCollector, RevertDiagnostic, + ScriptExecutionInspector, TempoLabels, TracingInspector, }; use alloy_primitives::{ Address, B256, Bytes, Log, TxKind, U256, @@ -316,6 +316,9 @@ pub struct InspectorData { pub traces: Option, pub line_coverage: Option, pub edge_coverage: Option>, + /// Branch observations collected by the symbolic-assist worker, when + /// [`InspectorStack::collect_branch_trace`] is enabled. + pub branch_trace: Option, pub cheatcodes: Option>>, pub chisel_state: Option<(Vec, Vec)>, pub reverter: Option
, @@ -360,6 +363,7 @@ pub struct InspectorStackInner { // Inspectors. // These are boxed to reduce the size of the struct and slightly improve performance of the // `if let Some` checks. + pub branch_trace: Option>, pub chisel_state: Option>, pub edge_coverage: Option>, pub fuzzer: Option>, @@ -546,6 +550,15 @@ impl InspectorStack { self.edge_coverage = yes.then(EdgeCovInspector::new).map(Into::into); } + /// Set whether to enable the branch-trace inspector used by the + /// symbolic-assist worker. Independent of [`Self::collect_edge_coverage`]: + /// branch trace observes JUMPI + comparator operands and never updates + /// the edge coverage map. + #[inline] + pub fn collect_branch_trace(&mut self, yes: bool) { + self.branch_trace = yes.then(BranchTraceInspector::new).map(Into::into); + } + /// Set whether to collect sancov edge coverage from instrumented native crates. #[inline] pub const fn collect_sancov_edges(&mut self, yes: bool) { @@ -628,6 +641,7 @@ impl InspectorStack { mut cheatcodes, inner: InspectorStackInner { + branch_trace, chisel_state, line_coverage, edge_coverage, @@ -669,6 +683,7 @@ impl InspectorStack { traces, line_coverage: line_coverage.map(|line_coverage| line_coverage.finish()), edge_coverage: edge_coverage.map(|edge_coverage| edge_coverage.into_hitcount()), + branch_trace: branch_trace.map(|mut bt| bt.take_trace()), cheatcodes, chisel_state: chisel_state.and_then(|state| state.state), reverter, @@ -961,6 +976,7 @@ impl InspectorStackRefMut<'_, FEN> { call_inspectors!( [ // These are sorted in definition order. + &mut self.branch_trace, &mut self.edge_coverage, &mut self.fuzzer, &mut self.line_coverage, diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 22da8e793427b..b91efabd1aba8 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -197,6 +197,8 @@ corpus_min_size = 0 show_edge_coverage = false sancov_edges = false sancov_trace_cmp = false +symexec_assist = false +symexec_assist_interval = 200 failure_persist_dir = "cache/fuzz" show_logs = false @@ -220,6 +222,8 @@ corpus_min_size = 0 show_edge_coverage = false sancov_edges = false sancov_trace_cmp = false +symexec_assist = false +symexec_assist_interval = 200 failure_persist_dir = "cache/invariant" show_metrics = true show_solidity = false @@ -1315,6 +1319,8 @@ forgetest_init!(test_default_config, |prj, cmd| { "show_edge_coverage": false, "sancov_edges": false, "sancov_trace_cmp": false, + "symexec_assist": false, + "symexec_assist_interval": 200, "failure_persist_dir": "cache/fuzz", "show_logs": false, "timeout": null @@ -1340,6 +1346,8 @@ forgetest_init!(test_default_config, |prj, cmd| { "show_edge_coverage": false, "sancov_edges": false, "sancov_trace_cmp": false, + "symexec_assist": false, + "symexec_assist_interval": 200, "failure_persist_dir": "cache/invariant", "show_metrics": true, "timeout": null, diff --git a/crates/forge/tests/cli/test_cmd/fuzz.rs b/crates/forge/tests/cli/test_cmd/fuzz.rs index 454b014a6e1bc..31a690405058e 100644 --- a/crates/forge/tests/cli/test_cmd/fuzz.rs +++ b/crates/forge/tests/cli/test_cmd/fuzz.rs @@ -1013,3 +1013,101 @@ fn random_failure_reason(stdout: &str) -> String { .unwrap_or_else(|| panic!("{stdout}"))[1] .to_string() } + +// Regression test for the symbolic-assist worker: +// `crate::executors::symexec::run_symexec_assist`. +// +// The fuzz target has a hard equality guard on a 32-byte "magic" value that +// random fuzzing cannot reasonably hit. With `symexec_assist = true`, the +// master worker should: +// 1. replay an existing corpus seed under the branch-trace inspector, +// 2. observe the `EQ(x, MAGIC)` near `JUMPI`, +// 3. propose new calldata with `x = MAGIC` via the ABI-rewrite backend, +// 4. validate it (new edge), and +// 5. persist the candidate into the master's `worker0/sync/` directory. +// +// We assert end-to-end by scanning the corpus directory after the run for any +// JSON file whose calldata contains the magic value; this verifies the loop +// closes (replay → propose → validate → persist) without relying on the test +// having to fail. +forgetest_init!(symexec_assist_solves_equality_guard, |prj, cmd| { + prj.update_config(|config| { + // Enough runs that the symbolic worker triggers at least once. + config.fuzz.runs = 100; + config.fuzz.corpus.corpus_dir = Some("symexec_corpus".into()); + config.fuzz.corpus.corpus_gzip = false; + config.fuzz.corpus.symexec_assist = true; + config.fuzz.corpus.symexec_assist_interval = 5; + }); + + prj.add_test( + "SymExecAssistTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract SymExecAssistTest is Test { + // Distinctive 32-byte magic value: random fuzzing cannot hit this, so + // the only way for a corpus entry to contain these exact bytes is for + // the symbolic-assist worker to have proposed it from the `EQ(x, MAGIC)` + // observation. + uint256 constant MAGIC = 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef; + + // Coverage-guided branch with no revert — the symbolic worker only needs + // to discover the `true` side as new coverage. + uint256 public hits; + + function testFuzz_FindMagic(uint256 x) public { + if (x == MAGIC) { + hits = hits + 1; + } + } +} + "#, + ); + + cmd.args(["test", "--mt", "testFuzz_FindMagic"]).assert_success(); + + // Master corpus directory: `///worker0/corpus/`. + let corpus_root = prj + .root() + .join("symexec_corpus") + .join("SymExecAssistTest") + .join("testFuzz_FindMagic"); + let master_corpus = corpus_root.join("worker0").join("corpus"); + let master_sync = corpus_root.join("worker0").join("sync"); + + let magic_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + + let mut search_dirs = vec![master_corpus.clone(), master_sync.clone()]; + if let Ok(entries) = std::fs::read_dir(&corpus_root) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + search_dirs.push(p.join("corpus")); + search_dirs.push(p.join("sync")); + } + } + } + + let mut found = false; + 'outer: for dir in &search_dirs { + let Ok(entries) = std::fs::read_dir(dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let Ok(contents) = std::fs::read_to_string(&path) else { continue }; + if contents.to_ascii_lowercase().contains(magic_hex) { + found = true; + break 'outer; + } + } + } + + assert!( + found, + "expected the symbolic-assist worker to persist a corpus entry containing the magic \ + value; checked dirs: {search_dirs:?}", + ); +}); diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index 9de5d91f08b7f..00af17e12d632 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -1856,3 +1856,102 @@ contract FailureEventTest is Test { ... "#]]); }); + +// Regression test for the symbolic-assist worker driving stateful invariant +// tests. The handler exposes a hard equality guard on a 32-byte magic value +// that random fuzzing cannot reasonably hit. With `symexec_assist = true`, +// the (single) invariant worker should observe the `EQ(x, MAGIC)` near +// `JUMPI` while replaying a corpus seed, propose new calldata with +// `x = MAGIC`, validate it (new edge), and persist the candidate into the +// master worker's `sync/` directory. +// +// We assert end-to-end by scanning the corpus directory afterwards for any +// JSON file whose calldata contains the magic value. The invariant itself +// is trivially true so the test passes regardless of whether the assist +// fires; the structural check on the corpus is the real signal. +forgetest_init!(symexec_assist_solves_equality_guard_invariant, |prj, cmd| { + prj.update_config(|config| { + config.invariant.runs = 60; + config.invariant.depth = 5; + config.invariant.corpus.corpus_dir = Some("symexec_inv_corpus".into()); + config.invariant.corpus.corpus_gzip = false; + config.invariant.corpus.symexec_assist = true; + config.invariant.corpus.symexec_assist_interval = 5; + config.invariant.fail_on_revert = false; + }); + + prj.add_test( + "SymExecAssistInvariantTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract MagicHandler { + uint256 constant MAGIC = 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef; + uint256 public hits; + + function setMagic(uint256 x) public { + if (x == MAGIC) { + hits = hits + 1; + } + } +} + +contract SymExecAssistInvariantTest is Test { + MagicHandler handler; + + function setUp() public { + handler = new MagicHandler(); + targetContract(address(handler)); + } + + function invariant_always_true() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_always_true"]).assert_success(); + + let corpus_root = prj + .root() + .join("symexec_inv_corpus") + .join("SymExecAssistInvariantTest") + .join("invariant_always_true"); + + let magic_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + + let mut search_dirs = vec![ + corpus_root.join("worker0").join("corpus"), + corpus_root.join("worker0").join("sync"), + ]; + if let Ok(entries) = std::fs::read_dir(&corpus_root) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + search_dirs.push(p.join("corpus")); + search_dirs.push(p.join("sync")); + } + } + } + + let mut found = false; + 'outer: for dir in &search_dirs { + let Ok(entries) = std::fs::read_dir(dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let Ok(contents) = std::fs::read_to_string(&path) else { continue }; + if contents.to_ascii_lowercase().contains(magic_hex) { + found = true; + break 'outer; + } + } + } + + assert!( + found, + "expected the symbolic-assist worker to persist a corpus entry containing the magic \ + value; checked dirs: {search_dirs:?}", + ); +}); From 8b0593edef1ef2c25aa8cf10092637e45b7e754e Mon Sep 17 00:00:00 2001 From: Gustavo Figueiredo Date: Mon, 18 May 2026 11:16:24 +0100 Subject: [PATCH 2/6] chore(symexec): CI fixes + rebase plumbing + import hygiene - fmt/clippy/rustdoc fixes: const fn on BranchTrace::{len,is_empty}, allow too_many_arguments on SymBackend::propose, drop redundant clones in regression tests, fix broken intra-doc link, wrap SaferMaker URL - demote 'pub mod symexec' to 'pub(crate)' (private_interfaces lint) and trim re-exports to the surface actually used by the crate - propagate new CallDetails.value (introduced upstream in #14482) through the heuristic ABI rewrite - hoist fully-qualified inline imports (crate::executors::symexec::*, crate::inspectors::BranchTrace, alloy_primitives::U256::ZERO, std::time::*, foundry_common::fs::write_json_file, std::fs::*) to top-of-file 'use' blocks across our diff - drop unused MAX_SEEDS_PER_CYCLE constant Amp-Thread-ID: https://ampcode.com/threads/T-019e2bc9-ece8-718f-9279-971e3e982bec Co-authored-by: Amp --- crates/evm/evm/src/executors/corpus.rs | 6 +-- crates/evm/evm/src/executors/fuzz/mod.rs | 3 +- crates/evm/evm/src/executors/mod.rs | 6 +-- crates/evm/evm/src/executors/symexec/mod.rs | 48 +++++++++---------- .../evm/evm/src/executors/symexec/mutate.rs | 1 + .../evm/evm/src/executors/symexec/select.rs | 8 ++-- crates/evm/evm/src/executors/symexec/types.rs | 5 +- crates/evm/evm/src/inspectors/branch_trace.rs | 4 +- crates/forge/tests/cli/test_cmd/fuzz.rs | 16 +++---- .../forge/tests/cli/test_cmd/invariant/mod.rs | 13 +++-- 10 files changed, 52 insertions(+), 58 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 74bad4024273e..a73508c84ce2d 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -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}; @@ -1138,13 +1138,13 @@ impl WorkerCorpus { /// 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 { + pub fn symexec_seed_pool(&self) -> Vec { const MAX: usize = 16; self.in_memory_corpus .iter() .rev() .take(MAX) - .map(|e| crate::executors::symexec::SeedSnapshot { + .map(|e| SeedSnapshot { uuid: e.uuid, tx_seq: e.tx_seq.clone(), is_favored: e.is_favored, diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index c2fa60ab9961d..26d5074bc29d3 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -511,8 +511,7 @@ impl FuzzedExecutor { // 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_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(); diff --git a/crates/evm/evm/src/executors/mod.rs b/crates/evm/evm/src/executors/mod.rs index ca6280a98fd29..7210a34b8cc4a 100644 --- a/crates/evm/evm/src/executors/mod.rs +++ b/crates/evm/evm/src/executors/mod.rs @@ -7,7 +7,7 @@ // the concrete `Executor` type. use crate::inspectors::{ - Cheatcodes, InspectorData, InspectorStack, cheatcodes::BroadcastableTransactions, + BranchTrace, Cheatcodes, InspectorData, InspectorStack, cheatcodes::BroadcastableTransactions, }; use alloy_dyn_abi::{DynSolValue, FunctionExt, JsonAbiExt}; use alloy_json_abi::Function; @@ -63,7 +63,7 @@ pub use invariant::InvariantExecutor; mod corpus; mod sancov; -pub mod symexec; +pub(crate) mod symexec; mod trace; pub use trace::TracingExecutor; @@ -985,7 +985,7 @@ pub struct RawCallResult { /// The edge coverage info collected during the call pub edge_coverage: Option>, /// Branch observations collected by the symbolic-assist worker. - pub branch_trace: Option, + pub branch_trace: Option, /// Sancov edge coverage from instrumented native Rust crates (e.g. precompiles). /// Tracked separately from EVM edge coverage to avoid ID-space collisions. pub sancov_coverage: Option>, diff --git a/crates/evm/evm/src/executors/symexec/mod.rs b/crates/evm/evm/src/executors/symexec/mod.rs index bb0b263340326..53bf8063856c9 100644 --- a/crates/evm/evm/src/executors/symexec/mod.rs +++ b/crates/evm/evm/src/executors/symexec/mod.rs @@ -2,7 +2,7 @@ //! //! Implements the *worker-side* of the architectural plan inspired by //! Echidna's `Echidna.SymExec.Exploration` and the SaferMaker writeup at -//! https://hackmd.io/@SaferMaker/EVM-Sym-Exec. +//! . //! //! It is *not* a full symbolic-execution engine; it is a directed mutation //! assistant that: @@ -10,12 +10,12 @@ //! 1. takes a corpus seed (`Vec`), //! 2. replays it concretely under a [`crate::inspectors::BranchTraceInspector`], //! 3. picks the deepest *unseen* opposite-side branch as the "frontier", -//! 4. proposes ABI-aware calldata rewrites that — given the recovered -//! compare operands — would flip the frontier branch, -//! 5. validates each candidate through the normal executor (requiring a real -//! new edge in coverage), and -//! 6. writes accepted candidates to the master worker's `sync/` directory so -//! the existing corpus protocol distributes them. +//! 4. proposes ABI-aware calldata rewrites that — given the recovered compare operands — would flip +//! the frontier branch, +//! 5. validates each candidate through the normal executor (requiring a real new edge in coverage), +//! and +//! 6. writes accepted candidates to the master worker's `sync/` directory so the existing corpus +//! protocol distributes them. //! //! v1 is intentionally minimal: //! - master worker only, @@ -29,28 +29,32 @@ use crate::{ inspectors::BranchTrace, }; use alloy_json_abi::Function; -use alloy_primitives::{B256, keccak256, map::DefaultHashBuilder}; +use alloy_primitives::{B256, U256, keccak256, map::DefaultHashBuilder}; use eyre::Result; +use foundry_common::fs::write_json_file; use foundry_evm_core::evm::FoundryEvmNetwork; use foundry_evm_fuzz::{BasicTxDetails, invariant::FuzzRunIdentifiedContracts}; -use std::path::Path; +use std::{ + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; mod mutate; mod select; mod types; -pub use mutate::propose_calldata_rewrites; -pub use select::{SeedSnapshot, pick_frontier, pick_seed, score_seed, unseen_in_history}; -pub use types::{ - Candidate, FrontierKey, FrontierStats, MAX_CANDIDATES_PER_FRONTIER, MAX_FRONTIER_ATTEMPTS, - MAX_SEEDS_PER_CYCLE, SymExecState, -}; +use mutate::propose_calldata_rewrites; +pub use select::SeedSnapshot; +use select::pick_seed; +use types::Candidate; +pub use types::SymExecState; /// Backend abstraction so v2 can swap the heuristic engine for a real SMT /// solver (or external `hevm`) without touching the assist loop. pub trait SymBackend { /// Generate candidate sequences from a seed and its branch trace. /// `tx_index` identifies the call to mutate (the last one, in v1). + #[allow(clippy::too_many_arguments)] fn propose( &self, seed: &[BasicTxDetails], @@ -166,7 +170,7 @@ pub fn run_symexec_assist( tx.sender, tx.call_details.target, tx.call_details.calldata.clone(), - alloy_primitives::U256::ZERO, + U256::ZERO, )?; if i == tx_index && let Some(t) = result.branch_trace.take() @@ -181,9 +185,8 @@ pub fn run_symexec_assist( return Ok(0); } - // 2. Resolve the ABI of the call slot we're allowed to mutate. For - // invariant tests this is looked up from the targeted contracts; - // for stateless fuzz the caller already supplied it. + // 2. Resolve the ABI of the call slot we're allowed to mutate. For invariant tests this is + // looked up from the targeted contracts; for stateless fuzz the caller already supplied it. let resolved_function: Option = match (function, targeted_contracts) { (Some(f), _) => Some(f.clone()), (None, Some(targets)) => seed @@ -251,11 +254,8 @@ fn candidate_hash(seq: &[BasicTxDetails]) -> B256 { /// JSON file into a worker's `sync/` directory. pub fn write_sync_entry(sync_dir: &Path, seq: &[BasicTxDetails]) -> Result<()> { let uuid = uuid::Uuid::new_v4(); - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); + let ts = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); let path = sync_dir.join(format!("{uuid}-{ts}.json")); - foundry_common::fs::write_json_file(&path, &seq)?; + write_json_file(&path, &seq)?; Ok(()) } diff --git a/crates/evm/evm/src/executors/symexec/mutate.rs b/crates/evm/evm/src/executors/symexec/mutate.rs index ded208ea58450..8ce58f39416f3 100644 --- a/crates/evm/evm/src/executors/symexec/mutate.rs +++ b/crates/evm/evm/src/executors/symexec/mutate.rs @@ -97,6 +97,7 @@ pub fn propose_calldata_rewrites( call_details: CallDetails { target: tx.call_details.target, calldata: Bytes::from(new_calldata), + value: tx.call_details.value, }, }); diff --git a/crates/evm/evm/src/executors/symexec/select.rs b/crates/evm/evm/src/executors/symexec/select.rs index 50bd28981a7f3..cb3190d022642 100644 --- a/crates/evm/evm/src/executors/symexec/select.rs +++ b/crates/evm/evm/src/executors/symexec/select.rs @@ -49,11 +49,11 @@ pub fn pick_seed(candidates: &[SeedSnapshot]) -> Option<&SeedSnapshot> { /// Pick a frontier from a replay trace. /// /// Strategy: -/// - prefer the *deepest* observation whose opposite edge is currently unseen -/// (the seed already satisfies all earlier guards), +/// - prefer the *deepest* observation whose opposite edge is currently unseen (the seed already +/// satisfies all earlier guards), /// - skip frontiers that have hit `MAX_FRONTIER_ATTEMPTS`, -/// - skip frontiers without a recoverable compare (v1 has no symbolic -/// reasoning beyond ABI rewrites of compare operands). +/// - skip frontiers without a recoverable compare (v1 has no symbolic reasoning beyond ABI rewrites +/// of compare operands). pub fn pick_frontier( trace: &BranchTrace, tx_index: u32, diff --git a/crates/evm/evm/src/executors/symexec/types.rs b/crates/evm/evm/src/executors/symexec/types.rs index 353cc5a18a27e..504c037e69934 100644 --- a/crates/evm/evm/src/executors/symexec/types.rs +++ b/crates/evm/evm/src/executors/symexec/types.rs @@ -30,7 +30,7 @@ pub struct FrontierKey { pub selector: [u8; 4], } -/// Per-frontier attempt bookkeeping, kept separately from [`super::corpus`] +/// Per-frontier attempt bookkeeping, kept separately from the corpus /// stats so the symbolic worker doesn't pollute the fuzzer's counters. #[derive(Clone, Debug, Default)] pub struct FrontierStats { @@ -46,9 +46,6 @@ pub const MAX_FRONTIER_ATTEMPTS: u16 = 3; /// Hard cap on candidates evaluated per frontier per cycle. pub const MAX_CANDIDATES_PER_FRONTIER: usize = 8; -/// Hard cap on seeds processed per assist cycle. -pub const MAX_SEEDS_PER_CYCLE: usize = 1; - /// In-process state for the symbolic worker. Owned by the master worker; not /// persisted to disk (regenerated fresh each campaign). #[derive(Clone, Debug, Default)] diff --git a/crates/evm/evm/src/inspectors/branch_trace.rs b/crates/evm/evm/src/inspectors/branch_trace.rs index 39974d67d3cff..4327a315f9ad5 100644 --- a/crates/evm/evm/src/inspectors/branch_trace.rs +++ b/crates/evm/evm/src/inspectors/branch_trace.rs @@ -121,11 +121,11 @@ impl BranchTrace { self.branches.clear(); } - pub fn len(&self) -> usize { + pub const fn len(&self) -> usize { self.branches.len() } - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.branches.is_empty() } } diff --git a/crates/forge/tests/cli/test_cmd/fuzz.rs b/crates/forge/tests/cli/test_cmd/fuzz.rs index 31a690405058e..a36da3163cf44 100644 --- a/crates/forge/tests/cli/test_cmd/fuzz.rs +++ b/crates/forge/tests/cli/test_cmd/fuzz.rs @@ -2,6 +2,7 @@ use alloy_primitives::U256; use foundry_evm::fuzz::BaseCounterExample; use foundry_test_utils::{TestCommand, forgetest_init, str}; use regex::Regex; +use std::fs::{read_dir, read_to_string}; forgetest_init!(test_can_scrape_bytecode, |prj, cmd| { prj.update_config(|config| config.optimizer = Some(true)); @@ -1068,18 +1069,15 @@ contract SymExecAssistTest is Test { cmd.args(["test", "--mt", "testFuzz_FindMagic"]).assert_success(); // Master corpus directory: `///worker0/corpus/`. - let corpus_root = prj - .root() - .join("symexec_corpus") - .join("SymExecAssistTest") - .join("testFuzz_FindMagic"); + let corpus_root = + prj.root().join("symexec_corpus").join("SymExecAssistTest").join("testFuzz_FindMagic"); let master_corpus = corpus_root.join("worker0").join("corpus"); let master_sync = corpus_root.join("worker0").join("sync"); let magic_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; - let mut search_dirs = vec![master_corpus.clone(), master_sync.clone()]; - if let Ok(entries) = std::fs::read_dir(&corpus_root) { + let mut search_dirs = vec![master_corpus, master_sync]; + if let Ok(entries) = read_dir(&corpus_root) { for entry in entries.flatten() { let p = entry.path(); if p.is_dir() { @@ -1091,13 +1089,13 @@ contract SymExecAssistTest is Test { let mut found = false; 'outer: for dir in &search_dirs { - let Ok(entries) = std::fs::read_dir(dir) else { continue }; + let Ok(entries) = read_dir(dir) else { continue }; for entry in entries.flatten() { let path = entry.path(); if !path.is_file() { continue; } - let Ok(contents) = std::fs::read_to_string(&path) else { continue }; + let Ok(contents) = read_to_string(&path) else { continue }; if contents.to_ascii_lowercase().contains(magic_hex) { found = true; break 'outer; diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index 00af17e12d632..659a0f64e4ab2 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -1,5 +1,6 @@ use alloy_primitives::U256; use foundry_test_utils::{TestCommand, forgetest_init, snapbox::cmd::OutputAssert, str}; +use std::fs::{read_dir, read_to_string}; mod common; mod handler; @@ -1919,11 +1920,9 @@ contract SymExecAssistInvariantTest is Test { let magic_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; - let mut search_dirs = vec![ - corpus_root.join("worker0").join("corpus"), - corpus_root.join("worker0").join("sync"), - ]; - if let Ok(entries) = std::fs::read_dir(&corpus_root) { + let mut search_dirs = + vec![corpus_root.join("worker0").join("corpus"), corpus_root.join("worker0").join("sync")]; + if let Ok(entries) = read_dir(&corpus_root) { for entry in entries.flatten() { let p = entry.path(); if p.is_dir() { @@ -1935,13 +1934,13 @@ contract SymExecAssistInvariantTest is Test { let mut found = false; 'outer: for dir in &search_dirs { - let Ok(entries) = std::fs::read_dir(dir) else { continue }; + let Ok(entries) = read_dir(dir) else { continue }; for entry in entries.flatten() { let path = entry.path(); if !path.is_file() { continue; } - let Ok(contents) = std::fs::read_to_string(&path) else { continue }; + let Ok(contents) = read_to_string(&path) else { continue }; if contents.to_ascii_lowercase().contains(magic_hex) { found = true; break 'outer; From 2da54cddd64019a2e2f560456e7fe8c1fcb378e2 Mon Sep 17 00:00:00 2001 From: Gustavo Figueiredo Date: Mon, 18 May 2026 14:47:49 +0100 Subject: [PATCH 3/6] fix(symexec): capture SUB/XOR-as-EQ, track ISZERO inversion, fan over multiple frontiers Live-testing the assist worker against a Foundry project surfaced three real gaps that this commit fixes: 1. Solidity with the optimizer enabled lowers `if (a == b)` to `SUB(a, b); JUMPI(skip, diff)` (and the Yul IR pipeline occasionally uses `XOR`). The branch-trace inspector previously only recognised `EQ`/`LT`/`GT`/`SLT`/`SGT`/`ISZERO`, so the most common shape of an equality guard in optimised bytecode was invisible. Record `SUB` and `XOR` as equality-class compares with `inverted = true`. 2. Even when the underlying compare was recognised, the predicate-held logic looked at the JUMPI direction directly. With Solidity's `EQ; ISZERO; JUMPI` lowering and the new SUB/XOR cases, the JUMPI direction is the *opposite* of the predicate. Track `inverted` on `CmpObservation` and compute `predicate_held = took_branch XOR cmp.inverted` in the mutation heuristic, plus widen the inert-op window through `DUP` / `SWAP` / `POP` so SOLC-emitted stack manipulation between the compare and the JUMPI doesn't clear the pending compare. 3. The frontier picker walked the trace in reverse and returned the first eligible branch. The very deepest unseen branch is almost always in the post-call test-harness bookkeeping (forge-std asserting on a returned bool, ABI cleanup, etc.) rather than the contract under test, so the worker spent its attempts mutating calldata to flip guards that no rewrite could possibly satisfy. Expose `collect_frontiers` to gather up to `MAX_FRONTIERS_PER_CYCLE = 16` deepest-first frontiers, skip trivially uninvertible `EQ(a, a)` compares, and have `HeuristicAbiRewrite::propose` fan candidate rewrites across all of them so a single replay reaches guards that live several frames back along the trace. Existing regression tests (`symexec_assist_solves_equality_guard` and its invariant counterpart) continue to pass. Amp-Thread-ID: https://ampcode.com/threads/T-019e2bc9-ece8-718f-9279-971e3e982bec Co-authored-by: Amp --- crates/evm/evm/src/executors/symexec/mod.rs | 37 ++++++++----- .../evm/evm/src/executors/symexec/mutate.rs | 11 ++-- .../evm/evm/src/executors/symexec/select.rs | 41 ++++++++++++-- crates/evm/evm/src/inspectors/branch_trace.rs | 53 +++++++++++++++---- 4 files changed, 112 insertions(+), 30 deletions(-) diff --git a/crates/evm/evm/src/executors/symexec/mod.rs b/crates/evm/evm/src/executors/symexec/mod.rs index 53bf8063856c9..d8aa03f03b922 100644 --- a/crates/evm/evm/src/executors/symexec/mod.rs +++ b/crates/evm/evm/src/executors/symexec/mod.rs @@ -90,7 +90,13 @@ impl SymBackend for HeuristicAbiRewrite { let mut selector = [0u8; 4]; selector.copy_from_slice(&calldata[..4]); - let Some((frontier, obs)) = select::pick_frontier( + // Collect several deepest-first frontiers, not just the very last + // one — the deepest unseen branch is often in post-call test-harness + // bookkeeping (e.g. forge-std asserting on the returned bool) and + // not in the contract under test. Propose calldata rewrites for + // each so a single replay cycle can flip a guard several frames + // back along the trace. + let frontiers = select::collect_frontiers( trace, tx_index as u32, selector, @@ -98,25 +104,32 @@ impl SymBackend for HeuristicAbiRewrite { history_map, hash_builder, select::unseen_in_history, - ) else { + MAX_FRONTIERS_PER_CYCLE, + ); + if frontiers.is_empty() { return Vec::new(); - }; - - let rewrites = propose_calldata_rewrites(tx, function, &obs); + } - // The mutation only changes the final tx; build the full sequence. let source_uuid = uuid::Uuid::nil(); - rewrites - .into_iter() - .map(|new_tx| { + let mut out = Vec::new(); + for (frontier, obs) in frontiers { + let rewrites = propose_calldata_rewrites(tx, function, &obs); + for new_tx in rewrites { let mut tx_seq = seed.to_vec(); tx_seq[tx_index] = new_tx; - Candidate { tx_seq, frontier, source_uuid } - }) - .collect() + out.push(Candidate { tx_seq, frontier, source_uuid }); + } + } + out } } +/// Maximum number of distinct frontier branches a single replay cycle is +/// allowed to fan candidate calldata rewrites over. Keeps the per-cycle +/// validation cost bounded while still letting the worker reach guards +/// that live *before* the test-harness post-call code. +const MAX_FRONTIERS_PER_CYCLE: usize = 16; + /// Run a single symbolic-assist cycle on the master worker. /// /// `function` is the ABI of the call slot the worker is allowed to mutate diff --git a/crates/evm/evm/src/executors/symexec/mutate.rs b/crates/evm/evm/src/executors/symexec/mutate.rs index 8ce58f39416f3..34680e29a5a40 100644 --- a/crates/evm/evm/src/executors/symexec/mutate.rs +++ b/crates/evm/evm/src/executors/symexec/mutate.rs @@ -20,13 +20,16 @@ fn target_values(obs: &BranchObservation) -> Vec { let Some(cmp) = obs.cmp else { return Vec::new() }; // We want LHS to satisfy `LHS OP RHS` to land on the *other* side. - // If the branch was taken, the predicate currently holds; we want to - // negate it. If the branch was not taken, we want to satisfy it. - // Both can be achieved by trying values near `rhs`. + // The underlying compare predicate may be inverted by an ISZERO before + // the JUMPI (Solidity's `if (a == b)` lowers to `EQ; ISZERO; JUMPI`), + // so the predicate currently holds iff `took_branch XOR cmp.inverted`. + // If it holds, we want to break it; if it doesn't, we want to satisfy + // it. Both are achieved by trying values near `rhs`. + let predicate_held = obs.took_branch ^ cmp.inverted; let r = cmp.rhs; let candidates: Vec = match cmp.kind { CmpKind::Eq => { - if obs.took_branch { + if predicate_held { // Currently `lhs == rhs`. Flip by trying `rhs ± 1`. vec![r.wrapping_add(U256::from(1)), r.wrapping_sub(U256::from(1))] } else { diff --git a/crates/evm/evm/src/executors/symexec/select.rs b/crates/evm/evm/src/executors/symexec/select.rs index cb3190d022642..3ddec38b6be03 100644 --- a/crates/evm/evm/src/executors/symexec/select.rs +++ b/crates/evm/evm/src/executors/symexec/select.rs @@ -5,7 +5,7 @@ //! done by `mod.rs`. use super::types::{FrontierKey, SymExecState}; -use crate::inspectors::{BranchObservation, BranchTrace}; +use crate::inspectors::{BranchObservation, BranchTrace, CmpKind}; use alloy_primitives::map::DefaultHashBuilder; use foundry_evm_fuzz::BasicTxDetails; @@ -66,9 +66,42 @@ pub fn pick_frontier( where F: Fn(&[u8], usize) -> bool, { + collect_frontiers(trace, tx_index, selector, state, history_map, hash_builder, is_unseen, 1) + .into_iter() + .next() +} + +/// Collect up to `limit` eligible frontiers from the trace, deepest first. +/// Used by the assist loop to fan out a single replay across several +/// candidate frontiers when the deepest one is uninteresting (e.g. it +/// lives in test-harness post-call code). +#[allow(clippy::too_many_arguments)] +pub fn collect_frontiers( + trace: &BranchTrace, + tx_index: u32, + selector: [u8; 4], + state: &SymExecState, + history_map: &[u8], + hash_builder: &DefaultHashBuilder, + is_unseen: F, + limit: usize, +) -> Vec<(FrontierKey, BranchObservation)> +where + F: Fn(&[u8], usize) -> bool, +{ + let mut out = Vec::new(); for obs in trace.branches.iter().rev() { + if out.len() >= limit { + break; + } // No compare → v1 has no targeted mutation to offer. - if obs.cmp.is_none() { + let Some(cmp) = obs.cmp else { continue }; + + // Skip trivially uninvertible frontiers — equality-class compares + // where both operands are already equal (e.g. `EQ(0, 0)` emitted + // by Solidity's runtime cleanup) cannot be flipped by rewriting a + // single calldata word. + if matches!(cmp.kind, CmpKind::Eq) && cmp.lhs == cmp.rhs { continue; } @@ -87,10 +120,10 @@ where }; if state.should_try(&key) { - return Some((key, obs.clone())); + out.push((key, obs.clone())); } } - None + out } /// Default predicate: an edge is "unseen" if its hitcount in the history map diff --git a/crates/evm/evm/src/inspectors/branch_trace.rs b/crates/evm/evm/src/inspectors/branch_trace.rs index 4327a315f9ad5..bef6f4d79a05b 100644 --- a/crates/evm/evm/src/inspectors/branch_trace.rs +++ b/crates/evm/evm/src/inspectors/branch_trace.rs @@ -58,6 +58,12 @@ pub struct CmpObservation { pub lhs: U256, /// `U256::ZERO` for `ISZERO`. pub rhs: U256, + /// `true` when the underlying compare was negated by an `ISZERO` + /// before reaching the `JUMPI`. Solidity's `if (a == b)` lowers to + /// `EQ; ISZERO; JUMPI`, so the JUMPI-taken direction is the + /// *opposite* of the predicate holding. Mutation heuristics use this + /// to decide whether they need to satisfy or break the predicate. + pub inverted: bool, } /// One observed `JUMPI` along the executed path. @@ -162,7 +168,7 @@ impl BranchTraceInspector { self.prev_pending_cmp = None; } - fn record_cmp(&mut self, kind: CmpKind, interp: &Interpreter) { + fn record_cmp(&mut self, kind: CmpKind, interp: &Interpreter, inverted: bool) { // Operand order on the EVM stack: top = a, next = b. // Comparisons compute `a OP b`; `ISZERO` just consumes `a`. let lhs = interp.stack.peek(0).unwrap_or(U256::ZERO); @@ -174,7 +180,7 @@ impl BranchTraceInspector { // Shift the previous pending compare down so we can recover it past an // intervening `ISZERO`. self.prev_pending_cmp = self.pending_cmp.take(); - self.pending_cmp = Some(CmpObservation { kind, lhs, rhs }); + self.pending_cmp = Some(CmpObservation { kind, lhs, rhs, inverted }); } fn record_jumpi(&mut self, interp: &Interpreter) { @@ -191,9 +197,15 @@ impl BranchTraceInspector { // Prefer the underlying compare if the immediate predecessor was // `ISZERO` (Solidity's `if (x == y)` becomes `EQ; ISZERO; PUSH; JUMPI` - // and its negation `EQ; PUSH; JUMPI`). + // and its negation `EQ; PUSH; JUMPI`). When we unwrap past an + // `ISZERO`, mark the recovered compare as `inverted` so mutation + // heuristics know the JUMPI-taken direction is the *opposite* of + // the predicate holding. let cmp = match self.pending_cmp { - Some(c) if c.kind == CmpKind::IsZero => self.prev_pending_cmp, + Some(c) if c.kind == CmpKind::IsZero => self.prev_pending_cmp.map(|mut prev| { + prev.inverted = !prev.inverted; + prev + }), other => other, }; @@ -213,12 +225,23 @@ impl Inspector for BranchTraceInspector { fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { let op = interp.bytecode.opcode(); match op { - opcode::EQ => self.record_cmp(CmpKind::Eq, interp), - opcode::LT => self.record_cmp(CmpKind::Lt, interp), - opcode::GT => self.record_cmp(CmpKind::Gt, interp), - opcode::SLT => self.record_cmp(CmpKind::Slt, interp), - opcode::SGT => self.record_cmp(CmpKind::Sgt, interp), - opcode::ISZERO => self.record_cmp(CmpKind::IsZero, interp), + opcode::EQ => self.record_cmp(CmpKind::Eq, interp, false), + opcode::LT => self.record_cmp(CmpKind::Lt, interp, false), + opcode::GT => self.record_cmp(CmpKind::Gt, interp, false), + opcode::SLT => self.record_cmp(CmpKind::Slt, interp, false), + opcode::SGT => self.record_cmp(CmpKind::Sgt, interp, false), + opcode::ISZERO => self.record_cmp(CmpKind::IsZero, interp, false), + // Solidity with the optimizer enabled lowers `if (a == b)` to + // `SUB(a, b); JUMPI(skip, diff)` — diff is zero iff `a == b`, + // so the JUMPI-taken direction is the *opposite* of EQ. Record + // SUB as an equality-class compare with `inverted = true` so + // mutation heuristics see the same `EQ`-shaped operands. If + // SUB is being used for plain arithmetic, the next non-inert + // opcode (e.g. ADD, MUL, ...) clears the pending window. + opcode::SUB => self.record_cmp(CmpKind::Eq, interp, true), + // `XOR(a, b)` is also zero iff `a == b`; the Yul IR pipeline + // sometimes prefers it over SUB. Same logic as SUB. + opcode::XOR => self.record_cmp(CmpKind::Eq, interp, true), opcode::JUMPI => { self.record_jumpi(interp); // Reset pending compares — we only care about the cmp that @@ -226,6 +249,16 @@ impl Inspector for BranchTraceInspector { self.pending_cmp = None; self.prev_pending_cmp = None; } + // PUSH0..PUSH32, JUMPDEST, and DUP/SWAP/POP are pure stack / + // no-op fillers that commonly sit between the compare and the + // JUMPI in Solidity's `EQ; ISZERO; PUSH; JUMPI` lowering and + // similar IR-emitted patterns (e.g. `EQ; SWAP; ISZERO; PUSH; + // JUMPI`). Keep the pending compare window open across them. + opcode::PUSH0..=opcode::PUSH32 + | opcode::DUP1..=opcode::DUP16 + | opcode::SWAP1..=opcode::SWAP16 + | opcode::POP + | opcode::JUMPDEST => {} _ => { // Any other opcode invalidates the pending-compare window. if self.pending_cmp.is_some() { From 4b6623ceb5c610a26edc7b00f9eb4e59f9e8c6f5 Mon Sep 17 00:00:00 2001 From: Gustavo Figueiredo Date: Mon, 18 May 2026 15:05:10 +0100 Subject: [PATCH 4/6] chore(symexec): drop aspirational docstrings, dead trait, and v1/v2 tags - Remove the `SymBackend` trait and `HeuristicAbiRewrite` newtype (single impl, single caller) and replace with a plain `propose_candidates` free function. The trait was placeholder scaffolding for a hypothetical SMT backend that does not exist; the worker has no symbolic engine of its own to feed one. - Drop module-level mentions of Echidna, the SaferMaker writeup, AFL / Redqueen, and "concolic-lite v1" / "v2". Describe the module by what it does today: a directed-mutation helper for coverage-guided fuzzing that captures EVM compare operands at runtime and rewrites scalar ABI args to flip frontier branches, with no SMT solver. - Drop the now-unused `pick_frontier` wrapper (`collect_frontiers` is the only caller path). Resolves the `dead_code` warning surfaced by rustc after the previous frontier-fan-out change. Amp-Thread-ID: https://ampcode.com/threads/T-019e2bc9-ece8-718f-9279-971e3e982bec Co-authored-by: Amp --- crates/evm/evm/src/executors/symexec/mod.rs | 159 ++++++++---------- .../evm/evm/src/executors/symexec/mutate.rs | 10 +- .../evm/evm/src/executors/symexec/select.rs | 39 ++--- crates/evm/evm/src/inspectors/branch_trace.rs | 16 +- 4 files changed, 91 insertions(+), 133 deletions(-) diff --git a/crates/evm/evm/src/executors/symexec/mod.rs b/crates/evm/evm/src/executors/symexec/mod.rs index d8aa03f03b922..a2a3233e0b598 100644 --- a/crates/evm/evm/src/executors/symexec/mod.rs +++ b/crates/evm/evm/src/executors/symexec/mod.rs @@ -1,23 +1,22 @@ -//! Symbolic-assist worker — concolic-lite v1. +//! Symbolic-assist worker. //! -//! Implements the *worker-side* of the architectural plan inspired by -//! Echidna's `Echidna.SymExec.Exploration` and the SaferMaker writeup at -//! . -//! -//! It is *not* a full symbolic-execution engine; it is a directed mutation -//! assistant that: +//! Directed-mutation helper for coverage-guided fuzzing. Each cycle: //! //! 1. takes a corpus seed (`Vec`), //! 2. replays it concretely under a [`crate::inspectors::BranchTraceInspector`], -//! 3. picks the deepest *unseen* opposite-side branch as the "frontier", -//! 4. proposes ABI-aware calldata rewrites that — given the recovered compare operands — would flip -//! the frontier branch, -//! 5. validates each candidate through the normal executor (requiring a real new edge in coverage), -//! and -//! 6. writes accepted candidates to the master worker's `sync/` directory so the existing corpus -//! protocol distributes them. +//! 3. picks unseen opposite-side branches ("frontiers") from the trace, +//! 4. proposes ABI-aware calldata rewrites that — given the recovered +//! compare operands — would flip a frontier, +//! 5. validates each candidate through a clone of the live executor, +//! requiring a real new EVM edge in coverage, and +//! 6. writes accepted candidates to the master worker's `sync/` directory +//! so the existing corpus protocol distributes them. +//! +//! There is no SMT solver here; the worker has no symbolic engine of its +//! own to feed one, so it can only flip branches whose compare operands +//! are visible at runtime and reachable by rewriting a scalar ABI arg. //! -//! v1 is intentionally minimal: +//! Scope: //! - master worker only, //! - EVM `EdgeCovInspector`-based coverage only (no sancov), //! - mutates only the final tx of the seed sequence, @@ -49,79 +48,62 @@ use select::pick_seed; use types::Candidate; pub use types::SymExecState; -/// Backend abstraction so v2 can swap the heuristic engine for a real SMT -/// solver (or external `hevm`) without touching the assist loop. -pub trait SymBackend { - /// Generate candidate sequences from a seed and its branch trace. - /// `tx_index` identifies the call to mutate (the last one, in v1). - #[allow(clippy::too_many_arguments)] - fn propose( - &self, - seed: &[BasicTxDetails], - trace: &BranchTrace, - tx_index: usize, - function: Option<&Function>, - state: &SymExecState, - history_map: &[u8], - hash_builder: &DefaultHashBuilder, - ) -> Vec; -} - -/// v1 backend: ABI-aware Redqueen-style rewrites; no SMT. -#[derive(Clone, Debug, Default)] -pub struct HeuristicAbiRewrite; - -impl SymBackend for HeuristicAbiRewrite { - fn propose( - &self, - seed: &[BasicTxDetails], - trace: &BranchTrace, - tx_index: usize, - function: Option<&Function>, - state: &SymExecState, - history_map: &[u8], - hash_builder: &DefaultHashBuilder, - ) -> Vec { - let Some(tx) = seed.get(tx_index) else { return Vec::new() }; - let calldata = &tx.call_details.calldata; - if calldata.len() < 4 { - return Vec::new(); - } - let mut selector = [0u8; 4]; - selector.copy_from_slice(&calldata[..4]); - - // Collect several deepest-first frontiers, not just the very last - // one — the deepest unseen branch is often in post-call test-harness - // bookkeeping (e.g. forge-std asserting on the returned bool) and - // not in the contract under test. Propose calldata rewrites for - // each so a single replay cycle can flip a guard several frames - // back along the trace. - let frontiers = select::collect_frontiers( - trace, - tx_index as u32, - selector, - state, - history_map, - hash_builder, - select::unseen_in_history, - MAX_FRONTIERS_PER_CYCLE, - ); - if frontiers.is_empty() { - return Vec::new(); - } +/// Generate ABI-rewrite candidate sequences from a seed and its branch +/// trace. `tx_index` identifies the call to mutate (always the last one). +/// +/// This is the only backend the worker has today; it has no SMT solver +/// and no symbolic engine to feed one, so it can only flip branches whose +/// compare operands are visible at runtime and reachable by rewriting a +/// scalar ABI arg. +#[allow(clippy::too_many_arguments)] +pub fn propose_candidates( + seed: &[BasicTxDetails], + trace: &BranchTrace, + tx_index: usize, + function: Option<&Function>, + state: &SymExecState, + history_map: &[u8], + hash_builder: &DefaultHashBuilder, +) -> Vec { + let Some(tx) = seed.get(tx_index) else { return Vec::new() }; + let calldata = &tx.call_details.calldata; + if calldata.len() < 4 { + return Vec::new(); + } + let mut selector = [0u8; 4]; + selector.copy_from_slice(&calldata[..4]); + + // Collect several deepest-first frontiers, not just the very last + // one — the deepest unseen branch is often in post-call test-harness + // bookkeeping (e.g. forge-std asserting on the returned bool) and + // not in the contract under test. Propose calldata rewrites for + // each so a single replay cycle can flip a guard several frames + // back along the trace. + let frontiers = select::collect_frontiers( + trace, + tx_index as u32, + selector, + state, + history_map, + hash_builder, + select::unseen_in_history, + MAX_FRONTIERS_PER_CYCLE, + ); + if frontiers.is_empty() { + return Vec::new(); + } - let source_uuid = uuid::Uuid::nil(); - let mut out = Vec::new(); - for (frontier, obs) in frontiers { - let rewrites = propose_calldata_rewrites(tx, function, &obs); - for new_tx in rewrites { - let mut tx_seq = seed.to_vec(); - tx_seq[tx_index] = new_tx; - out.push(Candidate { tx_seq, frontier, source_uuid }); - } + let source_uuid = uuid::Uuid::nil(); + let mut out = Vec::new(); + for (frontier, obs) in frontiers { + let rewrites = propose_calldata_rewrites(tx, function, &obs); + for new_tx in rewrites { + let mut tx_seq = seed.to_vec(); + tx_seq[tx_index] = new_tx; + out.push(Candidate { tx_seq, frontier, source_uuid }); } - out } + out } /// Maximum number of distinct frontier branches a single replay cycle is @@ -133,7 +115,7 @@ const MAX_FRONTIERS_PER_CYCLE: usize = 16; /// Run a single symbolic-assist cycle on the master worker. /// /// `function` is the ABI of the call slot the worker is allowed to mutate -/// for stateless fuzz (v1 always mutates the *last* tx of the seed). +/// for stateless fuzz (the worker always mutates the *last* tx of the seed). /// `targeted_contracts` is used by stateful invariant tests to resolve /// the final tx's ABI dynamically — pass `None` for stateless fuzz. /// Exactly one of `function` / `targeted_contracts` must be `Some`. @@ -168,7 +150,7 @@ pub fn run_symexec_assist( // map. replay_executor.inspector_mut().collect_edge_coverage(false); - // v1: only the final tx's branches are eligible for mutation, so we + // Only the final tx's branches are eligible for mutation, so we // only need to *trace* that final tx — the prefix is replayed purely // to set up state for stateful tests. let tx_index = seed.tx_seq.len().saturating_sub(1); @@ -213,10 +195,9 @@ pub fn run_symexec_assist( }; // 3. Pick a frontier + propose candidates. - let backend = HeuristicAbiRewrite; let history = corpus.history_map_snapshot(); let hash_builder = DefaultHashBuilder::default(); - let candidates = backend.propose( + let candidates = propose_candidates( &seed.tx_seq, &trace, tx_index, diff --git a/crates/evm/evm/src/executors/symexec/mutate.rs b/crates/evm/evm/src/executors/symexec/mutate.rs index 34680e29a5a40..00df099c38c88 100644 --- a/crates/evm/evm/src/executors/symexec/mutate.rs +++ b/crates/evm/evm/src/executors/symexec/mutate.rs @@ -4,8 +4,8 @@ //! `BasicTxDetails`, produce a small bounded set of new calldatas that — if //! the branch's left-hand side is calldata-derived — would flip the branch. //! -//! v1 only handles scalar ABI args (`uintN`, `intN`, `bool`, `address`, -//! `bytes32`). Dynamic types (`bytes`, `string`, arrays) are skipped. +//! Only scalar ABI args (`uintN`, `intN`, `bool`, `address`, `bytes32`) +//! are mutated. Dynamic types (`bytes`, `string`, arrays) are skipped. use super::types::MAX_CANDIDATES_PER_FRONTIER; use crate::inspectors::{BranchObservation, CmpKind}; @@ -14,8 +14,8 @@ use alloy_json_abi::Function; use alloy_primitives::{Address, B256, Bytes, I256, U256}; use foundry_evm_fuzz::{BasicTxDetails, CallDetails}; -/// Generate the set of "interesting" RHS values for a compare. These are the -/// classic Redqueen tries: the operand itself plus boundary values. +/// Generate the set of "interesting" RHS values for a compare: the +/// operand itself plus boundary values. fn target_values(obs: &BranchObservation) -> Vec { let Some(cmp) = obs.cmp else { return Vec::new() }; @@ -134,7 +134,7 @@ fn rewrite_scalar(current: &DynSolValue, target: U256) -> Option { let bytes: [u8; 32] = target.to_be_bytes(); Some(DynSolValue::FixedBytes(B256::from(bytes), *size)) } - // Dynamic types and tuples skipped in v1. + // Dynamic types and tuples are not mutated. _ => None, } } diff --git a/crates/evm/evm/src/executors/symexec/select.rs b/crates/evm/evm/src/executors/symexec/select.rs index 3ddec38b6be03..4ba4ffadae7e7 100644 --- a/crates/evm/evm/src/executors/symexec/select.rs +++ b/crates/evm/evm/src/executors/symexec/select.rs @@ -20,7 +20,7 @@ pub struct SeedSnapshot { /// Score a seed for symbolic exploration. Higher is better. /// -/// v1 scoring (intentionally simple): +/// Scoring (intentionally simple): /// 1. favored seeds first /// 2. seeds with more `new_finds_produced` (proven productive) /// 3. shorter sequences (cheaper to replay & easier to mutate) @@ -46,35 +46,16 @@ pub fn pick_seed(candidates: &[SeedSnapshot]) -> Option<&SeedSnapshot> { candidates.iter().max_by_key(|s| score_seed(s)) } -/// Pick a frontier from a replay trace. +/// Collect up to `limit` eligible frontiers from a replay trace, deepest first. /// /// Strategy: -/// - prefer the *deepest* observation whose opposite edge is currently unseen (the seed already -/// satisfies all earlier guards), -/// - skip frontiers that have hit `MAX_FRONTIER_ATTEMPTS`, -/// - skip frontiers without a recoverable compare (v1 has no symbolic reasoning beyond ABI rewrites -/// of compare operands). -pub fn pick_frontier( - trace: &BranchTrace, - tx_index: u32, - selector: [u8; 4], - state: &SymExecState, - history_map: &[u8], - hash_builder: &DefaultHashBuilder, - is_unseen: F, -) -> Option<(FrontierKey, BranchObservation)> -where - F: Fn(&[u8], usize) -> bool, -{ - collect_frontiers(trace, tx_index, selector, state, history_map, hash_builder, is_unseen, 1) - .into_iter() - .next() -} - -/// Collect up to `limit` eligible frontiers from the trace, deepest first. -/// Used by the assist loop to fan out a single replay across several -/// candidate frontiers when the deepest one is uninteresting (e.g. it -/// lives in test-harness post-call code). +/// - walk the trace in reverse so that branches the seed reached late are +/// preferred (the seed already satisfies all earlier guards), +/// - skip branches without a recoverable compare (this worker only does +/// ABI rewrites of compare operands), +/// - skip trivially uninvertible `EQ(a, a)` compares emitted by Solidity +/// runtime cleanup, +/// - skip frontiers that have hit `MAX_FRONTIER_ATTEMPTS`. #[allow(clippy::too_many_arguments)] pub fn collect_frontiers( trace: &BranchTrace, @@ -94,7 +75,7 @@ where if out.len() >= limit { break; } - // No compare → v1 has no targeted mutation to offer. + // No compare → no targeted mutation to offer. let Some(cmp) = obs.cmp else { continue }; // Skip trivially uninvertible frontiers — equality-class compares diff --git a/crates/evm/evm/src/inspectors/branch_trace.rs b/crates/evm/evm/src/inspectors/branch_trace.rs index bef6f4d79a05b..95e9380b66a49 100644 --- a/crates/evm/evm/src/inspectors/branch_trace.rs +++ b/crates/evm/evm/src/inspectors/branch_trace.rs @@ -1,19 +1,15 @@ //! Branch-trace inspector for the symbolic-assist worker. //! -//! Records every conditional jump (`JUMPI`) the EVM executes, together with the -//! comparator operands of the immediately preceding compare opcode -//! (`EQ` / `LT` / `GT` / `SLT` / `SGT` / `ISZERO`) when present. The resulting -//! [`BranchTrace`] is consumed by the symbolic-assist worker -//! (`crate::executors::symexec`) to: +//! Records every conditional jump (`JUMPI`) the EVM executes, together with +//! the comparator operands of the immediately preceding compare opcode +//! (`EQ` / `LT` / `GT` / `SLT` / `SGT` / `ISZERO`, plus `SUB` / `XOR` used +//! as equality lowerings) when present. The resulting [`BranchTrace`] is +//! consumed by the symbolic-assist worker (`crate::executors::symexec`) to: //! //! 1. find "frontier" branches whose opposite edge has never been covered, and //! 2. propose ABI-aware mutations of the calldata that would flip the branch. //! -//! This is the first piece of the concolic-lite engine described in the -//! architectural plan: it captures the same information AFL/Redqueen and -//! libFuzzer's `trace_cmp` use, but at the EVM level. -//! -//! NOTE: this inspector is intentionally *observational* only. It must not +//! This inspector is intentionally *observational* only. It must not //! mutate any EVM state and must be safe to run alongside the normal //! `EdgeCovInspector`. From b25354f889e612efee972e15672a94a3f097c38b Mon Sep 17 00:00:00 2001 From: Gustavo Figueiredo Date: Mon, 18 May 2026 16:16:05 +0100 Subject: [PATCH 5/6] fix(symexec): make worker candidates actually reach the corpus Three concrete bugs that left the worker's accepted candidates silently inert in live runs: 1. mutate.rs: `abi_encode_input` includes the function selector, so prepending the original selector ourselves produced calldata that was 4 bytes too long. Switch to `abi_encode_input_raw`. 2. corpus.rs::symexec_validate: defensively re-enable the edge coverage inspector on the cloned executor so candidate replay actually populates `result.edge_coverage` (otherwise `merge_all_coverage` could only ever report "no new coverage"). 3. corpus.rs::run_symexec_assist: route accepted candidates directly into the master's in-memory corpus via a new `insert_symexec_candidate` helper, instead of relying on the `sync/` -> `calibrate()` path. The sync filter is in second-resolution timestamps, so candidates produced inside the same second as the last sync (very common) were filtered out and never imported. corpus.rs::new_input now also returns a freshly-imported corpus entry verbatim on its first selection (when `total_mutations == 0`) before letting `abi_mutate` scramble it. Without this, a candidate whose value is the exact magic the worker discovered would be mutated immediately on its first selection and the precise value would be lost. Live benchmark (5000 runs, runtime-derived keccak guard, dictionary PUSH-bytes + storage scraping disabled) on seed 0x1: baseline passes (no counterexample found in 5000 runs); assist returns a real counterexample with the magic preimage as calldata. Amp-Thread-ID: https://ampcode.com/threads/T-019e2bc9-ece8-718f-9279-971e3e982bec Co-authored-by: Amp --- crates/evm/evm/src/executors/corpus.rs | 63 +++++++++++++++++-- crates/evm/evm/src/executors/symexec/mod.rs | 10 ++- .../evm/evm/src/executors/symexec/mutate.rs | 11 +++- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index a73508c84ce2d..0ce591c8618e7 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -716,12 +716,31 @@ 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 has never been tried verbatim + // (`total_mutations == 0`), return its calldata as-is so that + // newly-imported entries (in particular ones the symbolic-assist + // worker pushes via `insert_symexec_candidate`) get one shot at + // the contract before their bytes are scrambled by `abi_mutate`. + // Without this, candidates that hit a precise guard (e.g. a + // 32-byte EQ magic the worker copied from a runtime compare) + // are immediately mutated and lose the value the worker spent + // effort discovering. + let is_pristine = self.in_memory_corpus[idx].total_mutations == 0; + if is_pristine { + let tx = self.in_memory_corpus[idx].tx_seq.first().unwrap().clone(); + // Bump `total_mutations` so the same entry isn't returned + // verbatim forever; the next selection will go through + // `abi_mutate` normally. + self.in_memory_corpus[idx].total_mutations += 1; + tx + } 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) @@ -1167,6 +1186,32 @@ impl WorkerCorpus { self.config.corpus_dir.as_ref().map(|d| d.join(format!("{WORKER}0")).join(SYNC_DIR)) } + /// 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) -> 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 corpus = CorpusEntry::new(tx_seq); + 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 @@ -1188,6 +1233,12 @@ impl WorkerCorpus { return Ok(false); } let mut executor = executor.clone(); + // Ensure edge coverage is enabled on the clone we replay through. + // Without this the cloned inspector could be missing the + // `EdgeCovInspector` (depending on how the caller built it), and + // `result.edge_coverage` would be `None`, making `merge_all_coverage` + // silently report no new coverage. + executor.inspector_mut().collect_edge_coverage(true); let mut history = self.history_map.clone(); let mut sancov_history = self.sancov_history_map.clone(); let mut produced_new = false; diff --git a/crates/evm/evm/src/executors/symexec/mod.rs b/crates/evm/evm/src/executors/symexec/mod.rs index a2a3233e0b598..2b2427643b0c7 100644 --- a/crates/evm/evm/src/executors/symexec/mod.rs +++ b/crates/evm/evm/src/executors/symexec/mod.rs @@ -222,10 +222,16 @@ pub fn run_symexec_assist( continue; } + // Insert directly into the master's in-memory corpus instead of + // routing through `sync/` + `calibrate()`. The sync timestamp is + // only second-resolution, so candidates produced within the same + // second as the most recent sync were silently filtered out. + // Also persist a `sync/` copy for inspection / crash recovery. if let Some(sync_dir) = corpus.master_sync_dir() { - write_sync_entry(&sync_dir, &candidate.tx_seq)?; - accepted += 1; + let _ = write_sync_entry(&sync_dir, &candidate.tx_seq); } + corpus.insert_symexec_candidate(candidate.tx_seq)?; + accepted += 1; } Ok(accepted) diff --git a/crates/evm/evm/src/executors/symexec/mutate.rs b/crates/evm/evm/src/executors/symexec/mutate.rs index 00df099c38c88..cd1cc663323e1 100644 --- a/crates/evm/evm/src/executors/symexec/mutate.rs +++ b/crates/evm/evm/src/executors/symexec/mutate.rs @@ -86,12 +86,17 @@ pub fn propose_calldata_rewrites( }; let mut new_args = decoded.clone(); new_args[arg_idx] = new_value; - let Ok(encoded) = function.abi_encode_input(&new_args) else { + // `abi_encode_input` *includes* the function selector at the + // front — using it here would double-prepend the 4-byte + // selector and produce a calldata that is 4 bytes longer than + // the contract expects. Use `abi_encode_input_raw` (args only) + // and prepend the original selector ourselves. + let Ok(encoded_args) = function.abi_encode_input_raw(&new_args) else { continue; }; - let mut new_calldata = Vec::with_capacity(4 + encoded.len()); + let mut new_calldata = Vec::with_capacity(4 + encoded_args.len()); new_calldata.extend_from_slice(&calldata[..4]); - new_calldata.extend_from_slice(&encoded); + new_calldata.extend_from_slice(&encoded_args); out.push(BasicTxDetails { warp: tx.warp, From 4d65ab4a4b5ff633d203c2465a3f68c0e9afb3e1 Mon Sep 17 00:00:00 2001 From: Gustavo Figueiredo Date: Mon, 18 May 2026 17:17:41 +0100 Subject: [PATCH 6/6] fix(symexec): address external audit (hash parity, pending window, replay fidelity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concrete bugs identified by the audit on the previous commit: 1. Hash-builder mismatch (CRITICAL). frontier_edge_id and EdgeCovInspector::store_hit each used an independent DefaultHashBuilder::default(). alloy's DefaultHashBuilder is foldhash::fast::RandomState — randomly seeded per instance — so frontier IDs landed in completely different history_map buckets than the real coverage map. Now expose the live inspector's hash_builder via EdgeCovInspector::hash_builder() and pass that exact builder into propose_candidates. 2. collect_edge_coverage(true) in symexec_validate was harmful: it replaced the cloned executor's EdgeCovInspector with a fresh instance whose hasher had a different random seed, so the validation's edge_coverage and the corpus's history_map were bucketing into mismatched indices. Bail out instead when the live executor has no EdgeCovInspector (runner.rs already enables it whenever symexec is active). 3. new_input's verbatim-on-first-pick was global to all coverage-guided fuzzing and credited no favorability for productive verbatim runs. Replaced with a per-entry try_verbatim_once flag on CorpusEntry, set only by insert_symexec_candidate, and we now set current_mutated so process_inputs credits productive verbatim hits. 4. Pending compare window in branch_trace was too permissive: it preserved across DUP, SWAP, POP — opcodes that can copy, reorder or discard the cmp result on the stack and let us attribute a stale compare to an unrelated JUMPI. Restricted to PUSH0..PUSH32 and JUMPDEST. 5. Replay used call_raw(..., U256::ZERO) and ignored warp/roll/value. Replaced with execute_tx (same helper invariant/fuzz use) so the traced path matches the path the real fuzz loop took. 6. Removed the duplicate write to worker0/sync/ — going through the sync timestamp filter was a re-import / stale-skip hazard, and the candidate already lives in the in-memory corpus + corpus/ directory via insert_symexec_candidate. After these fixes the worker on the 10-seed × 6-case benchmark finds 50/50 planted bugs (same as baseline) and correctly leaves the honest negative alone. It's no longer a robustness win on most cases; only one outlier (TB5 seed 0x7: baseline 1247 runs, assist 151) is materially faster. The previous "baseline misses, assist finds" pattern was largely an artifact of the buggy worker flooding the corpus with noise. Amp-Thread-ID: https://ampcode.com/threads/T-019e2bc9-ece8-718f-9279-971e3e982bec Co-authored-by: Amp --- crates/evm/evm/src/executors/corpus.rs | 71 +++++++++++-------- crates/evm/evm/src/executors/symexec/mod.rs | 67 ++++++++--------- crates/evm/evm/src/inspectors/branch_trace.rs | 18 +++-- crates/evm/evm/src/inspectors/edge_cov.rs | 11 +++ 4 files changed, 94 insertions(+), 73 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 0ce591c8618e7..a0f7d6080fb8e 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -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 { @@ -149,6 +157,7 @@ impl CorpusEntry { .duration_since(UNIX_EPOCH) .expect("time went backwards") .as_secs(), + try_verbatim_once: false, } } @@ -717,23 +726,22 @@ impl WorkerCorpus { self.new_tx(test_runner)? } else { let idx = test_runner.rng().random_range(0..self.in_memory_corpus.len()); - // If a corpus entry has never been tried verbatim - // (`total_mutations == 0`), return its calldata as-is so that - // newly-imported entries (in particular ones the symbolic-assist - // worker pushes via `insert_symexec_candidate`) get one shot at - // the contract before their bytes are scrambled by `abi_mutate`. - // Without this, candidates that hit a precise guard (e.g. a - // 32-byte EQ magic the worker copied from a runtime compare) - // are immediately mutated and lose the value the worker spent - // effort discovering. - let is_pristine = self.in_memory_corpus[idx].total_mutations == 0; - if is_pristine { - let tx = self.in_memory_corpus[idx].tx_seq.first().unwrap().clone(); - // Bump `total_mutations` so the same entry isn't returned - // verbatim forever; the next selection will go through - // `abi_mutate` normally. - self.in_memory_corpus[idx].total_mutations += 1; - tx + // 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); @@ -1179,13 +1187,6 @@ impl WorkerCorpus { self.history_map.clone() } - /// Path to the master worker's `sync/` directory. The symbolic-assist - /// worker writes accepted candidates here so the next `sync()` cycle - /// imports them. - pub fn master_sync_dir(&self) -> Option { - self.config.corpus_dir.as_ref().map(|d| d.join(format!("{WORKER}0")).join(SYNC_DIR)) - } - /// Directly insert a validated symbolic-assist candidate into the /// master's in-memory corpus *and* persist it to disk. /// @@ -1199,7 +1200,11 @@ impl WorkerCorpus { let corpus_dir = worker_dir.join(CORPUS_DIR); foundry_common::fs::create_dir_all(&corpus_dir)?; - let corpus = CorpusEntry::new(tx_seq); + 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"); } @@ -1232,13 +1237,17 @@ impl WorkerCorpus { 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(); - // Ensure edge coverage is enabled on the clone we replay through. - // Without this the cloned inspector could be missing the - // `EdgeCovInspector` (depending on how the caller built it), and - // `result.edge_coverage` would be `None`, making `merge_all_coverage` - // silently report no new coverage. - executor.inspector_mut().collect_edge_coverage(true); let mut history = self.history_map.clone(); let mut sancov_history = self.sancov_history_map.clone(); let mut produced_new = false; diff --git a/crates/evm/evm/src/executors/symexec/mod.rs b/crates/evm/evm/src/executors/symexec/mod.rs index 2b2427643b0c7..9eae5d3a5780d 100644 --- a/crates/evm/evm/src/executors/symexec/mod.rs +++ b/crates/evm/evm/src/executors/symexec/mod.rs @@ -24,19 +24,24 @@ //! - hard CPU budget per cycle. use crate::{ - executors::{Executor, corpus::WorkerCorpus}, + executors::{Executor, corpus::WorkerCorpus, invariant::execute_tx}, inspectors::BranchTrace, }; use alloy_json_abi::Function; -use alloy_primitives::{B256, U256, keccak256, map::DefaultHashBuilder}; +use alloy_primitives::{B256, keccak256, map::DefaultHashBuilder}; use eyre::Result; -use foundry_common::fs::write_json_file; use foundry_evm_core::evm::FoundryEvmNetwork; use foundry_evm_fuzz::{BasicTxDetails, invariant::FuzzRunIdentifiedContracts}; -use std::{ - path::Path, - time::{SystemTime, UNIX_EPOCH}, -}; + +/// Fallback hash builder used when the live executor has no +/// `EdgeCovInspector`. This is only safe to use as a *parity*-with-itself +/// builder — frontier IDs computed against it will not match `history_map`, +/// so the worker only ever produces this when there is nothing to compare +/// against. Real cycles must use [`Executor::inspector().inner.edge_coverage`]'s +/// hash builder instead (see `run_symexec_assist`). +fn fallback_hash_builder() -> DefaultHashBuilder { + DefaultHashBuilder::default() +} mod mutate; mod select; @@ -152,7 +157,10 @@ pub fn run_symexec_assist( // Only the final tx's branches are eligible for mutation, so we // only need to *trace* that final tx — the prefix is replayed purely - // to set up state for stateful tests. + // to set up state for stateful tests. Use `execute_tx` (the same + // helper invariant/fuzz use) so warp, roll and bounded value are + // honored faithfully — otherwise the replay can diverge from the + // path the real fuzz loop took. let tx_index = seed.tx_seq.len().saturating_sub(1); let mut trace = BranchTrace::default(); for (i, tx) in seed.tx_seq.iter().enumerate() { @@ -161,12 +169,7 @@ pub fn run_symexec_assist( } else { replay_executor.inspector_mut().collect_branch_trace(false); } - let mut result = replay_executor.call_raw( - tx.sender, - tx.call_details.target, - tx.call_details.calldata.clone(), - U256::ZERO, - )?; + let mut result = execute_tx(&mut replay_executor, tx)?; if i == tx_index && let Some(t) = result.branch_trace.take() { @@ -196,7 +199,18 @@ pub fn run_symexec_assist( // 3. Pick a frontier + propose candidates. let history = corpus.history_map_snapshot(); - let hash_builder = DefaultHashBuilder::default(); + // CRITICAL: frontier IDs must hash through the *same* `BuildHasher` + // that `EdgeCovInspector::store_hit` used to populate `history_map`. + // `DefaultHashBuilder` (alloy's `foldhash::fast::RandomState`) is + // randomly seeded per instance, so a fresh `::default()` here would + // index into a completely different bucket than the live history map. + let hash_builder = executor + .inspector() + .inner + .edge_coverage + .as_deref() + .map(|ec| ec.hash_builder().clone()) + .unwrap_or_else(fallback_hash_builder); let candidates = propose_candidates( &seed.tx_seq, &trace, @@ -222,14 +236,11 @@ pub fn run_symexec_assist( continue; } - // Insert directly into the master's in-memory corpus instead of - // routing through `sync/` + `calibrate()`. The sync timestamp is - // only second-resolution, so candidates produced within the same - // second as the most recent sync were silently filtered out. - // Also persist a `sync/` copy for inspection / crash recovery. - if let Some(sync_dir) = corpus.master_sync_dir() { - let _ = write_sync_entry(&sync_dir, &candidate.tx_seq); - } + // Insert directly into the master's in-memory corpus and on + // disk. We deliberately do NOT also write to `worker0/sync/`: + // that path is second-resolution and would cause the same + // candidate to be re-imported (or stale-skipped) by a later + // `calibrate()` call. corpus.insert_symexec_candidate(candidate.tx_seq)?; accepted += 1; } @@ -250,12 +261,4 @@ fn candidate_hash(seq: &[BasicTxDetails]) -> B256 { keccak256(&bytes) } -/// Helper used by [`run_symexec_assist`] to write a raw `Vec` -/// JSON file into a worker's `sync/` directory. -pub fn write_sync_entry(sync_dir: &Path, seq: &[BasicTxDetails]) -> Result<()> { - let uuid = uuid::Uuid::new_v4(); - let ts = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let path = sync_dir.join(format!("{uuid}-{ts}.json")); - write_json_file(&path, &seq)?; - Ok(()) -} + diff --git a/crates/evm/evm/src/inspectors/branch_trace.rs b/crates/evm/evm/src/inspectors/branch_trace.rs index 95e9380b66a49..3e78467ce9e5c 100644 --- a/crates/evm/evm/src/inspectors/branch_trace.rs +++ b/crates/evm/evm/src/inspectors/branch_trace.rs @@ -245,16 +245,14 @@ impl Inspector for BranchTraceInspector { self.pending_cmp = None; self.prev_pending_cmp = None; } - // PUSH0..PUSH32, JUMPDEST, and DUP/SWAP/POP are pure stack / - // no-op fillers that commonly sit between the compare and the - // JUMPI in Solidity's `EQ; ISZERO; PUSH; JUMPI` lowering and - // similar IR-emitted patterns (e.g. `EQ; SWAP; ISZERO; PUSH; - // JUMPI`). Keep the pending compare window open across them. - opcode::PUSH0..=opcode::PUSH32 - | opcode::DUP1..=opcode::DUP16 - | opcode::SWAP1..=opcode::SWAP16 - | opcode::POP - | opcode::JUMPDEST => {} + // Only opcodes that cannot move or consume the compare result + // before it reaches the next `JUMPI`. PUSH/JUMPDEST are + // common between the compare and the JUMPI in Solidity + // lowerings (`EQ; ISZERO; PUSH dest; JUMPI`). DUP/SWAP/POP + // are deliberately not included: they can copy, reorder, or + // discard the cmp result on the stack, which would let us + // attribute a stale compare to an unrelated JUMPI. + opcode::PUSH0..=opcode::PUSH32 | opcode::JUMPDEST => {} _ => { // Any other opcode invalidates the pending-compare window. if self.pending_cmp.is_some() { diff --git a/crates/evm/evm/src/inspectors/edge_cov.rs b/crates/evm/evm/src/inspectors/edge_cov.rs index 72b3ade9726ef..1da67b0f9363b 100644 --- a/crates/evm/evm/src/inspectors/edge_cov.rs +++ b/crates/evm/evm/src/inspectors/edge_cov.rs @@ -53,6 +53,17 @@ impl EdgeCovInspector { self.hitcount } + /// Returns the `BuildHasher` this inspector uses for edge IDs. + /// + /// `DefaultHashBuilder` (alloy's `foldhash::fast::RandomState`) is + /// randomly seeded per-instance, so any helper that wants to compute + /// the same `edge_id` as `store_hit` MUST hash through this exact + /// builder. The symbolic-assist worker uses it to derive frontier + /// edge IDs that match the same buckets in `history_map`. + pub const fn hash_builder(&self) -> &DefaultHashBuilder { + &self.hash_builder + } + /// Mark the edge, H(address, pc, jump_dest), as hit. fn store_hit(&mut self, address: Address, pc: usize, jump_dest: U256) { let mut hasher = self.hash_builder.build_hasher();