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(ref browser_url) = browser_url {
323 client_builder = client_builder.with_url(browser_url)?;
324 }
325
326 client_builder = client_builder.with_api_url(api_url.clone())?;
328 if browser_url.is_none() {
330 client_builder = client_builder.with_url(api_url)?;
331 }
332 client_builder.build()
333 }
334}
335
336#[derive(Clone, Debug, PartialEq, Eq)]
343pub enum EtherscanApiKey {
344 Key(String),
346 Env(String),
350}
351
352impl EtherscanApiKey {
353 pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
359 match self {
360 Self::Key(key) => Ok(key),
361 Self::Env(val) => interpolate(&val),
362 }
363 }
364}
365
366impl Serialize for EtherscanApiKey {
367 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
368 where
369 S: Serializer,
370 {
371 serializer.serialize_str(&self.to_string())
372 }
373}
374
375impl<'de> Deserialize<'de> for EtherscanApiKey {
376 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
377 where
378 D: Deserializer<'de>,
379 {
380 let val = String::deserialize(deserializer)?;
381 let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Key(val) };
382
383 Ok(endpoint)
384 }
385}
386
387impl fmt::Display for EtherscanApiKey {
388 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
389 match self {
390 Self::Key(key) => key.fmt(f),
391 Self::Env(var) => var.fmt(f),
392 }
393 }
394}
395
396#[inline]
399fn into_url(url: impl reqwest::IntoUrl) -> std::result::Result<reqwest::Url, reqwest::Error> {
400 url.into_url()
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use NamedChain::Mainnet;
407
408 #[test]
409 fn can_create_client_via_chain() {
410 let mut configs = EtherscanConfigs::default();
411 configs.insert(
412 "mainnet".to_string(),
413 EtherscanConfig {
414 chain: Some(Mainnet.into()),
415 url: None,
416 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
417 },
418 );
419
420 let mut resolved = configs.resolved();
421 let config = resolved.remove("mainnet").unwrap().unwrap();
422
423 let client = config.into_client().unwrap();
424 assert_eq!(
425 client.etherscan_api_url().as_str(),
426 "https://api.etherscan.io/v2/api?chainid=1"
427 );
428 }
429
430 #[test]
431 fn can_create_client_via_url_and_chain() {
432 let mut configs = EtherscanConfigs::default();
433 configs.insert(
434 "mainnet".to_string(),
435 EtherscanConfig {
436 chain: Some(Mainnet.into()),
437 url: Some("https://api.etherscan.io/api".to_string()),
438 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
439 },
440 );
441
442 let mut resolved = configs.resolved();
443 let config = resolved.remove("mainnet").unwrap().unwrap();
444 let _ = config.into_client().unwrap();
445 }
446
447 #[test]
448 fn can_create_client_via_url_and_chain_env_var() {
449 let mut configs = EtherscanConfigs::default();
450 let env = "_CONFIG_ETHERSCAN_API_KEY";
451 configs.insert(
452 "mainnet".to_string(),
453 EtherscanConfig {
454 chain: Some(Mainnet.into()),
455 url: Some("https://api.etherscan.io/api".to_string()),
456 key: EtherscanApiKey::Env(format!("${{{env}}}")),
457 },
458 );
459
460 let mut resolved = configs.clone().resolved();
461 let config = resolved.remove("mainnet").unwrap();
462 assert!(config.is_err());
463
464 unsafe {
465 std::env::set_var(env, "ABCDEFG");
466 }
467
468 let mut resolved = configs.resolved();
469 let config = resolved.remove("mainnet").unwrap().unwrap();
470 assert_eq!(config.key, "ABCDEFG");
471 let client = config.into_client().unwrap();
472 assert_eq!(client.etherscan_api_url().as_str(), "https://api.etherscan.io/api");
474
475 unsafe {
476 std::env::remove_var(env);
477 }
478 }
479
480 #[test]
481 fn resolve_etherscan_alias_config() {
482 let mut configs = EtherscanConfigs::default();
483 configs.insert(
484 "blast_sepolia".to_string(),
485 EtherscanConfig {
486 chain: None,
487 url: Some("https://api.etherscan.io/api".to_string()),
488 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
489 },
490 );
491
492 let mut resolved = configs.clone().resolved();
493 let config = resolved.remove("blast_sepolia").unwrap().unwrap();
494 assert_eq!(config.chain, Some(Chain::blast_sepolia()));
495 }
496
497 #[test]
498 fn resolve_etherscan_alias() {
499 let config = EtherscanConfig {
500 chain: None,
501 url: Some("https://api.etherscan.io/api".to_string()),
502 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
503 };
504 let resolved = config.clone().resolve(Some("base_sepolia")).unwrap();
505 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
506
507 let resolved = config.resolve(Some("base-sepolia")).unwrap();
508 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
509 }
510
511 #[test]
512 fn can_create_client_with_custom_url_for_chain_without_default_url() {
513 let mut configs = EtherscanConfigs::default();
516 configs.insert(
517 "dev".to_string(),
518 EtherscanConfig {
519 chain: Some(Chain::dev()),
520 url: Some("https://custom.api.url/verify/etherscan".to_string()),
521 key: EtherscanApiKey::Key("test_key".to_string()),
522 },
523 );
524
525 let mut resolved = configs.resolved();
526 let config = resolved.remove("dev").unwrap().unwrap();
527 let result = config.into_client();
528 assert!(
529 result.is_ok(),
530 "Should succeed with custom URL even for chains without default Etherscan URLs"
531 );
532 }
533
534 #[test]
535 fn fails_without_custom_url_for_chain_without_default_url() {
536 let mut configs = EtherscanConfigs::default();
539 configs.insert(
540 "dev".to_string(),
541 EtherscanConfig {
542 chain: Some(Chain::dev()),
543 url: None,
544 key: EtherscanApiKey::Key("test_key".to_string()),
545 },
546 );
547
548 let mut resolved = configs.resolved();
549 let config = resolved.remove("dev").unwrap();
550
551 assert!(
552 config.is_err(),
553 "Should fail: chains without default Etherscan URLs require custom URL"
554 );
555 }
556}