Skip to main content

foundry_evm/executors/fuzz/
frontier.rs

1use crate::inspectors::CmpOperands;
2use alloy_json_abi::Function;
3use alloy_primitives::{
4    Address, Bytes, I256, U256,
5    map::{Entry, HashMap},
6};
7use foundry_common::fs;
8use foundry_evm_fuzz::{BasicTxDetails, CallDetails, FuzzRunMetadata};
9use revm::bytecode::opcode;
10use serde::{Serialize, Serializer};
11use std::{
12    path::Path,
13    sync::Arc,
14    time::{SystemTime, UNIX_EPOCH},
15};
16
17const FRONTIER_SCHEMA: &str = "foundry:fuzz.branch-frontiers@v1";
18pub(super) const FRONTIER_FILE: &str = "branch-frontiers.json";
19
20#[derive(Debug, Serialize)]
21pub(super) struct FuzzBranchFrontierArtifact {
22    /// Stable artifact schema identifier for downstream symbolic consumers.
23    schema: &'static str,
24    /// Schema version for consumers that prefer numeric dispatch.
25    version: u32,
26    /// Unix timestamp, in seconds, when the artifact was written.
27    generated_at: u64,
28    /// Fuzz test signature that produced the frontier records.
29    test: String,
30    /// Configured maximum number of records retained for this test.
31    limit: usize,
32    /// Captured comparison frontiers.
33    frontiers: Vec<FuzzBranchFrontier>,
34}
35
36impl FuzzBranchFrontierArtifact {
37    pub(super) fn new(
38        func: &Function,
39        limit: usize,
40        mut frontiers: Vec<FuzzBranchFrontier>,
41    ) -> Self {
42        for (id, frontier) in frontiers.iter_mut().enumerate() {
43            frontier.id = id as u64;
44        }
45
46        Self {
47            schema: FRONTIER_SCHEMA,
48            version: 1,
49            generated_at: SystemTime::now()
50                .duration_since(UNIX_EPOCH)
51                .expect("time went backwards")
52                .as_secs(),
53            test: func.signature(),
54            limit,
55            frontiers,
56        }
57    }
58}
59
60#[derive(Debug, Serialize)]
61pub(super) struct FuzzBranchFrontier {
62    /// Unique record identifier.
63    id: u64,
64    /// Reproducible fuzz seed, if configured.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    seed: Option<U256>,
67    /// 1-based fuzz run number, if known.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    run: Option<u32>,
70    /// Fuzz worker that produced the record, if known.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    worker: Option<u32>,
73    /// Whether this call also expanded coverage from the worker's current map. Only present when
74    /// edge coverage is collected (corpus, edge-coverage metrics, or sancov); omitted otherwise.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    new_coverage: Option<bool>,
77    /// Index of the call in the recorded sequence. Stateless fuzzing records one call.
78    call_index: usize,
79    /// Concrete call sequence that reached the frontier.
80    #[serde(serialize_with = "serialize_sequence")]
81    sequence: Arc<[BasicTxDetails]>,
82    /// EVM comparison site to target symbolically.
83    site: FuzzBranchFrontierSite,
84    /// Concrete operands observed at the site.
85    operands: FuzzBranchFrontierOperands,
86}
87
88#[derive(Clone, Copy, Debug, Serialize)]
89struct FuzzBranchFrontierSite {
90    address: Address,
91    pc: usize,
92    opcode: u8,
93    opcode_name: &'static str,
94}
95
96#[derive(Clone, Copy, Debug, Serialize)]
97struct FuzzBranchFrontierOperands {
98    lhs: U256,
99    rhs: U256,
100    /// Result of evaluating the captured comparison with these concrete operands.
101    result: bool,
102    /// Absolute operand delta interpreted according to the comparison opcode's signedness.
103    operand_delta: U256,
104}
105
106#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
107struct FuzzBranchFrontierKey {
108    address: Address,
109    pc: usize,
110    opcode: u8,
111    result: bool,
112}
113
114impl FuzzBranchFrontierKey {
115    const fn new(cmp: &CmpOperands, result: bool) -> Self {
116        Self { address: cmp.address, pc: cmp.pc, opcode: cmp.opcode, result }
117    }
118}
119
120#[derive(Debug, Default)]
121pub(super) struct FuzzFrontierRecorder {
122    limit: usize,
123    frontiers: Vec<FuzzBranchFrontier>,
124    indexes: HashMap<FuzzBranchFrontierKey, usize>,
125}
126
127impl FuzzFrontierRecorder {
128    pub(super) fn new(limit: usize) -> Self {
129        Self { limit, frontiers: Vec::with_capacity(limit.min(32)), indexes: HashMap::default() }
130    }
131
132    pub(super) fn capture_stateless_call(
133        &mut self,
134        run: Option<&FuzzRunMetadata>,
135        sender: Address,
136        target: Address,
137        calldata: &Bytes,
138        cmp_values: &[CmpOperands],
139        new_coverage: Option<bool>,
140    ) {
141        if self.limit == 0 || cmp_values.is_empty() {
142            return;
143        }
144
145        let mut sequence = None;
146        let mut new_frontier = |cmp: &CmpOperands, result, operand_delta| {
147            let sequence = sequence.get_or_insert_with(|| {
148                let call = BasicTxDetails {
149                    warp: None,
150                    roll: None,
151                    sender,
152                    call_details: CallDetails { target, calldata: calldata.clone(), value: None },
153                };
154                Arc::from(Vec::from([call]).into_boxed_slice())
155            });
156            FuzzBranchFrontier::new(
157                run,
158                Arc::clone(sequence),
159                *cmp,
160                result,
161                operand_delta,
162                new_coverage,
163                0,
164            )
165        };
166
167        for cmp in cmp_values {
168            let result = comparison_result(cmp);
169            let key = FuzzBranchFrontierKey::new(cmp, result);
170            let operand_delta = operand_delta(cmp);
171
172            match self.indexes.entry(key) {
173                Entry::Occupied(entry) => {
174                    let index = *entry.get();
175                    if operand_delta < self.frontiers[index].operands.operand_delta {
176                        self.frontiers[index] = new_frontier(cmp, result, operand_delta);
177                    }
178                }
179                Entry::Vacant(entry) => {
180                    if self.frontiers.len() < self.limit {
181                        entry.insert(self.frontiers.len());
182                        let frontier = new_frontier(cmp, result, operand_delta);
183                        self.frontiers.push(frontier);
184                    }
185                }
186            }
187        }
188    }
189
190    pub(super) fn into_frontiers(self) -> Vec<FuzzBranchFrontier> {
191        self.frontiers
192    }
193}
194
195impl FuzzBranchFrontier {
196    const fn key(&self) -> FuzzBranchFrontierKey {
197        FuzzBranchFrontierKey {
198            address: self.site.address,
199            pc: self.site.pc,
200            opcode: self.site.opcode,
201            result: self.operands.result,
202        }
203    }
204
205    fn new(
206        run: Option<&FuzzRunMetadata>,
207        sequence: Arc<[BasicTxDetails]>,
208        cmp: CmpOperands,
209        result: bool,
210        operand_delta: U256,
211        new_coverage: Option<bool>,
212        call_index: usize,
213    ) -> Self {
214        Self {
215            id: 0,
216            seed: run.and_then(|run| run.seed),
217            run: run.and_then(|run| run.run),
218            worker: run.and_then(|run| run.worker),
219            new_coverage,
220            call_index,
221            sequence,
222            site: FuzzBranchFrontierSite {
223                address: cmp.address,
224                pc: cmp.pc,
225                opcode: cmp.opcode,
226                opcode_name: opcode_name(cmp.opcode),
227            },
228            operands: FuzzBranchFrontierOperands {
229                lhs: cmp.op1,
230                rhs: cmp.op2,
231                result,
232                operand_delta,
233            },
234        }
235    }
236}
237
238/// Merges per-worker frontier records into a single bounded, globally deduplicated set.
239///
240/// Each worker deduplicates its own records by comparison site key while keeping the smallest
241/// `operand_delta`, but workers run independently, so the same site can appear in several workers'
242/// records with different observed deltas. This applies the same key dedup and smallest-delta
243/// policy across all workers so the artifact keeps one globally closest record per site and does
244/// not spend `limit` on duplicates. Iteration continues after `limit` is reached because a later
245/// record may be a smaller-delta duplicate of an already retained key.
246pub(super) fn merge_frontiers(
247    limit: usize,
248    frontiers: impl IntoIterator<Item = FuzzBranchFrontier>,
249) -> Vec<FuzzBranchFrontier> {
250    if limit == 0 {
251        return Vec::new();
252    }
253
254    let mut merged = Vec::<FuzzBranchFrontier>::with_capacity(limit.min(32));
255    let mut indexes = HashMap::<FuzzBranchFrontierKey, usize>::default();
256    for frontier in frontiers {
257        match indexes.entry(frontier.key()) {
258            Entry::Occupied(entry) => {
259                let index = *entry.get();
260                if frontier.operands.operand_delta < merged[index].operands.operand_delta {
261                    merged[index] = frontier;
262                }
263            }
264            Entry::Vacant(entry) => {
265                if merged.len() < limit {
266                    entry.insert(merged.len());
267                    merged.push(frontier);
268                }
269            }
270        }
271    }
272
273    merged
274}
275
276pub(super) fn write_frontier_artifact(
277    dir: &Path,
278    artifact: &FuzzBranchFrontierArtifact,
279) -> fs::Result<()> {
280    fs::create_dir_all(dir)?;
281    fs::write_json_file(&dir.join(FRONTIER_FILE), artifact)
282}
283
284fn serialize_sequence<S>(sequence: &Arc<[BasicTxDetails]>, serializer: S) -> Result<S::Ok, S::Error>
285where
286    S: Serializer,
287{
288    Serialize::serialize(sequence.as_ref(), serializer)
289}
290
291fn comparison_result(cmp: &CmpOperands) -> bool {
292    match cmp.opcode {
293        opcode::EQ => cmp.op1 == cmp.op2,
294        opcode::LT => cmp.op1 < cmp.op2,
295        opcode::GT => cmp.op1 > cmp.op2,
296        opcode::SLT => I256::from_raw(cmp.op1) < I256::from_raw(cmp.op2),
297        opcode::SGT => I256::from_raw(cmp.op1) > I256::from_raw(cmp.op2),
298        opcode::ISZERO => cmp.op1.is_zero(),
299        _ => false,
300    }
301}
302
303fn operand_delta(cmp: &CmpOperands) -> U256 {
304    match cmp.opcode {
305        opcode::SLT | opcode::SGT => signed_operand_delta(cmp.op1, cmp.op2),
306        _ => unsigned_operand_delta(cmp.op1, cmp.op2),
307    }
308}
309
310fn unsigned_operand_delta(left: U256, right: U256) -> U256 {
311    if left >= right { left - right } else { right - left }
312}
313
314fn signed_operand_delta(left: U256, right: U256) -> U256 {
315    let (left_negative, left_magnitude) = signed_magnitude(left);
316    let (right_negative, right_magnitude) = signed_magnitude(right);
317
318    if left_negative == right_negative {
319        unsigned_operand_delta(left_magnitude, right_magnitude)
320    } else {
321        left_magnitude + right_magnitude
322    }
323}
324
325fn signed_magnitude(value: U256) -> (bool, U256) {
326    let negative = I256::from_raw(value) < I256::ZERO;
327    let magnitude = if negative { U256::ZERO.wrapping_sub(value) } else { value };
328    (negative, magnitude)
329}
330
331const fn opcode_name(op: u8) -> &'static str {
332    match op {
333        opcode::EQ => "EQ",
334        opcode::LT => "LT",
335        opcode::GT => "GT",
336        opcode::SLT => "SLT",
337        opcode::SGT => "SGT",
338        opcode::ISZERO => "ISZERO",
339        _ => "UNKNOWN",
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn operand_delta_uses_signed_distance_for_signed_comparisons() {
349        let unsigned = CmpOperands {
350            op1: U256::MAX,
351            op2: U256::from(1),
352            pc: 0,
353            address: Address::ZERO,
354            opcode: opcode::LT,
355        };
356        assert_eq!(operand_delta(&unsigned), U256::MAX - U256::from(1));
357
358        let signed = CmpOperands { opcode: opcode::SLT, ..unsigned };
359        assert_eq!(operand_delta(&signed), U256::from(2));
360    }
361
362    #[test]
363    fn signed_operand_delta_handles_full_int256_range() {
364        let min = I256::MIN.into_raw();
365        let max = I256::MAX.into_raw();
366
367        assert_eq!(signed_operand_delta(min, max), U256::MAX);
368    }
369
370    fn frontier(pc: usize, result: bool, operand_delta: u64) -> FuzzBranchFrontier {
371        frontier_at(Address::ZERO, pc, result, operand_delta)
372    }
373
374    fn frontier_at(
375        address: Address,
376        pc: usize,
377        result: bool,
378        operand_delta: u64,
379    ) -> FuzzBranchFrontier {
380        let cmp = CmpOperands { op1: U256::ZERO, op2: U256::ZERO, pc, address, opcode: opcode::LT };
381        FuzzBranchFrontier::new(
382            None,
383            Arc::from(Vec::<BasicTxDetails>::new().into_boxed_slice()),
384            cmp,
385            result,
386            U256::from(operand_delta),
387            None,
388            0,
389        )
390    }
391
392    #[test]
393    fn merge_frontiers_dedupes_across_workers_keeping_smallest_delta() {
394        let merged = merge_frontiers(
395            8,
396            [frontier(1, false, 30), frontier(1, false, 10), frontier(1, false, 20)],
397        );
398
399        assert_eq!(merged.len(), 1);
400        assert_eq!(merged[0].operands.operand_delta, U256::from(10));
401    }
402
403    #[test]
404    fn merge_frontiers_keeps_records_with_distinct_result_keys() {
405        // Same site but different comparison result is a distinct key.
406        let merged = merge_frontiers(8, [frontier(1, false, 5), frontier(1, true, 5)]);
407
408        assert_eq!(merged.len(), 2);
409    }
410
411    #[test]
412    fn merge_frontiers_replaces_retained_key_after_limit_is_full() {
413        // The first two unique keys fill the limit; a later new key is dropped, but a later
414        // smaller-delta duplicate of an already retained key still replaces it.
415        let merged = merge_frontiers(
416            2,
417            [
418                frontier(1, false, 30),
419                frontier(2, false, 30),
420                frontier(3, false, 5),
421                frontier(1, false, 10),
422            ],
423        );
424
425        assert_eq!(merged.len(), 2);
426        let retained = merged.iter().find(|f| f.site.pc == 1).unwrap();
427        assert_eq!(retained.operands.operand_delta, U256::from(10));
428        assert!(merged.iter().all(|f| f.site.pc != 3));
429    }
430
431    #[test]
432    fn merge_frontiers_does_not_spend_limit_on_duplicates() {
433        // A duplicate of a retained key must not count against the limit, so a later distinct key
434        // still fits. This guards against counting processed records instead of unique keys.
435        let merged = merge_frontiers(
436            2,
437            [frontier(1, false, 30), frontier(1, false, 10), frontier(2, false, 5)],
438        );
439
440        assert_eq!(merged.len(), 2);
441        assert_eq!(
442            merged.iter().find(|f| f.site.pc == 1).unwrap().operands.operand_delta,
443            U256::from(10)
444        );
445        assert!(merged.iter().any(|f| f.site.pc == 2));
446    }
447
448    #[test]
449    fn merge_frontiers_distinguishes_by_address() {
450        // Same pc/opcode/result at different addresses are distinct sites.
451        let other = Address::with_last_byte(1);
452        let merged = merge_frontiers(
453            8,
454            [frontier_at(Address::ZERO, 1, false, 5), frontier_at(other, 1, false, 5)],
455        );
456
457        assert_eq!(merged.len(), 2);
458    }
459
460    #[test]
461    fn merge_frontiers_with_zero_limit_is_empty() {
462        assert!(merge_frontiers(0, [frontier(1, false, 1)]).is_empty());
463    }
464}