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 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
22pub const ETHERSCAN_USER_AGENT: &str = concat!("foundry/", env!("CARGO_PKG_VERSION"));
24
25#[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#[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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(transparent)]
70pub struct EtherscanConfigs {
71 configs: BTreeMap<String, EtherscanConfig>,
72}
73
74impl EtherscanConfigs {
75 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 pub fn is_empty(&self) -> bool {
82 self.configs.is_empty()
83 }
84
85 pub fn find_chain(&self, chain: Chain) -> Option<&EtherscanConfig> {
87 self.configs.values().find(|config| config.chain == Some(chain))
88 }
89
90 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
121pub struct ResolvedEtherscanConfigs {
122 configs: BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>,
125}
126
127impl ResolvedEtherscanConfigs {
128 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 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 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
174pub struct EtherscanConfig {
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub chain: Option<Chain>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub url: Option<String>,
181 #[serde(default, alias = "api-version", skip_serializing_if = "Option::is_none")]
183 pub api_version: Option<EtherscanApiVersion>,
184 pub key: EtherscanApiKey,
186}
187
188impl EtherscanConfig {
189 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 (Some(chain), None) => (Some(chain), Some(chain.to_string())),
211 (None, Some(alias)) => {
212 (
214 alias.to_kebab_case().parse().ok().or_else(|| {
215 serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
218 .map(Into::into)
219 .ok()
220 }),
221 Some(alias.into()),
222 )
223 }
224 (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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
262pub struct ResolvedEtherscanConfig {
263 #[serde(rename = "url")]
265 pub api_url: String,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub browser_url: Option<String>,
269 pub key: String,
271 #[serde(default)]
273 pub api_version: EtherscanApiVersion,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub chain: Option<Chain>,
277}
278
279impl ResolvedEtherscanConfig {
280 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 pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
301 self.set_chain(chain);
302 self
303 }
304
305 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
358pub enum EtherscanApiKey {
359 Key(String),
361 Env(String),
365}
366
367impl EtherscanApiKey {
368 pub fn as_key(&self) -> Option<&str> {
370 match self {
371 Self::Key(url) => Some(url),
372 Self::Env(_) => None,
373 }
374 }
375
376 pub fn as_env(&self) -> Option<&str> {
378 match self {
379 Self::Env(val) => Some(val),
380 Self::Key(_) => None,
381 }
382 }
383
384 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#[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 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}