1use crate::{
4 resolve::{interpolate, UnresolvedEnvVarError, RE_PLACEHOLDER},
5 Chain, Config, NamedChain,
6};
7use figment::{
8 providers::Env,
9 value::{Dict, Map},
10 Error, Metadata, Profile, Provider,
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 if !value.trim().is_empty() {
42 dict.insert(key.as_str().to_string(), value.into());
43 }
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("No known Etherscan API URL for config{0} with chain `{1}`. Please specify a `url`")]
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, default_api_version: EtherscanApiVersion) -> ResolvedEtherscanConfigs {
88 ResolvedEtherscanConfigs {
89 configs: self
90 .configs
91 .into_iter()
92 .map(|(name, e)| {
93 let resolved = e.resolve(Some(&name), default_api_version);
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.into_iter() {
139 match config {
140 Ok(c) if c.chain == Some(chain) => return Some(Ok(c)),
141 Err(e) => return Some(Err(e)),
142 _ => continue,
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 #[serde(default, alias = "api-version", skip_serializing_if = "Option::is_none")]
179 pub api_version: Option<EtherscanApiVersion>,
180 pub key: EtherscanApiKey,
182}
183
184impl EtherscanConfig {
185 pub fn resolve(
192 self,
193 alias: Option<&str>,
194 default_api_version: EtherscanApiVersion,
195 ) -> Result<ResolvedEtherscanConfig, EtherscanConfigError> {
196 let Self { chain, mut url, key, api_version } = self;
197
198 let api_version = api_version.unwrap_or(default_api_version);
199
200 if let Some(url) = &mut url {
201 *url = interpolate(url)?;
202 }
203
204 let (chain, alias) = match (chain, alias) {
205 (Some(chain), None) => (Some(chain), Some(chain.to_string())),
207 (None, Some(alias)) => {
208 (
210 alias.to_kebab_case().parse().ok().or_else(|| {
211 serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
214 .map(Into::into)
215 .ok()
216 }),
217 Some(alias.into()),
218 )
219 }
220 (Some(chain), Some(alias)) => (Some(chain), Some(alias.into())),
222 (None, None) => (None, None),
223 };
224 let key = key.resolve()?;
225
226 match (chain, url) {
227 (Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig {
228 api_url,
229 api_version,
230 browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()),
231 key,
232 chain: Some(chain),
233 }),
234 (Some(chain), None) => ResolvedEtherscanConfig::create(key, chain, api_version)
235 .ok_or_else(|| {
236 let msg = alias.map(|a| format!(" `{a}`")).unwrap_or_default();
237 EtherscanConfigError::UnknownChain(msg, chain)
238 }),
239 (None, Some(api_url)) => Ok(ResolvedEtherscanConfig {
240 api_url,
241 browser_url: None,
242 key,
243 chain: None,
244 api_version,
245 }),
246 (None, None) => {
247 let msg = alias
248 .map(|a| format!(" for Etherscan config with unknown alias `{a}`"))
249 .unwrap_or_default();
250 Err(EtherscanConfigError::MissingUrlOrChain(msg))
251 }
252 }
253 }
254}
255
256#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
258pub struct ResolvedEtherscanConfig {
259 #[serde(rename = "url")]
261 pub api_url: String,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub browser_url: Option<String>,
265 pub key: String,
267 #[serde(default)]
269 pub api_version: EtherscanApiVersion,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub chain: Option<Chain>,
273}
274
275impl ResolvedEtherscanConfig {
276 pub fn create(
278 api_key: impl Into<String>,
279 chain: impl Into<Chain>,
280 api_version: EtherscanApiVersion,
281 ) -> Option<Self> {
282 let chain = chain.into();
283 let (api_url, browser_url) = chain.etherscan_urls()?;
284 Some(Self {
285 api_url: api_url.to_string(),
286 api_version,
287 browser_url: Some(browser_url.to_string()),
288 key: api_key.into(),
289 chain: Some(chain),
290 })
291 }
292
293 pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
297 self.set_chain(chain);
298 self
299 }
300
301 pub fn set_chain(&mut self, chain: impl Into<Chain>) -> &mut Self {
303 let chain = chain.into();
304 if let Some((api, browser)) = chain.etherscan_urls() {
305 self.api_url = api.to_string();
306 self.browser_url = Some(browser.to_string());
307 }
308 self.chain = Some(chain);
309 self
310 }
311
312 pub fn into_client(
315 self,
316 ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
317 {
318 let Self { api_url, browser_url, key: api_key, chain, api_version } = self;
319
320 let chain = chain.unwrap_or_default();
321 let cache = Config::foundry_etherscan_chain_cache_dir(chain);
322
323 if let Some(cache_path) = &cache {
324 if let Err(err) = std::fs::create_dir_all(cache_path.join("sources")) {
326 warn!("could not create etherscan cache dir: {:?}", err);
327 }
328 }
329
330 let api_url = into_url(&api_url)?;
331 let client = reqwest::Client::builder()
332 .user_agent(ETHERSCAN_USER_AGENT)
333 .tls_built_in_root_certs(api_url.scheme() == "https")
334 .build()?;
335 let mut client_builder = foundry_block_explorers::Client::builder()
336 .with_client(client)
337 .with_api_version(api_version)
338 .with_api_key(api_key)
339 .with_cache(cache, Duration::from_secs(24 * 60 * 60));
340 if let Some(browser_url) = browser_url {
341 client_builder = client_builder.with_url(browser_url)?;
342 }
343 client_builder.chain(chain)?.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 as_key(&self) -> Option<&str> {
366 match self {
367 Self::Key(url) => Some(url),
368 Self::Env(_) => None,
369 }
370 }
371
372 pub fn as_env(&self) -> Option<&str> {
374 match self {
375 Self::Env(val) => Some(val),
376 Self::Key(_) => None,
377 }
378 }
379
380 pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
386 match self {
387 Self::Key(key) => Ok(key),
388 Self::Env(val) => interpolate(&val),
389 }
390 }
391}
392
393impl Serialize for EtherscanApiKey {
394 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
395 where
396 S: Serializer,
397 {
398 serializer.serialize_str(&self.to_string())
399 }
400}
401
402impl<'de> Deserialize<'de> for EtherscanApiKey {
403 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
404 where
405 D: Deserializer<'de>,
406 {
407 let val = String::deserialize(deserializer)?;
408 let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Key(val) };
409
410 Ok(endpoint)
411 }
412}
413
414impl fmt::Display for EtherscanApiKey {
415 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416 match self {
417 Self::Key(key) => key.fmt(f),
418 Self::Env(var) => var.fmt(f),
419 }
420 }
421}
422
423#[inline]
426fn into_url(url: impl reqwest::IntoUrl) -> std::result::Result<reqwest::Url, reqwest::Error> {
427 url.into_url()
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use NamedChain::Mainnet;
434
435 #[test]
436 fn can_create_client_via_chain() {
437 let mut configs = EtherscanConfigs::default();
438 configs.insert(
439 "mainnet".to_string(),
440 EtherscanConfig {
441 chain: Some(Mainnet.into()),
442 url: None,
443 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
444 api_version: None,
445 },
446 );
447
448 let mut resolved = configs.resolved(EtherscanApiVersion::V2);
449 let config = resolved.remove("mainnet").unwrap().unwrap();
450 assert_eq!(config.api_version, EtherscanApiVersion::V2);
452 let client = config.into_client().unwrap();
453 assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V2);
454 }
455
456 #[test]
457 fn can_create_v1_client_via_chain() {
458 let mut configs = EtherscanConfigs::default();
459 configs.insert(
460 "mainnet".to_string(),
461 EtherscanConfig {
462 chain: Some(Mainnet.into()),
463 url: None,
464 api_version: Some(EtherscanApiVersion::V1),
465 key: EtherscanApiKey::Key("ABCDEG".to_string()),
466 },
467 );
468
469 let mut resolved = configs.resolved(EtherscanApiVersion::V2);
470 let config = resolved.remove("mainnet").unwrap().unwrap();
471 assert_eq!(config.api_version, EtherscanApiVersion::V1);
472 let client = config.into_client().unwrap();
473 assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V1);
474 }
475
476 #[test]
477 fn can_create_client_via_url_and_chain() {
478 let mut configs = EtherscanConfigs::default();
479 configs.insert(
480 "mainnet".to_string(),
481 EtherscanConfig {
482 chain: Some(Mainnet.into()),
483 url: Some("https://api.etherscan.io/api".to_string()),
484 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
485 api_version: None,
486 },
487 );
488
489 let mut resolved = configs.resolved(EtherscanApiVersion::V2);
490 let config = resolved.remove("mainnet").unwrap().unwrap();
491 let _ = config.into_client().unwrap();
492 }
493
494 #[test]
495 fn can_create_client_via_url_and_chain_env_var() {
496 let mut configs = EtherscanConfigs::default();
497 let env = "_CONFIG_ETHERSCAN_API_KEY";
498 configs.insert(
499 "mainnet".to_string(),
500 EtherscanConfig {
501 chain: Some(Mainnet.into()),
502 url: Some("https://api.etherscan.io/api".to_string()),
503 api_version: None,
504 key: EtherscanApiKey::Env(format!("${{{env}}}")),
505 },
506 );
507
508 let mut resolved = configs.clone().resolved(EtherscanApiVersion::V2);
509 let config = resolved.remove("mainnet").unwrap();
510 assert!(config.is_err());
511
512 std::env::set_var(env, "ABCDEFG");
513
514 let mut resolved = configs.resolved(EtherscanApiVersion::V2);
515 let config = resolved.remove("mainnet").unwrap().unwrap();
516 assert_eq!(config.key, "ABCDEFG");
517 let client = config.into_client().unwrap();
518 assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V2);
519
520 std::env::remove_var(env);
521 }
522
523 #[test]
524 fn resolve_etherscan_alias_config() {
525 let mut configs = EtherscanConfigs::default();
526 configs.insert(
527 "blast_sepolia".to_string(),
528 EtherscanConfig {
529 chain: None,
530 url: Some("https://api.etherscan.io/api".to_string()),
531 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
532 api_version: None,
533 },
534 );
535
536 let mut resolved = configs.clone().resolved(EtherscanApiVersion::V2);
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 api_version: None,
548 };
549 let resolved =
550 config.clone().resolve(Some("base_sepolia"), EtherscanApiVersion::V2).unwrap();
551 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
552
553 let resolved = config.resolve(Some("base-sepolia"), EtherscanApiVersion::V2).unwrap();
554 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
555 }
556}