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
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
25#[non_exhaustive]
26pub(crate) struct EtherscanEnvProvider;
27
28impl Provider for EtherscanEnvProvider {
29 fn metadata(&self) -> Metadata {
30 Env::raw().metadata()
31 }
32
33 fn data(&self) -> Result<Map<Profile, Dict>, Error> {
34 let mut dict = Dict::default();
35 let env_provider = Env::raw().only(&["ETHERSCAN_API_KEY"]);
36 if let Some((key, value)) = env_provider.iter().next()
37 && !value.trim().is_empty()
38 {
39 dict.insert(key.as_str().to_string(), value.into());
40 }
41
42 Ok(Map::from([(Config::selected_profile(), dict)]))
43 }
44}
45
46#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
48pub enum EtherscanConfigError {
49 #[error(transparent)]
50 Unresolved(#[from] UnresolvedEnvVarError),
51
52 #[error(
53 "No known Etherscan API URL for chain `{1}`. To fix this, please:\n\
54 1. Specify a `url` {0}\n\
55 2. Verify the chain `{1}` is correct"
56 )]
57 UnknownChain(String, Chain),
58
59 #[error("At least one of `url` or `chain` must be present{0}")]
60 MissingUrlOrChain(String),
61}
62
63#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(transparent)]
66pub struct EtherscanConfigs {
67 configs: BTreeMap<String, EtherscanConfig>,
68}
69
70impl EtherscanConfigs {
71 pub fn new(configs: impl IntoIterator<Item = (impl Into<String>, EtherscanConfig)>) -> Self {
73 Self { configs: configs.into_iter().map(|(name, config)| (name.into(), config)).collect() }
74 }
75
76 pub fn is_empty(&self) -> bool {
78 self.configs.is_empty()
79 }
80
81 pub fn find_chain(&self, chain: Chain) -> Option<&EtherscanConfig> {
83 self.configs.values().find(|config| config.chain == Some(chain))
84 }
85
86 pub fn resolve_for(
96 &self,
97 alias: Option<&str>,
98 api_key: Option<&str>,
99 chain: Option<Chain>,
100 ) -> Result<Option<ResolvedEtherscanConfig>, EtherscanConfigError> {
101 if let Some(alias) = alias
102 && self.contains_key(alias)
103 {
104 return self.clone().resolved().remove(alias).transpose();
105 }
106
107 if let Some(res) = chain.and_then(|chain| self.clone().resolved().find_chain(chain)) {
108 match (res, api_key) {
109 (Ok(mut config), Some(key)) => {
110 config.key = key.to_string();
111 return Ok(Some(config));
112 }
113 (Ok(config), None) => return Ok(Some(config)),
114 (Err(err), None) => return Err(err),
115 (Err(_), Some(_)) => {}
117 }
118 }
119
120 if let Some(key) = api_key {
121 return Ok(ResolvedEtherscanConfig::create(key, chain.unwrap_or_default()));
122 }
123 Ok(None)
124 }
125
126 pub fn resolved(self) -> ResolvedEtherscanConfigs {
128 ResolvedEtherscanConfigs {
129 configs: self
130 .configs
131 .into_iter()
132 .map(|(name, e)| {
133 let resolved = e.resolve(Some(&name));
134 (name, resolved)
135 })
136 .collect(),
137 }
138 }
139}
140
141impl Deref for EtherscanConfigs {
142 type Target = BTreeMap<String, EtherscanConfig>;
143
144 fn deref(&self) -> &Self::Target {
145 &self.configs
146 }
147}
148
149impl DerefMut for EtherscanConfigs {
150 fn deref_mut(&mut self) -> &mut Self::Target {
151 &mut self.configs
152 }
153}
154
155#[derive(Clone, Debug, Default, PartialEq, Eq)]
157pub struct ResolvedEtherscanConfigs {
158 configs: BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>,
161}
162
163impl ResolvedEtherscanConfigs {
164 pub fn new(
166 configs: impl IntoIterator<Item = (impl Into<String>, ResolvedEtherscanConfig)>,
167 ) -> Self {
168 Self {
169 configs: configs.into_iter().map(|(name, config)| (name.into(), Ok(config))).collect(),
170 }
171 }
172
173 pub fn find_chain(
175 self,
176 chain: Chain,
177 ) -> Option<Result<ResolvedEtherscanConfig, EtherscanConfigError>> {
178 for (_, config) in self.configs {
179 match config {
180 Ok(c) if c.chain == Some(chain) => return Some(Ok(c)),
181 Err(e) => return Some(Err(e)),
182 _ => {}
183 }
184 }
185 None
186 }
187
188 pub fn has_unresolved(&self) -> bool {
190 self.configs.values().any(|val| val.is_err())
191 }
192}
193
194impl Deref for ResolvedEtherscanConfigs {
195 type Target = BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>;
196
197 fn deref(&self) -> &Self::Target {
198 &self.configs
199 }
200}
201
202impl DerefMut for ResolvedEtherscanConfigs {
203 fn deref_mut(&mut self) -> &mut Self::Target {
204 &mut self.configs
205 }
206}
207
208#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
210pub struct EtherscanConfig {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub chain: Option<Chain>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub url: Option<String>,
217 pub key: EtherscanApiKey,
219}
220
221impl EtherscanConfig {
222 pub fn resolve(
229 self,
230 alias: Option<&str>,
231 ) -> Result<ResolvedEtherscanConfig, EtherscanConfigError> {
232 let Self { chain, mut url, key } = self;
233
234 if let Some(url) = &mut url {
235 *url = interpolate(url)?;
236 }
237
238 let (chain, alias) = match (chain, alias) {
239 (Some(chain), None) => (Some(chain), Some(chain.to_string())),
241 (None, Some(alias)) => {
242 (
244 alias.to_kebab_case().parse().ok().or_else(|| {
245 serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
248 .map(Into::into)
249 .ok()
250 }),
251 Some(alias.into()),
252 )
253 }
254 (Some(chain), Some(alias)) => (Some(chain), Some(alias.into())),
256 (None, None) => (None, None),
257 };
258 let key = key.resolve()?;
259
260 match (chain, url) {
261 (Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig {
262 api_url,
263 browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()),
264 key,
265 chain: Some(chain),
266 }),
267 (Some(chain), None) => ResolvedEtherscanConfig::create(key, chain).ok_or_else(|| {
268 let msg = alias.map(|a| format!("for `{a}`")).unwrap_or_default();
269 EtherscanConfigError::UnknownChain(msg, chain)
270 }),
271 (None, Some(api_url)) => {
272 Ok(ResolvedEtherscanConfig { api_url, browser_url: None, key, chain: None })
273 }
274 (None, None) => {
275 let msg = alias
276 .map(|a| format!(" for Etherscan config with unknown alias `{a}`"))
277 .unwrap_or_default();
278 Err(EtherscanConfigError::MissingUrlOrChain(msg))
279 }
280 }
281 }
282}
283
284#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
286pub struct ResolvedEtherscanConfig {
287 #[serde(rename = "url")]
289 pub api_url: String,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub browser_url: Option<String>,
293 pub key: String,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub chain: Option<Chain>,
298}
299
300impl ResolvedEtherscanConfig {
301 pub fn create(api_key: impl Into<String>, chain: impl Into<Chain>) -> Option<Self> {
303 let chain = chain.into();
304 let (api_url, browser_url) = chain.etherscan_urls()?;
305 Some(Self {
306 api_url: api_url.to_string(),
307 browser_url: Some(browser_url.to_string()),
308 key: api_key.into(),
309 chain: Some(chain),
310 })
311 }
312
313 pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
317 self.set_chain(chain);
318 self
319 }
320
321 pub fn set_chain(&mut self, chain: impl Into<Chain>) -> &mut Self {
323 let chain = chain.into();
324 if let Some((api, browser)) = chain.etherscan_urls() {
325 self.api_url = api.to_string();
326 self.browser_url = Some(browser.to_string());
327 }
328 self.chain = Some(chain);
329 self
330 }
331
332 pub fn into_client(
335 self,
336 ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
337 {
338 self.into_client_with_no_proxy(false)
339 }
340
341 pub fn into_client_with_no_proxy(
348 self,
349 no_proxy: bool,
350 ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
351 {
352 let Self { api_url, browser_url, key: api_key, chain } = self;
353
354 let chain = chain.unwrap_or_default();
355 let cache = Config::foundry_etherscan_chain_cache_dir(chain);
356
357 if let Some(cache_path) = &cache {
358 if let Err(err) = std::fs::create_dir_all(cache_path.join("sources")) {
360 warn!("could not create etherscan cache dir: {:?}", err);
361 }
362 }
363
364 let mut client_builder = foundry_block_explorers::Client::builder()
368 .with_api_key(api_key)
369 .with_cache(cache, Duration::from_secs(24 * 60 * 60));
370 if no_proxy {
371 client_builder = client_builder.no_proxy();
372 }
373 if let Some(ref browser_url) = browser_url {
374 client_builder = client_builder.with_url(browser_url)?;
375 }
376
377 client_builder = client_builder.with_api_url(&api_url)?;
379 if browser_url.is_none() {
381 client_builder = client_builder.with_url(&api_url)?;
382 }
383 client_builder.build()
384 }
385}
386
387#[derive(Clone, Debug, PartialEq, Eq)]
394pub enum EtherscanApiKey {
395 Key(String),
397 Env(String),
401}
402
403impl EtherscanApiKey {
404 pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
410 match self {
411 Self::Key(key) => Ok(key),
412 Self::Env(val) => interpolate(&val),
413 }
414 }
415}
416
417impl Serialize for EtherscanApiKey {
418 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
419 where
420 S: Serializer,
421 {
422 serializer.serialize_str(&self.to_string())
423 }
424}
425
426impl<'de> Deserialize<'de> for EtherscanApiKey {
427 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
428 where
429 D: Deserializer<'de>,
430 {
431 let val = String::deserialize(deserializer)?;
432 let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Key(val) };
433
434 Ok(endpoint)
435 }
436}
437
438impl fmt::Display for EtherscanApiKey {
439 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
440 match self {
441 Self::Key(key) => key.fmt(f),
442 Self::Env(var) => var.fmt(f),
443 }
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use NamedChain::Mainnet;
451
452 #[test]
453 fn can_create_client_via_chain() {
454 let mut configs = EtherscanConfigs::default();
455 configs.insert(
456 "mainnet".to_string(),
457 EtherscanConfig {
458 chain: Some(Mainnet.into()),
459 url: None,
460 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
461 },
462 );
463
464 let mut resolved = configs.resolved();
465 let config = resolved.remove("mainnet").unwrap().unwrap();
466
467 let client = config.into_client().unwrap();
468 assert_eq!(
469 client.etherscan_api_url().as_str(),
470 "https://api.etherscan.io/v2/api?chainid=1"
471 );
472 }
473
474 #[test]
475 fn can_create_client_via_url_and_chain() {
476 let mut configs = EtherscanConfigs::default();
477 configs.insert(
478 "mainnet".to_string(),
479 EtherscanConfig {
480 chain: Some(Mainnet.into()),
481 url: Some("https://api.etherscan.io/api".to_string()),
482 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
483 },
484 );
485
486 let mut resolved = configs.resolved();
487 let config = resolved.remove("mainnet").unwrap().unwrap();
488 let _ = config.into_client().unwrap();
489 }
490
491 #[test]
492 fn can_create_client_via_url_and_chain_env_var() {
493 let mut configs = EtherscanConfigs::default();
494 let env = "_CONFIG_ETHERSCAN_API_KEY";
495 configs.insert(
496 "mainnet".to_string(),
497 EtherscanConfig {
498 chain: Some(Mainnet.into()),
499 url: Some("https://api.etherscan.io/api".to_string()),
500 key: EtherscanApiKey::Env(format!("${{{env}}}")),
501 },
502 );
503
504 let mut resolved = configs.clone().resolved();
505 let config = resolved.remove("mainnet").unwrap();
506 assert!(config.is_err());
507
508 unsafe {
509 std::env::set_var(env, "ABCDEFG");
510 }
511
512 let mut resolved = configs.resolved();
513 let config = resolved.remove("mainnet").unwrap().unwrap();
514 assert_eq!(config.key, "ABCDEFG");
515 let client = config.into_client().unwrap();
516 assert_eq!(client.etherscan_api_url().as_str(), "https://api.etherscan.io/api");
518
519 unsafe {
520 std::env::remove_var(env);
521 }
522 }
523
524 #[test]
525 fn resolve_etherscan_alias_config() {
526 let mut configs = EtherscanConfigs::default();
527 configs.insert(
528 "blast_sepolia".to_string(),
529 EtherscanConfig {
530 chain: None,
531 url: Some("https://api.etherscan.io/api".to_string()),
532 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
533 },
534 );
535
536 let mut resolved = configs.clone().resolved();
537 let config = resolved.remove("blast_sepolia").unwrap().unwrap();
538 assert_eq!(config.chain, Some(Chain::blast_sepolia()));
539 }
540
541 #[test]
542 fn resolve_etherscan_alias() {
543 let config = EtherscanConfig {
544 chain: None,
545 url: Some("https://api.etherscan.io/api".to_string()),
546 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
547 };
548 let resolved = config.clone().resolve(Some("base_sepolia")).unwrap();
549 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
550
551 let resolved = config.resolve(Some("base-sepolia")).unwrap();
552 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
553 }
554
555 #[test]
556 fn can_create_client_with_custom_url_for_chain_without_default_url() {
557 let mut configs = EtherscanConfigs::default();
560 configs.insert(
561 "dev".to_string(),
562 EtherscanConfig {
563 chain: Some(Chain::dev()),
564 url: Some("https://custom.api.url/verify/etherscan".to_string()),
565 key: EtherscanApiKey::Key("test_key".to_string()),
566 },
567 );
568
569 let mut resolved = configs.resolved();
570 let config = resolved.remove("dev").unwrap().unwrap();
571 let result = config.into_client();
572 assert!(
573 result.is_ok(),
574 "Should succeed with custom URL even for chains without default Etherscan URLs"
575 );
576 }
577
578 #[test]
579 fn fails_without_custom_url_for_chain_without_default_url() {
580 let mut configs = EtherscanConfigs::default();
583 configs.insert(
584 "dev".to_string(),
585 EtherscanConfig {
586 chain: Some(Chain::dev()),
587 url: None,
588 key: EtherscanApiKey::Key("test_key".to_string()),
589 },
590 );
591
592 let mut resolved = configs.resolved();
593 let config = resolved.remove("dev").unwrap();
594
595 assert!(
596 config.is_err(),
597 "Should fail: chains without default Etherscan URLs require custom URL"
598 );
599 }
600}