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, Default, PartialEq, Eq)]
12pub enum InvariantWorkers {
13    /// Automatically derive invariant workers from the active `--jobs` / rayon thread pool.
14    #[default]
15    Auto,
16    /// Explicit user override for invariant campaign sharding.
17    Fixed(NonZeroUsize),
18}
19
20impl Serialize for InvariantWorkers {
21    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
22    where
23        S: Serializer,
24    {
25        match self {
26            Self::Auto => serializer.serialize_str("auto"),
27            Self::Fixed(workers) => workers.get().serialize(serializer),
28        }
29    }
30}
31
32impl<'de> Deserialize<'de> for InvariantWorkers {
33    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
34    where
35        D: Deserializer<'de>,
36    {
37        deserializer.deserialize_any(InvariantWorkersVisitor)
38    }
39}
40
41impl FromStr for InvariantWorkers {
42    type Err = String;
43
44    fn from_str(value: &str) -> Result<Self, Self::Err> {
45        let value = value.trim();
46        if value.eq_ignore_ascii_case("auto") {
47            return Ok(Self::Auto);
48        }
49
50        let workers = value.parse::<usize>().map_err(|err| err.to_string())?;
51        fixed_workers(workers)
52    }
53}
54
55struct InvariantWorkersVisitor;
56
57impl Visitor<'_> for InvariantWorkersVisitor {
58    type Value = InvariantWorkers;
59
60    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        formatter.write_str("`auto` or a positive integer worker count")
62    }
63
64    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
65    where
66        E: Error,
67    {
68        value.parse().map_err(E::custom)
69    }
70
71    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
72    where
73        E: Error,
74    {
75        let workers = usize::try_from(value).map_err(E::custom)?;
76        fixed_workers(workers).map_err(E::custom)
77    }
78
79    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
80    where
81        E: Error,
82    {
83        let workers =
84            usize::try_from(value).map_err(|_| E::custom("invariant workers must be positive"))?;
85        fixed_workers(workers).map_err(E::custom)
86    }
87}
88
89fn fixed_workers(workers: usize) -> Result<InvariantWorkers, String> {
90    NonZeroUsize::new(workers)
91        .map(InvariantWorkers::Fixed)
92        .ok_or_else(|| "invariant workers must be greater than 0".to_string())
93}
94
95/// Per-run invariant depth selection mode.
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum InvariantDepthMode {
99    /// Execute every invariant run up to the configured `depth`.
100    #[default]
101    Fixed,
102    /// Sample every invariant run depth uniformly between `min_depth` and `depth`.
103    #[serde(alias = "uniform")]
104    Random,
105}
106
107impl FromStr for InvariantDepthMode {
108    type Err = String;
109
110    fn from_str(value: &str) -> Result<Self, Self::Err> {
111        match value.trim().to_ascii_lowercase().as_str() {
112            "fixed" => Ok(Self::Fixed),
113            "random" | "uniform" => Ok(Self::Random),
114            value => {
115                Err(format!("unknown invariant depth mode `{value}`, expected `fixed` or `random`"))
116            }
117        }
118    }
119}
120
121/// Contains for invariant testing
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123pub struct InvariantConfig {
124    /// The number of runs that must execute for each invariant test group.
125    pub runs: u32,
126    /// The number of calls executed to attempt to break invariants in one run.
127    pub depth: u32,
128    /// Minimum sampled run depth when `depth_mode = "random"`.
129    pub min_depth: u32,
130    /// How to choose the effective depth for each invariant run.
131    pub depth_mode: InvariantDepthMode,
132    /// Worker selection mode used to shard invariant runs.
133    ///
134    /// Defaults to `auto`, which derives the worker count from `--jobs`. Use a positive integer
135    /// for an explicit worker count.
136    pub workers: InvariantWorkers,
137    /// Fails the invariant fuzzing if a revert occurs
138    pub fail_on_revert: bool,
139    /// Allows overriding an unsafe external call when running invariant tests. eg. reentrancy
140    /// checks
141    pub call_override: bool,
142    /// The fuzz dictionary configuration
143    #[serde(flatten)]
144    pub dictionary: FuzzDictionaryConfig,
145    /// The maximum number of attempts to shrink the sequence
146    pub shrink_run_limit: u32,
147    /// The maximum number of rejects via `vm.assume` which can be encountered during a single
148    /// invariant run.
149    pub max_assume_rejects: u32,
150    /// Number of runs to execute and include in the gas report.
151    pub gas_report_samples: u32,
152    /// The fuzz corpus configuration.
153    #[serde(flatten)]
154    pub corpus: FuzzCorpusConfig,
155    /// Whether `corpus_random_sequence_weight` was supplied by a config provider.
156    #[serde(default, skip_serializing)]
157    #[doc(hidden)]
158    pub corpus_random_sequence_weight_configured: bool,
159    /// Whether `workers` was supplied by a config provider.
160    #[serde(default, skip_serializing)]
161    #[doc(hidden)]
162    pub workers_configured: bool,
163    /// Path where invariant failures are recorded and replayed.
164    pub failure_persist_dir: Option<PathBuf>,
165    /// Whether to collect and display fuzzed selectors metrics.
166    pub show_metrics: bool,
167    /// Optional campaign-global timeout (in seconds) for each invariant test.
168    pub timeout: Option<u32>,
169    /// Display counterexample as solidity calls.
170    pub show_solidity: bool,
171    /// Maximum time (in seconds) between generated txs.
172    pub max_time_delay: Option<u32>,
173    /// Maximum number of blocks elapsed between generated txs.
174    pub max_block_delay: Option<u32>,
175    /// Number of calls to execute between invariant assertions.
176    ///
177    /// - `0`: Only assert on the last call of each run (fastest, but may miss exact breaking call)
178    /// - `1` (default): Assert after every call (current behavior, most precise)
179    /// - `N`: Assert every N calls AND always on the last call
180    ///
181    /// Example: `check_interval = 10` means assert after calls 10, 20, 30, ... and the last call.
182    pub check_interval: u32,
183}
184
185impl Default for InvariantConfig {
186    fn default() -> Self {
187        Self {
188            runs: 256,
189            depth: 500,
190            min_depth: 1,
191            depth_mode: InvariantDepthMode::default(),
192            workers: InvariantWorkers::default(),
193            fail_on_revert: false,
194            call_override: false,
195            dictionary: FuzzDictionaryConfig { dictionary_weight: 80, ..Default::default() },
196            shrink_run_limit: 5000,
197            max_assume_rejects: 65536,
198            gas_report_samples: 256,
199            corpus: FuzzCorpusConfig::default(),
200            corpus_random_sequence_weight_configured: false,
201            workers_configured: false,
202            failure_persist_dir: None,
203            show_metrics: true,
204            timeout: None,
205            show_solidity: false,
206            max_time_delay: None,
207            max_block_delay: None,
208            check_interval: 1,
209        }
210    }
211}
212
213impl InvariantConfig {
214    /// Creates invariant configuration to write failures in `{PROJECT_ROOT}/cache/fuzz` dir.
215    pub fn new(cache_dir: PathBuf) -> Self {
216        Self { failure_persist_dir: Some(cache_dir), ..Default::default() }
217    }
218
219    /// Returns true if generated invariant calls may advance block time or height.
220    pub const fn has_delay(&self) -> bool {
221        matches!(self.max_block_delay, Some(delay) if delay > 0)
222            || matches!(self.max_time_delay, Some(delay) if delay > 0)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn invariant_workers_accept_auto_and_fixed_counts() {
232        assert_eq!("AUTO".parse::<InvariantWorkers>().unwrap(), InvariantWorkers::Auto);
233        assert_eq!(
234            serde_json::from_str::<InvariantWorkers>(r#""auto""#).unwrap(),
235            InvariantWorkers::Auto
236        );
237        assert_eq!(
238            serde_json::from_str::<InvariantWorkers>(r#"4"#).unwrap(),
239            InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap())
240        );
241        assert_eq!(
242            serde_json::from_str::<InvariantWorkers>(r#""4""#).unwrap(),
243            InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap())
244        );
245    }
246
247    #[test]
248    fn invariant_workers_default_to_auto() {
249        assert_eq!(InvariantWorkers::default(), InvariantWorkers::Auto);
250        assert_eq!(InvariantConfig::default().workers, InvariantWorkers::Auto);
251    }
252
253    #[test]
254    fn invariant_workers_reject_zero() {
255        let err = serde_json::from_str::<InvariantWorkers>(r#"0"#).unwrap_err();
256        assert!(err.to_string().contains("greater than 0"));
257    }
258
259    #[test]
260    fn invariant_config_zero_delays_are_disabled() {
261        let mut config = InvariantConfig { max_block_delay: Some(0), ..Default::default() };
262        assert!(!config.has_delay());
263
264        config.max_block_delay = None;
265        config.max_time_delay = Some(0);
266        assert!(!config.has_delay());
267
268        config.max_time_delay = Some(1);
269        assert!(config.has_delay());
270    }
271
272    #[test]
273    fn invariant_config_equality_includes_corpus_random_sequence_provenance() {
274        let explicit = InvariantConfig {
275            corpus_random_sequence_weight_configured: true,
276            ..InvariantConfig::default()
277        };
278
279        assert_ne!(InvariantConfig::default(), explicit);
280    }
281
282    #[test]
283    fn invariant_depth_mode_accepts_fixed_and_random() {
284        assert_eq!("fixed".parse::<InvariantDepthMode>().unwrap(), InvariantDepthMode::Fixed);
285        assert_eq!("uniform".parse::<InvariantDepthMode>().unwrap(), InvariantDepthMode::Random);
286        assert_eq!(
287            serde_json::from_str::<InvariantDepthMode>(r#""random""#).unwrap(),
288            InvariantDepthMode::Random
289        );
290        assert_eq!(
291            serde_json::from_str::<InvariantDepthMode>(r#""uniform""#).unwrap(),
292            InvariantDepthMode::Random
293        );
294    }
295}