Skip to main content

foundry_config/
invariant.rs

1//! Configuration for invariant testing
2
3use crate::fuzz::{FuzzCorpusConfig, FuzzDictionaryConfig};
4use serde::{
5    Deserialize, Deserializer, Serialize, Serializer,
6    de::{Error, Visitor},
7};
8use std::{fmt, num::NonZeroUsize, path::PathBuf, str::FromStr};
9
10/// Worker selection mode for invariant campaign sharding.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum InvariantWorkers {
13    /// Automatically derive invariant workers from the active `--jobs` / rayon thread pool.
14    Auto,
15    /// Explicit user override for invariant campaign sharding.
16    Fixed(NonZeroUsize),
17}
18
19impl Default for InvariantWorkers {
20    fn default() -> Self {
21        Self::Fixed(NonZeroUsize::MIN)
22    }
23}
24
25impl Serialize for InvariantWorkers {
26    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
27    where
28        S: Serializer,
29    {
30        match self {
31            Self::Auto => serializer.serialize_str("auto"),
32            Self::Fixed(workers) => workers.get().serialize(serializer),
33        }
34    }
35}
36
37impl<'de> Deserialize<'de> for InvariantWorkers {
38    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
39    where
40        D: Deserializer<'de>,
41    {
42        deserializer.deserialize_any(InvariantWorkersVisitor)
43    }
44}
45
46impl FromStr for InvariantWorkers {
47    type Err = String;
48
49    fn from_str(value: &str) -> Result<Self, Self::Err> {
50        let value = value.trim();
51        if value.eq_ignore_ascii_case("auto") {
52            return Ok(Self::Auto);
53        }
54
55        let workers = value.parse::<usize>().map_err(|err| err.to_string())?;
56        fixed_workers(workers)
57    }
58}
59
60struct InvariantWorkersVisitor;
61
62impl Visitor<'_> for InvariantWorkersVisitor {
63    type Value = InvariantWorkers;
64
65    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        formatter.write_str("`auto` or a positive integer worker count")
67    }
68
69    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
70    where
71        E: Error,
72    {
73        value.parse().map_err(E::custom)
74    }
75
76    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
77    where
78        E: Error,
79    {
80        let workers = usize::try_from(value).map_err(E::custom)?;
81        fixed_workers(workers).map_err(E::custom)
82    }
83
84    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
85    where
86        E: Error,
87    {
88        let workers =
89            usize::try_from(value).map_err(|_| E::custom("invariant workers must be positive"))?;
90        fixed_workers(workers).map_err(E::custom)
91    }
92}
93
94fn fixed_workers(workers: usize) -> Result<InvariantWorkers, String> {
95    NonZeroUsize::new(workers)
96        .map(InvariantWorkers::Fixed)
97        .ok_or_else(|| "invariant workers must be greater than 0".to_string())
98}
99
100/// Contains for invariant testing
101#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
102pub struct InvariantConfig {
103    /// The number of runs that must execute for each invariant test group.
104    pub runs: u32,
105    /// The number of calls executed to attempt to break invariants in one run.
106    pub depth: u32,
107    /// Worker selection mode used to shard invariant runs.
108    ///
109    /// Defaults to `1` for reproducible seeded campaigns. Use `auto` to derive the worker count
110    /// from `--jobs`, or a positive integer for an explicit worker count.
111    pub workers: InvariantWorkers,
112    /// Fails the invariant fuzzing if a revert occurs
113    pub fail_on_revert: bool,
114    /// Allows overriding an unsafe external call when running invariant tests. eg. reentrancy
115    /// checks
116    pub call_override: bool,
117    /// The fuzz dictionary configuration
118    #[serde(flatten)]
119    pub dictionary: FuzzDictionaryConfig,
120    /// The maximum number of attempts to shrink the sequence
121    pub shrink_run_limit: u32,
122    /// The maximum number of rejects via `vm.assume` which can be encountered during a single
123    /// invariant run.
124    pub max_assume_rejects: u32,
125    /// Number of runs to execute and include in the gas report.
126    pub gas_report_samples: u32,
127    /// The fuzz corpus configuration.
128    #[serde(flatten)]
129    pub corpus: FuzzCorpusConfig,
130    /// Path where invariant failures are recorded and replayed.
131    pub failure_persist_dir: Option<PathBuf>,
132    /// Whether to collect and display fuzzed selectors metrics.
133    pub show_metrics: bool,
134    /// Optional campaign-global timeout (in seconds) for each invariant test.
135    pub timeout: Option<u32>,
136    /// Display counterexample as solidity calls.
137    pub show_solidity: bool,
138    /// Maximum time (in seconds) between generated txs.
139    pub max_time_delay: Option<u32>,
140    /// Maximum number of blocks elapsed between generated txs.
141    pub max_block_delay: Option<u32>,
142    /// Number of calls to execute between invariant assertions.
143    ///
144    /// - `0`: Only assert on the last call of each run (fastest, but may miss exact breaking call)
145    /// - `1` (default): Assert after every call (current behavior, most precise)
146    /// - `N`: Assert every N calls AND always on the last call
147    ///
148    /// Example: `check_interval = 10` means assert after calls 10, 20, 30, ... and the last call.
149    pub check_interval: u32,
150}
151
152impl Default for InvariantConfig {
153    fn default() -> Self {
154        Self {
155            runs: 256,
156            depth: 500,
157            workers: InvariantWorkers::default(),
158            fail_on_revert: false,
159            call_override: false,
160            dictionary: FuzzDictionaryConfig { dictionary_weight: 80, ..Default::default() },
161            shrink_run_limit: 5000,
162            max_assume_rejects: 65536,
163            gas_report_samples: 256,
164            corpus: FuzzCorpusConfig::default(),
165            failure_persist_dir: None,
166            show_metrics: true,
167            timeout: None,
168            show_solidity: false,
169            max_time_delay: None,
170            max_block_delay: None,
171            check_interval: 1,
172        }
173    }
174}
175
176impl InvariantConfig {
177    /// Creates invariant configuration to write failures in `{PROJECT_ROOT}/cache/fuzz` dir.
178    pub fn new(cache_dir: PathBuf) -> Self {
179        Self { failure_persist_dir: Some(cache_dir), ..Default::default() }
180    }
181
182    /// Returns true if generated invariant calls may advance block time or height.
183    pub const fn has_delay(&self) -> bool {
184        self.max_block_delay.is_some() || self.max_time_delay.is_some()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn invariant_workers_accept_auto_and_fixed_counts() {
194        assert_eq!("AUTO".parse::<InvariantWorkers>().unwrap(), InvariantWorkers::Auto);
195        assert_eq!(
196            serde_json::from_str::<InvariantWorkers>(r#""auto""#).unwrap(),
197            InvariantWorkers::Auto
198        );
199        assert_eq!(
200            serde_json::from_str::<InvariantWorkers>(r#"4"#).unwrap(),
201            InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap())
202        );
203        assert_eq!(
204            serde_json::from_str::<InvariantWorkers>(r#""4""#).unwrap(),
205            InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap())
206        );
207    }
208
209    #[test]
210    fn invariant_workers_default_to_one() {
211        assert_eq!(InvariantWorkers::default(), InvariantWorkers::Fixed(NonZeroUsize::MIN));
212        assert_eq!(InvariantConfig::default().workers, InvariantWorkers::Fixed(NonZeroUsize::MIN));
213    }
214
215    #[test]
216    fn invariant_workers_reject_zero() {
217        let err = serde_json::from_str::<InvariantWorkers>(r#"0"#).unwrap_err();
218        assert!(err.to_string().contains("greater than 0"));
219    }
220}