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 post_query = match self.chain_id {
266 Some(chain_id) if self.etherscan_api_version == EtherscanApiVersion::V2 => {
267 HashMap::from([("chainid", chain_id)])
268 }
269 _ => HashMap::new(),
270 };
271
272 let response = self
273 .client
274 .post(self.etherscan_api_url.clone())
275 .form(form)
276 .query(&post_query)
277 .send()
278 .await?
279 .text()
280 .await?;
281 Ok(response)
282 }
283
284 fn sanitize_response<T: DeserializeOwned>(&self, res: impl AsRef<str>) -> Result<Response<T>> {
286 let res = res.as_ref();
287 let res: ResponseData<T> = serde_json::from_str(res).map_err(|error| {
288 error!(target: "etherscan", ?res, "Failed to deserialize response: {}", error);
289 if res == "Page not found" {
290 EtherscanError::PageNotFound
291 } else if is_blocked_by_cloudflare_response(res) {
292 EtherscanError::BlockedByCloudflare
293 } else if is_cloudflare_security_challenge(res) {
294 EtherscanError::CloudFlareSecurityChallenge
295 } else {
296 EtherscanError::Serde { error, content: res.to_string() }
297 }
298 })?;
299
300 match res {
301 ResponseData::Error { result, message, status } => {
302 if let Some(ref result) = result {
303 if result.starts_with("Max rate limit reached") {
304 return Err(EtherscanError::RateLimitExceeded);
305 } else if result.to_lowercase().contains("invalid api key") {
306 return Err(EtherscanError::InvalidApiKey);
307 }
308 }
309 Err(EtherscanError::ErrorResponse { status, message, result })
310 }
311 ResponseData::Success(res) => Ok(res),
312 }
313 }
314
315 fn create_query<T: Serialize>(
316 &self,
317 module: &'static str,
318 action: &'static str,
319 other: T,
320 ) -> Query<'_, T> {
321 Query {
322 apikey: self.api_key.as_deref().map(Cow::Borrowed),
323 module: Cow::Borrowed(module),
324 action: Cow::Borrowed(action),
325 chain_id: self.chain_id,
326 other,
327 }
328 }
329}
330
331#[derive(Clone, Debug, Default)]
332pub struct ClientBuilder {
333 client: Option<reqwest::Client>,
335 api_key: Option<String>,
337 etherscan_api_url: Option<Url>,
339 etherscan_api_version: EtherscanApiVersion,
341 etherscan_url: Option<Url>,
343 cache: Option<Cache>,
345 chain_id: Option<u64>,
347}
348
349impl ClientBuilder {
352 pub fn chain(self, chain: Chain) -> Result<Self> {
360 fn urls(
361 (api, url): (impl IntoUrl, impl IntoUrl),
362 ) -> (reqwest::Result<Url>, reqwest::Result<Url>) {
363 (api.into_url(), url.into_url())
364 }
365 let (default_etherscan_api_url, etherscan_url) = chain
366 .named()
367 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?
368 .etherscan_urls()
369 .map(urls)
370 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?;
371
372 let etherscan_api_url = if self.etherscan_api_version == EtherscanApiVersion::V2 {
374 Url::parse(ETHERSCAN_V2_API_BASE_URL)
375 .map_err(|_| EtherscanError::Builder("Bad URL Parse".into()))?
376 } else {
377 default_etherscan_api_url?
378 };
379
380 self.with_chain_id(chain).with_api_url(etherscan_api_url)?.with_url(etherscan_url?)
381 }
382
383 pub fn with_api_version(mut self, api_version: EtherscanApiVersion) -> Self {
385 self.etherscan_api_version = api_version;
386 self
387 }
388
389 pub fn with_url(mut self, etherscan_url: impl IntoUrl) -> Result<Self> {
395 self.etherscan_url = Some(into_url(etherscan_url)?);
396 Ok(self)
397 }
398
399 pub fn with_client(mut self, client: reqwest::Client) -> Self {
401 self.client = Some(client);
402 self
403 }
404
405 pub fn with_api_url(mut self, etherscan_api_url: impl IntoUrl) -> Result<Self> {
411 self.etherscan_api_url = Some(into_url(etherscan_api_url)?);
412 Ok(self)
413 }
414
415 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
417 self.api_key = Some(api_key.into()).filter(|s| !s.is_empty());
418 self
419 }
420
421 pub fn with_cache(mut self, cache_root: Option<PathBuf>, cache_ttl: Duration) -> Self {
423 self.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
424 self
425 }
426
427 pub fn with_chain_id(mut self, chain: Chain) -> Self {
429 self.chain_id = Some(chain.id());
430 self
431 }
432
433 pub fn get_chain(&self) -> Option<Chain> {
435 self.chain_id.map(Chain::from_id)
436 }
437
438 pub fn build(self) -> Result<Client> {
446 let ClientBuilder {
447 client,
448 api_key,
449 etherscan_api_version,
450 etherscan_api_url,
451 etherscan_url,
452 cache,
453 chain_id,
454 } = self;
455
456 let client = Client {
457 client: client.unwrap_or_default(),
458 api_key,
459 etherscan_api_url: etherscan_api_url
460 .clone()
461 .ok_or_else(|| EtherscanError::Builder("etherscan api url".to_string()))?,
462 etherscan_api_version,
464 etherscan_url: etherscan_url
465 .ok_or_else(|| EtherscanError::Builder("etherscan url".to_string()))?,
466 cache,
467 chain_id,
468 };
469 Ok(client)
470 }
471}
472
473#[derive(Clone, Debug, Deserialize, Serialize)]
476struct CacheEnvelope<T> {
477 expiry: u64,
480 data: T,
482}
483
484#[derive(Clone, Debug)]
495struct Cache {
496 root: PathBuf,
498 ttl: Duration,
500}
501
502impl Cache {
503 fn new(root: PathBuf, ttl: Duration) -> Self {
504 Self { root, ttl }
505 }
506
507 fn get_abi(&self, address: Address) -> Option<Option<JsonAbi>> {
508 self.get("abi", address)
509 }
510
511 fn set_abi(&self, address: Address, abi: Option<&JsonAbi>) {
512 self.set("abi", address, abi)
513 }
514
515 fn get_source(&self, address: Address) -> Option<Option<ContractMetadata>> {
516 self.get("sources", address)
517 }
518
519 fn set_source(&self, address: Address, source: Option<&ContractMetadata>) {
520 self.set("sources", address, source)
521 }
522
523 fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) {
524 let path = self.root.join(prefix);
526 if std::fs::create_dir_all(&path).is_err() {
527 return;
528 }
529
530 let path = path.join(format!("{address:?}.json"));
531 let writer = std::fs::File::create(path).ok().map(std::io::BufWriter::new);
532 if let Some(mut writer) = writer {
533 let _ = serde_json::to_writer(
534 &mut writer,
535 &CacheEnvelope {
536 expiry: SystemTime::now()
537 .checked_add(self.ttl)
538 .expect("cache ttl overflowed")
539 .duration_since(UNIX_EPOCH)
540 .expect("system time is before unix epoch")
541 .as_secs(),
542 data: item,
543 },
544 );
545 let _ = writer.flush();
546 }
547 }
548
549 fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Option<T> {
550 let path = self.root.join(prefix).join(format!("{address:?}.json"));
551
552 let Ok(contents) = std::fs::read_to_string(path) else {
553 return None;
554 };
555
556 let Ok(inner) = serde_json::from_str::<CacheEnvelope<T>>(&contents) else {
557 return None;
558 };
559
560 SystemTime::now()
562 .duration_since(UNIX_EPOCH)
563 .expect("system time is before unix epoch")
564 .lt(&Duration::from_secs(inner.expiry))
567 .then_some(inner.data)
570 }
571}
572
573#[derive(Debug, Clone, Deserialize)]
575pub struct Response<T> {
576 pub status: String,
577 pub message: String,
578 pub result: T,
579}
580
581#[derive(Deserialize, Debug, Clone)]
582#[serde(untagged)]
583pub enum ResponseData<T> {
584 Success(Response<T>),
585 Error { status: String, message: String, result: Option<String> },
586}
587
588#[derive(Clone, Debug, Serialize)]
590struct Query<'a, T: Serialize> {
591 #[serde(skip_serializing_if = "Option::is_none")]
592 apikey: Option<Cow<'a, str>>,
593 module: Cow<'a, str>,
594 action: Cow<'a, str>,
595 #[serde(rename = "chainId", skip_serializing_if = "Option::is_none")]
596 chain_id: Option<u64>,
597 #[serde(flatten)]
598 other: T,
599}
600
601#[inline]
604fn into_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
605 url.into_url()
606}
607
608fn get_api_key_from_chain(
609 chain: Chain,
610 api_version: EtherscanApiVersion,
611) -> Result<String, EtherscanError> {
612 match chain.kind() {
613 ChainKind::Named(named) => match named {
614 NamedChain::Fantom | NamedChain::FantomTestnet => std::env::var("FMTSCAN_API_KEY")
616 .or_else(|_| std::env::var("FANTOMSCAN_API_KEY"))
617 .map_err(Into::into),
618
619 NamedChain::Gnosis
621 | NamedChain::Chiado
622 | NamedChain::Sepolia
623 | NamedChain::Rsk
624 | NamedChain::Sokol
625 | NamedChain::Poa
626 | NamedChain::Oasis
627 | NamedChain::Emerald
628 | NamedChain::EmeraldTestnet
629 | NamedChain::Evmos
630 | NamedChain::EvmosTestnet => Ok(String::new()),
631 NamedChain::AnvilHardhat | NamedChain::Dev => {
632 Err(EtherscanError::LocalNetworksNotSupported)
633 }
634
635 _ => {
638 if api_version == EtherscanApiVersion::V1 {
639 named
640 .etherscan_api_key_name()
641 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))
642 .and_then(|key_name| std::env::var(key_name).map_err(Into::into))
643 } else {
644 std::env::var("ETHERSCAN_API_KEY").map_err(Into::into)
645 }
646 }
647 },
648 ChainKind::Id(_) => Err(EtherscanError::ChainNotSupported(chain)),
649 }
650}
651
652#[cfg(test)]
653mod tests {
654 use crate::{Client, EtherscanApiVersion, EtherscanError, ResponseData};
655 use alloy_chains::Chain;
656 use alloy_primitives::{Address, B256};
657
658 #[test]
660 fn can_parse_block_scout_err() {
661 let err = "{\"message\":\"Something went wrong.\",\"result\":null,\"status\":\"0\"}";
662 let resp: ResponseData<Address> = serde_json::from_str(err).unwrap();
663 assert!(matches!(resp, ResponseData::Error { .. }));
664 }
665
666 #[test]
667 fn test_api_paths_v1() {
668 let client =
669 Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V1).unwrap();
670 assert_eq!(client.etherscan_api_url.as_str(), "https://api-goerli.etherscan.io/api");
671
672 assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
673 }
674
675 #[test]
676 fn test_api_paths_v2() {
677 let client =
678 Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V2).unwrap();
679 assert_eq!(client.etherscan_api_url.as_str(), "https://api.etherscan.io/v2/api");
680
681 assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
682 }
683
684 #[test]
685 fn stringifies_block_url() {
686 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
687 let block: u64 = 1;
688 let block_url: String = etherscan.block_url(block);
689 assert_eq!(block_url, format!("https://etherscan.io/block/{block}"));
690 }
691
692 #[test]
693 fn stringifies_address_url() {
694 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
695 let addr: Address = Address::ZERO;
696 let address_url: String = etherscan.address_url(addr);
697 assert_eq!(address_url, format!("https://etherscan.io/address/{addr:?}"));
698 }
699
700 #[test]
701 fn stringifies_transaction_url() {
702 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
703 let tx_hash = B256::ZERO;
704 let tx_url: String = etherscan.transaction_url(tx_hash);
705 assert_eq!(tx_url, format!("https://etherscan.io/tx/{tx_hash:?}"));
706 }
707
708 #[test]
709 fn stringifies_token_url() {
710 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
711 let token_hash = Address::ZERO;
712 let token_url: String = etherscan.token_url(token_hash);
713 assert_eq!(token_url, format!("https://etherscan.io/token/{token_hash:?}"));
714 }
715
716 #[test]
717 fn local_networks_not_supported() {
718 let err = Client::new_from_env(Chain::dev()).unwrap_err();
719 assert!(matches!(err, EtherscanError::LocalNetworksNotSupported));
720 }
721
722 #[test]
723 fn can_parse_etherscan_mainnet_invalid_api_key() {
724 let err = serde_json::json!({
725 "status":"0",
726 "message":"NOTOK",
727 "result":"Missing/Invalid API Key"
728 });
729 let resp: ResponseData<Address> = serde_json::from_value(err).unwrap();
730 assert!(matches!(resp, ResponseData::Error { .. }));
731 }
732
733 #[test]
734 fn can_parse_api_version() {
735 assert_eq!(
736 EtherscanApiVersion::try_from("v1".to_string()).unwrap(),
737 EtherscanApiVersion::V1
738 );
739 assert_eq!(
740 EtherscanApiVersion::try_from("v2".to_string()).unwrap(),
741 EtherscanApiVersion::V2
742 );
743
744 let parse_err = EtherscanApiVersion::try_from("fail".to_string()).unwrap_err();
745 assert!(matches!(parse_err, EtherscanError::InvalidApiVersion));
746 }
747}