ethers_etherscan/
lib.rs

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/// The Etherscan.io API client.
39#[derive(Clone, Debug)]
40pub struct Client {
41    /// Client that executes HTTP requests
42    client: reqwest::Client,
43    /// Etherscan API key
44    api_key: Option<String>,
45    /// Etherscan API endpoint like <https://api(-chain).etherscan.io/api>
46    etherscan_api_url: Url,
47    /// Etherscan base endpoint like <https://etherscan.io>
48    etherscan_url: Url,
49    /// Path to where ABI files should be cached
50    cache: Option<Cache>,
51}
52
53impl Client {
54    /// Creates a `ClientBuilder` to configure a `Client`.
55    ///
56    /// This is the same as `ClientBuilder::default()`.
57    ///
58    /// # Example
59    ///
60    /// ```rust
61    /// use ethers_core::types::Chain;
62    /// use ethers_etherscan::Client;
63    /// let client = Client::builder().with_api_key("<API KEY>").chain(Chain::Mainnet).unwrap().build().unwrap();
64    /// ```
65    pub fn builder() -> ClientBuilder {
66        ClientBuilder::default()
67    }
68
69    /// Creates a new instance that caches etherscan requests
70    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    /// Create a new client with the correct endpoints based on the chain and provided API key
82    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    /// Create a new client with the correct endpoints based on the chain and API key
87    /// from the default environment variable defined in [`Chain`].
88    pub fn new_from_env(chain: Chain) -> Result<Self> {
89        let api_key = match chain {
90            // Extra aliases
91            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            // Backwards compatibility, ideally these should return an error.
96            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    /// Create a new client with the correct endpoints based on the chain and API key
117    /// from the default environment variable defined in [`Chain`].
118    ///
119    /// If the environment variable is not set, create a new client without it.
120    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    /// Sets the root to the cache dir and the ttl to use
131    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    /// Return the URL for the given block number
145    pub fn block_url(&self, block: u64) -> String {
146        format!("{}block/{block}", self.etherscan_url)
147    }
148
149    /// Return the URL for the given address
150    pub fn address_url(&self, address: Address) -> String {
151        format!("{}address/{address:?}", self.etherscan_url)
152    }
153
154    /// Return the URL for the given transaction hash
155    pub fn transaction_url(&self, tx_hash: H256) -> String {
156        format!("{}tx/{tx_hash:?}", self.etherscan_url)
157    }
158
159    /// Return the URL for the given token hash
160    pub fn token_url(&self, token_hash: Address) -> String {
161        format!("{}token/{token_hash:?}", self.etherscan_url)
162    }
163
164    /// Execute an GET request with parameters.
165    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    /// Execute a GET request with parameters, without sanity checking the response.
171    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    /// Execute a POST request with a form.
186    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    /// Execute a POST request with a form, without sanity checking the response.
192    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    /// Perform sanity checks on a response and deserialize it into a [Response].
206    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 that executes HTTP requests
256    client: Option<reqwest::Client>,
257    /// Etherscan API key
258    api_key: Option<String>,
259    /// Etherscan API endpoint like <https://api(-chain).etherscan.io/api>
260    etherscan_api_url: Option<Url>,
261    /// Etherscan base endpoint like <https://etherscan.io>
262    etherscan_url: Option<Url>,
263    /// Path to where ABI files should be cached
264    cache: Option<Cache>,
265}
266
267// === impl ClientBuilder ===
268
269impl ClientBuilder {
270    /// Configures the etherscan url and api url for the given chain
271    ///
272    /// # Errors
273    ///
274    /// Fails if the chain is not supported by etherscan
275    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    /// Configures the etherscan url
290    ///
291    /// # Errors
292    ///
293    /// Fails if the `etherscan_url` is not a valid `Url`
294    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    /// Configures the `reqwest::Client`
300    pub fn with_client(mut self, client: reqwest::Client) -> Self {
301        self.client = Some(client);
302        self
303    }
304
305    /// Configures the etherscan api url
306    ///
307    /// # Errors
308    ///
309    /// Fails if the `etherscan_api_url` is not a valid `Url`
310    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    /// Configures the etherscan api key
316    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    /// Configures cache for etherscan request
322    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    /// Returns a Client that uses this ClientBuilder configuration.
328    ///
329    /// # Errors
330    ///
331    /// If the following required fields are missing:
332    ///   - `etherscan_api_url`
333    ///   - `etherscan_url`
334    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/// A wrapper around an Etherscan cache object with an expiry
351#[derive(Clone, Debug, Deserialize, Serialize)]
352struct CacheEnvelope<T> {
353    expiry: u64,
354    data: T,
355}
356
357/// Simple cache for etherscan requests
358#[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        // If this does not return None then we have passed the expiry
410        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/// The API response type
419#[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/// The type that gets serialized as query
434#[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
444/// Ensures that the url is well formatted to be used by the Client's functions that join paths.
445fn ensure_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
446    let url_str = url.as_str();
447
448    // ensure URL ends with `/`
449    if url_str.ends_with('/') {
450        url.into_url()
451    } else {
452        into_url(format!("{url_str}/"))
453    }
454}
455
456/// This is a hack to work around `IntoUrl`'s sealed private functions, which can't be called
457/// normally.
458#[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    // <https://github.com/foundry-rs/foundry/issues/4406>
469    #[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}