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(-chain).etherscan.io/api>
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 post_query = match self.chain_id {
266            Some(chain_id) if self.etherscan_api_version == EtherscanApiVersion::V2 => {
267                HashMap::from([("chainid", chain_id)])
268            }
269            _ => HashMap::new(),
270        };
271
272        let response = self
273            .client
274            .post(self.etherscan_api_url.clone())
275            .form(form)
276            .query(&post_query)
277            .send()
278            .await?
279            .text()
280            .await?;
281        Ok(response)
282    }
283
284    /// Perform sanity checks on a response and deserialize it into a [Response].
285    fn sanitize_response<T: DeserializeOwned>(&self, res: impl AsRef<str>) -> Result<Response<T>> {
286        let res = res.as_ref();
287        let res: ResponseData<T> = serde_json::from_str(res).map_err(|error| {
288            error!(target: "etherscan", ?res, "Failed to deserialize response: {}", error);
289            if res == "Page not found" {
290                EtherscanError::PageNotFound
291            } else if is_blocked_by_cloudflare_response(res) {
292                EtherscanError::BlockedByCloudflare
293            } else if is_cloudflare_security_challenge(res) {
294                EtherscanError::CloudFlareSecurityChallenge
295            } else {
296                EtherscanError::Serde { error, content: res.to_string() }
297            }
298        })?;
299
300        match res {
301            ResponseData::Error { result, message, status } => {
302                if let Some(ref result) = result {
303                    if result.starts_with("Max rate limit reached") {
304                        return Err(EtherscanError::RateLimitExceeded);
305                    } else if result.to_lowercase().contains("invalid api key") {
306                        return Err(EtherscanError::InvalidApiKey);
307                    }
308                }
309                Err(EtherscanError::ErrorResponse { status, message, result })
310            }
311            ResponseData::Success(res) => Ok(res),
312        }
313    }
314
315    fn create_query<T: Serialize>(
316        &self,
317        module: &'static str,
318        action: &'static str,
319        other: T,
320    ) -> Query<'_, T> {
321        Query {
322            apikey: self.api_key.as_deref().map(Cow::Borrowed),
323            module: Cow::Borrowed(module),
324            action: Cow::Borrowed(action),
325            chain_id: self.chain_id,
326            other,
327        }
328    }
329}
330
331#[derive(Clone, Debug, Default)]
332pub struct ClientBuilder {
333    /// Client that executes HTTP requests
334    client: Option<reqwest::Client>,
335    /// Etherscan API key
336    api_key: Option<String>,
337    /// Etherscan API endpoint like <https://api(-chain).etherscan.io/api>
338    etherscan_api_url: Option<Url>,
339    /// Etherscan API version (v2 is new verifier version, v1 is the default)
340    etherscan_api_version: EtherscanApiVersion,
341    /// Etherscan base endpoint like <https://etherscan.io>
342    etherscan_url: Option<Url>,
343    /// Path to where ABI files should be cached
344    cache: Option<Cache>,
345    /// Chain ID
346    chain_id: Option<u64>,
347}
348
349// === impl ClientBuilder ===
350
351impl ClientBuilder {
352    /// Configures the etherscan url and api url for the given chain
353    ///
354    /// Note: This method also sets the chain_id for etherscan multichain verification: <https://docs.etherscan.io/contract-verification/multichain-verification>
355    ///
356    /// # Errors
357    ///
358    /// Fails if the chain is not supported by etherscan
359    pub fn chain(self, chain: Chain) -> Result<Self> {
360        fn urls(
361            (api, url): (impl IntoUrl, impl IntoUrl),
362        ) -> (reqwest::Result<Url>, reqwest::Result<Url>) {
363            (api.into_url(), url.into_url())
364        }
365        let (default_etherscan_api_url, etherscan_url) = chain
366            .named()
367            .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?
368            .etherscan_urls()
369            .map(urls)
370            .ok_or_else(|| EtherscanError::ChainNotSupported(chain))?;
371
372        // V2 etherscan default API urls are different – this handles that case.
373        let etherscan_api_url = if self.etherscan_api_version == EtherscanApiVersion::V2 {
374            Url::parse(ETHERSCAN_V2_API_BASE_URL)
375                .map_err(|_| EtherscanError::Builder("Bad URL Parse".into()))?
376        } else {
377            default_etherscan_api_url?
378        };
379
380        self.with_chain_id(chain).with_api_url(etherscan_api_url)?.with_url(etherscan_url?)
381    }
382
383    /// Configures the etherscan api version
384    pub fn with_api_version(mut self, api_version: EtherscanApiVersion) -> Self {
385        self.etherscan_api_version = api_version;
386        self
387    }
388
389    /// Configures the etherscan url
390    ///
391    /// # Errors
392    ///
393    /// Fails if the `etherscan_url` is not a valid `Url`
394    pub fn with_url(mut self, etherscan_url: impl IntoUrl) -> Result<Self> {
395        self.etherscan_url = Some(into_url(etherscan_url)?);
396        Ok(self)
397    }
398
399    /// Configures the `reqwest::Client`
400    pub fn with_client(mut self, client: reqwest::Client) -> Self {
401        self.client = Some(client);
402        self
403    }
404
405    /// Configures the etherscan api url
406    ///
407    /// # Errors
408    ///
409    /// Fails if the `etherscan_api_url` is not a valid `Url`
410    pub fn with_api_url(mut self, etherscan_api_url: impl IntoUrl) -> Result<Self> {
411        self.etherscan_api_url = Some(into_url(etherscan_api_url)?);
412        Ok(self)
413    }
414
415    /// Configures the etherscan api key
416    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
417        self.api_key = Some(api_key.into()).filter(|s| !s.is_empty());
418        self
419    }
420
421    /// Configures cache for etherscan request
422    pub fn with_cache(mut self, cache_root: Option<PathBuf>, cache_ttl: Duration) -> Self {
423        self.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
424        self
425    }
426
427    /// Configures the chain id for etherscan verification: <https://docs.etherscan.io/contract-verification/multichain-verification>
428    pub fn with_chain_id(mut self, chain: Chain) -> Self {
429        self.chain_id = Some(chain.id());
430        self
431    }
432
433    /// Returns the chain the client is built on.
434    pub fn get_chain(&self) -> Option<Chain> {
435        self.chain_id.map(Chain::from_id)
436    }
437
438    /// Returns a Client that uses this ClientBuilder configuration.
439    ///
440    /// # Errors
441    ///
442    /// If the following required fields are missing:
443    ///   - `etherscan_api_url`
444    ///   - `etherscan_url`
445    pub fn build(self) -> Result<Client> {
446        let ClientBuilder {
447            client,
448            api_key,
449            etherscan_api_version,
450            etherscan_api_url,
451            etherscan_url,
452            cache,
453            chain_id,
454        } = self;
455
456        let client = Client {
457            client: client.unwrap_or_default(),
458            api_key,
459            etherscan_api_url: etherscan_api_url
460                .clone()
461                .ok_or_else(|| EtherscanError::Builder("etherscan api url".to_string()))?,
462            // Set default API version to V1 if missing
463            etherscan_api_version,
464            etherscan_url: etherscan_url
465                .ok_or_else(|| EtherscanError::Builder("etherscan url".to_string()))?,
466            cache,
467            chain_id,
468        };
469        Ok(client)
470    }
471}
472
473/// A wrapper around an Etherscan cache object with an expiry
474/// time for each item.
475#[derive(Clone, Debug, Deserialize, Serialize)]
476struct CacheEnvelope<T> {
477    // The expiry time is the time the cache item was created + the cache TTL.
478    // The cache item is considered expired if the current time is greater than the expiry time.
479    expiry: u64,
480    // The cached data.
481    data: T,
482}
483
484/// Simple cache for Etherscan requests.
485///
486/// The cache is stored at the defined `root` with the following structure:
487///
488/// - $root/abi/$address.json
489/// - $root/sources/$address.json
490///
491/// Each cache item is stored as a JSON file with the following structure:
492///
493/// - { "expiry": $expiry, "data": $data }
494#[derive(Clone, Debug)]
495struct Cache {
496    // Path to the cache directory root.
497    root: PathBuf,
498    // Time to live for each cache item.
499    ttl: Duration,
500}
501
502impl Cache {
503    fn new(root: PathBuf, ttl: Duration) -> Self {
504        Self { root, ttl }
505    }
506
507    fn get_abi(&self, address: Address) -> Option<Option<JsonAbi>> {
508        self.get("abi", address)
509    }
510
511    fn set_abi(&self, address: Address, abi: Option<&JsonAbi>) {
512        self.set("abi", address, abi)
513    }
514
515    fn get_source(&self, address: Address) -> Option<Option<ContractMetadata>> {
516        self.get("sources", address)
517    }
518
519    fn set_source(&self, address: Address, source: Option<&ContractMetadata>) {
520        self.set("sources", address, source)
521    }
522
523    fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) {
524        // Create the cache directory if it does not exist.
525        let path = self.root.join(prefix);
526        if std::fs::create_dir_all(&path).is_err() {
527            return;
528        }
529
530        let path = path.join(format!("{address:?}.json"));
531        let writer = std::fs::File::create(path).ok().map(std::io::BufWriter::new);
532        if let Some(mut writer) = writer {
533            let _ = serde_json::to_writer(
534                &mut writer,
535                &CacheEnvelope {
536                    expiry: SystemTime::now()
537                        .checked_add(self.ttl)
538                        .expect("cache ttl overflowed")
539                        .duration_since(UNIX_EPOCH)
540                        .expect("system time is before unix epoch")
541                        .as_secs(),
542                    data: item,
543                },
544            );
545            let _ = writer.flush();
546        }
547    }
548
549    fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Option<T> {
550        let path = self.root.join(prefix).join(format!("{address:?}.json"));
551
552        let Ok(contents) = std::fs::read_to_string(path) else {
553            return None;
554        };
555
556        let Ok(inner) = serde_json::from_str::<CacheEnvelope<T>>(&contents) else {
557            return None;
558        };
559
560        // Check if the cache item is still valid.
561        SystemTime::now()
562            .duration_since(UNIX_EPOCH)
563            .expect("system time is before unix epoch")
564            // Check if the current time is less than the expiry time
565            // to determine if the cache item is still valid.
566            .lt(&Duration::from_secs(inner.expiry))
567            // If the cache item is still valid, return the data.
568            // Otherwise, return None.
569            .then_some(inner.data)
570    }
571}
572
573/// The API response type
574#[derive(Debug, Clone, Deserialize)]
575pub struct Response<T> {
576    pub status: String,
577    pub message: String,
578    pub result: T,
579}
580
581#[derive(Deserialize, Debug, Clone)]
582#[serde(untagged)]
583pub enum ResponseData<T> {
584    Success(Response<T>),
585    Error { status: String, message: String, result: Option<String> },
586}
587
588/// The type that gets serialized as query
589#[derive(Clone, Debug, Serialize)]
590struct Query<'a, T: Serialize> {
591    #[serde(skip_serializing_if = "Option::is_none")]
592    apikey: Option<Cow<'a, str>>,
593    module: Cow<'a, str>,
594    action: Cow<'a, str>,
595    #[serde(rename = "chainId", skip_serializing_if = "Option::is_none")]
596    chain_id: Option<u64>,
597    #[serde(flatten)]
598    other: T,
599}
600
601/// This is a hack to work around `IntoUrl`'s sealed private functions, which can't be called
602/// normally.
603#[inline]
604fn into_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
605    url.into_url()
606}
607
608fn get_api_key_from_chain(
609    chain: Chain,
610    api_version: EtherscanApiVersion,
611) -> Result<String, EtherscanError> {
612    match chain.kind() {
613        ChainKind::Named(named) => match named {
614            // Fantom is special and doesn't support etherscan api v2
615            NamedChain::Fantom | NamedChain::FantomTestnet => std::env::var("FMTSCAN_API_KEY")
616                .or_else(|_| std::env::var("FANTOMSCAN_API_KEY"))
617                .map_err(Into::into),
618
619            // Backwards compatibility, ideally these should return an error.
620            NamedChain::Gnosis
621            | NamedChain::Chiado
622            | NamedChain::Sepolia
623            | NamedChain::Rsk
624            | NamedChain::Sokol
625            | NamedChain::Poa
626            | NamedChain::Oasis
627            | NamedChain::Emerald
628            | NamedChain::EmeraldTestnet
629            | NamedChain::Evmos
630            | NamedChain::EvmosTestnet => Ok(String::new()),
631            NamedChain::AnvilHardhat | NamedChain::Dev => {
632                Err(EtherscanError::LocalNetworksNotSupported)
633            }
634
635            // Rather than get special ENV vars here, normal case is to pull overall
636            // ETHERSCAN_API_KEY
637            _ => {
638                if api_version == EtherscanApiVersion::V1 {
639                    named
640                        .etherscan_api_key_name()
641                        .ok_or_else(|| EtherscanError::ChainNotSupported(chain))
642                        .and_then(|key_name| std::env::var(key_name).map_err(Into::into))
643                } else {
644                    std::env::var("ETHERSCAN_API_KEY").map_err(Into::into)
645                }
646            }
647        },
648        ChainKind::Id(_) => Err(EtherscanError::ChainNotSupported(chain)),
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use crate::{Client, EtherscanApiVersion, EtherscanError, ResponseData};
655    use alloy_chains::Chain;
656    use alloy_primitives::{Address, B256};
657
658    // <https://github.com/foundry-rs/foundry/issues/4406>
659    #[test]
660    fn can_parse_block_scout_err() {
661        let err = "{\"message\":\"Something went wrong.\",\"result\":null,\"status\":\"0\"}";
662        let resp: ResponseData<Address> = serde_json::from_str(err).unwrap();
663        assert!(matches!(resp, ResponseData::Error { .. }));
664    }
665
666    #[test]
667    fn test_api_paths_v1() {
668        let client =
669            Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V1).unwrap();
670        assert_eq!(client.etherscan_api_url.as_str(), "https://api-goerli.etherscan.io/api");
671
672        assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
673    }
674
675    #[test]
676    fn test_api_paths_v2() {
677        let client =
678            Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V2).unwrap();
679        assert_eq!(client.etherscan_api_url.as_str(), "https://api.etherscan.io/v2/api");
680
681        assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
682    }
683
684    #[test]
685    fn stringifies_block_url() {
686        let etherscan = Client::new(Chain::mainnet(), "").unwrap();
687        let block: u64 = 1;
688        let block_url: String = etherscan.block_url(block);
689        assert_eq!(block_url, format!("https://etherscan.io/block/{block}"));
690    }
691
692    #[test]
693    fn stringifies_address_url() {
694        let etherscan = Client::new(Chain::mainnet(), "").unwrap();
695        let addr: Address = Address::ZERO;
696        let address_url: String = etherscan.address_url(addr);
697        assert_eq!(address_url, format!("https://etherscan.io/address/{addr:?}"));
698    }
699
700    #[test]
701    fn stringifies_transaction_url() {
702        let etherscan = Client::new(Chain::mainnet(), "").unwrap();
703        let tx_hash = B256::ZERO;
704        let tx_url: String = etherscan.transaction_url(tx_hash);
705        assert_eq!(tx_url, format!("https://etherscan.io/tx/{tx_hash:?}"));
706    }
707
708    #[test]
709    fn stringifies_token_url() {
710        let etherscan = Client::new(Chain::mainnet(), "").unwrap();
711        let token_hash = Address::ZERO;
712        let token_url: String = etherscan.token_url(token_hash);
713        assert_eq!(token_url, format!("https://etherscan.io/token/{token_hash:?}"));
714    }
715
716    #[test]
717    fn local_networks_not_supported() {
718        let err = Client::new_from_env(Chain::dev()).unwrap_err();
719        assert!(matches!(err, EtherscanError::LocalNetworksNotSupported));
720    }
721
722    #[test]
723    fn can_parse_etherscan_mainnet_invalid_api_key() {
724        let err = serde_json::json!({
725            "status":"0",
726            "message":"NOTOK",
727            "result":"Missing/Invalid API Key"
728        });
729        let resp: ResponseData<Address> = serde_json::from_value(err).unwrap();
730        assert!(matches!(resp, ResponseData::Error { .. }));
731    }
732
733    #[test]
734    fn can_parse_api_version() {
735        assert_eq!(
736            EtherscanApiVersion::try_from("v1".to_string()).unwrap(),
737            EtherscanApiVersion::V1
738        );
739        assert_eq!(
740            EtherscanApiVersion::try_from("v2".to_string()).unwrap(),
741            EtherscanApiVersion::V2
742        );
743
744        let parse_err = EtherscanApiVersion::try_from("fail".to_string()).unwrap_err();
745        assert!(matches!(parse_err, EtherscanError::InvalidApiVersion));
746    }
747}