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