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(browser_url) = browser_url {
323            client_builder = client_builder.with_url(browser_url)?;
324        }
325        client_builder.chain(chain)?.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
432        let client = config.into_client().unwrap();
433        assert_eq!(
434            client.etherscan_api_url().as_str(),
435            "https://api.etherscan.io/v2/api?chainid=1"
436        );
437    }
438
439    #[test]
440    fn can_create_client_via_url_and_chain() {
441        let mut configs = EtherscanConfigs::default();
442        configs.insert(
443            "mainnet".to_string(),
444            EtherscanConfig {
445                chain: Some(Mainnet.into()),
446                url: Some("https://api.etherscan.io/api".to_string()),
447                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
448            },
449        );
450
451        let mut resolved = configs.resolved();
452        let config = resolved.remove("mainnet").unwrap().unwrap();
453        let _ = config.into_client().unwrap();
454    }
455
456    #[test]
457    fn can_create_client_via_url_and_chain_env_var() {
458        let mut configs = EtherscanConfigs::default();
459        let env = "_CONFIG_ETHERSCAN_API_KEY";
460        configs.insert(
461            "mainnet".to_string(),
462            EtherscanConfig {
463                chain: Some(Mainnet.into()),
464                url: Some("https://api.etherscan.io/api".to_string()),
465                key: EtherscanApiKey::Env(format!("${{{env}}}")),
466            },
467        );
468
469        let mut resolved = configs.clone().resolved();
470        let config = resolved.remove("mainnet").unwrap();
471        assert!(config.is_err());
472
473        unsafe {
474            std::env::set_var(env, "ABCDEFG");
475        }
476
477        let mut resolved = configs.resolved();
478        let config = resolved.remove("mainnet").unwrap().unwrap();
479        assert_eq!(config.key, "ABCDEFG");
480        let client = config.into_client().unwrap();
481        assert_eq!(
482            client.etherscan_api_url().as_str(),
483            "https://api.etherscan.io/v2/api?chainid=1"
484        );
485
486        unsafe {
487            std::env::remove_var(env);
488        }
489    }
490
491    #[test]
492    fn resolve_etherscan_alias_config() {
493        let mut configs = EtherscanConfigs::default();
494        configs.insert(
495            "blast_sepolia".to_string(),
496            EtherscanConfig {
497                chain: None,
498                url: Some("https://api.etherscan.io/api".to_string()),
499                key: EtherscanApiKey::Key("ABCDEFG".to_string()),
500            },
501        );
502
503        let mut resolved = configs.clone().resolved();
504        let config = resolved.remove("blast_sepolia").unwrap().unwrap();
505        assert_eq!(config.chain, Some(Chain::blast_sepolia()));
506    }
507
508    #[test]
509    fn resolve_etherscan_alias() {
510        let config = EtherscanConfig {
511            chain: None,
512            url: Some("https://api.etherscan.io/api".to_string()),
513            key: EtherscanApiKey::Key("ABCDEFG".to_string()),
514        };
515        let resolved = config.clone().resolve(Some("base_sepolia")).unwrap();
516        assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
517
518        let resolved = config.resolve(Some("base-sepolia")).unwrap();
519        assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
520    }
521}