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))]
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::{IntoUrl, Url, header};
24use serde::{Deserialize, Serialize, de::DeserializeOwned};
25use std::{
26 borrow::Cow,
27 io::Write,
28 path::PathBuf,
29 time::{Duration, SystemTime, UNIX_EPOCH},
30};
31
32pub mod account;
33pub mod block_number;
34pub mod blocks;
35pub mod contract;
36pub mod errors;
37pub mod gas;
38pub mod serde_helpers;
39pub mod source_tree;
40mod transaction;
41pub mod units;
42pub mod utils;
43pub mod verify;
44
45pub(crate) type Result<T, E = EtherscanError> = std::result::Result<T, E>;
46
47#[derive(Clone, Debug)]
49pub struct Client {
50 client: reqwest::Client,
52 api_key: Option<String>,
54 etherscan_api_url: Url,
56 etherscan_url: Url,
58 cache: Option<Cache>,
60}
61
62impl Client {
63 pub fn builder() -> ClientBuilder {
80 ClientBuilder::default()
81 }
82
83 pub fn new_cached(
85 chain: Chain,
86 api_key: impl Into<String>,
87 cache_root: Option<PathBuf>,
88 cache_ttl: Duration,
89 ) -> Result<Self> {
90 let mut this = Self::new(chain, api_key)?;
91 this.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
92 Ok(this)
93 }
94
95 pub fn new(chain: Chain, api_key: impl Into<String>) -> Result<Self> {
97 Client::builder().with_api_key(api_key).chain(chain)?.build()
98 }
99
100 pub fn new_from_env(chain: Chain) -> Result<Self> {
102 Client::builder().with_api_key(get_api_key_from_chain(chain)?).chain(chain)?.build()
103 }
104
105 pub fn new_from_opt_env(chain: Chain) -> Result<Self> {
110 match Self::new_from_env(chain) {
111 Ok(client) => Ok(client),
112 Err(EtherscanError::EnvVarNotFound(_)) => {
113 Self::builder().chain(chain).and_then(|c| c.build())
114 }
115 Err(e) => Err(e),
116 }
117 }
118
119 pub fn set_cache(&mut self, root: impl Into<PathBuf>, ttl: Duration) -> &mut Self {
121 self.cache = Some(Cache { root: root.into(), ttl });
122 self
123 }
124
125 pub fn etherscan_api_url(&self) -> &Url {
126 &self.etherscan_api_url
127 }
128
129 pub fn etherscan_url(&self) -> &Url {
130 &self.etherscan_url
131 }
132
133 pub fn api_key(&self) -> Option<&str> {
135 self.api_key.as_deref()
136 }
137
138 pub fn block_url(&self, block: u64) -> String {
140 self.etherscan_url.join(&format!("block/{block}")).unwrap().to_string()
141 }
142
143 pub fn address_url(&self, address: Address) -> String {
145 self.etherscan_url.join(&format!("address/{address:?}")).unwrap().to_string()
146 }
147
148 pub fn transaction_url(&self, tx_hash: B256) -> String {
150 self.etherscan_url.join(&format!("tx/{tx_hash:?}")).unwrap().to_string()
151 }
152
153 pub fn token_url(&self, token_hash: Address) -> String {
155 self.etherscan_url.join(&format!("token/{token_hash:?}")).unwrap().to_string()
156 }
157
158 async fn get_json<T: DeserializeOwned, Q: Serialize>(&self, query: &Q) -> Result<Response<T>> {
160 let res = self.get(query).await?;
161 self.sanitize_response(res)
162 }
163
164 async fn get<Q: Serialize>(&self, query: &Q) -> Result<String> {
166 trace!(target: "etherscan", "GET {}", self.etherscan_api_url);
167 let response = self
168 .client
169 .get(self.etherscan_api_url.clone())
170 .header(header::ACCEPT, "application/json")
171 .query(query)
172 .send()
173 .await?
174 .text()
175 .await?;
176 Ok(response)
177 }
178
179 async fn post_form<T: DeserializeOwned, F: Serialize>(&self, form: &F) -> Result<Response<T>> {
181 let res = self.post(form).await?;
182 self.sanitize_response(res)
183 }
184
185 async fn post<F: Serialize>(&self, form: &F) -> Result<String> {
187 trace!(target: "etherscan", "POST {}", self.etherscan_api_url);
188
189 let response = self
190 .client
191 .post(self.etherscan_api_url.clone())
192 .form(form)
193 .send()
194 .await?
195 .text()
196 .await?;
197
198 Ok(response)
199 }
200
201 fn sanitize_response<T: DeserializeOwned>(&self, res: impl AsRef<str>) -> Result<Response<T>> {
203 let res = res.as_ref();
204 let res: ResponseData<T> = serde_json::from_str(res).map_err(|error| {
205 error!(target: "etherscan", ?res, "Failed to deserialize response: {}", error);
206 if res == "Page not found" {
207 EtherscanError::PageNotFound
208 } else if is_blocked_by_cloudflare_response(res) {
209 EtherscanError::BlockedByCloudflare
210 } else if is_cloudflare_security_challenge(res) {
211 EtherscanError::CloudFlareSecurityChallenge
212 } else {
213 EtherscanError::Serde { error, content: res.to_string() }
214 }
215 })?;
216
217 match res {
218 ResponseData::Error { result, message, status } => {
219 if let Some(ref result) = result {
220 if result.starts_with("Max rate limit reached") {
221 return Err(EtherscanError::RateLimitExceeded);
222 } else if result.to_lowercase().contains("invalid api key") {
223 return Err(EtherscanError::InvalidApiKey);
224 }
225 }
226 Err(EtherscanError::ErrorResponse { status, message, result })
227 }
228 ResponseData::Success(res) => Ok(res),
229 }
230 }
231
232 fn create_query<T: Serialize>(
233 &self,
234 module: &'static str,
235 action: &'static str,
236 other: T,
237 ) -> Query<'_, T> {
238 Query {
239 apikey: self.api_key.as_deref().map(Cow::Borrowed),
240 module: Cow::Borrowed(module),
241 action: Cow::Borrowed(action),
242 other,
243 }
244 }
245}
246
247#[derive(Clone, Debug, Default)]
248pub struct ClientBuilder {
249 client: Option<reqwest::Client>,
251 api_key: Option<String>,
253 etherscan_api_url: Option<Url>,
255 etherscan_url: Option<Url>,
257 cache: Option<Cache>,
259 no_proxy: bool,
262}
263
264impl ClientBuilder {
267 pub fn chain(self, chain: Chain) -> Result<Self> {
275 fn urls(
276 (api, url): (impl IntoUrl, impl IntoUrl),
277 ) -> (reqwest::Result<Url>, reqwest::Result<Url>) {
278 (api.into_url(), url.into_url())
279 }
280 let (etherscan_api_url, etherscan_url) = chain
281 .named()
282 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?
283 .etherscan_urls()
284 .map(urls)
285 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?;
286
287 self.with_api_url(etherscan_api_url?)?.with_url(etherscan_url?)
288 }
289
290 pub fn with_url(mut self, etherscan_url: impl IntoUrl) -> Result<Self> {
296 self.etherscan_url = Some(into_url(etherscan_url)?);
297 Ok(self)
298 }
299
300 pub fn with_client(mut self, client: reqwest::Client) -> Self {
302 self.client = Some(client);
303 self
304 }
305
306 pub fn no_proxy(mut self) -> Self {
315 self.no_proxy = true;
316 self
317 }
318
319 pub fn set_no_proxy(mut self, no_proxy: bool) -> Self {
327 self.no_proxy = no_proxy;
328 self
329 }
330
331 pub fn with_api_url(mut self, etherscan_api_url: impl IntoUrl) -> Result<Self> {
337 self.etherscan_api_url = Some(into_url(etherscan_api_url)?);
338 Ok(self)
339 }
340
341 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
343 self.api_key = Some(api_key.into()).filter(|s| !s.is_empty());
344 self
345 }
346
347 pub fn with_cache(mut self, cache_root: Option<PathBuf>, cache_ttl: Duration) -> Self {
349 self.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
350 self
351 }
352
353 pub fn build(self) -> Result<Client> {
361 let ClientBuilder { client, api_key, etherscan_api_url, etherscan_url, cache, no_proxy } =
362 self;
363
364 let client = match client {
365 Some(c) => c,
366 None if no_proxy => reqwest::Client::builder().no_proxy().build()?,
367 None => reqwest::Client::default(),
368 };
369
370 let client = Client {
371 client,
372 api_key,
373 etherscan_api_url: etherscan_api_url
374 .clone()
375 .ok_or_else(|| EtherscanError::Builder("etherscan api url".to_string()))?,
376 etherscan_url: etherscan_url
377 .ok_or_else(|| EtherscanError::Builder("etherscan url".to_string()))?,
378 cache,
379 };
380 Ok(client)
381 }
382}
383
384#[derive(Clone, Debug, Deserialize, Serialize)]
387struct CacheEnvelope<T> {
388 expiry: u64,
391 data: T,
393}
394
395#[derive(Clone, Debug)]
406struct Cache {
407 root: PathBuf,
409 ttl: Duration,
411}
412
413impl Cache {
414 fn new(root: PathBuf, ttl: Duration) -> Self {
415 Self { root, ttl }
416 }
417
418 fn get_abi(&self, address: Address) -> Option<Option<JsonAbi>> {
419 self.get("abi", address)
420 }
421
422 fn set_abi(&self, address: Address, abi: Option<&JsonAbi>) {
423 self.set("abi", address, abi)
424 }
425
426 fn get_source(&self, address: Address) -> Option<Option<ContractMetadata>> {
427 self.get("sources", address)
428 }
429
430 fn set_source(&self, address: Address, source: Option<&ContractMetadata>) {
431 self.set("sources", address, source)
432 }
433
434 fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) {
435 let path = self.root.join(prefix);
437 if std::fs::create_dir_all(&path).is_err() {
438 return;
439 }
440
441 let path = path.join(format!("{address:?}.json"));
442 let writer = std::fs::File::create(path).ok().map(std::io::BufWriter::new);
443 if let Some(mut writer) = writer {
444 let _ = serde_json::to_writer(
445 &mut writer,
446 &CacheEnvelope {
447 expiry: SystemTime::now()
448 .checked_add(self.ttl)
449 .expect("cache ttl overflowed")
450 .duration_since(UNIX_EPOCH)
451 .expect("system time is before unix epoch")
452 .as_secs(),
453 data: item,
454 },
455 );
456 let _ = writer.flush();
457 }
458 }
459
460 fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Option<T> {
461 let path = self.root.join(prefix).join(format!("{address:?}.json"));
462
463 let Ok(contents) = std::fs::read_to_string(path) else {
464 return None;
465 };
466
467 let Ok(inner) = serde_json::from_str::<CacheEnvelope<T>>(&contents) else {
468 return None;
469 };
470
471 SystemTime::now()
473 .duration_since(UNIX_EPOCH)
474 .expect("system time is before unix epoch")
475 .lt(&Duration::from_secs(inner.expiry))
478 .then_some(inner.data)
481 }
482}
483
484#[derive(Debug, Clone, Deserialize)]
486pub struct Response<T> {
487 pub status: String,
488 pub message: String,
489 pub result: T,
490}
491
492#[derive(Deserialize, Debug, Clone)]
493#[serde(untagged)]
494pub enum ResponseData<T> {
495 Success(Response<T>),
496 Error { status: String, message: String, result: Option<String> },
497}
498
499#[derive(Clone, Debug, Serialize)]
501struct Query<'a, T: Serialize> {
502 #[serde(skip_serializing_if = "Option::is_none")]
503 apikey: Option<Cow<'a, str>>,
504 module: Cow<'a, str>,
505 action: Cow<'a, str>,
506 #[serde(flatten)]
507 other: T,
508}
509
510#[inline]
513fn into_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
514 url.into_url()
515}
516
517fn get_api_key_from_chain(chain: Chain) -> Result<String, EtherscanError> {
518 match chain.kind() {
519 ChainKind::Named(named) => match named {
520 NamedChain::Gnosis
522 | NamedChain::Chiado
523 | NamedChain::Sepolia
524 | NamedChain::Rsk
525 | NamedChain::Sokol
526 | NamedChain::Poa
527 | NamedChain::Oasis
528 | NamedChain::Emerald
529 | NamedChain::EmeraldTestnet
530 | NamedChain::Evmos
531 | NamedChain::EvmosTestnet => Ok(String::new()),
532 NamedChain::AnvilHardhat | NamedChain::Dev => {
533 Err(EtherscanError::LocalNetworksNotSupported)
534 }
535
536 _ => std::env::var("ETHERSCAN_API_KEY").map_err(Into::into),
539 },
540 ChainKind::Id(_) => Err(EtherscanError::ChainNotSupported(chain)),
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use crate::{Client, EtherscanError, ResponseData};
547 use alloy_chains::Chain;
548 use alloy_primitives::{Address, B256};
549
550 #[test]
552 fn can_parse_block_scout_err() {
553 let err = "{\"message\":\"Something went wrong.\",\"result\":null,\"status\":\"0\"}";
554 let resp: ResponseData<Address> = serde_json::from_str(err).unwrap();
555 assert!(matches!(resp, ResponseData::Error { .. }));
556 }
557
558 #[test]
559 fn test_api_paths() {
560 let client = Client::new(Chain::sepolia(), "").unwrap();
561 assert_eq!(
562 client.etherscan_api_url.as_str(),
563 "https://api.etherscan.io/v2/api?chainid=11155111"
564 );
565 assert_eq!(client.block_url(100), "https://sepolia.etherscan.io/block/100");
566 }
567
568 #[test]
569 fn stringifies_block_url() {
570 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
571 let block: u64 = 1;
572 let block_url: String = etherscan.block_url(block);
573 assert_eq!(block_url, format!("https://etherscan.io/block/{block}"));
574 }
575
576 #[test]
577 fn stringifies_address_url() {
578 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
579 let addr: Address = Address::ZERO;
580 let address_url: String = etherscan.address_url(addr);
581 assert_eq!(address_url, format!("https://etherscan.io/address/{addr:?}"));
582 }
583
584 #[test]
585 fn stringifies_transaction_url() {
586 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
587 let tx_hash = B256::ZERO;
588 let tx_url: String = etherscan.transaction_url(tx_hash);
589 assert_eq!(tx_url, format!("https://etherscan.io/tx/{tx_hash:?}"));
590 }
591
592 #[test]
593 fn stringifies_token_url() {
594 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
595 let token_hash = Address::ZERO;
596 let token_url: String = etherscan.token_url(token_hash);
597 assert_eq!(token_url, format!("https://etherscan.io/token/{token_hash:?}"));
598 }
599
600 #[test]
601 fn local_networks_not_supported() {
602 let err = Client::new_from_env(Chain::dev()).unwrap_err();
603 assert!(matches!(err, EtherscanError::LocalNetworksNotSupported));
604 }
605
606 #[test]
607 fn builder_no_proxy_builds() {
608 let client = Client::builder().chain(Chain::mainnet()).unwrap().no_proxy().build().unwrap();
609 assert_eq!(client.etherscan_url.as_str(), "https://etherscan.io/");
610 }
611
612 #[test]
613 fn can_parse_etherscan_mainnet_invalid_api_key() {
614 let err = serde_json::json!({
615 "status":"0",
616 "message":"NOTOK",
617 "result":"Missing/Invalid API Key"
618 });
619 let resp: ResponseData<Address> = serde_json::from_value(err).unwrap();
620 assert!(matches!(resp, ResponseData::Error { .. }));
621 }
622}