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 inflector::Inflector;
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 if !value.trim().is_empty() {
41 dict.insert(key.as_str().to_string(), value.into());
42 }
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("No known Etherscan API URL for config{0} with chain `{1}`. Please specify a `url`")]
56 UnknownChain(String, Chain),
57
58 #[error("At least one of `url` or `chain` must be present{0}")]
59 MissingUrlOrChain(String),
60}
61
62#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(transparent)]
65pub struct EtherscanConfigs {
66 configs: BTreeMap<String, EtherscanConfig>,
67}
68
69impl EtherscanConfigs {
70 pub fn new(configs: impl IntoIterator<Item = (impl Into<String>, EtherscanConfig)>) -> Self {
72 Self { configs: configs.into_iter().map(|(name, config)| (name.into(), config)).collect() }
73 }
74
75 pub fn is_empty(&self) -> bool {
77 self.configs.is_empty()
78 }
79
80 pub fn find_chain(&self, chain: Chain) -> Option<&EtherscanConfig> {
82 self.configs.values().find(|config| config.chain == Some(chain))
83 }
84
85 pub fn resolved(self) -> ResolvedEtherscanConfigs {
87 ResolvedEtherscanConfigs {
88 configs: self
89 .configs
90 .into_iter()
91 .map(|(name, e)| {
92 let resolved = e.resolve(Some(&name));
93 (name, resolved)
94 })
95 .collect(),
96 }
97 }
98}
99
100impl Deref for EtherscanConfigs {
101 type Target = BTreeMap<String, EtherscanConfig>;
102
103 fn deref(&self) -> &Self::Target {
104 &self.configs
105 }
106}
107
108impl DerefMut for EtherscanConfigs {
109 fn deref_mut(&mut self) -> &mut Self::Target {
110 &mut self.configs
111 }
112}
113
114#[derive(Clone, Debug, Default, PartialEq, Eq)]
116pub struct ResolvedEtherscanConfigs {
117 configs: BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>,
120}
121
122impl ResolvedEtherscanConfigs {
123 pub fn new(
125 configs: impl IntoIterator<Item = (impl Into<String>, ResolvedEtherscanConfig)>,
126 ) -> Self {
127 Self {
128 configs: configs.into_iter().map(|(name, config)| (name.into(), Ok(config))).collect(),
129 }
130 }
131
132 pub fn find_chain(
134 self,
135 chain: Chain,
136 ) -> Option<Result<ResolvedEtherscanConfig, EtherscanConfigError>> {
137 for (_, config) in self.configs.into_iter() {
138 match config {
139 Ok(c) if c.chain == Some(chain) => return Some(Ok(c)),
140 Err(e) => return Some(Err(e)),
141 _ => continue,
142 }
143 }
144 None
145 }
146
147 pub fn has_unresolved(&self) -> bool {
149 self.configs.values().any(|val| val.is_err())
150 }
151}
152
153impl Deref for ResolvedEtherscanConfigs {
154 type Target = BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>;
155
156 fn deref(&self) -> &Self::Target {
157 &self.configs
158 }
159}
160
161impl DerefMut for ResolvedEtherscanConfigs {
162 fn deref_mut(&mut self) -> &mut Self::Target {
163 &mut self.configs
164 }
165}
166
167#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
169pub struct EtherscanConfig {
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub chain: Option<Chain>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub url: Option<String>,
176 pub key: EtherscanApiKey,
178}
179
180impl EtherscanConfig {
181 pub fn resolve(
188 self,
189 alias: Option<&str>,
190 ) -> Result<ResolvedEtherscanConfig, EtherscanConfigError> {
191 let Self { chain, mut url, key } = self;
192
193 if let Some(url) = &mut url {
194 *url = interpolate(url)?;
195 }
196
197 let (chain, alias) = match (chain, alias) {
198 (Some(chain), None) => (Some(chain), Some(chain.to_string())),
200 (None, Some(alias)) => {
201 (
203 alias.to_kebab_case().parse().ok().or_else(|| {
204 serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
207 .map(Into::into)
208 .ok()
209 }),
210 Some(alias.into()),
211 )
212 }
213 (Some(chain), Some(alias)) => (Some(chain), Some(alias.into())),
215 (None, None) => (None, None),
216 };
217 let key = key.resolve()?;
218
219 match (chain, url) {
220 (Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig {
221 api_url,
222 browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()),
223 key,
224 chain: Some(chain),
225 }),
226 (Some(chain), None) => ResolvedEtherscanConfig::create(key, chain).ok_or_else(|| {
227 let msg = alias.map(|a| format!(" `{a}`")).unwrap_or_default();
228 EtherscanConfigError::UnknownChain(msg, chain)
229 }),
230 (None, Some(api_url)) => {
231 Ok(ResolvedEtherscanConfig { api_url, browser_url: None, key, chain: None })
232 }
233 (None, None) => {
234 let msg = alias
235 .map(|a| format!(" for Etherscan config with unknown alias `{a}`"))
236 .unwrap_or_default();
237 Err(EtherscanConfigError::MissingUrlOrChain(msg))
238 }
239 }
240 }
241}
242
243#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ResolvedEtherscanConfig {
246 #[serde(rename = "url")]
248 pub api_url: String,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub browser_url: Option<String>,
252 pub key: String,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub chain: Option<Chain>,
257}
258
259impl ResolvedEtherscanConfig {
260 pub fn create(api_key: impl Into<String>, chain: impl Into<Chain>) -> Option<Self> {
262 let chain = chain.into();
263 let (api_url, browser_url) = chain.etherscan_urls()?;
264 Some(Self {
265 api_url: api_url.to_string(),
266 browser_url: Some(browser_url.to_string()),
267 key: api_key.into(),
268 chain: Some(chain),
269 })
270 }
271
272 pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
276 self.set_chain(chain);
277 self
278 }
279
280 pub fn set_chain(&mut self, chain: impl Into<Chain>) -> &mut Self {
282 let chain = chain.into();
283 if let Some((api, browser)) = chain.etherscan_urls() {
284 self.api_url = api.to_string();
285 self.browser_url = Some(browser.to_string());
286 }
287 self.chain = Some(chain);
288 self
289 }
290
291 pub fn into_client(
294 self,
295 ) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
296 {
297 let Self { api_url, browser_url, key: api_key, chain } = self;
298 let (mainnet_api, mainnet_url) = NamedChain::Mainnet.etherscan_urls().expect("exist; qed");
299
300 let cache = chain
301 .or_else(|| (api_url == mainnet_api).then(Chain::mainnet))
303 .and_then(Config::foundry_etherscan_chain_cache_dir);
304
305 if let Some(cache_path) = &cache {
306 if let Err(err) = std::fs::create_dir_all(cache_path.join("sources")) {
308 warn!("could not create etherscan cache dir: {:?}", err);
309 }
310 }
311
312 let api_url = into_url(&api_url)?;
313 let client = reqwest::Client::builder()
314 .user_agent(ETHERSCAN_USER_AGENT)
315 .tls_built_in_root_certs(api_url.scheme() == "https")
316 .build()?;
317 foundry_block_explorers::Client::builder()
318 .with_client(client)
319 .with_api_key(api_key)
320 .with_api_url(api_url)?
321 .with_url(browser_url.as_deref().unwrap_or(mainnet_url))?
324 .with_cache(cache, Duration::from_secs(24 * 60 * 60))
325 .build()
326 }
327}
328
329#[derive(Clone, Debug, PartialEq, Eq)]
336pub enum EtherscanApiKey {
337 Key(String),
339 Env(String),
343}
344
345impl EtherscanApiKey {
346 pub fn as_key(&self) -> Option<&str> {
348 match self {
349 Self::Key(url) => Some(url),
350 Self::Env(_) => None,
351 }
352 }
353
354 pub fn as_env(&self) -> Option<&str> {
356 match self {
357 Self::Env(val) => Some(val),
358 Self::Key(_) => None,
359 }
360 }
361
362 pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
368 match self {
369 Self::Key(key) => Ok(key),
370 Self::Env(val) => interpolate(&val),
371 }
372 }
373}
374
375impl Serialize for EtherscanApiKey {
376 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
377 where
378 S: Serializer,
379 {
380 serializer.serialize_str(&self.to_string())
381 }
382}
383
384impl<'de> Deserialize<'de> for EtherscanApiKey {
385 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
386 where
387 D: Deserializer<'de>,
388 {
389 let val = String::deserialize(deserializer)?;
390 let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Key(val) };
391
392 Ok(endpoint)
393 }
394}
395
396impl fmt::Display for EtherscanApiKey {
397 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398 match self {
399 Self::Key(key) => key.fmt(f),
400 Self::Env(var) => var.fmt(f),
401 }
402 }
403}
404
405#[inline]
408fn into_url(url: impl reqwest::IntoUrl) -> std::result::Result<reqwest::Url, reqwest::Error> {
409 url.into_url()
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use NamedChain::Mainnet;
416
417 #[test]
418 fn can_create_client_via_chain() {
419 let mut configs = EtherscanConfigs::default();
420 configs.insert(
421 "mainnet".to_string(),
422 EtherscanConfig {
423 chain: Some(Mainnet.into()),
424 url: None,
425 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
426 },
427 );
428
429 let mut resolved = configs.resolved();
430 let config = resolved.remove("mainnet").unwrap().unwrap();
431 let _ = config.into_client().unwrap();
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 std::env::set_var(env, "ABCDEFG");
469
470 let mut resolved = configs.resolved();
471 let config = resolved.remove("mainnet").unwrap().unwrap();
472 assert_eq!(config.key, "ABCDEFG");
473 let _ = config.into_client().unwrap();
474
475 std::env::remove_var(env);
476 }
477
478 #[test]
479 fn resolve_etherscan_alias_config() {
480 let mut configs = EtherscanConfigs::default();
481 configs.insert(
482 "blast_sepolia".to_string(),
483 EtherscanConfig {
484 chain: None,
485 url: Some("https://api.etherscan.io/api".to_string()),
486 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
487 },
488 );
489
490 let mut resolved = configs.clone().resolved();
491 let config = resolved.remove("blast_sepolia").unwrap().unwrap();
492 assert_eq!(config.chain, Some(Chain::blast_sepolia()));
493 }
494
495 #[test]
496 fn resolve_etherscan_alias() {
497 let config = EtherscanConfig {
498 chain: None,
499 url: Some("https://api.etherscan.io/api".to_string()),
500 key: EtherscanApiKey::Key("ABCDEFG".to_string()),
501 };
502 let resolved = config.clone().resolve(Some("base_sepolia")).unwrap();
503 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
504
505 let resolved = config.resolve(Some("base-sepolia")).unwrap();
506 assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
507 }
508}