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    collections::HashMap,
28    io::Write,
29    path::PathBuf,
30    str::FromStr,
31    time::{Duration, SystemTime, UNIX_EPOCH},
32};
33
34pub mod account;
35pub mod block_number;
36pub mod blocks;
37pub mod contract;
38pub mod errors;
39pub mod gas;
40pub mod serde_helpers;
41pub mod source_tree;
42mod transaction;
43pub mod units;
44pub mod utils;
45pub mod verify;
46
47pub(crate) type Result<T, E = EtherscanError> = std::result::Result<T, E>;
48
49/// The URL for the etherscan V2 API without the chainid param set.
50pub const ETHERSCAN_V2_API_BASE_URL: &str = "https://api.etherscan.io/v2/api";
51
52/// The Etherscan.io API version 1 - classic verifier, one API per chain, 2 - new multichain
53/// verifier
54#[derive(Clone, Default, Debug, PartialEq, Copy, Eq, Deserialize, Serialize)]
55#[serde(rename_all = "lowercase")]
56pub enum EtherscanApiVersion {
57    V1,
58    #[default]
59    V2,
60}
61
62impl std::fmt::Display for EtherscanApiVersion {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            EtherscanApiVersion::V1 => write!(f, "v1"),
66            EtherscanApiVersion::V2 => write!(f, "v2"),
67        }
68    }
69}
70
71impl TryFrom<String> for EtherscanApiVersion {
72    type Error = EtherscanError;
73
74    fn try_from(value: String) -> Result<Self, Self::Error> {
75        Self::from_str(value.as_str())
76    }
77}
78
79impl FromStr for EtherscanApiVersion {
80    type Err = EtherscanError;
81
82    fn from_str(value: &str) -> Result<Self, Self::Err> {
83        match value {
84            "v1" | "V1" => Ok(EtherscanApiVersion::V1),
85            "v2" | "V2" => Ok(EtherscanApiVersion::V2),
86            _ => Err(EtherscanError::InvalidApiVersion),
87        }
88    }
89}
90
91/// The Etherscan.io API client.
92#[derive(Clone, Debug)]
93pub struct Client {
94    /// Client that executes HTTP requests
95    client: reqwest::Client,
96    /// Etherscan API key
97    api_key: Option<String>,
98    /// Etherscan API version
99    etherscan_api_version: EtherscanApiVersion,
100    /// Etherscan API endpoint like <https://api.etherscan.io/v2/api?chainid=(chain_id)>
101    etherscan_api_url: Url,
102    /// Etherscan base endpoint like <https://etherscan.io>
103    etherscan_url: Url,
104    /// Path to where ABI files should be cached
105    cache: Option<Cache>,
106    /// Chain ID
107    chain_id: Option<u64>,
108}
109
110impl Client {
111    /// Creates a `ClientBuilder` to configure a `Client`.
112    ///
113    /// This is the same as `ClientBuilder::default()`.
114    ///
115    /// # Example
116    ///
117    /// ```rust
118    /// use alloy_chains::Chain;
119    /// use foundry_block_explorers::Client;
120    /// let client = Client::builder()
121    ///     .with_api_key("<API KEY>")
122    ///     .chain(Chain::mainnet())
123    ///     .unwrap()
124    ///     .build()
125    ///     .unwrap();
126    /// ```
127    pub fn builder() -> ClientBuilder {
128        ClientBuilder::default()
129    }
130
131    /// Creates a new instance that caches etherscan requests
132    pub fn new_cached(
133        chain: Chain,
134        api_key: impl Into<String>,
135        cache_root: Option<PathBuf>,
136        cache_ttl: Duration,
137    ) -> Result<Self> {
138        let mut this = Self::new(chain, api_key)?;
139        this.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
140        Ok(this)
141    }
142
143    /// Create a new client with the correct endpoints based on the chain and provided API key
144    pub fn new(chain: Chain, api_key: impl Into<String>) -> Result<Self> {
145        Client::builder().with_api_key(api_key).chain(chain)?.build()
146    }
147
148    /// Create a new client for the given [`EtherscanApiVersion`].
149    pub fn new_with_api_version(
150        chain: Chain,
151        api_key: impl Into<String>,
152        api_version: EtherscanApiVersion,
153    ) -> Result<Self> {
154        Client::builder().with_api_key(api_key).with_api_version(api_version).chain(chain)?.build()
155    }
156
157    /// Create a new client with the correct endpoint with the chain
158    pub fn new_from_env(chain: Chain) -> Result<Self> {
159        Self::new_with_api_version(
160            chain,
161            get_api_key_from_chain(chain, EtherscanApiVersion::V2)?,
162            EtherscanApiVersion::V2,
163        )
164    }
165
166    /// Create a new client with the correct endpoints based on the chain and API key
167    /// from the default environment variable defined in [`Chain`].
168    pub fn new_v1_from_env(chain: Chain) -> Result<Self> {
169        Self::new_with_api_version(
170            chain,
171            get_api_key_from_chain(chain, EtherscanApiVersion::V1)?,
172            EtherscanApiVersion::V1,
173        )
174    }
175
176    /// Create a new client with the correct endpoints based on the chain and API key
177    /// from the default environment variable defined in [`Chain`].
178    ///
179    /// If the environment variable is not set, create a new client without it.
180    pub fn new_from_opt_env(chain: Chain) -> Result<Self> {
181        match Self::new_from_env(chain) {
182            Ok(client) => Ok(client),
183            Err(EtherscanError::EnvVarNotFound(_)) => {
184                Self::builder().chain(chain).and_then(|c| c.build())
185            }
186            Err(e) => Err(e),
187        }
188    }
189
190    /// Sets the root to the cache dir and the ttl to use
191    pub fn set_cache(&mut self, root: impl Into<PathBuf>, ttl: Duration) -> &mut Self {
192        self.cache = Some(Cache { root: root.into(), ttl });
193        self
194    }
195
196    /// Returns the configured etherscan api version.
197    pub fn etherscan_api_version(&self) -> &EtherscanApiVersion {
198        &self.etherscan_api_version
199    }
200
201    pub fn etherscan_api_url(&self) -> &Url {
202        &self.etherscan_api_url
203    }
204
205    pub fn etherscan_url(&self) -> &Url {
206        &self.etherscan_url
207    }
208
209    /// Returns the configured API key, if any
210    pub fn api_key(&self) -> Option<&str> {
211        self.api_key.as_deref()
212    }
213
214    /// Return the URL for the given block number
215    pub fn block_url(&self, block: u64) -> String {
216        self.etherscan_url.join(&format!("block/{block}")).unwrap().to_string()
217    }
218
219    /// Return the URL for the given address
220    pub fn address_url(&self, address: Address) -> String {
221        self.etherscan_url.join(&format!("address/{address:?}")).unwrap().to_string()
222    }
223
224    /// Return the URL for the given transaction hash
225    pub fn transaction_url(&self, tx_hash: B256) -> String {
226        self.etherscan_url.join(&format!("tx/{tx_hash:?}")).unwrap().to_string()
227    }
228
229    /// Return the URL for the given token hash
230    pub fn token_url(&self, token_hash: Address) -> String {
231        self.etherscan_url.join(&format!("token/{token_hash:?}")).unwrap().to_string()
232    }
233
234    /// Execute an GET request with parameters.
235    async fn get_json<T: DeserializeOwned, Q: Serialize>(&self, query: &Q) -> Result<Response<T>> {
236        let res = self.get(query).await?;
237        self.sanitize_response(res)
238    }
239
240    /// Execute a GET request with parameters, without sanity checking the response.
241    async fn get<Q: Serialize>(&self, query: &Q) -> Result<String> {
242        trace!(target: "etherscan", "GET {}", self.etherscan_api_url);
243        let response = self
244            .client
245            .get(self.etherscan_api_url.clone())
246            .header(header::ACCEPT, "application/json")
247            .query(query)
248            .send()
249            .await?
250            .text()
251            .await?;
252        Ok(response)
253    }
254
255    /// Execute a POST request with a form.
256    async fn post_form<T: DeserializeOwned, F: Serialize>(&self, form: &F) -> Result<Response<T>> {
257        let res = self.post(form).await?;
258        self.sanitize_response(res)
259    }
260
261    /// Execute a POST request with a form, without sanity checking the response.
262    async fn post<F: Serialize>(&self, form: &F) -> Result<String> {
263        trace!(target: "etherscan", "POST {}", self.etherscan_api_url);
264
265        let mut post_query = HashMap::new();
266
267        if self.etherscan_api_version == EtherscanApiVersion::V2
268            && self.chain_id.is_some()
269            && !self.url_contains_chainid()
270        {
271            post_query.insert("chainid", self.chain_id.unwrap());
272        }
273
274        let response = self
275            .client
276            .post(self.etherscan_api_url.clone())
277            .form(form)
278            .query(&post_query)
279            .send()
280            .await?
281            .text()
282            .await?;
283
284        Ok(response)
285    }
286
287    /// Perform sanity checks on a response and deserialize it into a [Response].
288    fn sanitize_response<T: DeserializeOwned>(&self, res: impl AsRef<str>) -> Result<Response<T>> {
289        let res = res.as_ref();
290        let res: ResponseData<T> = serde_json::from_str(res).map_err(|error| {
291            error!(target: "etherscan", ?res, "Failed to deserialize response: {}", error);
292            if res == "Page not found" {
293                EtherscanError::PageNotFound
294            } else if is_blocked_by_cloudflare_response(res) {
295                EtherscanError::BlockedByCloudflare
296            } else if is_cloudflare_security_challenge(res) {
297                EtherscanError::CloudFlareSecurityChallenge
298            } else {
299                EtherscanError::Serde { error, content: res.to_string() }
300            }
301        })?;
302
303        match res {
304            ResponseData::Error { result, message, status } => {
305                if let Some(ref result) = result {
306                    if result.starts_with("Max rate limit reached") {
307                        return Err(EtherscanError::RateLimitExceeded);
308                    } else if result.to_lowercase().contains("invalid api key") {
309                        return Err(EtherscanError::InvalidApiKey);
310                    }
311                }
312                Err(EtherscanError::ErrorResponse { status, message, result })
313            }
314            ResponseData::Success(res) => Ok(res),
315        }
316    }
317
318    fn create_query<T: Serialize>(
319        &self,
320        module: &'static str,
321        action: &'static str,
322        other: T,
323    ) -> Query<'_, T> {
324        Query {
325            apikey: self.api_key.as_deref().map(Cow::Borrowed),
326            module: Cow::Borrowed(module),
327            action: Cow::Borrowed(action),
328            chain_id: if self.url_contains_chainid() { None } else { self.chain_id },
329            other,
330        }
331    }
332
333    fn url_contains_chainid(&self) -> bool {
334        self.etherscan_api_url.query_pairs().any(|(key, _)| key.eq_ignore_ascii_case("chainid"))
335    }
336}
337
338#[derive(Clone, Debug, Default)]
339pub struct ClientBuilder {
340    /// Client that executes HTTP requests
341    client: Option<reqwest::Client>,
342    /// Etherscan API key
343    api_key: Option<String>,
344    /// Etherscan API endpoint like <https://api.etherscan.io/v2/api?chainid=(chain_id)>
345    etherscan_api_url: Option<Url>,
346    /// Etherscan API version (v2 is new verifier version, v1 is the default)
347    etherscan_api_version: EtherscanApiVersion,
348    /// Etherscan base endpoint like <https://etherscan.io>
349    etherscan_url: Option<Url>,
350    /// Path to where ABI files should be cached
351    cache: Option<Cache>,
352    /// Chain ID
353    chain_id: Option<u64>,
354}
355
356// === impl ClientBuilder ===
357
358impl ClientBuilder {
359    /// Configures the etherscan url and api url for the given chain
360    ///
361    /// Note: This method also sets the chain_id for etherscan multichain verification: <https://docs.etherscan.io/contract-verification/multichain-verification>
362    ///
363    /// # Errors
364    ///
365    /// Fails if the chain is not supported by etherscan
366    pub fn chain(self, chain: Chain) -> Result<Self> {
367        fn urls(
368            (api, url): (impl IntoUrl, impl IntoUrl),
369        ) -> (reqwest::Result<Url>, reqwest::Result<Url>) {
370            (api.into_url(), url.into_url())
371        }
372        let (default_etherscan_api_url, etherscan_url) = chain
373            .named()
374            .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?
375            .etherscan_urls()
376            .map(urls)
377            .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?;
378
379        // V2 etherscan default API urls are different – this handles that case.
380        let etherscan_api_url = if self.etherscan_api_version == EtherscanApiVersion::V2 {
381            Url::parse(ETHERSCAN_V2_API_BASE_URL)
382                .map_err(|_| EtherscanError::Builder("Bad URL Parse".into()))?
383        } else {
384            default_etherscan_api_url?
385        };
386
387        self.with_chain_id(chain).with_api_url(etherscan_api_url)?.with_url(etherscan_url?)
388    }
389
390    /// Configures the etherscan api version
391    pub fn with_api_version(mut self, api_version: EtherscanApiVersion) -> Self {
392        self.etherscan_api_version = api_version;
393        self
394    }
395
396    /// Configures the etherscan url
397    ///
398    /// # Errors
399    ///
400    /// Fails if the `etherscan_url` is not a valid `Url`
401    pub fn with_url(mut self, etherscan_url: impl IntoUrl) -> Result<Self> {
402        self.etherscan_url = Some(into_url(etherscan_url)?);
403        Ok(self)
404    }
405
406    /// Configures the `reqwest::Client`
407    pub fn with_client(mut self, client: reqwest::Client) -> Self {
408        self.client = Some(client);
409        self
410    }
411
412    /// Configures the etherscan api url
413    ///
414    /// # Errors
415    ///
416    /// Fails if the `etherscan_api_url` is not a valid `Url`
417    pub fn with_api_url(mut self, etherscan_api_url: impl IntoUrl) -> Result<Self> {
418        self.etherscan_api_url = Some(into_url(etherscan_api_url)?);
419        Ok(self)
420    }
421
422    /// Configures the etherscan api key
423    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
424        self.api_key = Some(api_key.into()).filter(|s| !s.is_empty());
425        self
426    }
427
428    /// Configures cache for etherscan request
429    pub fn with_cache(mut self, cache_root: Option<PathBuf>, cache_ttl: Duration) -> Self {
430        self.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
431        self
432    }
433
434    /// Configures the chain id for etherscan verification: <https://docs.etherscan.io/contract-verification/multichain-verification>
435    pub fn with_chain_id(mut self, chain: Chain) -> Self {
436        self.chain_id = Some(chain.id());
437        self
438    }
439
440    /// Returns the chain the client is built on.
441    pub fn get_chain(&self) -> Option<Chain> {
442        self.chain_id.map(Chain::from_id)
443    }
444
445    /// Returns a Client that uses this ClientBuilder configuration.
446    ///
447    /// # Errors
448    ///
449    /// If the following required fields are missing:
450    ///   - `etherscan_api_url`
451    ///   - `etherscan_url`
452    pub fn build(self) -> Result<Client> {
453        let ClientBuilder {
454            client,
455            api_key,
456            etherscan_api_version,
457            etherscan_api_url,
458            etherscan_url,
459            cache,
460            chain_id,
461        } = self;
462
463        let client = Client {
464            client: client.unwrap_or_default(),
465            api_key,
466            etherscan_api_url: etherscan_api_url
467                .clone()
468                .ok_or_else(|| EtherscanError::Builder("etherscan api url".to_string()))?,
469            // Set default API version to V1 if missing
470            etherscan_api_version,
471            etherscan_url: etherscan_url
472                .ok_or_else(|| EtherscanError::Builder("etherscan url".to_string()))?,
473            cache,
474            chain_id,
475        };
476        Ok(client)
477    }
478}
479
480/// A wrapper around an Etherscan cache object with an expiry
481/// time for each item.
482#[derive(Clone, Debug, Deserialize, Serialize)]
483struct CacheEnvelope<T> {
484    // The expiry time is the time the cache item was created + the cache TTL.
485    // The cache item is considered expired if the current time is greater than the expiry time.
486    expiry: u64,
487    // The cached data.
488    data: T,
489}
490
491/// Simple cache for Etherscan requests.
492///
493/// The cache is stored at the defined `root` with the following structure:
494///
495/// - $root/abi/$address.json
496/// - $root/sources/$address.json
497///
498/// Each cache item is stored as a JSON file with the following structure:
499///
500/// - { "expiry": $expiry, "data": $data }
501#[derive(Clone, Debug)]
502struct Cache {
503    // Path to the cache directory root.
504    root: PathBuf,
505    // Time to live for each cache item.
506    ttl: Duration,
507}
508
509impl Cache {
510    fn new(root: PathBuf, ttl: Duration) -> Self {
511        Self { root, ttl }
512    }
513
514    fn get_abi(&self, address: Address) -> Option<Option<JsonAbi>> {
515        self.get("abi", address)
516    }
517
518    fn set_abi(&self, address: Address, abi: Option<&JsonAbi>) {
519        self.set("abi", address, abi)
520    }
521
522    fn get_source(&self, address: Address) -> Option<Option<ContractMetadata>> {
523        self.get("sources", address)
524    }
525
526    fn set_source(&self, address: Address, source: Option<&ContractMetadata>) {
527        self.set("sources", address, source)
528    }
529
530    fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) {
531        // Create the cache directory if it does not exist.
532        let path = self.root.join(prefix);
533        if std::fs::create_dir_all(&path).is_err() {
534            return;
535        }
536
537        let path = path.join(format!("{address:?}.json"));
538        let writer = std::fs::File::create(path).ok().map(std::io::BufWriter::new);
539        if let Some(mut writer) = writer {
540            let _ = serde_json::to_writer(
541                &mut writer,
542                &CacheEnvelope {
543                    expiry: SystemTime::now()
544                        .checked_add(self.ttl)
545                        .expect("cache ttl overflowed")
546                        .duration_since(UNIX_EPOCH)
547                        .expect("system time is before unix epoch")
548                        .as_secs(),
549                    data: item,
550                },
551            );
552            let _ = writer.flush();
553        }
554    }
555
556    fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Option<T> {
557        let path = self.root.join(prefix).join(format!("{address:?}.json"));
558
559        let Ok(contents) = std::fs::read_to_string(path) else {
560            return None;
561        };
562
563        let Ok(inner) = serde_json::from_str::<CacheEnvelope<T>>(&contents) else {
564            return None;
565        };
566
567        // Check if the cache item is still valid.
568        SystemTime::now()
569            .duration_since(UNIX_EPOCH)
570            .expect("system time is before unix epoch")
571            // Check if the current time is less than the expiry time
572            // to determine if the cache item is still valid.
573            .lt(&Duration::from_secs(inner.expiry))
574            // If the cache item is still valid, return the data.
575            // Otherwise, return None.
576            .then_some(inner.data)
577    }
578}
579
580/// The API response type
581#[derive(Debug, Clone, Deserialize)]
582pub struct Response<T> {
583    pub status: String,
584    pub message: String,
585    pub result: T,
586}
587
588#[derive(Deserialize, Debug, Clone)]
589#[serde(untagged)]
590pub enum ResponseData<T> {
591    Success(Response<T>),
592    Error { status: String, message: String, result: Option<String> },
593}
594
595/// The type that gets serialized as query
596#[derive(Clone, Debug, Serialize)]
597struct Query<'a, T: Serialize> {
598    #[serde(skip_serializing_if = "Option::is_none")]
599    apikey: Option<Cow<'a, str>>,
600    module: Cow<'a, str>,
601    action: Cow<'a, str>,
602    #[serde(rename = "chainId", skip_serializing_if = "Option::is_none")]
603    chain_id: Option<u64>,
604    #[serde(flatten)]
605    other: T,
606}
607
608/// This is a hack to work around `IntoUrl`'s sealed private functions, which can't be called
609/// normally.
610#[inline]
611fn into_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
612    url.into_url()
613}
614
615fn get_api_key_from_chain(
616    chain: Chain,
617    api_version: EtherscanApiVersion,
618) -> Result<String, EtherscanError> {
619    match chain.kind() {
620        ChainKind::Named(named) => match named {
621            // Fantom is special and doesn't support etherscan api v2
622            NamedChain::Fantom | NamedChain::FantomTestnet => std::env::var("FMTSCAN_API_KEY")
623                .or_else(|_| std::env::var("FANTOMSCAN_API_KEY"))
624                .map_err(Into::into),
625
626            // Backwards compatibility, ideally these should return an error.
627            NamedChain::Gnosis
628            | NamedChain::Chiado
629            | NamedChain::Sepolia
630            | NamedChain::Rsk
631            | NamedChain::Sokol
632            | NamedChain::Poa
633            | NamedChain::Oasis
634            | NamedChain::Emerald
635            | NamedChain::EmeraldTestnet
636            | NamedChain::Evmos
637            | NamedChain::EvmosTestnet => Ok(String::new()),
638            NamedChain::AnvilHardhat | NamedChain::Dev => {
639                Err(EtherscanError::LocalNetworksNotSupported)
640            }
641
642            // Rather than get special ENV vars here, normal case is to pull overall
643            // ETHERSCAN_API_KEY
644            _ => {
645                if api_version == EtherscanApiVersion::V1 {
646                    named
647                        .etherscan_api_key_name()
648                        .ok_or_else(|| EtherscanError::ChainNotSupported(chain))
649                        .and_then(|key_name| std::env::var(key_name).map_err(Into::into))
650                } else {
651                    std::env::var("ETHERSCAN_API_KEY").map_err(Into::into)
652                }
653            }
654        },
655        ChainKind::Id(_) => Err(EtherscanError::ChainNotSupported(chain)),
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use crate::{Client, EtherscanApiVersion, EtherscanError, ResponseData};
662    use alloy_chains::Chain;
663    use alloy_primitives::{Address, B256};
664
665    // <https://github.com/foundry-rs/foundry/issues/4406>
666    #[test]
667    fn can_parse_block_scout_err() {
668        let err = "{\"message\":\"Something went wrong.\",\"result\":null,\"status\":\"0\"}";
669        let resp: ResponseData<Address> = serde_json::from_str(err).unwrap();
670        assert!(matches!(resp, ResponseData::Error { .. }));
671    }
672
673    #[test]
674    fn test_api_paths_v1() {
675        let client =
676            Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V1).unwrap();
677        assert_eq!(client.etherscan_api_url.as_str(), "https://api-goerli.etherscan.io/api");
678
679        assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
680    }
681
682    #[test]
683    fn test_api_paths_v2() {
684        let client =
685            Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V2).unwrap();
686        assert_eq!(client.etherscan_api_url.as_str(), "https://api.etherscan.io/v2/api");
687
688        assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
689    }
690
691    #[test]
692    fn stringifies_block_url() {
693        let etherscan = Client::new(Chain::mainnet(), "").unwrap();
694        let block: u64 = 1;
695        let block_url: String = etherscan.block_url(block);
696        assert_eq!(block_url, format!("https://etherscan.io/block/{block}"));
697    }
698
699    #[test]
700    fn stringifies_address_url() {
701        let etherscan = Client::new(Chain::mainnet(), "").unwrap();
702        let addr: Address = Address::ZERO;
703        let address_url: String = etherscan.address_url(addr);
704        assert_eq!(address_url, format!("https://etherscan.io/address/{addr:?}"));
705    }
706
707    #[test]
708    fn stringifies_transaction_url() {
709        let etherscan = Client::new(Chain::mainnet(), "").unwrap();
710        let tx_hash = B256::ZERO;
711        let tx_url: String = etherscan.transaction_url(tx_hash);
712        assert_eq!(tx_url, format!("https://etherscan.io/tx/{tx_hash:?}"));
713    }
714
715    #[test]
716    fn stringifies_token_url() {
717        let etherscan = Client::new(Chain::mainnet(), "").unwrap();
718        let token_hash = Address::ZERO;
719        let token_url: String = etherscan.token_url(token_hash);
720        assert_eq!(token_url, format!("https://etherscan.io/token/{token_hash:?}"));
721    }
722
723    #[test]
724    fn local_networks_not_supported() {
725        let err = Client::new_from_env(Chain::dev()).unwrap_err();
726        assert!(matches!(err, EtherscanError::LocalNetworksNotSupported));
727    }
728
729    #[test]
730    fn can_parse_etherscan_mainnet_invalid_api_key() {
731        let err = serde_json::json!({
732            "status":"0",
733            "message":"NOTOK",
734            "result":"Missing/Invalid API Key"
735        });
736        let resp: ResponseData<Address> = serde_json::from_value(err).unwrap();
737        assert!(matches!(resp, ResponseData::Error { .. }));
738    }
739
740    #[test]
741    fn can_parse_api_version() {
742        assert_eq!(
743            EtherscanApiVersion::try_from("v1".to_string()).unwrap(),
744            EtherscanApiVersion::V1
745        );
746        assert_eq!(
747            EtherscanApiVersion::try_from("v2".to_string()).unwrap(),
748            EtherscanApiVersion::V2
749        );
750
751        let parse_err = EtherscanApiVersion::try_from("fail".to_string()).unwrap_err();
752        assert!(matches!(parse_err, EtherscanError::InvalidApiVersion));
753    }
754}