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, 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/// 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}
260
261// === impl ClientBuilder ===
262
263impl ClientBuilder {
264    /// Configures the Etherscan url and api url for the given chain
265    ///
266    /// Note: This method also sets the chain_id for Etherscan multichain verification: <https://docs.etherscan.io/contract-verification/multichain-verification>
267    ///
268    /// # Errors
269    ///
270    /// Fails if the chain is not supported by Etherscan
271    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    /// Configures the Etherscan url
288    ///
289    /// # Errors
290    ///
291    /// Fails if the `etherscan_url` is not a valid `Url`
292    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    /// Configures the `reqwest::Client`
298    pub fn with_client(mut self, client: reqwest::Client) -> Self {
299        self.client = Some(client);
300        self
301    }
302
303    /// Configures the Etherscan api url
304    ///
305    /// # Errors
306    ///
307    /// Fails if the `etherscan_api_url` is not a valid `Url`
308    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    /// Configures the Etherscan api key
314    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    /// Configures cache for Etherscan request
320    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    /// Returns a Client that uses this ClientBuilder configuration.
326    ///
327    /// # Errors
328    ///
329    /// If the following required fields are missing:
330    ///   - `etherscan_api_url`
331    ///   - `etherscan_url`
332    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/// A wrapper around an Etherscan cache object with an expiry
350/// time for each item.
351#[derive(Clone, Debug, Deserialize, Serialize)]
352struct CacheEnvelope<T> {
353    // The expiry time is the time the cache item was created + the cache TTL.
354    // The cache item is considered expired if the current time is greater than the expiry time.
355    expiry: u64,
356    // The cached data.
357    data: T,
358}
359
360/// Simple cache for Etherscan requests.
361///
362/// The cache is stored at the defined `root` with the following structure:
363///
364/// - $root/abi/$address.json
365/// - $root/sources/$address.json
366///
367/// Each cache item is stored as a JSON file with the following structure:
368///
369/// - { "expiry": $expiry, "data": $data }
370#[derive(Clone, Debug)]
371struct Cache {
372    // Path to the cache directory root.
373    root: PathBuf,
374    // Time to live for each cache item.
375    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        // Create the cache directory if it does not exist.
401        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        // Check if the cache item is still valid.
437        SystemTime::now()
438            .duration_since(UNIX_EPOCH)
439            .expect("system time is before unix epoch")
440            // Check if the current time is less than the expiry time
441            // to determine if the cache item is still valid.
442            .lt(&Duration::from_secs(inner.expiry))
443            // If the cache item is still valid, return the data.
444            // Otherwise, return None.
445            .then_some(inner.data)
446    }
447}
448
449/// The API response type
450#[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/// The type that gets serialized as query
465#[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/// This is a hack to work around `IntoUrl`'s sealed private functions, which can't be called
476/// normally.
477#[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            // Fantom is special and doesn't support etherscan api v2
486            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            // Backwards compatibility, ideally these should return an error.
491            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            // Rather than get special ENV vars here, normal case is to pull overall
507            // ETHERSCAN_API_KEY
508            _ => 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    // <https://github.com/foundry-rs/foundry/issues/4406>
521    #[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}