Skip to main content

foundry_config/
etherscan.rs

1//! Support for multiple Etherscan keys.
2
3use crate::{
4    Chain, Config, NamedChain,
5    resolve::{RE_PLACEHOLDER, UnresolvedEnvVarError, interpolate},
6};
7use figment::{
8    Error, Metadata, Profile, Provider,
9    providers::Env,
10    value::{Dict, Map},
11};
12use heck::ToKebabCase;
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14use std::{
15    collections::BTreeMap,
16    fmt,
17    ops::{Deref, DerefMut},
18    time::Duration,
19};
20
21/// A [Provider] that provides Etherscan API key from the environment if it's not empty.
22///
23/// This prevents `ETHERSCAN_API_KEY=""` if it's set but empty
24#[derive(Debug, Clone, PartialEq, Eq, Default)]
25#[non_exhaustive]
26pub(crate) struct EtherscanEnvProvider;
27
28impl Provider for EtherscanEnvProvider {
29    fn metadata(&self) -> Metadata {
30        Env::raw().metadata()
31    }
32
33    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
34        let mut dict = Dict::default();
35        let env_provider = Env::raw().only(&["ETHERSCAN_API_KEY"]);
36        if let Some((key, value)) = env_provider.iter().next()
37            && !value.trim().is_empty()
38        {
39            dict.insert(key.as_str().to_string(), value.into());
40        }
41
42        Ok(Map::from([(Config::selected_profile(), dict)]))
43    }
44}
45
46/// Errors that can occur when creating an `EtherscanConfig`
47#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
48pub enum EtherscanConfigError {
49    #[error(transparent)]
50    Unresolved(#[from] UnresolvedEnvVarError),
51
52    #[error(
53        "No known Etherscan API URL for chain `{1}`. To fix this, please:\n\
54        1. Specify a `url` {0}\n\
55        2. Verify the chain `{1}` is correct"
56    )]
57    UnknownChain(String, Chain),
58
59    #[error("At least one of `url` or `chain` must be present{0}")]
60    MissingUrlOrChain(String),
61}
62
63/// Container type for Etherscan API keys and URLs.
64#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(transparent)]
66pub struct EtherscanConfigs {
67    configs: BTreeMap<String, EtherscanConfig>,
68}
69
70impl EtherscanConfigs {
71    /// Creates a new list of etherscan configs
72    pub fn new(configs: impl IntoIterator<Item = (impl Into<String>, EtherscanConfig)>) -> Self {
73        Self { configs: configs.into_iter().map(|(name, config)| (name.into(), config)).collect() }
74    }
75
76    /// Returns `true` if this type doesn't contain any configs
77    pub fn is_empty(&self) -> bool {
78        self.configs.is_empty()
79    }
80
81    /// Returns the first config that matches the chain
82    pub fn find_chain(&self, chain: Chain) -> Option<&EtherscanConfig> {
83        self.configs.values().find(|config| config.chain == Some(chain))
84    }
85
86    /// Returns all (alias -> url) pairs
87    pub fn resolved(self) -> ResolvedEtherscanConfigs {
88        ResolvedEtherscanConfigs {
89            configs: self
90                .configs
91                .into_iter()
92                .map(|(name, e)| {
93                    let resolved = e.resolve(Some(&name));
94                    (name, resolved)
95                })
96                .collect(),
97        }
98    }
99}
100
101impl Deref for EtherscanConfigs {
102    type Target = BTreeMap<String, EtherscanConfig>;
103
104    fn deref(&self) -> &Self::Target {
105        &self.configs
106    }
107}
108
109impl DerefMut for EtherscanConfigs {
110    fn deref_mut(&mut self) -> &mut Self::Target {
111        &mut self.configs
112    }
113}
114
115/// Container type for _resolved_ etherscan keys, see [`EtherscanConfigs::resolved`].
116#[derive(Clone, Debug, Default, PartialEq, Eq)]
117pub struct ResolvedEtherscanConfigs {
118    /// contains all named `ResolvedEtherscanConfig` or an error if we failed to resolve the env
119    /// var alias
120    configs: BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>,
121}
122
123impl ResolvedEtherscanConfigs {
124    /// Creates a new list of resolved etherscan configs
125    pub fn new(
126        configs: impl IntoIterator<Item = (impl Into<String>, ResolvedEtherscanConfig)>,
127    ) -> Self {
128        Self {
129            configs: configs.into_iter().map(|(name, config)| (name.into(), Ok(config))).collect(),
130        }
131    }
132
133    /// Returns the first config that matches the chain
134    pub fn find_chain(
135        self,
136        chain: Chain,
137    ) -> Option<Result<ResolvedEtherscanConfig, EtherscanConfigError>> {
138        for (_, config) in self.configs {
139            match config {
140                Ok(c) if c.chain == Some(chain) => return Some(Ok(c)),
141                Err(e) => return Some(Err(e)),
142                _ => {}
143            }
144        }
145        None
146    }
147
148    /// Returns true if there's a config that couldn't be resolved
149    pub fn has_unresolved(&self) -> bool {
150        self.configs.values().any(|val| val.is_err())
151    }
152}
153
154impl Deref for ResolvedEtherscanConfigs {
155    type Target = BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>;
156
157    fn deref(&self) -> &Self::Target {
158        &self.configs
159    }
160}
161
162impl DerefMut for ResolvedEtherscanConfigs {
163    fn deref_mut(&mut self) -> &mut Self::Target {
164        &mut self.configs
165    }
166}
167
168/// Represents all info required to create an etherscan client
169#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
170pub struct EtherscanConfig {
171    /// The chain name or EIP-155 chain ID used to derive the API URL.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub chain: Option<Chain>,
174    /// Etherscan API URL
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub url: Option<String>,
177    /// The etherscan API KEY that's required to make requests
178    pub key: EtherscanApiKey,
179}
180
181impl EtherscanConfig {
182    /// Returns the etherscan config required to create a client.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the type holds a reference to an env var and the env var is not set or
187    /// no chain or url is configured
188    pub fn resolve(
189        self,
190        alias: Option<&str>,
191    ) -> Result<ResolvedEtherscanConfig, EtherscanConfigError> {
192        let Self { chain, mut url, key } = self;
193
194        if let Some(url) = &mut url {
195            *url = interpolate(url)?;
196        }
197
198        let (chain, alias) = match (chain, alias) {
199            // fill one with the other
200            (Some(chain), None) => (Some(chain), Some(chain.to_string())),
201            (None, Some(alias)) => {
202                // alloy chain is parsed as kebab case
203                (
204                    alias.to_kebab_case().parse().ok().or_else(|| {
205                        // if this didn't work try to parse as json because the deserialize impl
206                        // supports more aliases
207                        serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
208                            .map(Into::into)
209                            .ok()
210                    }),
211                    Some(alias.into()),
212                )
213            }
214            // leave as is
215            (Some(chain), Some(alias)) => (Some(chain), Some(alias.into())),
216            (None, None) => (None, None),
217        };
218        let key = key.resolve()?;
219
220        match (chain, url) {
221            (Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig {
222                api_url,
223                browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()),
224                key,
225                chain: Some(chain),
226            }),
227            (Some(chain), None) => ResolvedEtherscanConfig::create(key, chain).ok_or_else(|| {
228                let msg = alias.map(|a| format!("for `{a}`")).unwrap_or_default();
229                EtherscanConfigError::UnknownChain(msg, chain)
230            }),
231            (None, Some(api_url)) => {
232                Ok(ResolvedEtherscanConfig { api_url, browser_url: None, key, chain: None })
233            }
234            (None, None) => {
235                let msg = alias
236                    .map(|a| format!(" for Etherscan config with unknown alias `{a}`"))
237                    .unwrap_or_default();
238                Err(EtherscanConfigError::MissingUrlOrChain(msg))
239            }
240        }
241    }
242}
243
244/// Contains required url + api key to set up an etherscan client
245#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246pub struct ResolvedEtherscanConfig {
247    /// Etherscan API URL.
248    #[serde(rename = "url")]
249    pub api_url: String,
250    /// Optional browser URL.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub browser_url: Option<String>,
253    /// The resolved API key.
254    pub key: String,
255    /// The chain name or EIP-155 chain ID.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub chain: Option<Chain>,
258}
259
260impl ResolvedEtherscanConfig {
261    /// Creates a new instance using the api key and chain
262    pub fn create(api_key: impl Into<String>, chain: impl Into<Chain>) -> Option<Self> {
263        let chain = chain.into();
264        let (api_url, browser_url) = chain.etherscan_urls()?;
265        Some(Self {
266            api_url: api_url.to_string(),
267            browser_url: Some(browser_url.to_string()),
268            key: api_key.into(),
269            chain: Some(chain),
270        })
271    }
272
273    /// Sets the chain value and consumes the type
274    ///
275    /// This is only used to set derive the appropriate Cache path for the etherscan client
276    pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
277        self.set_chain(chain);
278        self
279    }
280
281    /// Sets the chain value
282    pub fn set_chain(&mut self, chain: impl Into<Chain>) -> &mut Self {
283        let chain = chain.into();
284        if let Some((api, browser)) = chain.etherscan_urls() {
285            self.api_url = api.to_string();
286            self.browser_url = Some(browser.to_string());
287        }
288        self.chain = Some(chain);
289        self
290    }
291
292    /// Returns the corresponding `foundry_block_explorers::Client`, configured with the `api_url`,
293    /// `api_key` and cache
294    pub fn into_client(
295        self,
296    ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
297    {
298        self.into_client_with_no_proxy(false)
299    }
300
301    /// Same as [`Self::into_client`] but optionally disables automatic proxy detection.
302    ///
303    /// When `no_proxy` is `true`, calls [`foundry_block_explorers::ClientBuilder::no_proxy`],
304    /// which prevents system proxy lookups that can crash in sandboxed environments (e.g.,
305    /// Cursor IDE, macOS App Sandbox).
306    /// See: <https://github.com/foundry-rs/foundry/issues/12733>
307    pub fn into_client_with_no_proxy(
308        self,
309        no_proxy: bool,
310    ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
311    {
312        let Self { api_url, browser_url, key: api_key, chain } = self;
313
314        let chain = chain.unwrap_or_default();
315        let cache = Config::foundry_etherscan_chain_cache_dir(chain);
316
317        if let Some(cache_path) = &cache {
318            // we also create the `sources` sub dir here
319            if let Err(err) = std::fs::create_dir_all(cache_path.join("sources")) {
320                warn!("could not create etherscan cache dir: {:?}", err);
321            }
322        }
323
324        // Disable automatic proxy detection. In sandboxed environments (e.g., Cursor IDE,
325        // macOS App Sandbox), reqwest's system proxy lookup via SCDynamicStore can crash
326        // when the API returns NULL. See: https://github.com/foundry-rs/foundry/issues/12733
327        let mut client_builder = foundry_block_explorers::Client::builder()
328            .with_api_key(api_key)
329            .with_cache(cache, Duration::from_secs(24 * 60 * 60));
330        if no_proxy {
331            client_builder = client_builder.no_proxy();
332        }
333        if let Some(ref browser_url) = browser_url {
334            client_builder = client_builder.with_url(browser_url)?;
335        }
336
337        // Use the provided URL (either custom from foundry.toml or chain's default from resolve())
338        client_builder = client_builder.with_api_url(&api_url)?;
339        // Fallback: Use api_url as browser URL if browser_url is not set
340        if browser_url.is_none() {
341            client_builder = client_builder.with_url(&api_url)?;
342        }
343        client_builder.build()
344    }
345}
346
347/// Represents a single etherscan API key
348///
349/// This type preserves the value as it's stored in the config. If the value is a reference to an
350/// env var, then the `EtherscanKey::Key` var will hold the reference (`${MAIN_NET}`) and _not_ the
351/// value of the env var itself.
352/// In other words, this type does not resolve env vars when it's being deserialized
353#[derive(Clone, Debug, PartialEq, Eq)]
354pub enum EtherscanApiKey {
355    /// A raw key
356    Key(String),
357    /// An endpoint that contains at least one `${ENV_VAR}` placeholder
358    ///
359    /// **Note:** this contains the key or `${ETHERSCAN_KEY}`
360    Env(String),
361}
362
363impl EtherscanApiKey {
364    /// Returns the key this type holds
365    ///
366    /// # Error
367    ///
368    /// Returns an error if the type holds a reference to an env var and the env var is not set
369    pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
370        match self {
371            Self::Key(key) => Ok(key),
372            Self::Env(val) => interpolate(&val),
373        }
374    }
375}
376
377impl Serialize for EtherscanApiKey {
378    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
379    where
380        S: Serializer,
381    {
382        serializer.serialize_str(&self.to_string())
383    }
384}
385
386impl<'de> Deserialize<'de> for EtherscanApiKey {
387    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
388    where
389        D: Deserializer<'de>,
390    {
391        let val = String::deserialize(deserializer)?;
392        let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Key(val) };
393
394        Ok(endpoint)
395    }
396}
397
398impl fmt::Display for EtherscanApiKey {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        match self {
401            Self::Key(key) => key.fmt(f),
402            Self::Env(var) => var.fmt(f),
403        }
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use NamedChain::Mainnet;
411
412    #[test]
413    fn can_create_client_via_chain() {
414        let mut configs = EtherscanConfigs::default();
415        configs.insert(
416            "mainnet".to_string(),
417            EtherscanConfig {
418                chain: Some(Mainnet.into()),
419                url: None,
420                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
421            },
422        );
423
424        let mut resolved = configs.resolved();
425        let config = resolved.remove("mainnet").unwrap().unwrap();
426
427        let client = config.into_client().unwrap();
428        assert_eq!(
429            client.etherscan_api_url().as_str(),
430            "https://api.etherscan.io/v2/api?chainid=1"
431        );
432    }
433
434    #[test]
435    fn can_create_client_via_url_and_chain() {
436        let mut configs = EtherscanConfigs::default();
437        configs.insert(
438            "mainnet".to_string(),
439            EtherscanConfig {
440                chain: Some(Mainnet.into()),
441                url: Some("https://api.etherscan.io/api".to_string()),
442                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
443            },
444        );
445
446        let mut resolved = configs.resolved();
447        let config = resolved.remove("mainnet").unwrap().unwrap();
448        let _ = config.into_client().unwrap();
449    }
450
451    #[test]
452    fn can_create_client_via_url_and_chain_env_var() {
453        let mut configs = EtherscanConfigs::default();
454        let env = "_CONFIG_ETHERSCAN_API_KEY";
455        configs.insert(
456            "mainnet".to_string(),
457            EtherscanConfig {
458                chain: Some(Mainnet.into()),
459                url: Some("https://api.etherscan.io/api".to_string()),
460                key: EtherscanApiKey::Env(format!("${{{env}}}")),
461            },
462        );
463
464        let mut resolved = configs.clone().resolved();
465        let config = resolved.remove("mainnet").unwrap();
466        assert!(config.is_err());
467
468        unsafe {
469            std::env::set_var(env, "ABCDEFG");
470        }
471
472        let mut resolved = configs.resolved();
473        let config = resolved.remove("mainnet").unwrap().unwrap();
474        assert_eq!(config.key, "ABCDEFG");
475        let client = config.into_client().unwrap();
476        // Custom URL should be used even when chain has a default URL
477        assert_eq!(client.etherscan_api_url().as_str(), "https://api.etherscan.io/api");
478
479        unsafe {
480            std::env::remove_var(env);
481        }
482    }
483
484    #[test]
485    fn resolve_etherscan_alias_config() {
486        let mut configs = EtherscanConfigs::default();
487        configs.insert(
488            "blast_sepolia".to_string(),
489            EtherscanConfig {
490                chain: None,
491                url: Some("https://api.etherscan.io/api".to_string()),
492                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
493            },
494        );
495
496        let mut resolved = configs.clone().resolved();
497        let config = resolved.remove("blast_sepolia").unwrap().unwrap();
498        assert_eq!(config.chain, Some(Chain::blast_sepolia()));
499    }
500
501    #[test]
502    fn resolve_etherscan_alias() {
503        let config = EtherscanConfig {
504            chain: None,
505            url: Some("https://api.etherscan.io/api".to_string()),
506            key: EtherscanApiKey::Key("ABCDEFG".to_string()),
507        };
508        let resolved = config.clone().resolve(Some("base_sepolia")).unwrap();
509        assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
510
511        let resolved = config.resolve(Some("base-sepolia")).unwrap();
512        assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
513    }
514
515    #[test]
516    fn can_create_client_with_custom_url_for_chain_without_default_url() {
517        // Chains without default Etherscan URLs (e.g., Dev, AnvilHardhat networks)
518        // should work if a custom URL is provided in foundry.toml.
519        let mut configs = EtherscanConfigs::default();
520        configs.insert(
521            "dev".to_string(),
522            EtherscanConfig {
523                chain: Some(Chain::dev()),
524                url: Some("https://custom.api.url/verify/etherscan".to_string()),
525                key: EtherscanApiKey::Key("test_key".to_string()),
526            },
527        );
528
529        let mut resolved = configs.resolved();
530        let config = resolved.remove("dev").unwrap().unwrap();
531        let result = config.into_client();
532        assert!(
533            result.is_ok(),
534            "Should succeed with custom URL even for chains without default Etherscan URLs"
535        );
536    }
537
538    #[test]
539    fn fails_without_custom_url_for_chain_without_default_url() {
540        // Chains without default Etherscan URLs (e.g., Dev, AnvilHardhat networks)
541        // should fail if no custom URL is provided in foundry.toml.
542        let mut configs = EtherscanConfigs::default();
543        configs.insert(
544            "dev".to_string(),
545            EtherscanConfig {
546                chain: Some(Chain::dev()),
547                url: None,
548                key: EtherscanApiKey::Key("test_key".to_string()),
549            },
550        );
551
552        let mut resolved = configs.resolved();
553        let config = resolved.remove("dev").unwrap();
554
555        assert!(
556            config.is_err(),
557            "Should fail: chains without default Etherscan URLs require custom URL"
558        );
559    }
560}