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 resolved(self) -> ResolvedEtherscanConfigs {
88 ResolvedEtherscanConfigs {
89 configs: self
90 .configs
91 .into_iter()
92 .map(|(name, e)| {
93 let resolved = e.resolve(Some(&name));
94 (name, resolved)
95 })
96 .collect(),
97 }
98 }
99}
100
101impl Deref for EtherscanConfigs {
102 type Target = BTreeMap<String, EtherscanConfig>;
103
104 fn deref(&self) -> &Self::Target {
105 &self.configs
106 }
107}
108
109impl DerefMut for EtherscanConfigs {
110 fn deref_mut(&mut self) -> &mut Self::Target {
111 &mut self.configs
112 }
113}
114
115#[derive(Clone, Debug, Default, PartialEq, Eq)]
117pub struct ResolvedEtherscanConfigs {
118 configs: BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>,
121}
122
123impl ResolvedEtherscanConfigs {
124 pub fn new(
126 configs: impl IntoIterator<Item = (impl Into<String>, ResolvedEtherscanConfig)>,
127 ) -> Self {
128 Self {
129 configs: configs.into_iter().map(|(name, config)| (name.into(), Ok(config))).collect(),
130 }
131 }
132
133 pub fn find_chain(
135 self,
136 chain: Chain,
137 ) -> Option<Result<ResolvedEtherscanConfig, EtherscanConfigError>> {
138 for (_, config) in self.configs {
139 match config {
140 Ok(c) if c.chain == Some(chain) => return Some(Ok(c)),
141 Err(e) => return Some(Err(e)),
142 _ => {}
143 }
144 }
145 None
146 }
147
148 pub fn has_unresolved(&self) -> bool {
150 self.configs.values().any(|val| val.is_err())
151 }
152}
153
154impl Deref for ResolvedEtherscanConfigs {
155 type Target = BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>;
156
157 fn deref(&self) -> &Self::Target {
158 &self.configs
159 }
160}
161
162impl DerefMut for ResolvedEtherscanConfigs {
163 fn deref_mut(&mut self) -> &mut Self::Target {
164 &mut self.configs
165 }
166}
167
168#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
170pub struct EtherscanConfig {
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub chain: Option<Chain>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub url: Option<String>,
177 pub key: EtherscanApiKey,
179}
180
181impl EtherscanConfig {
182 pub fn resolve(
189 self,
190 alias: Option<&str>,
191 ) -> Result<ResolvedEtherscanConfig, EtherscanConfigError> {
192 let Self { chain, mut url, key } = self;
193
194 if let Some(url) = &mut url {
195 *url = interpolate(url)?;
196 }
197
198 let (chain, alias) = match (chain, alias) {
199 (Some(chain), None) => (Some(chain), Some(chain.to_string())),
201 (None, Some(alias)) => {
202 (
204 alias.to_kebab_case().parse().ok().or_else(|| {
205 serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
208 .map(Into::into)
209 .ok()
210 }),
211 Some(alias.into()),
212 )
213 }
214 (Some(chain), Some(alias)) => (Some(chain), Some(alias.into())),
216 (None, None) => (None, None),
217 };
218 let key = key.resolve()?;
219
220 match (chain, url) {
221 (Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig {
222 api_url,
223 browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()),
224 key,
225 chain: Some(chain),
226 }),
227 (Some(chain), None) => ResolvedEtherscanConfig::create(key, chain).ok_or_else(|| {
228 let msg = alias.map(|a| format!("for `{a}`")).unwrap_or_default();
229 EtherscanConfigError::UnknownChain(msg, chain)
230 }),
231 (None, Some(api_url)) => {
232 Ok(ResolvedEtherscanConfig { api_url, browser_url: None, key, chain: None })
233 }
234 (None, None) => {
235 let msg = alias
236 .map(|a| format!(" for Etherscan config with unknown alias `{a}`"))
237 .unwrap_or_default();
238 Err(EtherscanConfigError::MissingUrlOrChain(msg))
239 }
240 }
241 }
242}
243
244#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246pub struct ResolvedEtherscanConfig {
247 #[serde(rename = "url")]
249 pub api_url: String,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub browser_url: Option<String>,
253 pub key: String,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub chain: Option<Chain>,
258}
259
260impl ResolvedEtherscanConfig {
261 pub fn create(api_key: impl Into<String>, chain: impl Into<Chain>) -> Option<Self> {
263 let chain = chain.into();
264 let (api_url, browser_url) = chain.etherscan_urls()?;
265 Some(Self {
266 api_url: api_url.to_string(),
267 browser_url: Some(browser_url.to_string()),
268 key: api_key.into(),
269 chain: Some(chain),
270 })
271 }
272
273 pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
277 self.set_chain(chain);
278 self
279 }
280
281 pub fn set_chain(&mut self, chain: impl Into<Chain>) -> &mut Self {
283 let chain = chain.into();
284 if let Some((api, browser)) = chain.etherscan_urls() {
285 self.api_url = api.to_string();
286 self.browser_url = Some(browser.to_string());
287 }
288 self.chain = Some(chain);
289 self
290 }
291
292 pub fn into_client(
295 self,
296 ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
297 {
298 self.into_client_with_no_proxy(false)
299 }
300
301 pub fn into_client_with_no_proxy(
308 self,
309 no_proxy: bool,
310 ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
311 {
312 let Self { api_url, browser_url, key: api_key, chain } = self;
313
314 let chain = chain.unwrap_or_default();
315 let cache = Config::foundry_etherscan_chain_cache_dir(chain);
316
317 if let Some(cache_path) = &cache {
318 if let Err(err) = std::fs::create_dir_all(cache_path.join("sources")) {
320 warn!("could not create etherscan cache dir: {:?}", err);
321 }
322 }
323
324 let mut client_builder = foundry_block_explorers::Client::builder()
328 .with_api_key(api_key)
329 .with_cache(cache, Duration::from_secs(24 * 60 * 60));
330 if no_proxy {
331 client_builder = client_builder.no_proxy();
332 }
333 if let Some(ref browser_url) = browser_url {
334 client_builder = client_builder.with_url(browser_url)?;
335 }
336
337 client_builder = client_builder.with_api_url(&api_url)?;
339 if browser_url.is_none() {
341 client_builder = client_builder.with_url(&api_url)?;
342 }
343 client_builder.build()
344 }
345}
346
347#[derive(Clone, Debug, PartialEq, Eq)]
354pub enum EtherscanApiKey {
355 Key(String),
357 Env(String),
361}
362
363impl EtherscanApiKey {
364 pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
370 match self {
371 Self::Key(key) => Ok(key),
372 Self::Env(val) => interpolate(&val),
373 }
374 }
375}
376
377impl Serialize for EtherscanApiKey {
378 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
379 where
380 S: Serializer,
381 {
382 serializer.serialize_str(&self.to_string())
383 }
384}
385
386impl<'de> Deserialize<'de> for EtherscanApiKey {
387 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
388 where
389 D: Deserializer<'de>,
390 {
391 let val = String::deserialize(deserializer)?;
392 let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Key(val) };
393
394 Ok(endpoint)
395 }
396}
397
398impl fmt::Display for EtherscanApiKey {
399 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400 match self {
401 Self::Key(key) => key.fmt(f),
402 Self::Env(var) => var.fmt(f),
403 }
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use NamedChain::Mainnet;
411
412 #[test]
413 fn can_create_client_via_chain() {
414 let mut configs = EtherscanConfigs::default();
415 configs.insert(
416 "mainnet".to_string(),
417 EtherscanConfig {
418 chain: Some(Mainnet.into()),
419 url: None,
420 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
421 },
422 );
423
424 let mut resolved = configs.resolved();
425 let config = resolved.remove("mainnet").unwrap().unwrap();
426
427 let client = config.into_client().unwrap();
428 assert_eq!(
429 client.etherscan_api_url().as_str(),
430 "https://api.etherscan.io/v2/api?chainid=1"
431 );
432 }
433
434 #[test]
435 fn can_create_client_via_url_and_chain() {
436 let mut configs = EtherscanConfigs::default();
437 configs.insert(
438 "mainnet".to_string(),
439 EtherscanConfig {
440 chain: Some(Mainnet.into()),
441 url: Some("https://api.etherscan.io/api".to_string()),
442 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
443 },
444 );
445
446 let mut resolved = configs.resolved();
447 let config = resolved.remove("mainnet").unwrap().unwrap();
448 let _ = config.into_client().unwrap();
449 }
450
451 #[test]
452 fn can_create_client_via_url_and_chain_env_var() {
453 let mut configs = EtherscanConfigs::default();
454 let env = "_CONFIG_ETHERSCAN_API_KEY";
455 configs.insert(
456 "mainnet".to_string(),
457 EtherscanConfig {
458 chain: Some(Mainnet.into()),
459 url: Some("https://api.etherscan.io/api".to_string()),
460 key: EtherscanApiKey::Env(format!("${{{env}}}")),
461 },
462 );
463
464 let mut resolved = configs.clone().resolved();
465 let config = resolved.remove("mainnet").unwrap();
466 assert!(config.is_err());
467
468 unsafe {
469 std::env::set_var(env, "ABCDEFG");
470 }
471
472 let mut resolved = configs.resolved();
473 let config = resolved.remove("mainnet").unwrap().unwrap();
474 assert_eq!(config.key, "ABCDEFG");
475 let client = config.into_client().unwrap();
476 assert_eq!(client.etherscan_api_url().as_str(), "https://api.etherscan.io/api");
478
479 unsafe {
480 std::env::remove_var(env);
481 }
482 }
483
484 #[test]
485 fn resolve_etherscan_alias_config() {
486 let mut configs = EtherscanConfigs::default();
487 configs.insert(
488 "blast_sepolia".to_string(),
489 EtherscanConfig {
490 chain: None,
491 url: Some("https://api.etherscan.io/api".to_string()),
492 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
493 },
494 );
495
496 let mut resolved = configs.clone().resolved();
497 let config = resolved.remove("blast_sepolia").unwrap().unwrap();
498 assert_eq!(config.chain, Some(Chain::blast_sepolia()));
499 }
500
501 #[test]
502 fn resolve_etherscan_alias() {
503 let config = EtherscanConfig {
504 chain: None,
505 url: Some("https://api.etherscan.io/api".to_string()),
506 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
507 };
508 let resolved = config.clone().resolve(Some("base_sepolia")).unwrap();
509 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
510
511 let resolved = config.resolve(Some("base-sepolia")).unwrap();
512 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
513 }
514
515 #[test]
516 fn can_create_client_with_custom_url_for_chain_without_default_url() {
517 let mut configs = EtherscanConfigs::default();
520 configs.insert(
521 "dev".to_string(),
522 EtherscanConfig {
523 chain: Some(Chain::dev()),
524 url: Some("https://custom.api.url/verify/etherscan".to_string()),
525 key: EtherscanApiKey::Key("test_key".to_string()),
526 },
527 );
528
529 let mut resolved = configs.resolved();
530 let config = resolved.remove("dev").unwrap().unwrap();
531 let result = config.into_client();
532 assert!(
533 result.is_ok(),
534 "Should succeed with custom URL even for chains without default Etherscan URLs"
535 );
536 }
537
538 #[test]
539 fn fails_without_custom_url_for_chain_without_default_url() {
540 let mut configs = EtherscanConfigs::default();
543 configs.insert(
544 "dev".to_string(),
545 EtherscanConfig {
546 chain: Some(Chain::dev()),
547 url: None,
548 key: EtherscanApiKey::Key("test_key".to_string()),
549 },
550 );
551
552 let mut resolved = configs.resolved();
553 let config = resolved.remove("dev").unwrap();
554
555 assert!(
556 config.is_err(),
557 "Should fail: chains without default Etherscan URLs require custom URL"
558 );
559 }
560}