1#![doc = include_str!("../README.md")]
2#![deny(unsafe_code, rustdoc::broken_intra_doc_links)]
3#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
4
5use crate::errors::{
6 is_blocked_by_cloudflare_response, is_cloudflare_security_challenge,
7 is_security_challenge_prompt,
8};
9use contract::ContractMetadata;
10use errors::EtherscanError;
11use ethers_core::{
12 abi::{Abi, Address},
13 types::{Chain, H256},
14};
15use reqwest::{header, IntoUrl, Url};
16use serde::{de::DeserializeOwned, Deserialize, Serialize};
17use std::{
18 borrow::Cow,
19 io::Write,
20 path::PathBuf,
21 time::{Duration, SystemTime, UNIX_EPOCH},
22};
23use tracing::{error, trace};
24
25pub mod account;
26pub mod blocks;
27pub mod contract;
28pub mod errors;
29pub mod gas;
30pub mod source_tree;
31pub mod stats;
32mod transaction;
33pub mod utils;
34pub mod verify;
35
36pub(crate) type Result<T, E = EtherscanError> = std::result::Result<T, E>;
37
38#[derive(Clone, Debug)]
40pub struct Client {
41 client: reqwest::Client,
43 api_key: Option<String>,
45 etherscan_api_url: Url,
47 etherscan_url: Url,
49 cache: Option<Cache>,
51}
52
53impl Client {
54 pub fn builder() -> ClientBuilder {
66 ClientBuilder::default()
67 }
68
69 pub fn new_cached(
71 chain: Chain,
72 api_key: impl Into<String>,
73 cache_root: Option<PathBuf>,
74 cache_ttl: Duration,
75 ) -> Result<Self> {
76 let mut this = Self::new(chain, api_key)?;
77 this.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
78 Ok(this)
79 }
80
81 pub fn new(chain: Chain, api_key: impl Into<String>) -> Result<Self> {
83 Client::builder().with_api_key(api_key).chain(chain)?.build()
84 }
85
86 pub fn new_from_env(chain: Chain) -> Result<Self> {
89 let api_key = match chain {
90 Chain::Fantom | Chain::FantomTestnet => std::env::var("FMTSCAN_API_KEY")
92 .or_else(|_| std::env::var("FANTOMSCAN_API_KEY"))
93 .map_err(Into::into),
94
95 Chain::Chiado |
97 Chain::Sepolia |
98 Chain::Rsk |
99 Chain::Sokol |
100 Chain::Poa |
101 Chain::Oasis |
102 Chain::Emerald |
103 Chain::EmeraldTestnet |
104 Chain::Evmos |
105 Chain::EvmosTestnet => Ok(String::new()),
106 Chain::AnvilHardhat | Chain::Dev => Err(EtherscanError::LocalNetworksNotSupported),
107
108 _ => chain
109 .etherscan_api_key_name()
110 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))
111 .and_then(|key_name| std::env::var(key_name).map_err(Into::into)),
112 }?;
113 Self::new(chain, api_key)
114 }
115
116 pub fn new_from_opt_env(chain: Chain) -> Result<Self> {
121 match Self::new_from_env(chain) {
122 Ok(client) => Ok(client),
123 Err(EtherscanError::EnvVarNotFound(_)) => {
124 Self::builder().chain(chain).and_then(|c| c.build())
125 }
126 Err(e) => Err(e),
127 }
128 }
129
130 pub fn set_cache(&mut self, root: impl Into<PathBuf>, ttl: Duration) -> &mut Self {
132 self.cache = Some(Cache { root: root.into(), ttl });
133 self
134 }
135
136 pub fn etherscan_api_url(&self) -> &Url {
137 &self.etherscan_api_url
138 }
139
140 pub fn etherscan_url(&self) -> &Url {
141 &self.etherscan_url
142 }
143
144 pub fn block_url(&self, block: u64) -> String {
146 format!("{}block/{block}", self.etherscan_url)
147 }
148
149 pub fn address_url(&self, address: Address) -> String {
151 format!("{}address/{address:?}", self.etherscan_url)
152 }
153
154 pub fn transaction_url(&self, tx_hash: H256) -> String {
156 format!("{}tx/{tx_hash:?}", self.etherscan_url)
157 }
158
159 pub fn token_url(&self, token_hash: Address) -> String {
161 format!("{}token/{token_hash:?}", self.etherscan_url)
162 }
163
164 async fn get_json<T: DeserializeOwned, Q: Serialize>(&self, query: &Q) -> Result<Response<T>> {
166 let res = self.get(query).await?;
167 self.sanitize_response(res)
168 }
169
170 async fn get<Q: Serialize>(&self, query: &Q) -> Result<String> {
172 trace!(target: "etherscan", "GET {}", self.etherscan_api_url);
173 let response = self
174 .client
175 .get(self.etherscan_api_url.clone())
176 .header(header::ACCEPT, "application/json")
177 .query(query)
178 .send()
179 .await?
180 .text()
181 .await?;
182 Ok(response)
183 }
184
185 async fn post_form<T: DeserializeOwned, F: Serialize>(&self, form: &F) -> Result<Response<T>> {
187 let res = self.post(form).await?;
188 self.sanitize_response(res)
189 }
190
191 async fn post<F: Serialize>(&self, form: &F) -> Result<String> {
193 trace!(target: "etherscan", "POST {}", self.etherscan_api_url);
194 let response = self
195 .client
196 .post(self.etherscan_api_url.clone())
197 .form(form)
198 .send()
199 .await?
200 .text()
201 .await?;
202 Ok(response)
203 }
204
205 fn sanitize_response<T: DeserializeOwned>(&self, res: impl AsRef<str>) -> Result<Response<T>> {
207 let res = res.as_ref();
208 let res: ResponseData<T> = serde_json::from_str(res).map_err(|err| {
209 error!(target: "etherscan", ?res, "Failed to deserialize response: {}", err);
210 if res == "Page not found" {
211 EtherscanError::PageNotFound
212 } else if is_blocked_by_cloudflare_response(res) {
213 EtherscanError::BlockedByCloudflare
214 } else if is_cloudflare_security_challenge(res) {
215 EtherscanError::CloudFlareSecurityChallenge
216 } else if is_security_challenge_prompt(res) {
217 EtherscanError::SecurityChallenge(self.etherscan_api_url.clone())
218 } else {
219 EtherscanError::Serde(err)
220 }
221 })?;
222
223 match res {
224 ResponseData::Error { result, message, status } => {
225 if let Some(ref result) = result {
226 if result.starts_with("Max rate limit reached") {
227 return Err(EtherscanError::RateLimitExceeded)
228 } else if result.to_lowercase() == "invalid api key" {
229 return Err(EtherscanError::InvalidApiKey)
230 }
231 }
232 Err(EtherscanError::ErrorResponse { status, message, result })
233 }
234 ResponseData::Success(res) => Ok(res),
235 }
236 }
237
238 fn create_query<T: Serialize>(
239 &self,
240 module: &'static str,
241 action: &'static str,
242 other: T,
243 ) -> Query<T> {
244 Query {
245 apikey: self.api_key.as_deref().map(Cow::Borrowed),
246 module: Cow::Borrowed(module),
247 action: Cow::Borrowed(action),
248 other,
249 }
250 }
251}
252
253#[derive(Clone, Debug, Default)]
254pub struct ClientBuilder {
255 client: Option<reqwest::Client>,
257 api_key: Option<String>,
259 etherscan_api_url: Option<Url>,
261 etherscan_url: Option<Url>,
263 cache: Option<Cache>,
265}
266
267impl ClientBuilder {
270 pub fn chain(self, chain: Chain) -> Result<Self> {
276 fn urls(
277 api: impl IntoUrl,
278 url: impl IntoUrl,
279 ) -> (reqwest::Result<Url>, reqwest::Result<Url>) {
280 (api.into_url(), url.into_url())
281 }
282 let (etherscan_api_url, etherscan_url) = chain
283 .etherscan_urls()
284 .map(|(api, base)| urls(api, base))
285 .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?;
286 self.with_api_url(etherscan_api_url?)?.with_url(etherscan_url?)
287 }
288
289 pub fn with_url(mut self, etherscan_url: impl IntoUrl) -> Result<Self> {
295 self.etherscan_url = Some(ensure_url(etherscan_url)?);
296 Ok(self)
297 }
298
299 pub fn with_client(mut self, client: reqwest::Client) -> Self {
301 self.client = Some(client);
302 self
303 }
304
305 pub fn with_api_url(mut self, etherscan_api_url: impl IntoUrl) -> Result<Self> {
311 self.etherscan_api_url = Some(ensure_url(etherscan_api_url)?);
312 Ok(self)
313 }
314
315 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
317 self.api_key = Some(api_key.into()).filter(|s| !s.is_empty());
318 self
319 }
320
321 pub fn with_cache(mut self, cache_root: Option<PathBuf>, cache_ttl: Duration) -> Self {
323 self.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
324 self
325 }
326
327 pub fn build(self) -> Result<Client> {
335 let ClientBuilder { client, api_key, etherscan_api_url, etherscan_url, cache } = self;
336
337 let client = Client {
338 client: client.unwrap_or_default(),
339 api_key,
340 etherscan_api_url: etherscan_api_url
341 .ok_or_else(|| EtherscanError::Builder("etherscan api url".to_string()))?,
342 etherscan_url: etherscan_url
343 .ok_or_else(|| EtherscanError::Builder("etherscan url".to_string()))?,
344 cache,
345 };
346 Ok(client)
347 }
348}
349
350#[derive(Clone, Debug, Deserialize, Serialize)]
352struct CacheEnvelope<T> {
353 expiry: u64,
354 data: T,
355}
356
357#[derive(Clone, Debug)]
359struct Cache {
360 root: PathBuf,
361 ttl: Duration,
362}
363
364impl Cache {
365 fn new(root: PathBuf, ttl: Duration) -> Self {
366 Self { root, ttl }
367 }
368
369 fn get_abi(&self, address: Address) -> Option<Option<ethers_core::abi::Abi>> {
370 self.get("abi", address)
371 }
372
373 fn set_abi(&self, address: Address, abi: Option<&Abi>) {
374 self.set("abi", address, abi)
375 }
376
377 fn get_source(&self, address: Address) -> Option<Option<ContractMetadata>> {
378 self.get("sources", address)
379 }
380
381 fn set_source(&self, address: Address, source: Option<&ContractMetadata>) {
382 self.set("sources", address, source)
383 }
384
385 fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) {
386 let path = self.root.join(prefix).join(format!("{address:?}.json"));
387 let writer = std::fs::File::create(path).ok().map(std::io::BufWriter::new);
388 if let Some(mut writer) = writer {
389 let _ = serde_json::to_writer(
390 &mut writer,
391 &CacheEnvelope {
392 expiry: SystemTime::now()
393 .checked_add(self.ttl)
394 .expect("cache ttl overflowed")
395 .duration_since(UNIX_EPOCH)
396 .expect("system time is before unix epoch")
397 .as_secs(),
398 data: item,
399 },
400 );
401 let _ = writer.flush();
402 }
403 }
404
405 fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Option<T> {
406 let path = self.root.join(prefix).join(format!("{address:?}.json"));
407 let Ok(contents) = std::fs::read_to_string(path) else { return None };
408 let Ok(inner) = serde_json::from_str::<CacheEnvelope<T>>(&contents) else { return None };
409 SystemTime::now()
411 .duration_since(UNIX_EPOCH)
412 .expect("system time is before unix epoch")
413 .checked_sub(Duration::from_secs(inner.expiry))
414 .map(|_| inner.data)
415 }
416}
417
418#[derive(Debug, Clone, Deserialize)]
420pub struct Response<T> {
421 pub status: String,
422 pub message: String,
423 pub result: T,
424}
425
426#[derive(Deserialize, Debug, Clone)]
427#[serde(untagged)]
428pub enum ResponseData<T> {
429 Success(Response<T>),
430 Error { status: String, message: String, result: Option<String> },
431}
432
433#[derive(Clone, Debug, Serialize)]
435struct Query<'a, T: Serialize> {
436 #[serde(skip_serializing_if = "Option::is_none")]
437 apikey: Option<Cow<'a, str>>,
438 module: Cow<'a, str>,
439 action: Cow<'a, str>,
440 #[serde(flatten)]
441 other: T,
442}
443
444fn ensure_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
446 let url_str = url.as_str();
447
448 if url_str.ends_with('/') {
450 url.into_url()
451 } else {
452 into_url(format!("{url_str}/"))
453 }
454}
455
456#[inline]
459fn into_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
460 url.into_url()
461}
462
463#[cfg(test)]
464mod tests {
465 use crate::{Client, EtherscanError, ResponseData};
466 use ethers_core::types::{Address, Chain, H256};
467
468 #[test]
470 fn can_parse_block_scout_err() {
471 let err = "{\"message\":\"Something went wrong.\",\"result\":null,\"status\":\"0\"}";
472 let resp: ResponseData<Address> = serde_json::from_str(err).unwrap();
473 assert!(matches!(resp, ResponseData::Error { .. }));
474 }
475
476 #[test]
477 fn test_api_paths() {
478 let client = Client::new(Chain::Goerli, "").unwrap();
479 assert_eq!(client.etherscan_api_url.as_str(), "https://api-goerli.etherscan.io/api/");
480
481 assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
482 }
483
484 #[test]
485 fn stringifies_block_url() {
486 let etherscan = Client::new(Chain::Mainnet, "").unwrap();
487 let block: u64 = 1;
488 let block_url: String = etherscan.block_url(block);
489 assert_eq!(block_url, format!("https://etherscan.io/block/{block}"));
490 }
491
492 #[test]
493 fn stringifies_address_url() {
494 let etherscan = Client::new(Chain::Mainnet, "").unwrap();
495 let addr: Address = Address::zero();
496 let address_url: String = etherscan.address_url(addr);
497 assert_eq!(address_url, format!("https://etherscan.io/address/{addr:?}"));
498 }
499
500 #[test]
501 fn stringifies_transaction_url() {
502 let etherscan = Client::new(Chain::Mainnet, "").unwrap();
503 let tx_hash = H256::zero();
504 let tx_url: String = etherscan.transaction_url(tx_hash);
505 assert_eq!(tx_url, format!("https://etherscan.io/tx/{tx_hash:?}"));
506 }
507
508 #[test]
509 fn stringifies_token_url() {
510 let etherscan = Client::new(Chain::Mainnet, "").unwrap();
511 let token_hash = Address::zero();
512 let token_url: String = etherscan.token_url(token_hash);
513 assert_eq!(token_url, format!("https://etherscan.io/token/{token_hash:?}"));
514 }
515
516 #[test]
517 fn local_networks_not_supported() {
518 let err = Client::new_from_env(Chain::Dev).unwrap_err();
519 assert!(matches!(err, EtherscanError::LocalNetworksNotSupported));
520 }
521}