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