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, TempoHardfork};
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use tempo_contracts::precompiles::{
20    ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, NONCE_PRECOMPILE_ADDRESS,
21    RECEIVE_POLICY_GUARD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, STABLECOIN_DEX_ADDRESS,
22    TIP_FEE_MANAGER_ADDRESS, TIP20_CHANNEL_RESERVE_ADDRESS, TIP20_FACTORY_ADDRESS,
23    TIP403_REGISTRY_ADDRESS, VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS,
24};
25
26pub mod celo;
27
28#[cfg(feature = "optimism")]
29mod optimism;
30
31const TEMPO_PRECOMPILES: &[(&str, Address)] = &[
32    ("Nonce", NONCE_PRECOMPILE_ADDRESS),
33    ("StablecoinDex", STABLECOIN_DEX_ADDRESS),
34    ("TIP20Factory", TIP20_FACTORY_ADDRESS),
35    ("TIP403Registry", TIP403_REGISTRY_ADDRESS),
36    ("FeeManager", TIP_FEE_MANAGER_ADDRESS),
37    ("ValidatorConfig", VALIDATOR_CONFIG_ADDRESS),
38    ("ValidatorConfigV2", VALIDATOR_CONFIG_V2_ADDRESS),
39    ("AccountKeychain", ACCOUNT_KEYCHAIN_ADDRESS),
40    ("SignatureVerifier", SIGNATURE_VERIFIER_ADDRESS),
41    ("AddressRegistry", ADDRESS_REGISTRY_ADDRESS),
42    ("TIP20ChannelReserve", TIP20_CHANNEL_RESERVE_ADDRESS),
43    ("ReceivePolicyGuard", RECEIVE_POLICY_GUARD_ADDRESS),
44];
45
46/// All well-known Tempo precompile addresses.
47pub const TEMPO_PRECOMPILE_ADDRESSES: &[Address] = &[
48    NONCE_PRECOMPILE_ADDRESS,
49    STABLECOIN_DEX_ADDRESS,
50    TIP20_FACTORY_ADDRESS,
51    TIP403_REGISTRY_ADDRESS,
52    TIP_FEE_MANAGER_ADDRESS,
53    VALIDATOR_CONFIG_ADDRESS,
54    VALIDATOR_CONFIG_V2_ADDRESS,
55    ACCOUNT_KEYCHAIN_ADDRESS,
56    SIGNATURE_VERIFIER_ADDRESS,
57    ADDRESS_REGISTRY_ADDRESS,
58    TIP20_CHANNEL_RESERVE_ADDRESS,
59    RECEIVE_POLICY_GUARD_ADDRESS,
60];
61
62/// Returns whether a well-known Tempo precompile address is active at `hardfork`.
63pub fn is_tempo_precompile_active_at(address: Address, hardfork: TempoHardfork) -> bool {
64    if address == TIP20_CHANNEL_RESERVE_ADDRESS {
65        hardfork.is_t5()
66    } else if address == RECEIVE_POLICY_GUARD_ADDRESS {
67        hardfork.is_t6()
68    } else if address == ADDRESS_REGISTRY_ADDRESS || address == SIGNATURE_VERIFIER_ADDRESS {
69        hardfork.is_t3()
70    } else {
71        true
72    }
73}
74
75/// Returns the well-known Tempo precompile addresses active at `hardfork`.
76pub fn active_tempo_precompile_addresses(hardfork: TempoHardfork) -> impl Iterator<Item = Address> {
77    TEMPO_PRECOMPILE_ADDRESSES
78        .iter()
79        .copied()
80        .filter(move |&address| is_tempo_precompile_active_at(address, hardfork))
81}
82
83#[derive(
84    Clone,
85    Copy,
86    Debug,
87    Default,
88    PartialEq,
89    Eq,
90    PartialOrd,
91    Ord,
92    Hash,
93    Serialize,
94    Deserialize,
95    clap::ValueEnum,
96)]
97#[serde(rename_all = "lowercase")]
98#[clap(rename_all = "lowercase")]
99pub enum NetworkVariant {
100    #[default]
101    Ethereum,
102    #[cfg(feature = "optimism")]
103    Optimism,
104    Tempo,
105}
106
107impl std::str::FromStr for NetworkVariant {
108    type Err = String;
109
110    fn from_str(s: &str) -> Result<Self, Self::Err> {
111        match s {
112            "ethereum" => Ok(Self::Ethereum),
113            #[cfg(feature = "optimism")]
114            "optimism" => Ok(Self::Optimism),
115            "tempo" => Ok(Self::Tempo),
116            _ => Err(format!("unknown network variant: {s}")),
117        }
118    }
119}
120
121impl NetworkVariant {
122    pub const fn name(&self) -> &'static str {
123        match self {
124            Self::Ethereum => "ethereum",
125            #[cfg(feature = "optimism")]
126            Self::Optimism => "optimism",
127            Self::Tempo => "tempo",
128        }
129    }
130}
131
132impl std::fmt::Display for NetworkVariant {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.write_str(self.name())
135    }
136}
137
138impl From<ChainId> for NetworkVariant {
139    fn from(chain_id: ChainId) -> Self {
140        let chain = Chain::from_id(chain_id);
141        if chain.is_tempo() {
142            return Self::Tempo;
143        }
144        #[cfg(feature = "optimism")]
145        if chain.is_optimism() {
146            return Self::Optimism;
147        }
148        Self::Ethereum
149    }
150}
151
152#[derive(Clone, Debug, Default, Parser, Deserialize, Copy, PartialEq, Eq)]
153pub struct NetworkConfigs {
154    /// Enable a specific network family.
155    #[arg(help_heading = "Networks", long, short, num_args = 1, value_name = "NETWORK", value_enum, conflicts_with_all = ["celo", "tempo"])]
156    #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))]
157    #[serde(default)]
158    pub(crate) network: Option<NetworkVariant>,
159    /// Enable Celo network features.
160    #[arg(help_heading = "Networks", long, conflicts_with_all = ["network", "tempo"])]
161    #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))]
162    celo: bool,
163    /// Enable Optimism network features (deprecated: use --network optimism).
164    #[cfg(feature = "optimism")]
165    #[arg(long, hide = true, conflicts_with_all = ["network", "celo", "tempo"])]
166    // Deserialize-only legacy alias: accepted in foundry.toml but never serialized — the
167    // canonical form is `network = "optimism"`.
168    #[serde(default)]
169    pub(crate) optimism: bool,
170    /// Enable Tempo network features (deprecated: use --network tempo).
171    #[arg(long, hide = true, conflicts_with_all = ["network", "celo"])]
172    #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))]
173    // Deserialize-only legacy alias: accepted in foundry.toml but never serialized — the
174    // canonical form is `network = "tempo"`.
175    #[serde(default)]
176    tempo: bool,
177    /// Whether to bypass prevrandao.
178    #[arg(skip)]
179    #[serde(default)]
180    bypass_prevrandao: bool,
181}
182
183// Custom `Serialize` impl: always emits the *resolved* network as the canonical
184// `network = "..."` field, and never emits the legacy `tempo` / `optimism` aliases. This avoids
185// confusing output like `network = "tempo"` next to `tempo = false`, and ensures `tempo = true`
186// in foundry.toml round-trips as `network = "tempo"`.
187impl Serialize for NetworkConfigs {
188    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
189        use serde::ser::SerializeStruct;
190        let mut s = serializer.serialize_struct("NetworkConfigs", 3)?;
191        s.serialize_field("network", &self.resolved_network())?;
192        s.serialize_field("celo", &self.celo)?;
193        s.serialize_field("bypass_prevrandao", &self.bypass_prevrandao)?;
194        s.end()
195    }
196}
197
198impl NetworkConfigs {
199    pub fn with_celo() -> Self {
200        Self { celo: true, ..Default::default() }
201    }
202
203    pub fn with_tempo() -> Self {
204        Self { network: Some(NetworkVariant::Tempo), tempo: true, ..Default::default() }
205    }
206
207    pub const fn is_tempo(&self) -> bool {
208        matches!(self.resolved_network(), Some(NetworkVariant::Tempo))
209    }
210
211    pub const fn is_celo(&self) -> bool {
212        self.celo
213    }
214
215    /// Returns the resolved network variant, folding legacy flags.
216    pub const fn resolved_network(&self) -> Option<NetworkVariant> {
217        if let Some(n) = self.network {
218            return Some(n);
219        }
220        #[cfg(feature = "optimism")]
221        if self.optimism {
222            return Some(NetworkVariant::Optimism);
223        }
224        if self.tempo {
225            return Some(NetworkVariant::Tempo);
226        }
227        None
228    }
229
230    /// Returns the name of the currently active non-Ethereum network, or `None` for plain Ethereum.
231    pub fn active_network_name(&self) -> Option<&'static str> {
232        self.resolved_network().and_then(|n| match n {
233            NetworkVariant::Ethereum => None,
234            _ => Some(n.name()),
235        })
236    }
237
238    /// Returns the base fee parameters for the configured network.
239    ///
240    /// For Optimism networks, returns Canyon parameters if the Canyon hardfork is active
241    /// at the given timestamp, otherwise returns pre-Canyon parameters.
242    pub fn base_fee_params(&self, timestamp: u64) -> BaseFeeParams {
243        #[cfg(feature = "optimism")]
244        if self.is_optimism() {
245            return self.op_base_fee_params(timestamp);
246        }
247        let _ = timestamp;
248        BaseFeeParams::ethereum()
249    }
250
251    pub fn bypass_prevrandao(&self, chain_id: u64) -> bool {
252        if let Ok(
253            Moonbeam | Moonbase | Moonriver | MoonbeamDev | Rsk | RskTestnet | Gnosis | Chiado,
254        ) = NamedChain::try_from(chain_id)
255        {
256            return true;
257        }
258        self.bypass_prevrandao
259    }
260
261    pub fn with_chain_id(self, chain_id: u64) -> Self {
262        let chain = Chain::from_id(chain_id);
263        if self.resolved_network().is_some() {
264            return if !self.celo
265                && matches!(chain.named(), Some(NamedChain::Celo | NamedChain::CeloSepolia))
266            {
267                Self::with_celo()
268            } else {
269                self
270            };
271        }
272        if chain.is_tempo() {
273            return Self::with_tempo();
274        }
275        #[cfg(feature = "optimism")]
276        if chain.is_optimism() {
277            return Self::with_optimism();
278        }
279        self
280    }
281
282    /// Validates `hardfork` against the current `NetworkConfigs` and, if consistent, returns an
283    /// updated instance with the network implied by the enabled hardfork.
284    ///
285    /// Returns `Err` when the hardfork's network family conflicts with the configured one.
286    pub fn normalize_for_hardfork(self, hardfork: FoundryHardfork) -> Result<Self, String> {
287        if let Some(configured) =
288            self.active_network_name().filter(|&n| Some(n) != hardfork.namespace())
289        {
290            return Err(format!(
291                "hardfork `{}` conflicts with network config `{configured}`",
292                String::from(hardfork),
293            ));
294        }
295
296        let network = match hardfork {
297            FoundryHardfork::Ethereum(_) => self,
298            FoundryHardfork::Tempo(_) => Self::with_tempo(),
299            #[cfg(feature = "optimism")]
300            FoundryHardfork::Optimism(_) => Self::with_optimism(),
301        };
302
303        Ok(network)
304    }
305
306    /// Inject precompiles for configured networks.
307    pub fn inject_precompiles(self, precompiles: &mut PrecompilesMap) {
308        if self.celo {
309            precompiles.apply_precompile(&CELO_TRANSFER_ADDRESS, move |_| {
310                Some(celo::transfer::precompile())
311            });
312        }
313    }
314
315    /// Returns precompiles label for configured networks, to be used in traces.
316    pub fn precompiles_label(
317        self,
318        tempo_hardfork: Option<TempoHardfork>,
319    ) -> AddressHashMap<String> {
320        let mut labels = AddressHashMap::default();
321        if self.celo {
322            labels.insert(CELO_TRANSFER_ADDRESS, CELO_TRANSFER_LABEL.to_string());
323        }
324        if self.is_tempo() {
325            labels.extend(
326                TEMPO_PRECOMPILES
327                    .iter()
328                    .copied()
329                    .filter(|(_, address)| {
330                        tempo_hardfork.is_none_or(|hardfork| {
331                            is_tempo_precompile_active_at(*address, hardfork)
332                        })
333                    })
334                    .map(|(label, address)| (address, label.to_string())),
335            );
336        }
337        labels
338    }
339
340    /// Returns precompiles for configured networks.
341    pub fn precompiles(self, tempo_hardfork: Option<TempoHardfork>) -> BTreeMap<String, Address> {
342        let mut precompiles = BTreeMap::new();
343        if self.celo {
344            precompiles
345                .insert(PRECOMPILE_ID_CELO_TRANSFER.name().to_string(), CELO_TRANSFER_ADDRESS);
346        }
347        if self.is_tempo() {
348            precompiles.extend(
349                TEMPO_PRECOMPILES
350                    .iter()
351                    .copied()
352                    .filter(|(_, address)| {
353                        tempo_hardfork.is_none_or(|hardfork| {
354                            is_tempo_precompile_active_at(*address, hardfork)
355                        })
356                    })
357                    .map(|(label, address)| (label.to_string(), address)),
358            );
359        }
360        precompiles
361    }
362}
363
364impl From<NetworkVariant> for NetworkConfigs {
365    fn from(network: NetworkVariant) -> Self {
366        match network {
367            NetworkVariant::Ethereum => Self::default(),
368            NetworkVariant::Tempo => {
369                Self { network: Some(network), tempo: true, ..Default::default() }
370            }
371            #[cfg(feature = "optimism")]
372            NetworkVariant::Optimism => {
373                Self { network: Some(network), optimism: true, ..Default::default() }
374            }
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    // --- Equivalence: new flag == legacy flag ---
384
385    #[test]
386    fn new_tempo_flag_equivalent_to_legacy() {
387        let via_new = NetworkConfigs { network: Some(NetworkVariant::Tempo), ..Default::default() };
388        let via_old = NetworkConfigs { tempo: true, ..Default::default() };
389        assert_eq!(via_new.is_tempo(), via_old.is_tempo());
390        assert_eq!(via_new.active_network_name(), via_old.active_network_name());
391        assert_eq!(via_new.precompiles(None), via_old.precompiles(None));
392        assert_eq!(via_new.precompiles_label(None), via_old.precompiles_label(None));
393    }
394
395    #[test]
396    fn canonical_tempo_network_reports_precompiles() {
397        let cfg = NetworkConfigs { network: Some(NetworkVariant::Tempo), ..Default::default() };
398
399        assert_eq!(
400            cfg.precompiles(None).get("TIP20ChannelReserve"),
401            Some(&TIP20_CHANNEL_RESERVE_ADDRESS)
402        );
403        assert!(!cfg.precompiles(Some(TempoHardfork::T4)).contains_key("TIP20ChannelReserve"));
404        assert!(!cfg.precompiles(Some(TempoHardfork::T4)).contains_key("ReceivePolicyGuard"));
405        assert!(!cfg.precompiles(Some(TempoHardfork::T2)).contains_key("AddressRegistry"));
406        assert!(!cfg.precompiles(Some(TempoHardfork::T2)).contains_key("SignatureVerifier"));
407        assert_eq!(
408            cfg.precompiles(Some(TempoHardfork::T3)).get("AddressRegistry"),
409            Some(&ADDRESS_REGISTRY_ADDRESS)
410        );
411        assert_eq!(
412            cfg.precompiles(Some(TempoHardfork::T3)).get("SignatureVerifier"),
413            Some(&SIGNATURE_VERIFIER_ADDRESS)
414        );
415        assert_eq!(
416            cfg.precompiles_label(Some(TempoHardfork::T5)).get(&TIP20_CHANNEL_RESERVE_ADDRESS),
417            Some(&"TIP20ChannelReserve".to_string())
418        );
419        assert!(cfg.precompiles_label(None).contains_key(&TIP20_CHANNEL_RESERVE_ADDRESS));
420        assert!(
421            !cfg.precompiles_label(Some(TempoHardfork::T5))
422                .contains_key(&RECEIVE_POLICY_GUARD_ADDRESS)
423        );
424        assert!(
425            cfg.precompiles_label(Some(TempoHardfork::T6))
426                .contains_key(&RECEIVE_POLICY_GUARD_ADDRESS)
427        );
428    }
429
430    // --- resolved() / active_network_name ---
431
432    #[test]
433    fn active_network_name_tempo() {
434        let cfg = NetworkConfigs::with_tempo();
435        assert_eq!(cfg.active_network_name(), Some("tempo"));
436    }
437
438    #[test]
439    fn active_network_name_default_is_none() {
440        assert_eq!(NetworkConfigs::default().active_network_name(), None);
441    }
442
443    // --- Serde round-trip ---
444
445    #[test]
446    fn serde_roundtrip_tempo() {
447        let original = NetworkConfigs::with_tempo();
448        let json = serde_json::to_string(&original).unwrap();
449        let restored: NetworkConfigs = serde_json::from_str(&json).unwrap();
450        assert!(restored.is_tempo());
451    }
452
453    #[test]
454    fn serde_legacy_tempo_bool_deserialized() {
455        // Old foundry.toml format: `tempo = true`
456        let json = r#"{"tempo": true, "celo": false, "bypass_prevrandao": false}"#;
457        let cfg: NetworkConfigs = serde_json::from_str(json).unwrap();
458        assert!(cfg.is_tempo());
459    }
460
461    #[test]
462    fn serde_serializes_legacy_alias_as_canonical_network() {
463        // Legacy `tempo = true` should serialize as the canonical `network = "tempo"`,
464        // and the legacy `tempo` / `optimism` keys must not appear in the output.
465        let cfg = NetworkConfigs { tempo: true, ..Default::default() };
466        let json = serde_json::to_value(cfg).unwrap();
467        assert_eq!(json["network"], serde_json::json!("tempo"));
468        assert!(json.get("tempo").is_none(), "legacy `tempo` key should not be serialized");
469        assert!(json.get("optimism").is_none(), "legacy `optimism` key should not be serialized");
470    }
471
472    #[test]
473    fn serde_new_network_field_deserialized() {
474        let json_tempo = r#"{"network": "tempo", "celo": false, "bypass_prevrandao": false}"#;
475        let cfg_tempo: NetworkConfigs = serde_json::from_str(json_tempo).unwrap();
476        assert!(cfg_tempo.is_tempo());
477    }
478
479    #[cfg(feature = "optimism")]
480    mod optimism {
481        use super::*;
482
483        #[test]
484        fn new_optimism_flag_equivalent_to_legacy() {
485            let via_new =
486                NetworkConfigs { network: Some(NetworkVariant::Optimism), ..Default::default() };
487            let via_old = NetworkConfigs { optimism: true, ..Default::default() };
488            assert_eq!(via_new.is_optimism(), via_old.is_optimism());
489            assert_eq!(via_new.is_tempo(), via_old.is_tempo());
490            assert_eq!(via_new.active_network_name(), via_old.active_network_name());
491        }
492
493        #[test]
494        fn active_network_name_optimism() {
495            let cfg = NetworkConfigs::with_optimism();
496            assert_eq!(cfg.active_network_name(), Some("optimism"));
497        }
498
499        #[test]
500        fn new_flag_wins_over_legacy_when_both_set() {
501            // --network optimism --tempo: network field wins
502            let cfg = NetworkConfigs {
503                network: Some(NetworkVariant::Optimism),
504                tempo: true,
505                ..Default::default()
506            };
507            assert!(cfg.is_optimism());
508            assert!(!cfg.is_tempo());
509        }
510
511        #[test]
512        fn serde_roundtrip_optimism() {
513            let original = NetworkConfigs::with_optimism();
514            let json = serde_json::to_string(&original).unwrap();
515            let restored: NetworkConfigs = serde_json::from_str(&json).unwrap();
516            assert!(restored.is_optimism());
517            assert!(!restored.is_tempo());
518        }
519
520        #[test]
521        fn serde_optimism_field_deserialized() {
522            let json_optimism =
523                r#"{"network": "optimism", "celo": false, "bypass_prevrandao": false}"#;
524            let cfg_optimism: NetworkConfigs = serde_json::from_str(json_optimism).unwrap();
525            assert!(cfg_optimism.is_optimism());
526        }
527    }
528}