Skip to main content

foundry_evm/executors/fuzz/
frontier.rs

1use crate::inspectors::CmpOperands;
2use alloy_json_abi::Function;
3use alloy_primitives::{
4    Address, I256, U256,
5    map::{Entry, HashMap},
6};
7use foundry_common::fs;
8use foundry_evm_fuzz::{BasicTxDetails, 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        tx: &BasicTxDetails,
136        cmp_values: &[CmpOperands],
137        new_coverage: Option<bool>,
138    ) {
139        if self.limit == 0 || cmp_values.is_empty() {
140            return;
141        }
142
143        let mut sequence = None;
144        let mut new_frontier = |cmp: &CmpOperands, result, operand_delta| {
145            let sequence = sequence
146                .get_or_insert_with(|| Arc::from(Vec::from([tx.clone()]).into_boxed_slice()));
147            FuzzBranchFrontier::new(
148                run,
149                Arc::clone(sequence),
150                *cmp,
151                result,
152                operand_delta,
153                new_coverage,
154                0,
155            )
156        };
157
158        for cmp in cmp_values {
159            let result = comparison_result(cmp);
160            let key = FuzzBranchFrontierKey::new(cmp, result);
161            let operand_delta = operand_delta(cmp);
162
163            match self.indexes.entry(key) {
164                Entry::Occupied(entry) => {
165                    let index = *entry.get();
166                    if operand_delta < self.frontiers[index].operands.operand_delta {
167                        self.frontiers[index] = new_frontier(cmp, result, operand_delta);
168                    }
169                }
170                Entry::Vacant(entry) => {
171                    if self.frontiers.len() < self.limit {
172                        entry.insert(self.frontiers.len());
173                        let frontier = new_frontier(cmp, result, operand_delta);
174                        self.frontiers.push(frontier);
175                    }
176                }
177            }
178        }
179    }
180
181    pub(super) fn into_frontiers(self) -> Vec<FuzzBranchFrontier> {
182        self.frontiers
183    }
184}
185
186impl FuzzBranchFrontier {
187    const fn key(&self) -> FuzzBranchFrontierKey {
188        FuzzBranchFrontierKey {
189            address: self.site.address,
190            pc: self.site.pc,
191            opcode: self.site.opcode,
192            result: self.operands.result,
193        }
194    }
195
196    fn new(
197        run: Option<&FuzzRunMetadata>,
198        sequence: Arc<[BasicTxDetails]>,
199        cmp: CmpOperands,
200        result: bool,
201        operand_delta: U256,
202        new_coverage: Option<bool>,
203        call_index: usize,
204    ) -> Self {
205        Self {
206            id: 0,
207            seed: run.and_then(|run| run.seed),
208            run: run.and_then(|run| run.run),
209            worker: run.and_then(|run| run.worker),
210            new_coverage,
211            call_index,
212            sequence,
213            site: FuzzBranchFrontierSite {
214                address: cmp.address,
215                pc: cmp.pc,
216                opcode: cmp.opcode,
217                opcode_name: opcode_name(cmp.opcode),
218            },
219            operands: FuzzBranchFrontierOperands {
220                lhs: cmp.op1,
221                rhs: cmp.op2,
222                result,
223                operand_delta,
224            },
225        }
226    }
227}
228
229/// Merges per-worker frontier records into a single bounded, globally deduplicated set.
230///
231/// Each worker deduplicates its own records by comparison site key while keeping the smallest
232/// `operand_delta`, but workers run independently, so the same site can appear in several workers'
233/// records with different observed deltas. This applies the same key dedup and smallest-delta
234/// policy across all workers so the artifact keeps one globally closest record per site and does
235/// not spend `limit` on duplicates. Iteration continues after `limit` is reached because a later
236/// record may be a smaller-delta duplicate of an already retained key.
237pub(super) fn merge_frontiers(
238    limit: usize,
239    frontiers: impl IntoIterator<Item = FuzzBranchFrontier>,
240) -> Vec<FuzzBranchFrontier> {
241    if limit == 0 {
242        return Vec::new();
243    }
244
245    let mut merged = Vec::<FuzzBranchFrontier>::with_capacity(limit.min(32));
246    let mut indexes = HashMap::<FuzzBranchFrontierKey, usize>::default();
247    for frontier in frontiers {
248        match indexes.entry(frontier.key()) {
249            Entry::Occupied(entry) => {
250                let index = *entry.get();
251                if frontier.operands.operand_delta < merged[index].operands.operand_delta {
252                    merged[index] = frontier;
253                }
254            }
255            Entry::Vacant(entry) => {
256                if merged.len() < limit {
257                    entry.insert(merged.len());
258                    merged.push(frontier);
259                }
260            }
261        }
262    }
263
264    merged
265}
266
267pub(super) fn write_frontier_artifact(
268    dir: &Path,
269    artifact: &FuzzBranchFrontierArtifact,
270) -> fs::Result<()> {
271    fs::create_dir_all(dir)?;
272    fs::write_json_file(&dir.join(FRONTIER_FILE), artifact)
273}
274
275fn serialize_sequence<S>(sequence: &Arc<[BasicTxDetails]>, serializer: S) -> Result<S::Ok, S::Error>
276where
277    S: Serializer,
278{
279    Serialize::serialize(sequence.as_ref(), serializer)
280}
281
282fn comparison_result(cmp: &CmpOperands) -> bool {
283    match cmp.opcode {
284        opcode::EQ => cmp.op1 == cmp.op2,
285        opcode::LT => cmp.op1 < cmp.op2,
286        opcode::GT => cmp.op1 > cmp.op2,
287        opcode::SLT => I256::from_raw(cmp.op1) < I256::from_raw(cmp.op2),
288        opcode::SGT => I256::from_raw(cmp.op1) > I256::from_raw(cmp.op2),
289        opcode::ISZERO => cmp.op1.is_zero(),
290        _ => false,
291    }
292}
293
294fn operand_delta(cmp: &CmpOperands) -> U256 {
295    match cmp.opcode {
296        opcode::SLT | opcode::SGT => signed_operand_delta(cmp.op1, cmp.op2),
297        _ => unsigned_operand_delta(cmp.op1, cmp.op2),
298    }
299}
300
301fn unsigned_operand_delta(left: U256, right: U256) -> U256 {
302    if left >= right { left - right } else { right - left }
303}
304
305fn signed_operand_delta(left: U256, right: U256) -> U256 {
306    let (left_negative, left_magnitude) = signed_magnitude(left);
307    let (right_negative, right_magnitude) = signed_magnitude(right);
308
309    if left_negative == right_negative {
310        unsigned_operand_delta(left_magnitude, right_magnitude)
311    } else {
312        left_magnitude + right_magnitude
313    }
314}
315
316fn signed_magnitude(value: U256) -> (bool, U256) {
317    let negative = I256::from_raw(value) < I256::ZERO;
318    let magnitude = if negative { U256::ZERO.wrapping_sub(value) } else { value };
319    (negative, magnitude)
320}
321
322const fn opcode_name(op: u8) -> &'static str {
323    match op {
324        opcode::EQ => "EQ",
325        opcode::LT => "LT",
326        opcode::GT => "GT",
327        opcode::SLT => "SLT",
328        opcode::SGT => "SGT",
329        opcode::ISZERO => "ISZERO",
330        _ => "UNKNOWN",
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use alloy_primitives::Bytes;
338    use foundry_evm_fuzz::CallDetails;
339
340    #[test]
341    fn stateless_frontier_preserves_concrete_transaction() {
342        let tx = BasicTxDetails {
343            warp: None,
344            roll: None,
345            sender: Address::with_last_byte(1),
346            call_details: CallDetails {
347                target: Address::with_last_byte(2),
348                calldata: Bytes::from_static(&[1, 2, 3, 4]),
349                value: Some(U256::from(7)),
350            },
351        };
352        let cmp = CmpOperands {
353            op1: U256::ZERO,
354            op2: U256::from(1),
355            pc: 1,
356            address: tx.call_details.target,
357            opcode: opcode::LT,
358        };
359        let mut recorder = FuzzFrontierRecorder::new(1);
360
361        recorder.capture_stateless_call(None, &tx, &[cmp], Some(true));
362
363        let frontier = recorder.into_frontiers().pop().unwrap();
364        assert_eq!(frontier.sequence.len(), 1);
365        let recorded = &frontier.sequence[0];
366        assert_eq!(recorded.sender, tx.sender);
367        assert_eq!(recorded.call_details.target, tx.call_details.target);
368        assert_eq!(recorded.call_details.calldata, tx.call_details.calldata);
369        assert_eq!(recorded.call_details.value, tx.call_details.value);
370    }
371
372    #[test]
373    fn operand_delta_uses_signed_distance_for_signed_comparisons() {
374        let unsigned = CmpOperands {
375            op1: U256::MAX,
376            op2: U256::from(1),
377            pc: 0,
378            address: Address::ZERO,
379            opcode: opcode::LT,
380        };
381        assert_eq!(operand_delta(&unsigned), U256::MAX - U256::from(1));
382
383        let signed = CmpOperands { opcode: opcode::SLT, ..unsigned };
384        assert_eq!(operand_delta(&signed), U256::from(2));
385    }
386
387    #[test]
388    fn signed_operand_delta_handles_full_int256_range() {
389        let min = I256::MIN.into_raw();
390        let max = I256::MAX.into_raw();
391
392        assert_eq!(signed_operand_delta(min, max), U256::MAX);
393    }
394
395    fn frontier(pc: usize, result: bool, operand_delta: u64) -> FuzzBranchFrontier {
396        frontier_at(Address::ZERO, pc, result, operand_delta)
397    }
398
399    fn frontier_at(
400        address: Address,
401        pc: usize,
402        result: bool,
403        operand_delta: u64,
404    ) -> FuzzBranchFrontier {
405        let cmp = CmpOperands { op1: U256::ZERO, op2: U256::ZERO, pc, address, opcode: opcode::LT };
406        FuzzBranchFrontier::new(
407            None,
408            Arc::from(Vec::<BasicTxDetails>::new().into_boxed_slice()),
409            cmp,
410            result,
411            U256::from(operand_delta),
412            None,
413            0,
414        )
415    }
416
417    #[test]
418    fn merge_frontiers_dedupes_across_workers_keeping_smallest_delta() {
419        let merged = merge_frontiers(
420            8,
421            [frontier(1, false, 30), frontier(1, false, 10), frontier(1, false, 20)],
422        );
423
424        assert_eq!(merged.len(), 1);
425        assert_eq!(merged[0].operands.operand_delta, U256::from(10));
426    }
427
428    #[test]
429    fn merge_frontiers_keeps_records_with_distinct_result_keys() {
430        // Same site but different comparison result is a distinct key.
431        let merged = merge_frontiers(8, [frontier(1, false, 5), frontier(1, true, 5)]);
432
433        assert_eq!(merged.len(), 2);
434    }
435
436    #[test]
437    fn merge_frontiers_replaces_retained_key_after_limit_is_full() {
438        // The first two unique keys fill the limit; a later new key is dropped, but a later
439        // smaller-delta duplicate of an already retained key still replaces it.
440        let merged = merge_frontiers(
441            2,
442            [
443                frontier(1, false, 30),
444                frontier(2, false, 30),
445                frontier(3, false, 5),
446                frontier(1, false, 10),
447            ],
448        );
449
450        assert_eq!(merged.len(), 2);
451        let retained = merged.iter().find(|f| f.site.pc == 1).unwrap();
452        assert_eq!(retained.operands.operand_delta, U256::from(10));
453        assert!(merged.iter().all(|f| f.site.pc != 3));
454    }
455
456    #[test]
457    fn merge_frontiers_does_not_spend_limit_on_duplicates() {
458        // A duplicate of a retained key must not count against the limit, so a later distinct key
459        // still fits. This guards against counting processed records instead of unique keys.
460        let merged = merge_frontiers(
461            2,
462            [frontier(1, false, 30), frontier(1, false, 10), frontier(2, false, 5)],
463        );
464
465        assert_eq!(merged.len(), 2);
466        assert_eq!(
467            merged.iter().find(|f| f.site.pc == 1).unwrap().operands.operand_delta,
468            U256::from(10)
469        );
470        assert!(merged.iter().any(|f| f.site.pc == 2));
471    }
472
473    #[test]
474    fn merge_frontiers_distinguishes_by_address() {
475        // Same pc/opcode/result at different addresses are distinct sites.
476        let other = Address::with_last_byte(1);
477        let merged = merge_frontiers(
478            8,
479            [frontier_at(Address::ZERO, 1, false, 5), frontier_at(other, 1, false, 5)],
480        );
481
482        assert_eq!(merged.len(), 2);
483    }
484
485    #[test]
486    fn merge_frontiers_with_zero_limit_is_empty() {
487        assert!(merge_frontiers(0, [frontier(1, false, 1)]).is_empty());
488    }
489}