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 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}
260
261impl ClientBuilder {
264 pub fn chain(self, chain: Chain) -> Result<Self> {
272 fn urls(
273 (api, url): (impl IntoUrl, impl IntoUrl),
274 ) -> (reqwest::Result<Url>, reqwest::Result<Url>) {
275 (api.into_url(), url.into_url())
276 }
277 let (etherscan_api_url, etherscan_url) = chain
278 .named()
279 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?
280 .etherscan_urls()
281 .map(urls)
282 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?;
283
284 self.with_api_url(etherscan_api_url?)?.with_url(etherscan_url?)
285 }
286
287 pub fn with_url(mut self, etherscan_url: impl IntoUrl) -> Result<Self> {
293 self.etherscan_url = Some(into_url(etherscan_url)?);
294 Ok(self)
295 }
296
297 pub fn with_client(mut self, client: reqwest::Client) -> Self {
299 self.client = Some(client);
300 self
301 }
302
303 pub fn with_api_url(mut self, etherscan_api_url: impl IntoUrl) -> Result<Self> {
309 self.etherscan_api_url = Some(into_url(etherscan_api_url)?);
310 Ok(self)
311 }
312
313 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
315 self.api_key = Some(api_key.into()).filter(|s| !s.is_empty());
316 self
317 }
318
319 pub fn with_cache(mut self, cache_root: Option<PathBuf>, cache_ttl: Duration) -> Self {
321 self.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
322 self
323 }
324
325 pub fn build(self) -> Result<Client> {
333 let ClientBuilder { client, api_key, etherscan_api_url, etherscan_url, cache } = self;
334
335 let client = Client {
336 client: client.unwrap_or_default(),
337 api_key,
338 etherscan_api_url: etherscan_api_url
339 .clone()
340 .ok_or_else(|| EtherscanError::Builder("etherscan api url".to_string()))?,
341 etherscan_url: etherscan_url
342 .ok_or_else(|| EtherscanError::Builder("etherscan url".to_string()))?,
343 cache,
344 };
345 Ok(client)
346 }
347}
348
349#[derive(Clone, Debug, Deserialize, Serialize)]
352struct CacheEnvelope<T> {
353 expiry: u64,
356 data: T,
358}
359
360#[derive(Clone, Debug)]
371struct Cache {
372 root: PathBuf,
374 ttl: Duration,
376}
377
378impl Cache {
379 fn new(root: PathBuf, ttl: Duration) -> Self {
380 Self { root, ttl }
381 }
382
383 fn get_abi(&self, address: Address) -> Option<Option<JsonAbi>> {
384 self.get("abi", address)
385 }
386
387 fn set_abi(&self, address: Address, abi: Option<&JsonAbi>) {
388 self.set("abi", address, abi)
389 }
390
391 fn get_source(&self, address: Address) -> Option<Option<ContractMetadata>> {
392 self.get("sources", address)
393 }
394
395 fn set_source(&self, address: Address, source: Option<&ContractMetadata>) {
396 self.set("sources", address, source)
397 }
398
399 fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) {
400 let path = self.root.join(prefix);
402 if std::fs::create_dir_all(&path).is_err() {
403 return;
404 }
405
406 let path = path.join(format!("{address:?}.json"));
407 let writer = std::fs::File::create(path).ok().map(std::io::BufWriter::new);
408 if let Some(mut writer) = writer {
409 let _ = serde_json::to_writer(
410 &mut writer,
411 &CacheEnvelope {
412 expiry: SystemTime::now()
413 .checked_add(self.ttl)
414 .expect("cache ttl overflowed")
415 .duration_since(UNIX_EPOCH)
416 .expect("system time is before unix epoch")
417 .as_secs(),
418 data: item,
419 },
420 );
421 let _ = writer.flush();
422 }
423 }
424
425 fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Option<T> {
426 let path = self.root.join(prefix).join(format!("{address:?}.json"));
427
428 let Ok(contents) = std::fs::read_to_string(path) else {
429 return None;
430 };
431
432 let Ok(inner) = serde_json::from_str::<CacheEnvelope<T>>(&contents) else {
433 return None;
434 };
435
436 SystemTime::now()
438 .duration_since(UNIX_EPOCH)
439 .expect("system time is before unix epoch")
440 .lt(&Duration::from_secs(inner.expiry))
443 .then_some(inner.data)
446 }
447}
448
449#[derive(Debug, Clone, Deserialize)]
451pub struct Response<T> {
452 pub status: String,
453 pub message: String,
454 pub result: T,
455}
456
457#[derive(Deserialize, Debug, Clone)]
458#[serde(untagged)]
459pub enum ResponseData<T> {
460 Success(Response<T>),
461 Error { status: String, message: String, result: Option<String> },
462}
463
464#[derive(Clone, Debug, Serialize)]
466struct Query<'a, T: Serialize> {
467 #[serde(skip_serializing_if = "Option::is_none")]
468 apikey: Option<Cow<'a, str>>,
469 module: Cow<'a, str>,
470 action: Cow<'a, str>,
471 #[serde(flatten)]
472 other: T,
473}
474
475#[inline]
478fn into_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
479 url.into_url()
480}
481
482fn get_api_key_from_chain(chain: Chain) -> Result<String, EtherscanError> {
483 match chain.kind() {
484 ChainKind::Named(named) => match named {
485 NamedChain::Fantom | NamedChain::FantomTestnet => std::env::var("FMTSCAN_API_KEY")
487 .or_else(|_| std::env::var("FANTOMSCAN_API_KEY"))
488 .map_err(Into::into),
489
490 NamedChain::Gnosis
492 | NamedChain::Chiado
493 | NamedChain::Sepolia
494 | NamedChain::Rsk
495 | NamedChain::Sokol
496 | NamedChain::Poa
497 | NamedChain::Oasis
498 | NamedChain::Emerald
499 | NamedChain::EmeraldTestnet
500 | NamedChain::Evmos
501 | NamedChain::EvmosTestnet => Ok(String::new()),
502 NamedChain::AnvilHardhat | NamedChain::Dev => {
503 Err(EtherscanError::LocalNetworksNotSupported)
504 }
505
506 _ => std::env::var("ETHERSCAN_API_KEY").map_err(Into::into),
509 },
510 ChainKind::Id(_) => Err(EtherscanError::ChainNotSupported(chain)),
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use crate::{Client, EtherscanError, ResponseData};
517 use alloy_chains::Chain;
518 use alloy_primitives::{Address, B256};
519
520 #[test]
522 fn can_parse_block_scout_err() {
523 let err = "{\"message\":\"Something went wrong.\",\"result\":null,\"status\":\"0\"}";
524 let resp: ResponseData<Address> = serde_json::from_str(err).unwrap();
525 assert!(matches!(resp, ResponseData::Error { .. }));
526 }
527
528 #[test]
529 fn test_api_paths() {
530 let client = Client::new(Chain::sepolia(), "").unwrap();
531 assert_eq!(
532 client.etherscan_api_url.as_str(),
533 "https://api.etherscan.io/v2/api?chainid=11155111"
534 );
535 assert_eq!(client.block_url(100), "https://sepolia.etherscan.io/block/100");
536 }
537
538 #[test]
539 fn stringifies_block_url() {
540 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
541 let block: u64 = 1;
542 let block_url: String = etherscan.block_url(block);
543 assert_eq!(block_url, format!("https://etherscan.io/block/{block}"));
544 }
545
546 #[test]
547 fn stringifies_address_url() {
548 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
549 let addr: Address = Address::ZERO;
550 let address_url: String = etherscan.address_url(addr);
551 assert_eq!(address_url, format!("https://etherscan.io/address/{addr:?}"));
552 }
553
554 #[test]
555 fn stringifies_transaction_url() {
556 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
557 let tx_hash = B256::ZERO;
558 let tx_url: String = etherscan.transaction_url(tx_hash);
559 assert_eq!(tx_url, format!("https://etherscan.io/tx/{tx_hash:?}"));
560 }
561
562 #[test]
563 fn stringifies_token_url() {
564 let etherscan = Client::new(Chain::mainnet(), "").unwrap();
565 let token_hash = Address::ZERO;
566 let token_url: String = etherscan.token_url(token_hash);
567 assert_eq!(token_url, format!("https://etherscan.io/token/{token_hash:?}"));
568 }
569
570 #[test]
571 fn local_networks_not_supported() {
572 let err = Client::new_from_env(Chain::dev()).unwrap_err();
573 assert!(matches!(err, EtherscanError::LocalNetworksNotSupported));
574 }
575
576 #[test]
577 fn can_parse_etherscan_mainnet_invalid_api_key() {
578 let err = serde_json::json!({
579 "status":"0",
580 "message":"NOTOK",
581 "result":"Missing/Invalid API Key"
582 });
583 let resp: ResponseData<Address> = serde_json::from_value(err).unwrap();
584 assert!(matches!(resp, ResponseData::Error { .. }));
585 }
586}