Skip to main content

foundry_evm_networks/
lib.rs

1//! # foundry-evm-networks
2//!
3//! Foundry EVM network configuration.
4
5use crate::celo::transfer::{
6    CELO_TRANSFER_ADDRESS, CELO_TRANSFER_LABEL, PRECOMPILE_ID_CELO_TRANSFER,
7};
8use alloy_chains::{
9    Chain, NamedChain,
10    NamedChain::{Chiado, Gnosis, Moonbase, Moonbeam, MoonbeamDev, Moonriver, Rsk, RskTestnet},
11};
12use alloy_eips::eip1559::BaseFeeParams;
13use alloy_evm::precompiles::PrecompilesMap;
14use alloy_primitives::{Address, ChainId, map::AddressHashMap};
15use clap::Parser;
16use foundry_evm_hardforks::FoundryHardfork;
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19
20pub mod celo;
21
22#[cfg(feature = "optimism")]
23mod optimism;
24
25#[derive(
26    Clone,
27    Copy,
28    Debug,
29    Default,
30    PartialEq,
31    Eq,
32    PartialOrd,
33    Ord,
34    Hash,
35    Serialize,
36    Deserialize,
37    clap::ValueEnum,
38)]
39#[serde(rename_all = "lowercase")]
40#[clap(rename_all = "lowercase")]
41pub enum NetworkVariant {
42    #[default]
43    Ethereum,
44    #[cfg(feature = "optimism")]
45    Optimism,
46    Tempo,
47}
48
49impl std::str::FromStr for NetworkVariant {
50    type Err = String;
51
52    fn from_str(s: &str) -> Result<Self, Self::Err> {
53        match s {
54            "ethereum" => Ok(Self::Ethereum),
55            #[cfg(feature = "optimism")]
56            "optimism" => Ok(Self::Optimism),
57            "tempo" => Ok(Self::Tempo),
58            _ => Err(format!("unknown network variant: {s}")),
59        }
60    }
61}
62
63impl NetworkVariant {
64    pub const fn name(&self) -> &'static str {
65        match self {
66            Self::Ethereum => "ethereum",
67            #[cfg(feature = "optimism")]
68            Self::Optimism => "optimism",
69            Self::Tempo => "tempo",
70        }
71    }
72}
73
74impl std::fmt::Display for NetworkVariant {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str(self.name())
77    }
78}
79
80impl From<ChainId> for NetworkVariant {
81    fn from(chain_id: ChainId) -> Self {
82        let chain = Chain::from_id(chain_id);
83        if chain.is_tempo() {
84            return Self::Tempo;
85        }
86        #[cfg(feature = "optimism")]
87        if chain.is_optimism() {
88            return Self::Optimism;
89        }
90        Self::Ethereum
91    }
92}
93
94#[derive(Clone, Debug, Default, Parser, Deserialize, Copy, PartialEq, Eq)]
95pub struct NetworkConfigs {
96    /// Enable a specific network family.
97    #[arg(help_heading = "Networks", long, short, num_args = 1, value_name = "NETWORK", value_enum, conflicts_with_all = ["celo", "tempo"])]
98    #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))]
99    #[serde(default)]
100    pub(crate) network: Option<NetworkVariant>,
101    /// Enable Celo network features.
102    #[arg(help_heading = "Networks", long, conflicts_with_all = ["network", "tempo"])]
103    #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))]
104    celo: bool,
105    /// Enable Optimism network features (deprecated: use --network optimism).
106    #[cfg(feature = "optimism")]
107    #[arg(long, hide = true, conflicts_with_all = ["network", "celo", "tempo"])]
108    // Deserialize-only legacy alias: accepted in foundry.toml but never serialized — the
109    // canonical form is `network = "optimism"`.
110    #[serde(default)]
111    pub(crate) optimism: bool,
112    /// Enable Tempo network features (deprecated: use --network tempo).
113    #[arg(long, hide = true, conflicts_with_all = ["network", "celo"])]
114    #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))]
115    // Deserialize-only legacy alias: accepted in foundry.toml but never serialized — the
116    // canonical form is `network = "tempo"`.
117    #[serde(default)]
118    tempo: bool,
119    /// Whether to bypass prevrandao.
120    #[arg(skip)]
121    #[serde(default)]
122    bypass_prevrandao: bool,
123}
124
125// Custom `Serialize` impl: always emits the *resolved* network as the canonical
126// `network = "..."` field, and never emits the legacy `tempo` / `optimism` aliases. This avoids
127// confusing output like `network = "tempo"` next to `tempo = false`, and ensures `tempo = true`
128// in foundry.toml round-trips as `network = "tempo"`.
129impl Serialize for NetworkConfigs {
130    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
131        use serde::ser::SerializeStruct;
132        let mut s = serializer.serialize_struct("NetworkConfigs", 3)?;
133        s.serialize_field("network", &self.resolved_network())?;
134        s.serialize_field("celo", &self.celo)?;
135        s.serialize_field("bypass_prevrandao", &self.bypass_prevrandao)?;
136        s.end()
137    }
138}
139
140impl NetworkConfigs {
141    pub fn with_celo() -> Self {
142        Self { celo: true, ..Default::default() }
143    }
144
145    pub fn with_tempo() -> Self {
146        Self { network: Some(NetworkVariant::Tempo), tempo: true, ..Default::default() }
147    }
148
149    pub const fn is_tempo(&self) -> bool {
150        matches!(self.resolved_network(), Some(NetworkVariant::Tempo))
151    }
152
153    pub const fn is_celo(&self) -> bool {
154        self.celo
155    }
156
157    /// Returns the resolved network variant, folding legacy flags.
158    const fn resolved_network(&self) -> Option<NetworkVariant> {
159        if let Some(n) = self.network {
160            return Some(n);
161        }
162        #[cfg(feature = "optimism")]
163        if self.optimism {
164            return Some(NetworkVariant::Optimism);
165        }
166        if self.tempo {
167            return Some(NetworkVariant::Tempo);
168        }
169        None
170    }
171
172    /// Returns the name of the currently active non-Ethereum network, or `None` for plain Ethereum.
173    pub fn active_network_name(&self) -> Option<&'static str> {
174        self.resolved_network().and_then(|n| match n {
175            NetworkVariant::Ethereum => None,
176            _ => Some(n.name()),
177        })
178    }
179
180    /// Returns the base fee parameters for the configured network.
181    ///
182    /// For Optimism networks, returns Canyon parameters if the Canyon hardfork is active
183    /// at the given timestamp, otherwise returns pre-Canyon parameters.
184    pub fn base_fee_params(&self, timestamp: u64) -> BaseFeeParams {
185        #[cfg(feature = "optimism")]
186        if self.is_optimism() {
187            return self.op_base_fee_params(timestamp);
188        }
189        let _ = timestamp;
190        BaseFeeParams::ethereum()
191    }
192
193    pub fn bypass_prevrandao(&self, chain_id: u64) -> bool {
194        if let Ok(
195            Moonbeam | Moonbase | Moonriver | MoonbeamDev | Rsk | RskTestnet | Gnosis | Chiado,
196        ) = NamedChain::try_from(chain_id)
197        {
198            return true;
199        }
200        self.bypass_prevrandao
201    }
202
203    pub fn with_chain_id(self, chain_id: u64) -> Self {
204        let chain = Chain::from_id(chain_id);
205        if self.resolved_network().is_some() {
206            return if !self.celo
207                && matches!(chain.named(), Some(NamedChain::Celo | NamedChain::CeloSepolia))
208            {
209                Self::with_celo()
210            } else {
211                self
212            };
213        }
214        if chain.is_tempo() {
215            return Self::with_tempo();
216        }
217        #[cfg(feature = "optimism")]
218        if chain.is_optimism() {
219            return Self::with_optimism();
220        }
221        self
222    }
223
224    /// Validates `hardfork` against the current `NetworkConfigs` and, if consistent, returns an
225    /// updated instance with the network implied by the enabled hardfork.
226    ///
227    /// Returns `Err` when the hardfork's network family conflicts with the configured one.
228    pub fn normalize_for_hardfork(self, hardfork: FoundryHardfork) -> Result<Self, String> {
229        if let Some(configured) =
230            self.active_network_name().filter(|&n| Some(n) != hardfork.namespace())
231        {
232            return Err(format!(
233                "hardfork `{}` conflicts with network config `{configured}`",
234                String::from(hardfork),
235            ));
236        }
237
238        let network = match hardfork {
239            FoundryHardfork::Ethereum(_) => self,
240            FoundryHardfork::Tempo(_) => Self::with_tempo(),
241            #[cfg(feature = "optimism")]
242            FoundryHardfork::Optimism(_) => Self::with_optimism(),
243        };
244
245        Ok(network)
246    }
247
248    /// Inject precompiles for configured networks.
249    pub fn inject_precompiles(self, precompiles: &mut PrecompilesMap) {
250        if self.celo {
251            precompiles.apply_precompile(&CELO_TRANSFER_ADDRESS, move |_| {
252                Some(celo::transfer::precompile())
253            });
254        }
255    }
256
257    /// Returns precompiles label for configured networks, to be used in traces.
258    pub fn precompiles_label(self) -> AddressHashMap<String> {
259        let mut labels = AddressHashMap::default();
260        if self.celo {
261            labels.insert(CELO_TRANSFER_ADDRESS, CELO_TRANSFER_LABEL.to_string());
262        }
263        labels
264    }
265
266    /// Returns precompiles for configured networks.
267    pub fn precompiles(self) -> BTreeMap<String, Address> {
268        let mut precompiles = BTreeMap::new();
269        if self.celo {
270            precompiles
271                .insert(PRECOMPILE_ID_CELO_TRANSFER.name().to_string(), CELO_TRANSFER_ADDRESS);
272        }
273        precompiles
274    }
275}
276
277impl From<NetworkVariant> for NetworkConfigs {
278    fn from(network: NetworkVariant) -> Self {
279        match network {
280            NetworkVariant::Ethereum => Self::default(),
281            NetworkVariant::Tempo => {
282                Self { network: Some(network), tempo: true, ..Default::default() }
283            }
284            #[cfg(feature = "optimism")]
285            NetworkVariant::Optimism => {
286                Self { network: Some(network), optimism: true, ..Default::default() }
287            }
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    // --- Equivalence: new flag == legacy flag ---
297
298    #[test]
299    fn new_tempo_flag_equivalent_to_legacy() {
300        let via_new = NetworkConfigs { network: Some(NetworkVariant::Tempo), ..Default::default() };
301        let via_old = NetworkConfigs { tempo: true, ..Default::default() };
302        assert_eq!(via_new.is_tempo(), via_old.is_tempo());
303        assert_eq!(via_new.active_network_name(), via_old.active_network_name());
304    }
305
306    // --- resolved() / active_network_name ---
307
308    #[test]
309    fn active_network_name_tempo() {
310        let cfg = NetworkConfigs::with_tempo();
311        assert_eq!(cfg.active_network_name(), Some("tempo"));
312    }
313
314    #[test]
315    fn active_network_name_default_is_none() {
316        assert_eq!(NetworkConfigs::default().active_network_name(), None);
317    }
318
319    // --- Serde round-trip ---
320
321    #[test]
322    fn serde_roundtrip_tempo() {
323        let original = NetworkConfigs::with_tempo();
324        let json = serde_json::to_string(&original).unwrap();
325        let restored: NetworkConfigs = serde_json::from_str(&json).unwrap();
326        assert!(restored.is_tempo());
327    }
328
329    #[test]
330    fn serde_legacy_tempo_bool_deserialized() {
331        // Old foundry.toml format: `tempo = true`
332        let json = r#"{"tempo": true, "celo": false, "bypass_prevrandao": false}"#;
333        let cfg: NetworkConfigs = serde_json::from_str(json).unwrap();
334        assert!(cfg.is_tempo());
335    }
336
337    #[test]
338    fn serde_serializes_legacy_alias_as_canonical_network() {
339        // Legacy `tempo = true` should serialize as the canonical `network = "tempo"`,
340        // and the legacy `tempo` / `optimism` keys must not appear in the output.
341        let cfg = NetworkConfigs { tempo: true, ..Default::default() };
342        let json = serde_json::to_value(cfg).unwrap();
343        assert_eq!(json["network"], serde_json::json!("tempo"));
344        assert!(json.get("tempo").is_none(), "legacy `tempo` key should not be serialized");
345        assert!(json.get("optimism").is_none(), "legacy `optimism` key should not be serialized");
346    }
347
348    #[test]
349    fn serde_new_network_field_deserialized() {
350        let json_tempo = r#"{"network": "tempo", "celo": false, "bypass_prevrandao": false}"#;
351        let cfg_tempo: NetworkConfigs = serde_json::from_str(json_tempo).unwrap();
352        assert!(cfg_tempo.is_tempo());
353    }
354
355    #[cfg(feature = "optimism")]
356    mod optimism {
357        use super::*;
358
359        #[test]
360        fn new_optimism_flag_equivalent_to_legacy() {
361            let via_new =
362                NetworkConfigs { network: Some(NetworkVariant::Optimism), ..Default::default() };
363            let via_old = NetworkConfigs { optimism: true, ..Default::default() };
364            assert_eq!(via_new.is_optimism(), via_old.is_optimism());
365            assert_eq!(via_new.is_tempo(), via_old.is_tempo());
366            assert_eq!(via_new.active_network_name(), via_old.active_network_name());
367        }
368
369        #[test]
370        fn active_network_name_optimism() {
371            let cfg = NetworkConfigs::with_optimism();
372            assert_eq!(cfg.active_network_name(), Some("optimism"));
373        }
374
375        #[test]
376        fn new_flag_wins_over_legacy_when_both_set() {
377            // --network optimism --tempo: network field wins
378            let cfg = NetworkConfigs {
379                network: Some(NetworkVariant::Optimism),
380                tempo: true,
381                ..Default::default()
382            };
383            assert!(cfg.is_optimism());
384            assert!(!cfg.is_tempo());
385        }
386
387        #[test]
388        fn serde_roundtrip_optimism() {
389            let original = NetworkConfigs::with_optimism();
390            let json = serde_json::to_string(&original).unwrap();
391            let restored: NetworkConfigs = serde_json::from_str(&json).unwrap();
392            assert!(restored.is_optimism());
393            assert!(!restored.is_tempo());
394        }
395
396        #[test]
397        fn serde_optimism_field_deserialized() {
398            let json_optimism =
399                r#"{"network": "optimism", "celo": false, "bypass_prevrandao": false}"#;
400            let cfg_optimism: NetworkConfigs = serde_json::from_str(json_optimism).unwrap();
401            assert!(cfg_optimism.is_optimism());
402        }
403    }
404}