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