1use 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
21pub const ETHERSCAN_USER_AGENT: &str = concat!("foundry/", env!("CARGO_PKG_VERSION"));
23
24#[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#[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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(transparent)]
69pub struct EtherscanConfigs {
70 configs: BTreeMap<String, EtherscanConfig>,
71}
72
73impl EtherscanConfigs {
74 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 pub fn is_empty(&self) -> bool {
81 self.configs.is_empty()
82 }
83
84 pub fn find_chain(&self, chain: Chain) -> Option<&EtherscanConfig> {
86 self.configs.values().find(|config| config.chain == Some(chain))
87 }
88
89 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
120pub struct ResolvedEtherscanConfigs {
121 configs: BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>,
124}
125
126impl ResolvedEtherscanConfigs {
127 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 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 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
173pub struct EtherscanConfig {
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub chain: Option<Chain>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub url: Option<String>,
180 pub key: EtherscanApiKey,
182}
183
184impl EtherscanConfig {
185 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 (Some(chain), None) => (Some(chain), Some(chain.to_string())),
204 (None, Some(alias)) => {
205 (
207 alias.to_kebab_case().parse().ok().or_else(|| {
208 serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
211 .map(Into::into)
212 .ok()
213 }),
214 Some(alias.into()),
215 )
216 }
217 (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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
249pub struct ResolvedEtherscanConfig {
250 #[serde(rename = "url")]
252 pub api_url: String,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub browser_url: Option<String>,
256 pub key: String,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub chain: Option<Chain>,
261}
262
263impl ResolvedEtherscanConfig {
264 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 pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
280 self.set_chain(chain);
281 self
282 }
283
284 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
336pub enum EtherscanApiKey {
337 Key(String),
339 Env(String),
343}
344
345impl EtherscanApiKey {
346 pub fn as_key(&self) -> Option<&str> {
348 match self {
349 Self::Key(url) => Some(url),
350 Self::Env(_) => None,
351 }
352 }
353
354 pub fn as_env(&self) -> Option<&str> {
356 match self {
357 Self::Env(val) => Some(val),
358 Self::Key(_) => None,
359 }
360 }
361
362 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#[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}