foundry_config/
etherscan.rs

1//! Support for multiple Etherscan keys.
2
3use crate::{
4    resolve::{interpolate, UnresolvedEnvVarError, RE_PLACEHOLDER},
5    Chain, Config, NamedChain,
6};
7use figment::{
8    providers::Env,
9    value::{Dict, Map},
10    Error, Metadata, Profile, Provider,
11};
12use foundry_block_explorers::EtherscanApiVersion;
13use heck::ToKebabCase;
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use std::{
16    collections::BTreeMap,
17    fmt,
18    ops::{Deref, DerefMut},
19    time::Duration,
20};
21
22/// The user agent to use when querying the etherscan API.
23pub const ETHERSCAN_USER_AGENT: &str = concat!("foundry/", env!("CARGO_PKG_VERSION"));
24
25/// A [Provider] that provides Etherscan API key from the environment if it's not empty.
26///
27/// This prevents `ETHERSCAN_API_KEY=""` if it's set but empty
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29#[non_exhaustive]
30pub(crate) struct EtherscanEnvProvider;
31
32impl Provider for EtherscanEnvProvider {
33    fn metadata(&self) -> Metadata {
34        Env::raw().metadata()
35    }
36
37    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
38        let mut dict = Dict::default();
39        let env_provider = Env::raw().only(&["ETHERSCAN_API_KEY"]);
40        if let Some((key, value)) = env_provider.iter().next() {
41            if !value.trim().is_empty() {
42                dict.insert(key.as_str().to_string(), value.into());
43            }
44        }
45
46        Ok(Map::from([(Config::selected_profile(), dict)]))
47    }
48}
49
50/// Errors that can occur when creating an `EtherscanConfig`
51#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
52pub enum EtherscanConfigError {
53    #[error(transparent)]
54    Unresolved(#[from] UnresolvedEnvVarError),
55
56    #[error("No known Etherscan API URL for config{0} with chain `{1}`. Please specify a `url`")]
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, default_api_version: EtherscanApiVersion) -> ResolvedEtherscanConfigs {
88        ResolvedEtherscanConfigs {
89            configs: self
90                .configs
91                .into_iter()
92                .map(|(name, e)| {
93                    let resolved = e.resolve(Some(&name), default_api_version);
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.into_iter() {
139            match config {
140                Ok(c) if c.chain == Some(chain) => return Some(Ok(c)),
141                Err(e) => return Some(Err(e)),
142                _ => continue,
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    /// Etherscan API Version. Defaults to v2
178    #[serde(default, alias = "api-version", skip_serializing_if = "Option::is_none")]
179    pub api_version: Option<EtherscanApiVersion>,
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        default_api_version: EtherscanApiVersion,
195    ) -> Result<ResolvedEtherscanConfig, EtherscanConfigError> {
196        let Self { chain, mut url, key, api_version } = self;
197
198        let api_version = api_version.unwrap_or(default_api_version);
199
200        if let Some(url) = &mut url {
201            *url = interpolate(url)?;
202        }
203
204        let (chain, alias) = match (chain, alias) {
205            // fill one with the other
206            (Some(chain), None) => (Some(chain), Some(chain.to_string())),
207            (None, Some(alias)) => {
208                // alloy chain is parsed as kebab case
209                (
210                    alias.to_kebab_case().parse().ok().or_else(|| {
211                        // if this didn't work try to parse as json because the deserialize impl
212                        // supports more aliases
213                        serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
214                            .map(Into::into)
215                            .ok()
216                    }),
217                    Some(alias.into()),
218                )
219            }
220            // leave as is
221            (Some(chain), Some(alias)) => (Some(chain), Some(alias.into())),
222            (None, None) => (None, None),
223        };
224        let key = key.resolve()?;
225
226        match (chain, url) {
227            (Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig {
228                api_url,
229                api_version,
230                browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()),
231                key,
232                chain: Some(chain),
233            }),
234            (Some(chain), None) => ResolvedEtherscanConfig::create(key, chain, api_version)
235                .ok_or_else(|| {
236                    let msg = alias.map(|a| format!(" `{a}`")).unwrap_or_default();
237                    EtherscanConfigError::UnknownChain(msg, chain)
238                }),
239            (None, Some(api_url)) => Ok(ResolvedEtherscanConfig {
240                api_url,
241                browser_url: None,
242                key,
243                chain: None,
244                api_version,
245            }),
246            (None, None) => {
247                let msg = alias
248                    .map(|a| format!(" for Etherscan config with unknown alias `{a}`"))
249                    .unwrap_or_default();
250                Err(EtherscanConfigError::MissingUrlOrChain(msg))
251            }
252        }
253    }
254}
255
256/// Contains required url + api key to set up an etherscan client
257#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
258pub struct ResolvedEtherscanConfig {
259    /// Etherscan API URL.
260    #[serde(rename = "url")]
261    pub api_url: String,
262    /// Optional browser URL.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub browser_url: Option<String>,
265    /// The resolved API key.
266    pub key: String,
267    /// Etherscan API Version.
268    #[serde(default)]
269    pub api_version: EtherscanApiVersion,
270    /// The chain name or EIP-155 chain ID.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub chain: Option<Chain>,
273}
274
275impl ResolvedEtherscanConfig {
276    /// Creates a new instance using the api key and chain
277    pub fn create(
278        api_key: impl Into<String>,
279        chain: impl Into<Chain>,
280        api_version: EtherscanApiVersion,
281    ) -> Option<Self> {
282        let chain = chain.into();
283        let (api_url, browser_url) = chain.etherscan_urls()?;
284        Some(Self {
285            api_url: api_url.to_string(),
286            api_version,
287            browser_url: Some(browser_url.to_string()),
288            key: api_key.into(),
289            chain: Some(chain),
290        })
291    }
292
293    /// Sets the chain value and consumes the type
294    ///
295    /// This is only used to set derive the appropriate Cache path for the etherscan client
296    pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
297        self.set_chain(chain);
298        self
299    }
300
301    /// Sets the chain value
302    pub fn set_chain(&mut self, chain: impl Into<Chain>) -> &mut Self {
303        let chain = chain.into();
304        if let Some((api, browser)) = chain.etherscan_urls() {
305            self.api_url = api.to_string();
306            self.browser_url = Some(browser.to_string());
307        }
308        self.chain = Some(chain);
309        self
310    }
311
312    /// Returns the corresponding `foundry_block_explorers::Client`, configured with the `api_url`,
313    /// `api_key` and cache
314    pub fn into_client(
315        self,
316    ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
317    {
318        let Self { api_url, browser_url, key: api_key, chain, api_version } = self;
319
320        let chain = chain.unwrap_or_default();
321        let cache = Config::foundry_etherscan_chain_cache_dir(chain);
322
323        if let Some(cache_path) = &cache {
324            // we also create the `sources` sub dir here
325            if let Err(err) = std::fs::create_dir_all(cache_path.join("sources")) {
326                warn!("could not create etherscan cache dir: {:?}", err);
327            }
328        }
329
330        let api_url = into_url(&api_url)?;
331        let client = reqwest::Client::builder()
332            .user_agent(ETHERSCAN_USER_AGENT)
333            .tls_built_in_root_certs(api_url.scheme() == "https")
334            .build()?;
335        let mut client_builder = foundry_block_explorers::Client::builder()
336            .with_client(client)
337            .with_api_version(api_version)
338            .with_api_key(api_key)
339            .with_cache(cache, Duration::from_secs(24 * 60 * 60));
340        if let Some(browser_url) = browser_url {
341            client_builder = client_builder.with_url(browser_url)?;
342        }
343        client_builder.chain(chain)?.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 variant
365    pub fn as_key(&self) -> Option<&str> {
366        match self {
367            Self::Key(url) => Some(url),
368            Self::Env(_) => None,
369        }
370    }
371
372    /// Returns the env variant
373    pub fn as_env(&self) -> Option<&str> {
374        match self {
375            Self::Env(val) => Some(val),
376            Self::Key(_) => None,
377        }
378    }
379
380    /// Returns the key this type holds
381    ///
382    /// # Error
383    ///
384    /// Returns an error if the type holds a reference to an env var and the env var is not set
385    pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
386        match self {
387            Self::Key(key) => Ok(key),
388            Self::Env(val) => interpolate(&val),
389        }
390    }
391}
392
393impl Serialize for EtherscanApiKey {
394    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
395    where
396        S: Serializer,
397    {
398        serializer.serialize_str(&self.to_string())
399    }
400}
401
402impl<'de> Deserialize<'de> for EtherscanApiKey {
403    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
404    where
405        D: Deserializer<'de>,
406    {
407        let val = String::deserialize(deserializer)?;
408        let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Key(val) };
409
410        Ok(endpoint)
411    }
412}
413
414impl fmt::Display for EtherscanApiKey {
415    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416        match self {
417            Self::Key(key) => key.fmt(f),
418            Self::Env(var) => var.fmt(f),
419        }
420    }
421}
422
423/// This is a hack to work around `IntoUrl`'s sealed private functions, which can't be called
424/// normally.
425#[inline]
426fn into_url(url: impl reqwest::IntoUrl) -> std::result::Result<reqwest::Url, reqwest::Error> {
427    url.into_url()
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use NamedChain::Mainnet;
434
435    #[test]
436    fn can_create_client_via_chain() {
437        let mut configs = EtherscanConfigs::default();
438        configs.insert(
439            "mainnet".to_string(),
440            EtherscanConfig {
441                chain: Some(Mainnet.into()),
442                url: None,
443                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
444                api_version: None,
445            },
446        );
447
448        let mut resolved = configs.resolved(EtherscanApiVersion::V2);
449        let config = resolved.remove("mainnet").unwrap().unwrap();
450        // None version = None
451        assert_eq!(config.api_version, EtherscanApiVersion::V2);
452        let client = config.into_client().unwrap();
453        assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V2);
454    }
455
456    #[test]
457    fn can_create_v1_client_via_chain() {
458        let mut configs = EtherscanConfigs::default();
459        configs.insert(
460            "mainnet".to_string(),
461            EtherscanConfig {
462                chain: Some(Mainnet.into()),
463                url: None,
464                api_version: Some(EtherscanApiVersion::V1),
465                key: EtherscanApiKey::Key("ABCDEG".to_string()),
466            },
467        );
468
469        let mut resolved = configs.resolved(EtherscanApiVersion::V2);
470        let config = resolved.remove("mainnet").unwrap().unwrap();
471        assert_eq!(config.api_version, EtherscanApiVersion::V1);
472        let client = config.into_client().unwrap();
473        assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V1);
474    }
475
476    #[test]
477    fn can_create_client_via_url_and_chain() {
478        let mut configs = EtherscanConfigs::default();
479        configs.insert(
480            "mainnet".to_string(),
481            EtherscanConfig {
482                chain: Some(Mainnet.into()),
483                url: Some("https://api.etherscan.io/api".to_string()),
484                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
485                api_version: None,
486            },
487        );
488
489        let mut resolved = configs.resolved(EtherscanApiVersion::V2);
490        let config = resolved.remove("mainnet").unwrap().unwrap();
491        let _ = config.into_client().unwrap();
492    }
493
494    #[test]
495    fn can_create_client_via_url_and_chain_env_var() {
496        let mut configs = EtherscanConfigs::default();
497        let env = "_CONFIG_ETHERSCAN_API_KEY";
498        configs.insert(
499            "mainnet".to_string(),
500            EtherscanConfig {
501                chain: Some(Mainnet.into()),
502                url: Some("https://api.etherscan.io/api".to_string()),
503                api_version: None,
504                key: EtherscanApiKey::Env(format!("${{{env}}}")),
505            },
506        );
507
508        let mut resolved = configs.clone().resolved(EtherscanApiVersion::V2);
509        let config = resolved.remove("mainnet").unwrap();
510        assert!(config.is_err());
511
512        std::env::set_var(env, "ABCDEFG");
513
514        let mut resolved = configs.resolved(EtherscanApiVersion::V2);
515        let config = resolved.remove("mainnet").unwrap().unwrap();
516        assert_eq!(config.key, "ABCDEFG");
517        let client = config.into_client().unwrap();
518        assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V2);
519
520        std::env::remove_var(env);
521    }
522
523    #[test]
524    fn resolve_etherscan_alias_config() {
525        let mut configs = EtherscanConfigs::default();
526        configs.insert(
527            "blast_sepolia".to_string(),
528            EtherscanConfig {
529                chain: None,
530                url: Some("https://api.etherscan.io/api".to_string()),
531                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
532                api_version: None,
533            },
534        );
535
536        let mut resolved = configs.clone().resolved(EtherscanApiVersion::V2);
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            api_version: None,
548        };
549        let resolved =
550            config.clone().resolve(Some("base_sepolia"), EtherscanApiVersion::V2).unwrap();
551        assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
552
553        let resolved = config.resolve(Some("base-sepolia"), EtherscanApiVersion::V2).unwrap();
554        assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
555    }
556}