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    /// Picks the explorer config to use, given the settings that select between the entries.
87    ///
88    /// `alias` names an entry outright and wins if it matches one. Otherwise the first entry whose
89    /// chain id matches `chain` is used, with `api_key` — which usually comes from an env var or a
90    /// CLI flag — overriding the key that entry carries. With no matching entry, `api_key` alone is
91    /// enough to build a config for `chain`.
92    ///
93    /// Shared by [`Config::get_etherscan_config_with_chain`] and by consumers that keep only a
94    /// snapshot of the config and resolve later, against a chain they don't learn until runtime.
95    pub fn resolve_for(
96        &self,
97        alias: Option<&str>,
98        api_key: Option<&str>,
99        chain: Option<Chain>,
100    ) -> Result<Option<ResolvedEtherscanConfig>, EtherscanConfigError> {
101        if let Some(alias) = alias
102            && self.contains_key(alias)
103        {
104            return self.clone().resolved().remove(alias).transpose();
105        }
106
107        if let Some(res) = chain.and_then(|chain| self.clone().resolved().find_chain(chain)) {
108            match (res, api_key) {
109                (Ok(mut config), Some(key)) => {
110                    config.key = key.to_string();
111                    return Ok(Some(config));
112                }
113                (Ok(config), None) => return Ok(Some(config)),
114                (Err(err), None) => return Err(err),
115                // Unresolvable entry, but there is a key to fall back on.
116                (Err(_), Some(_)) => {}
117            }
118        }
119
120        if let Some(key) = api_key {
121            return Ok(ResolvedEtherscanConfig::create(key, chain.unwrap_or_default()));
122        }
123        Ok(None)
124    }
125
126    /// Returns all (alias -> url) pairs
127    pub fn resolved(self) -> ResolvedEtherscanConfigs {
128        ResolvedEtherscanConfigs {
129            configs: self
130                .configs
131                .into_iter()
132                .map(|(name, e)| {
133                    let resolved = e.resolve(Some(&name));
134                    (name, resolved)
135                })
136                .collect(),
137        }
138    }
139}
140
141impl Deref for EtherscanConfigs {
142    type Target = BTreeMap<String, EtherscanConfig>;
143
144    fn deref(&self) -> &Self::Target {
145        &self.configs
146    }
147}
148
149impl DerefMut for EtherscanConfigs {
150    fn deref_mut(&mut self) -> &mut Self::Target {
151        &mut self.configs
152    }
153}
154
155/// Container type for _resolved_ etherscan keys, see [`EtherscanConfigs::resolved`].
156#[derive(Clone, Debug, Default, PartialEq, Eq)]
157pub struct ResolvedEtherscanConfigs {
158    /// contains all named `ResolvedEtherscanConfig` or an error if we failed to resolve the env
159    /// var alias
160    configs: BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>,
161}
162
163impl ResolvedEtherscanConfigs {
164    /// Creates a new list of resolved etherscan configs
165    pub fn new(
166        configs: impl IntoIterator<Item = (impl Into<String>, ResolvedEtherscanConfig)>,
167    ) -> Self {
168        Self {
169            configs: configs.into_iter().map(|(name, config)| (name.into(), Ok(config))).collect(),
170        }
171    }
172
173    /// Returns the first config that matches the chain
174    pub fn find_chain(
175        self,
176        chain: Chain,
177    ) -> Option<Result<ResolvedEtherscanConfig, EtherscanConfigError>> {
178        for (_, config) in self.configs {
179            match config {
180                Ok(c) if c.chain == Some(chain) => return Some(Ok(c)),
181                Err(e) => return Some(Err(e)),
182                _ => {}
183            }
184        }
185        None
186    }
187
188    /// Returns true if there's a config that couldn't be resolved
189    pub fn has_unresolved(&self) -> bool {
190        self.configs.values().any(|val| val.is_err())
191    }
192}
193
194impl Deref for ResolvedEtherscanConfigs {
195    type Target = BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>;
196
197    fn deref(&self) -> &Self::Target {
198        &self.configs
199    }
200}
201
202impl DerefMut for ResolvedEtherscanConfigs {
203    fn deref_mut(&mut self) -> &mut Self::Target {
204        &mut self.configs
205    }
206}
207
208/// Represents all info required to create an etherscan client
209#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
210pub struct EtherscanConfig {
211    /// The chain name or EIP-155 chain ID used to derive the API URL.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub chain: Option<Chain>,
214    /// Etherscan API URL
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub url: Option<String>,
217    /// The etherscan API KEY that's required to make requests
218    pub key: EtherscanApiKey,
219}
220
221impl EtherscanConfig {
222    /// Returns the etherscan config required to create a client.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if the type holds a reference to an env var and the env var is not set or
227    /// no chain or url is configured
228    pub fn resolve(
229        self,
230        alias: Option<&str>,
231    ) -> Result<ResolvedEtherscanConfig, EtherscanConfigError> {
232        let Self { chain, mut url, key } = self;
233
234        if let Some(url) = &mut url {
235            *url = interpolate(url)?;
236        }
237
238        let (chain, alias) = match (chain, alias) {
239            // fill one with the other
240            (Some(chain), None) => (Some(chain), Some(chain.to_string())),
241            (None, Some(alias)) => {
242                // alloy chain is parsed as kebab case
243                (
244                    alias.to_kebab_case().parse().ok().or_else(|| {
245                        // if this didn't work try to parse as json because the deserialize impl
246                        // supports more aliases
247                        serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
248                            .map(Into::into)
249                            .ok()
250                    }),
251                    Some(alias.into()),
252                )
253            }
254            // leave as is
255            (Some(chain), Some(alias)) => (Some(chain), Some(alias.into())),
256            (None, None) => (None, None),
257        };
258        let key = key.resolve()?;
259
260        match (chain, url) {
261            (Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig {
262                api_url,
263                browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()),
264                key,
265                chain: Some(chain),
266            }),
267            (Some(chain), None) => ResolvedEtherscanConfig::create(key, chain).ok_or_else(|| {
268                let msg = alias.map(|a| format!("for `{a}`")).unwrap_or_default();
269                EtherscanConfigError::UnknownChain(msg, chain)
270            }),
271            (None, Some(api_url)) => {
272                Ok(ResolvedEtherscanConfig { api_url, browser_url: None, key, chain: None })
273            }
274            (None, None) => {
275                let msg = alias
276                    .map(|a| format!(" for Etherscan config with unknown alias `{a}`"))
277                    .unwrap_or_default();
278                Err(EtherscanConfigError::MissingUrlOrChain(msg))
279            }
280        }
281    }
282}
283
284/// Contains required url + api key to set up an etherscan client
285#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
286pub struct ResolvedEtherscanConfig {
287    /// Etherscan API URL.
288    #[serde(rename = "url")]
289    pub api_url: String,
290    /// Optional browser URL.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub browser_url: Option<String>,
293    /// The resolved API key.
294    pub key: String,
295    /// The chain name or EIP-155 chain ID.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub chain: Option<Chain>,
298}
299
300impl ResolvedEtherscanConfig {
301    /// Creates a new instance using the api key and chain
302    pub fn create(api_key: impl Into<String>, chain: impl Into<Chain>) -> Option<Self> {
303        let chain = chain.into();
304        let (api_url, browser_url) = chain.etherscan_urls()?;
305        Some(Self {
306            api_url: api_url.to_string(),
307            browser_url: Some(browser_url.to_string()),
308            key: api_key.into(),
309            chain: Some(chain),
310        })
311    }
312
313    /// Sets the chain value and consumes the type
314    ///
315    /// This is only used to set derive the appropriate Cache path for the etherscan client
316    pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
317        self.set_chain(chain);
318        self
319    }
320
321    /// Sets the chain value
322    pub fn set_chain(&mut self, chain: impl Into<Chain>) -> &mut Self {
323        let chain = chain.into();
324        if let Some((api, browser)) = chain.etherscan_urls() {
325            self.api_url = api.to_string();
326            self.browser_url = Some(browser.to_string());
327        }
328        self.chain = Some(chain);
329        self
330    }
331
332    /// Returns the corresponding `foundry_block_explorers::Client`, configured with the `api_url`,
333    /// `api_key` and cache
334    pub fn into_client(
335        self,
336    ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
337    {
338        self.into_client_with_no_proxy(false)
339    }
340
341    /// Same as [`Self::into_client`] but optionally disables automatic proxy detection.
342    ///
343    /// When `no_proxy` is `true`, calls [`foundry_block_explorers::ClientBuilder::no_proxy`],
344    /// which prevents system proxy lookups that can crash in sandboxed environments (e.g.,
345    /// Cursor IDE, macOS App Sandbox).
346    /// See: <https://github.com/foundry-rs/foundry/issues/12733>
347    pub fn into_client_with_no_proxy(
348        self,
349        no_proxy: bool,
350    ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
351    {
352        let Self { api_url, browser_url, key: api_key, chain } = self;
353
354        let chain = chain.unwrap_or_default();
355        let cache = Config::foundry_etherscan_chain_cache_dir(chain);
356
357        if let Some(cache_path) = &cache {
358            // we also create the `sources` sub dir here
359            if let Err(err) = std::fs::create_dir_all(cache_path.join("sources")) {
360                warn!("could not create etherscan cache dir: {:?}", err);
361            }
362        }
363
364        // Disable automatic proxy detection. In sandboxed environments (e.g., Cursor IDE,
365        // macOS App Sandbox), reqwest's system proxy lookup via SCDynamicStore can crash
366        // when the API returns NULL. See: https://github.com/foundry-rs/foundry/issues/12733
367        let mut client_builder = foundry_block_explorers::Client::builder()
368            .with_api_key(api_key)
369            .with_cache(cache, Duration::from_secs(24 * 60 * 60));
370        if no_proxy {
371            client_builder = client_builder.no_proxy();
372        }
373        if let Some(ref browser_url) = browser_url {
374            client_builder = client_builder.with_url(browser_url)?;
375        }
376
377        // Use the provided URL (either custom from foundry.toml or chain's default from resolve())
378        client_builder = client_builder.with_api_url(&api_url)?;
379        // Fallback: Use api_url as browser URL if browser_url is not set
380        if browser_url.is_none() {
381            client_builder = client_builder.with_url(&api_url)?;
382        }
383        client_builder.build()
384    }
385}
386
387/// Represents a single etherscan API key
388///
389/// This type preserves the value as it's stored in the config. If the value is a reference to an
390/// env var, then the `EtherscanKey::Key` var will hold the reference (`${MAIN_NET}`) and _not_ the
391/// value of the env var itself.
392/// In other words, this type does not resolve env vars when it's being deserialized
393#[derive(Clone, Debug, PartialEq, Eq)]
394pub enum EtherscanApiKey {
395    /// A raw key
396    Key(String),
397    /// An endpoint that contains at least one `${ENV_VAR}` placeholder
398    ///
399    /// **Note:** this contains the key or `${ETHERSCAN_KEY}`
400    Env(String),
401}
402
403impl EtherscanApiKey {
404    /// Returns the key this type holds
405    ///
406    /// # Error
407    ///
408    /// Returns an error if the type holds a reference to an env var and the env var is not set
409    pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
410        match self {
411            Self::Key(key) => Ok(key),
412            Self::Env(val) => interpolate(&val),
413        }
414    }
415}
416
417impl Serialize for EtherscanApiKey {
418    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
419    where
420        S: Serializer,
421    {
422        serializer.serialize_str(&self.to_string())
423    }
424}
425
426impl<'de> Deserialize<'de> for EtherscanApiKey {
427    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
428    where
429        D: Deserializer<'de>,
430    {
431        let val = String::deserialize(deserializer)?;
432        let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Key(val) };
433
434        Ok(endpoint)
435    }
436}
437
438impl fmt::Display for EtherscanApiKey {
439    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
440        match self {
441            Self::Key(key) => key.fmt(f),
442            Self::Env(var) => var.fmt(f),
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use NamedChain::Mainnet;
451
452    #[test]
453    fn can_create_client_via_chain() {
454        let mut configs = EtherscanConfigs::default();
455        configs.insert(
456            "mainnet".to_string(),
457            EtherscanConfig {
458                chain: Some(Mainnet.into()),
459                url: None,
460                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
461            },
462        );
463
464        let mut resolved = configs.resolved();
465        let config = resolved.remove("mainnet").unwrap().unwrap();
466
467        let client = config.into_client().unwrap();
468        assert_eq!(
469            client.etherscan_api_url().as_str(),
470            "https://api.etherscan.io/v2/api?chainid=1"
471        );
472    }
473
474    #[test]
475    fn can_create_client_via_url_and_chain() {
476        let mut configs = EtherscanConfigs::default();
477        configs.insert(
478            "mainnet".to_string(),
479            EtherscanConfig {
480                chain: Some(Mainnet.into()),
481                url: Some("https://api.etherscan.io/api".to_string()),
482                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
483            },
484        );
485
486        let mut resolved = configs.resolved();
487        let config = resolved.remove("mainnet").unwrap().unwrap();
488        let _ = config.into_client().unwrap();
489    }
490
491    #[test]
492    fn can_create_client_via_url_and_chain_env_var() {
493        let mut configs = EtherscanConfigs::default();
494        let env = "_CONFIG_ETHERSCAN_API_KEY";
495        configs.insert(
496            "mainnet".to_string(),
497            EtherscanConfig {
498                chain: Some(Mainnet.into()),
499                url: Some("https://api.etherscan.io/api".to_string()),
500                key: EtherscanApiKey::Env(format!("${{{env}}}")),
501            },
502        );
503
504        let mut resolved = configs.clone().resolved();
505        let config = resolved.remove("mainnet").unwrap();
506        assert!(config.is_err());
507
508        unsafe {
509            std::env::set_var(env, "ABCDEFG");
510        }
511
512        let mut resolved = configs.resolved();
513        let config = resolved.remove("mainnet").unwrap().unwrap();
514        assert_eq!(config.key, "ABCDEFG");
515        let client = config.into_client().unwrap();
516        // Custom URL should be used even when chain has a default URL
517        assert_eq!(client.etherscan_api_url().as_str(), "https://api.etherscan.io/api");
518
519        unsafe {
520            std::env::remove_var(env);
521        }
522    }
523
524    #[test]
525    fn resolve_etherscan_alias_config() {
526        let mut configs = EtherscanConfigs::default();
527        configs.insert(
528            "blast_sepolia".to_string(),
529            EtherscanConfig {
530                chain: None,
531                url: Some("https://api.etherscan.io/api".to_string()),
532                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
533            },
534        );
535
536        let mut resolved = configs.clone().resolved();
537        let config = resolved.remove("blast_sepolia").unwrap().unwrap();
538        assert_eq!(config.chain, Some(Chain::blast_sepolia()));
539    }
540
541    #[test]
542    fn resolve_etherscan_alias() {
543        let config = EtherscanConfig {
544            chain: None,
545            url: Some("https://api.etherscan.io/api".to_string()),
546            key: EtherscanApiKey::Key("ABCDEFG".to_string()),
547        };
548        let resolved = config.clone().resolve(Some("base_sepolia")).unwrap();
549        assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
550
551        let resolved = config.resolve(Some("base-sepolia")).unwrap();
552        assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
553    }
554
555    #[test]
556    fn can_create_client_with_custom_url_for_chain_without_default_url() {
557        // Chains without default Etherscan URLs (e.g., Dev, AnvilHardhat networks)
558        // should work if a custom URL is provided in foundry.toml.
559        let mut configs = EtherscanConfigs::default();
560        configs.insert(
561            "dev".to_string(),
562            EtherscanConfig {
563                chain: Some(Chain::dev()),
564                url: Some("https://custom.api.url/verify/etherscan".to_string()),
565                key: EtherscanApiKey::Key("test_key".to_string()),
566            },
567        );
568
569        let mut resolved = configs.resolved();
570        let config = resolved.remove("dev").unwrap().unwrap();
571        let result = config.into_client();
572        assert!(
573            result.is_ok(),
574            "Should succeed with custom URL even for chains without default Etherscan URLs"
575        );
576    }
577
578    #[test]
579    fn fails_without_custom_url_for_chain_without_default_url() {
580        // Chains without default Etherscan URLs (e.g., Dev, AnvilHardhat networks)
581        // should fail if no custom URL is provided in foundry.toml.
582        let mut configs = EtherscanConfigs::default();
583        configs.insert(
584            "dev".to_string(),
585            EtherscanConfig {
586                chain: Some(Chain::dev()),
587                url: None,
588                key: EtherscanApiKey::Key("test_key".to_string()),
589            },
590        );
591
592        let mut resolved = configs.resolved();
593        let config = resolved.remove("dev").unwrap();
594
595        assert!(
596            config.is_err(),
597            "Should fail: chains without default Etherscan URLs require custom URL"
598        );
599    }
600}