1#![doc = include_str!("../README.md")]
2#![warn(
3 missing_copy_implementations,
4 missing_debug_implementations,
5 unreachable_pub,
8 rustdoc::all
9)]
10#![cfg_attr(not(test), warn(unused_crate_dependencies))]
11#![deny(unused_must_use, rust_2018_idioms)]
12#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
13
14#[macro_use]
15extern crate tracing;
16
17use crate::errors::{is_blocked_by_cloudflare_response, is_cloudflare_security_challenge};
18use alloy_chains::{Chain, ChainKind, NamedChain};
19use alloy_json_abi::JsonAbi;
20use alloy_primitives::{Address, B256};
21use contract::ContractMetadata;
22use errors::EtherscanError;
23use reqwest::{header, IntoUrl, Url};
24use serde::{de::DeserializeOwned, Deserialize, Serialize};
25use std::{
26 borrow::Cow,
27 collections::HashMap,
28 io::Write,
29 path::PathBuf,
30 str::FromStr,
31 time::{Duration, SystemTime, UNIX_EPOCH},
32};
33
34pub mod account;
35pub mod block_number;
36pub mod blocks;
37pub mod contract;
38pub mod errors;
39pub mod gas;
40pub mod serde_helpers;
41pub mod source_tree;
42mod transaction;
43pub mod units;
44pub mod utils;
45pub mod verify;
46
47pub(crate) type Result<T, E = EtherscanError> = std::result::Result<T, E>;
48
49pub const ETHERSCAN_V2_API_BASE_URL: &str = "https://api.etherscan.io/v2/api";
51
52#[derive(Clone, Default, Debug, PartialEq, Copy, Eq, Deserialize, Serialize)]
55#[serde(rename_all = "lowercase")]
56pub enum EtherscanApiVersion {
57 V1,
58 #[default]
59 V2,
60}
61
62impl std::fmt::Display for EtherscanApiVersion {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 EtherscanApiVersion::V1 => write!(f, "v1"),
66 EtherscanApiVersion::V2 => write!(f, "v2"),
67 }
68 }
69}
70
71impl TryFrom<String> for EtherscanApiVersion {
72 type Error = EtherscanError;
73
74 fn try_from(value: String) -> Result<Self, Self::Error> {
75 Self::from_str(value.as_str())
76 }
77}
78
79impl FromStr for EtherscanApiVersion {
80 type Err = EtherscanError;
81
82 fn from_str(value: &str) -> Result<Self, Self::Err> {
83 match value {
84 "v1" | "V1" => Ok(EtherscanApiVersion::V1),
85 "v2" | "V2" => Ok(EtherscanApiVersion::V2),
86 _ => Err(EtherscanError::InvalidApiVersion),
87 }
88 }
89}
90
91#[derive(Clone, Debug)]
93pub struct Client {
94 client: reqwest::Client,
96 api_key: Option<String>,
98 etherscan_api_version: EtherscanApiVersion,
100 etherscan_api_url: Url,
102 etherscan_url: Url,
104 cache: Option<Cache>,
106 chain_id: Option<u64>,
108}
109
110impl Client {
111 pub fn builder() -> ClientBuilder {
128 ClientBuilder::default()
129 }
130
131 pub fn new_cached(
133 chain: Chain,
134 api_key: impl Into<String>,
135 cache_root: Option<PathBuf>,
136 cache_ttl: Duration,
137 ) -> Result<Self> {
138 let mut this = Self::new(chain, api_key)?;
139 this.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
140 Ok(this)
141 }
142
143 pub fn new(chain: Chain, api_key: impl Into<String>) -> Result<Self> {
145 Client::builder().with_api_key(api_key).chain(chain)?.build()
146 }
147
148 pub fn new_with_api_version(
150 chain: Chain,
151 api_key: impl Into<String>,
152 api_version: EtherscanApiVersion,
153 ) -> Result<Self> {
154 Client::builder().with_api_key(api_key).with_api_version(api_version).chain(chain)?.build()
155 }
156
157 pub fn new_from_env(chain: Chain) -> Result<Self> {
159 Self::new_with_api_version(
160 chain,
161 get_api_key_from_chain(chain, EtherscanApiVersion::V2)?,
162 EtherscanApiVersion::V2,
163 )
164 }
165
166 pub fn new_v1_from_env(chain: Chain) -> Result<Self> {
169 Self::new_with_api_version(
170 chain,
171 get_api_key_from_chain(chain, EtherscanApiVersion::V1)?,
172 EtherscanApiVersion::V1,
173 )
174 }
175
176 pub fn new_from_opt_env(chain: Chain) -> Result<Self> {
181 match Self::new_from_env(chain) {
182 Ok(client) => Ok(client),
183 Err(EtherscanError::EnvVarNotFound(_)) => {
184 Self::builder().chain(chain).and_then(|c| c.build())
185 }
186 Err(e) => Err(e),
187 }
188 }
189
190 pub fn set_cache(&mut self, root: impl Into<PathBuf>, ttl: Duration) -> &mut Self {
192 self.cache = Some(Cache { root: root.into(), ttl });
193 self
194 }
195
196 pub fn etherscan_api_version(&self) -> &EtherscanApiVersion {
198 &self.etherscan_api_version
199 }
200
201 pub fn etherscan_api_url(&self) -> &Url {
202 &self.etherscan_api_url
203 }
204
205 pub fn etherscan_url(&self) -> &Url {
206 &self.etherscan_url
207 }
208
209 pub fn api_key(&self) -> Option<&str> {
211 self.api_key.as_deref()
212 }
213
214 pub fn block_url(&self, block: u64) -> String {
216 self.etherscan_url.join(&format!("block/{block}")).unwrap().to_string()
217 }
218
219 pub fn address_url(&self, address: Address) -> String {
221 self.etherscan_url.join(&format!("address/{address:?}")).unwrap().to_string()
222 }
223
224 pub fn transaction_url(&self, tx_hash: B256) -> String {
226 self.etherscan_url.join(&format!("tx/{tx_hash:?}")).unwrap().to_string()
227 }
228
229 pub fn token_url(&self, token_hash: Address) -> String {
231 self.etherscan_url.join(&format!("token/{token_hash:?}")).unwrap().to_string()
232 }
233
234 async fn get_json<T: DeserializeOwned, Q: Serialize>(&self, query: &Q) -> Result<Response<T>> {
236 let res = self.get(query).await?;
237 self.sanitize_response(res)
238 }
239
240 async fn get<Q: Serialize>(&self, query: &Q) -> Result<String> {
242 trace!(target: "etherscan", "GET {}", self.etherscan_api_url);
243 let response = self
244 .client
245 .get(self.etherscan_api_url.clone())
246 .header(header::ACCEPT, "application/json")
247 .query(query)
248 .send()
249 .await?
250 .text()
251 .await?;
252 Ok(response)
253 }
254
255 async fn post_form<T: DeserializeOwned, F: Serialize>(&self, form: &F) -> Result<Response<T>> {
257 let res = self.post(form).await?;
258 self.sanitize_response(res)
259 }
260
261 async fn post<F: Serialize>(&self, form: &F) -> Result<String> {
263 trace!(target: "etherscan", "POST {}", self.etherscan_api_url);
264
265 let mut post_query = HashMap::new();
266
267 if self.etherscan_api_version == EtherscanApiVersion::V2
268 && self.chain_id.is_some()
269 && !self.url_contains_chainid()
270 {
271 post_query.insert("chainid", self.chain_id.unwrap());
272 }
273
274 let response = self
275 .client
276 .post(self.etherscan_api_url.clone())
277 .form(form)
278 .query(&post_query)
279 .send()
280 .await?
281 .text()
282 .await?;
283
284 Ok(response)
285 }
286
287 fn sanitize_response<T: DeserializeOwned>(&self, res: impl AsRef<str>) -> Result<Response<T>> {
289 let res = res.as_ref();
290 let res: ResponseData<T> = serde_json::from_str(res).map_err(|error| {
291 error!(target: "etherscan", ?res, "Failed to deserialize response: {}", error);
292 if res == "Page not found" {
293 EtherscanError::PageNotFound
294 } else if is_blocked_by_cloudflare_response(res) {
295 EtherscanError::BlockedByCloudflare
296 } else if is_cloudflare_security_challenge(res) {
297 EtherscanError::CloudFlareSecurityChallenge
298 } else {
299 EtherscanError::Serde { error, content: res.to_string() }
300 }
301 })?;
302
303 match res {
304 ResponseData::Error { result, message, status } => {
305 if let Some(ref result) = result {
306 if result.starts_with("Max rate limit reached") {
307 return Err(EtherscanError::RateLimitExceeded);
308 } else if result.to_lowercase().contains("invalid api key") {
309 return Err(EtherscanError::InvalidApiKey);
310 }
311 }
312 Err(EtherscanError::ErrorResponse { status, message, result })
313 }
314 ResponseData::Success(res) => Ok(res),
315 }
316 }
317
318 fn create_query<T: Serialize>(
319 &self,
320 module: &'static str,
321 action: &'static str,
322 other: T,
323 ) -> Query<'_, T> {
324 Query {
325 apikey: self.api_key.as_deref().map(Cow::Borrowed),
326 module: Cow::Borrowed(module),
327 action: Cow::Borrowed(action),
328 chain_id: if self.url_contains_chainid() { None } else { self.chain_id },
329 other,
330 }
331 }
332
333 fn url_contains_chainid(&self) -> bool {
334 self.etherscan_api_url.query_pairs().any(|(key, _)| key.eq_ignore_ascii_case("chainid"))
335 }
336}
337
338#[derive(Clone, Debug, Default)]
339pub struct ClientBuilder {
340 client: Option<reqwest::Client>,
342 api_key: Option<String>,
344 etherscan_api_url: Option<Url>,
346 etherscan_api_version: EtherscanApiVersion,
348 etherscan_url: Option<Url>,
350 cache: Option<Cache>,
352 chain_id: Option<u64>,
354}
355
356impl ClientBuilder {
359 pub fn chain(self, chain: Chain) -> Result<Self> {
367 fn urls(
368 (api, url): (impl IntoUrl, impl IntoUrl),
369 ) -> (reqwest::Result<Url>, reqwest::Result<Url>) {
370 (api.into_url(), url.into_url())
371 }
372 let (default_etherscan_api_url, etherscan_url) = chain
373 .named()
374 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?
375 .etherscan_urls()
376 .map(urls)
377 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?;
378
379 let etherscan_api_url = if self.etherscan_api_version == EtherscanApiVersion::V2 {
381 Url::parse(ETHERSCAN_V2_API_BASE_URL)
382 .map_err(|_| EtherscanError::Builder("Bad URL Parse".into()))?
383 } else {
384 default_etherscan_api_url?
385 };
386
387 self.with_chain_id(chain).with_api_url(etherscan_api_url)?.with_url(etherscan_url?)
388 }
389
390 pub fn with_api_version(mut self, api_version: EtherscanApiVersion) -> Self {
392 self.etherscan_api_version = api_version;
393 self
394 }
395
396 pub fn with_url(mut self, etherscan_url: impl IntoUrl) -> Result<Self> {
402 self.etherscan_url = Some(into_url(etherscan_url)?);
403 Ok(self)
404 }
405
406 pub fn with_client(mut self, client: reqwest::Client) -> Self {
408 self.client = Some(client);
409 self
410 }
411
412 pub fn with_api_url(mut self, etherscan_api_url: impl IntoUrl) -> Result<Self> {
418 self.etherscan_api_url = Some(into_url(etherscan_api_url)?);
419 Ok(self)
420 }
421
422 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
424 self.api_key = Some(api_key.into()).filter(|s| !s.is_empty());
425 self
426 }
427
428 pub fn with_cache(mut self, cache_root: Option<PathBuf>, cache_ttl: Duration) -> Self {
430 self.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
431 self
432 }
433
434 pub fn with_chain_id(mut self, chain: Chain) -> Self {
436 self.chain_id = Some(chain.id());
437 self
438 }
439
440 pub fn get_chain(&self) -> Option<Chain> {
442 self.chain_id.map(Chain::from_id)
443 }
444
445 pub fn build(self) -> Result<Client> {
453 let ClientBuilder {
454 client,
455 api_key,
456 etherscan_api_version,
457 etherscan_api_url,
458 etherscan_url,
459 cache,
460 chain_id,
461 } = self;
462
463 let client = Client {
464 client: client.unwrap_or_default(),
465 api_key,
466 etherscan_api_url: etherscan_api_url
467 .clone()
468 .ok_or_else(|| EtherscanError::Builder("etherscan api url".to_string()))?,
469 etherscan_api_version,
471 etherscan_url: etherscan_url
472 .ok_or_else(|| EtherscanError::Builder("etherscan url".to_string()))?,
473 cache,
474 chain_id,
475 };
476 Ok(client)
477 }
478}
479
480#[derive(Clone, Debug, Deserialize, Serialize)]
483struct CacheEnvelope<T> {
484 expiry: u64,
487 data: T,
489}
490
491#[derive(Clone, Debug)]
502struct Cache {
503 root: PathBuf,
505 ttl: Duration,
507}
508
509impl Cache {
510 fn new(root: PathBuf, ttl: Duration) -> Self {
511 Self { root, ttl }
512 }
513
514 fn get_abi(&self, address: Address) -> Option<Option<JsonAbi>> {
515 self.get("abi", address)
516 }
517
518 fn set_abi(&self, address: Address, abi: Option<&JsonAbi>) {
519 self.set("abi", address, abi)
520 }
521
522 fn get_source(&self, address: Address) -> Option<Option<ContractMetadata>> {
523 self.get("sources", address)
524 }
525
526 fn set_source(&self, address: Address, source: Option<&ContractMetadata>) {
527 self.set("sources", address, source)
528 }
529
530 fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) {
531 let path = self.root.join(prefix);
533 if std::fs::create_dir_all(&path).is_err() {
534 return;
535 }
536
537 let path = path.join(format!("{address:?}.json"));
538 let writer = std::fs::File::create(path).ok().map(std::io::BufWriter::new);
539 if let Some(mut writer) = writer {
540 let _ = serde_json::to_writer(
541 &mut writer,
542 &CacheEnvelope {
543 expiry: SystemTime::now()
544 .checked_add(self.ttl)
545 .expect("cache ttl overflowed")
546 .duration_since(UNIX_EPOCH)
547 .expect("system time is before unix epoch")
548 .as_secs(),
549 data: item,
550 },
551 );
552 let _ = writer.flush();
553 }
554 }
555
556 fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Option<T> {
557 let path = self.root.join(prefix).join(format!("{address:?}.json"));
558
559 let Ok(contents) = std::fs::read_to_string(path) else {
560 return None;
561 };
562
563 let Ok(inner) = serde_json::from_str::<CacheEnvelope<T>>(&contents) else {
564 return None;
565 };
566
567 SystemTime::now()
569 .duration_since(UNIX_EPOCH)
570 .expect("system time is before unix epoch")
571 .lt(&Duration::from_secs(inner.expiry))
574 .then_some(inner.data)
577 }
578}
579
580#[derive(Debug, Clone, Deserialize)]
582pub struct Response<T> {
583 pub status: String,
584 pub message: String,
585 pub result: T,
586}
587
588#[derive(Deserialize, Debug, Clone)]
589#[serde(untagged)]
590pub enum ResponseData<T> {
591 Success(Response<T>),
592 Error { status: String, message: String, result: Option<String> },
593}
594
595#[derive(Clone, Debug, Serialize)]
597struct Query<'a, T: Serialize> {
598 #[serde(skip_serializing_if = "Option::is_none")]
599 apikey: Option<Cow<'a, str>>,
600 module: Cow<'a, str>,
601 action: Cow<'a, str>,
602 #[serde(rename = "chainId", skip_serializing_if = "Option::is_none")]
603 chain_id: Option<u64>,
604 #[serde(flatten)]
605 other: T,
606}
607
608#[inline]
611fn into_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
612 url.into_url()
613}
614
615fn get_api_key_from_chain(
616 chain: Chain,
617 api_version: EtherscanApiVersion,
618) -> Result<String, EtherscanError> {
619 match chain.kind() {
620 ChainKind::Named(named) => match named {
621 NamedChain::Fantom | NamedChain::FantomTestnet => std::env::var("FMTSCAN_API_KEY")
623 .or_else(|_| std::env::var("FANTOMSCAN_API_KEY"))
624 .map_err(Into::into),
625
626 NamedChain::Gnosis
628 | NamedChain::Chiado
629 | NamedChain::Sepolia
630 | NamedChain::Rsk
631 | NamedChain::Sokol
632 | NamedChain::Poa
633 | NamedChain::Oasis
634 | NamedChain::Emerald
635 | NamedChain::EmeraldTestnet
636 | NamedChain::Evmos
637 | NamedChain::EvmosTestnet => Ok(String::new()),
638 NamedChain::AnvilHardhat | NamedChain::Dev => {
639 Err(EtherscanError::LocalNetworksNotSupported)
640 }
641
642 _ => {
645 if api_version == EtherscanApiVersion::V1 {
646 named
647 .etherscan_api_key_name()
648 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))
649 .and_then(|key_name| std::env::var(key_name).map_err(Into::into))
650 } else {
651 std::env::var("ETHERSCAN_API_KEY").map_err(Into::into)
652 }
653 }
654 },
655 ChainKind::Id(_) => Err(EtherscanError::ChainNotSupported(chain)),
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use crate::{Client, EtherscanApiVersion, EtherscanError, ResponseData};
662 use alloy_chains::Chain;
663 use alloy_primitives::{Address, B256};
664
665 #[test]
667 fn can_parse_block_scout_err() {
668 let err = "{\"message\":\"Something went wrong.\",\"result\":null,\"status\":\"0\"}";
669 let resp: ResponseData<Address> = serde_json::from_str(err).unwrap();
670 assert!(matches!(resp, ResponseData::Error { .. }));
671 }
672
673 #[test]
674 fn test_api_paths_v1() {
675 let client =
676 Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V1).unwrap();
677 assert_eq!(client.etherscan_api_url.as_str(), "https://api-goerli.etherscan.io/api");
678
679 assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
680 }
681
682 #[test]
683 fn test_api_paths_v2() {
684 let client =
685 Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V2).unwrap();
686 assert_eq!(client.etherscan_api_url.as_str(), "https://api.etherscan.io/v2/api");
687
688 assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
689 }
690
691 #[test]
692 fn stringifies_block_url() {
693 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
694 let block: u64 = 1;
695 let block_url: String = etherscan.block_url(block);
696 assert_eq!(block_url, format!("https://etherscan.io/block/{block}"));
697 }
698
699 #[test]
700 fn stringifies_address_url() {
701 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
702 let addr: Address = Address::ZERO;
703 let address_url: String = etherscan.address_url(addr);
704 assert_eq!(address_url, format!("https://etherscan.io/address/{addr:?}"));
705 }
706
707 #[test]
708 fn stringifies_transaction_url() {
709 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
710 let tx_hash = B256::ZERO;
711 let tx_url: String = etherscan.transaction_url(tx_hash);
712 assert_eq!(tx_url, format!("https://etherscan.io/tx/{tx_hash:?}"));
713 }
714
715 #[test]
716 fn stringifies_token_url() {
717 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
718 let token_hash = Address::ZERO;
719 let token_url: String = etherscan.token_url(token_hash);
720 assert_eq!(token_url, format!("https://etherscan.io/token/{token_hash:?}"));
721 }
722
723 #[test]
724 fn local_networks_not_supported() {
725 let err = Client::new_from_env(Chain::dev()).unwrap_err();
726 assert!(matches!(err, EtherscanError::LocalNetworksNotSupported));
727 }
728
729 #[test]
730 fn can_parse_etherscan_mainnet_invalid_api_key() {
731 let err = serde_json::json!({
732 "status":"0",
733 "message":"NOTOK",
734 "result":"Missing/Invalid API Key"
735 });
736 let resp: ResponseData<Address> = serde_json::from_value(err).unwrap();
737 assert!(matches!(resp, ResponseData::Error { .. }));
738 }
739
740 #[test]
741 fn can_parse_api_version() {
742 assert_eq!(
743 EtherscanApiVersion::try_from("v1".to_string()).unwrap(),
744 EtherscanApiVersion::V1
745 );
746 assert_eq!(
747 EtherscanApiVersion::try_from("v2".to_string()).unwrap(),
748 EtherscanApiVersion::V2
749 );
750
751 let parse_err = EtherscanApiVersion::try_from("fail".to_string()).unwrap_err();
752 assert!(matches!(parse_err, EtherscanError::InvalidApiVersion));
753 }
754}