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        self.max_block_delay.is_some() || self.max_time_delay.is_some()
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn invariant_workers_accept_auto_and_fixed_counts() {
231        assert_eq!("AUTO".parse::<InvariantWorkers>().unwrap(), InvariantWorkers::Auto);
232        assert_eq!(
233            serde_json::from_str::<InvariantWorkers>(r#""auto""#).unwrap(),
234            InvariantWorkers::Auto
235        );
236        assert_eq!(
237            serde_json::from_str::<InvariantWorkers>(r#"4"#).unwrap(),
238            InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap())
239        );
240        assert_eq!(
241            serde_json::from_str::<InvariantWorkers>(r#""4""#).unwrap(),
242            InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap())
243        );
244    }
245
246    #[test]
247    fn invariant_workers_default_to_auto() {
248        assert_eq!(InvariantWorkers::default(), InvariantWorkers::Auto);
249        assert_eq!(InvariantConfig::default().workers, InvariantWorkers::Auto);
250    }
251
252    #[test]
253    fn invariant_workers_reject_zero() {
254        let err = serde_json::from_str::<InvariantWorkers>(r#"0"#).unwrap_err();
255        assert!(err.to_string().contains("greater than 0"));
256    }
257
258    #[test]
259    fn invariant_config_equality_includes_corpus_random_sequence_provenance() {
260        let explicit = InvariantConfig {
261            corpus_random_sequence_weight_configured: true,
262            ..InvariantConfig::default()
263        };
264
265        assert_ne!(InvariantConfig::default(), explicit);
266    }
267
268    #[test]
269    fn invariant_depth_mode_accepts_fixed_and_random() {
270        assert_eq!("fixed".parse::<InvariantDepthMode>().unwrap(), InvariantDepthMode::Fixed);
271        assert_eq!("uniform".parse::<InvariantDepthMode>().unwrap(), InvariantDepthMode::Random);
272        assert_eq!(
273            serde_json::from_str::<InvariantDepthMode>(r#""random""#).unwrap(),
274            InvariantDepthMode::Random
275        );
276        assert_eq!(
277            serde_json::from_str::<InvariantDepthMode>(r#""uniform""#).unwrap(),
278            InvariantDepthMode::Random
279        );
280    }
281}