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