Skip to main content

foundry_block_explorers/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(
3    missing_copy_implementations,
4    missing_debug_implementations,
5    // TODO:
6    // missing_docs,
7    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/// The Etherscan.io API client.
48#[derive(Clone, Debug)]
49pub struct Client {
50    /// Client that executes HTTP requests
51    client: reqwest::Client,
52    /// Etherscan API key
53    api_key: Option<String>,
54    /// Etherscan API endpoint like <https://api.etherscan.io/v2/api?chainid=(chain_id)>
55    etherscan_api_url: Url,
56    /// Etherscan base endpoint like <https://etherscan.io>
57    etherscan_url: Url,
58    /// Path to where ABI files should be cached
59    cache: Option<Cache>,
60}
61
62impl Client {
63    /// Creates a `ClientBuilder` to configure a `Client`.
64    ///
65    /// This is the same as `ClientBuilder::default()`.
66    ///
67    /// # Example
68    ///
69    /// ```rust
70    /// use alloy_chains::Chain;
71    /// use foundry_block_explorers::Client;
72    /// let client = Client::builder()
73    ///     .with_api_key("<API KEY>")
74    ///     .chain(Chain::mainnet())
75    ///     .unwrap()
76    ///     .build()
77    ///     .unwrap();
78    /// ```
79    pub fn builder() -> ClientBuilder {
80        ClientBuilder::default()
81    }
82
83    /// Creates a new instance that caches etherscan requests
84    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    /// Create a new client with the correct endpoints based on the chain and provided API key
96    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    /// Create a new client with the correct endpoint with the chain
101    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    /// Create a new client with the correct endpoints based on the chain and API key
106    /// from the default environment variable defined in [`Chain`].
107    ///
108    /// If the environment variable is not set, create a new client without it.
109    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    /// Sets the root to the cache dir and the ttl to use
120    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    /// Returns the configured API key, if any
134    pub fn api_key(&self) -> Option<&str> {
135        self.api_key.as_deref()
136    }
137
138    /// Return the URL for the given block number
139    pub fn block_url(&self, block: u64) -> String {
140        self.etherscan_url.join(&format!("block/{block}")).unwrap().to_string()
141    }
142
143    /// Return the URL for the given address
144    pub fn address_url(&self, address: Address) -> String {
145        self.etherscan_url.join(&format!("address/{address:?}")).unwrap().to_string()
146    }
147
148    /// Return the URL for the given transaction hash
149    pub fn transaction_url(&self, tx_hash: B256) -> String {
150        self.etherscan_url.join(&format!("tx/{tx_hash:?}")).unwrap().to_string()
151    }
152
153    /// Return the URL for the given token hash
154    pub fn token_url(&self, token_hash: Address) -> String {
155        self.etherscan_url.join(&format!("token/{token_hash:?}")).unwrap().to_string()
156    }
157
158    /// Execute an GET request with parameters.
159    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    /// Execute a GET request with parameters, without sanity checking the response.
165    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    /// Execute a POST request with a form.
180    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    /// Execute a POST request with a form, without sanity checking the response.
186    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    /// Perform sanity checks on a response and deserialize it into a [Response].
202    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 that executes HTTP requests
250    client: Option<reqwest::Client>,
251    /// Etherscan API key
252    api_key: Option<String>,
253    /// Etherscan API endpoint like <https://api.etherscan.io/v2/api?chainid=(chain_id)>
254    etherscan_api_url: Option<Url>,
255    /// Etherscan base endpoint like <https://etherscan.io>
256    etherscan_url: Option<Url>,
257    /// Path to where ABI files should be cached
258    cache: Option<Cache>,
259    /// Whether to disable system proxy detection in the default HTTP client.
260    /// Ignored when a custom client is set via [`with_client`](Self::with_client).
261    no_proxy: bool,
262}
263
264// === impl ClientBuilder ===
265
266impl ClientBuilder {
267    /// Configures the Etherscan url and api url for the given chain
268    ///
269    /// Note: This method also sets the chain_id for Etherscan multichain verification: <https://docs.etherscan.io/contract-verification/multichain-verification>
270    ///
271    /// # Errors
272    ///
273    /// Fails if the chain is not supported by Etherscan
274    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    /// Configures the Etherscan url
291    ///
292    /// # Errors
293    ///
294    /// Fails if the `etherscan_url` is not a valid `Url`
295    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    /// Configures the `reqwest::Client`
301    pub fn with_client(mut self, client: reqwest::Client) -> Self {
302        self.client = Some(client);
303        self
304    }
305
306    /// Disables automatic system proxy detection in the default HTTP client.
307    ///
308    /// Mirrors [`reqwest::ClientBuilder::no_proxy`]. Useful in sandboxed
309    /// environments where `reqwest`'s system proxy lookup can panic (for
310    /// example on macOS when `SCDynamicStore` returns NULL).
311    ///
312    /// Has no effect when a custom client is supplied via
313    /// [`with_client`](Self::with_client).
314    pub fn no_proxy(mut self) -> Self {
315        self.no_proxy = true;
316        self
317    }
318
319    /// Conditionally disables automatic system proxy detection.
320    ///
321    /// When `no_proxy` is `true`, behaves like [`Self::no_proxy`]. When
322    /// `false`, proxy detection is left enabled (the default).
323    ///
324    /// This is useful for threading a config flag through without a
325    /// conditional branch at the call site.
326    pub fn set_no_proxy(mut self, no_proxy: bool) -> Self {
327        self.no_proxy = no_proxy;
328        self
329    }
330
331    /// Configures the Etherscan api url
332    ///
333    /// # Errors
334    ///
335    /// Fails if the `etherscan_api_url` is not a valid `Url`
336    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    /// Configures the Etherscan api key
342    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    /// Configures cache for Etherscan request
348    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    /// Returns a Client that uses this ClientBuilder configuration.
354    ///
355    /// # Errors
356    ///
357    /// If the following required fields are missing:
358    ///   - `etherscan_api_url`
359    ///   - `etherscan_url`
360    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/// A wrapper around an Etherscan cache object with an expiry
385/// time for each item.
386#[derive(Clone, Debug, Deserialize, Serialize)]
387struct CacheEnvelope<T> {
388    // The expiry time is the time the cache item was created + the cache TTL.
389    // The cache item is considered expired if the current time is greater than the expiry time.
390    expiry: u64,
391    // The cached data.
392    data: T,
393}
394
395/// Simple cache for Etherscan requests.
396///
397/// The cache is stored at the defined `root` with the following structure:
398///
399/// - $root/abi/$address.json
400/// - $root/sources/$address.json
401///
402/// Each cache item is stored as a JSON file with the following structure:
403///
404/// - { "expiry": $expiry, "data": $data }
405#[derive(Clone, Debug)]
406struct Cache {
407    // Path to the cache directory root.
408    root: PathBuf,
409    // Time to live for each cache item.
410    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        // Create the cache directory if it does not exist.
436        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        // Check if the cache item is still valid.
472        SystemTime::now()
473            .duration_since(UNIX_EPOCH)
474            .expect("system time is before unix epoch")
475            // Check if the current time is less than the expiry time
476            // to determine if the cache item is still valid.
477            .lt(&Duration::from_secs(inner.expiry))
478            // If the cache item is still valid, return the data.
479            // Otherwise, return None.
480            .then_some(inner.data)
481    }
482}
483
484/// The API response type
485#[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/// The type that gets serialized as query
500#[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/// This is a hack to work around `IntoUrl`'s sealed private functions, which can't be called
511/// normally.
512#[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            // Backwards compatibility, ideally these should return an error.
521            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            // Rather than get special ENV vars here, normal case is to pull overall
537            // ETHERSCAN_API_KEY
538            _ => 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    // <https://github.com/foundry-rs/foundry/issues/4406>
551    #[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}