//! Bindings for [etherscan.io web api](https://docs.etherscan.io/) use std::borrow::Cow; use reqwest::{header, Url}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use errors::EtherscanError; use ethers_core::{abi::Address, types::Chain}; pub mod contract; pub mod errors; pub mod gas; pub mod transaction; pub(crate) type Result = std::result::Result; /// The Etherscan.io API client. #[derive(Clone, Debug)] pub struct Client { /// Client that executes HTTP requests client: reqwest::Client, /// Etherscan API key api_key: String, /// Etherscan API endpoint like https://api(-chain).etherscan.io/api etherscan_api_url: Url, /// Etherscan base endpoint like https://etherscan.io etherscan_url: Url, } impl Client { /// Create a new client with the correct endpoints based on the chain and provided API key pub fn new(chain: Chain, api_key: impl Into) -> Result { let (etherscan_api_url, etherscan_url) = match chain { Chain::Mainnet => { (Url::parse("https://api.etherscan.io/api"), Url::parse("https://etherscan.io")) } Chain::Ropsten | Chain::Kovan | Chain::Rinkeby | Chain::Goerli => { let chain_name = chain.to_string().to_lowercase(); ( Url::parse(&format!("https://api-{}.etherscan.io/api", chain_name)), Url::parse(&format!("https://{}.etherscan.io", chain_name)), ) } Chain::Polygon => ( Url::parse("https://api.polygonscan.com/api"), Url::parse("https://polygonscan.com"), ), Chain::PolygonMumbai => ( Url::parse("https://api-testnet.polygonscan.com/api"), Url::parse("https://mumbai.polygonscan.com"), ), Chain::Avalanche => { (Url::parse("https://api.snowtrace.io/api"), Url::parse("https://snowtrace.io")) } Chain::AvalancheFuji => ( Url::parse("https://api-testnet.snowtrace.io/api"), Url::parse("https://testnet.snowtrace.io"), ), Chain::Optimism => ( Url::parse("https://api-optimistic.etherscan.io/api"), Url::parse("https://optimistic.etherscan.io"), ), Chain::OptimismKovan => ( Url::parse("https://api-kovan-optimistic.etherscan.io/api"), Url::parse("https://kovan-optimistic.etherscan.io"), ), Chain::Fantom => { (Url::parse("https://api.ftmscan.com"), Url::parse("https://ftmscan.com")) } Chain::FantomTestnet => ( Url::parse("https://api-testnet.ftmscan.com"), Url::parse("https://testnet.ftmscan.com"), ), Chain::BinanceSmartChain => { (Url::parse("https://api.bscscan.com/api"), Url::parse("https://bscscan.com")) } Chain::BinanceSmartChainTestnet => ( Url::parse("https://api-testnet.bscscan.com/api"), Url::parse("https://testnet.bscscan.com"), ), Chain::Arbitrum => { (Url::parse("https://api.arbiscan.io/api"), Url::parse("https://arbiscan.io")) } Chain::ArbitrumTestnet => ( Url::parse("https://api-testnet.arbiscan.io/api"), Url::parse("https://testnet.arbiscan.io"), ), chain => return Err(EtherscanError::ChainNotSupported(chain)), }; Ok(Self { client: Default::default(), api_key: api_key.into(), etherscan_api_url: etherscan_api_url.expect("is valid http"), etherscan_url: etherscan_url.expect("is valid http"), }) } /// Create a new client with the correct endpoints based on the chain and API key /// from ETHERSCAN_API_KEY environment variable pub fn new_from_env(chain: Chain) -> Result { let api_key = match chain { Chain::Avalanche | Chain::AvalancheFuji => std::env::var("SNOWTRACE_API_KEY")?, Chain::Polygon | Chain::PolygonMumbai => std::env::var("POLYGONSCAN_API_KEY")?, Chain::Mainnet | Chain::Ropsten | Chain::Kovan | Chain::Rinkeby | Chain::Goerli | Chain::Optimism | Chain::OptimismKovan | Chain::Fantom | Chain::FantomTestnet | Chain::BinanceSmartChain | Chain::BinanceSmartChainTestnet | Chain::Arbitrum | Chain::ArbitrumTestnet => std::env::var("ETHERSCAN_API_KEY")?, Chain::XDai | Chain::Sepolia => String::default(), Chain::Moonbeam | Chain::MoonbeamDev | Chain::Moonriver => { std::env::var("MOONSCAN_API_KEY")? } }; Self::new(chain, api_key) } pub fn etherscan_api_url(&self) -> &Url { &self.etherscan_api_url } pub fn etherscan_url(&self) -> &Url { &self.etherscan_url } /// Return the URL for the given block number pub fn block_url(&self, block: u64) -> String { format!("{}/block/{}", self.etherscan_url, block) } /// Return the URL for the given address pub fn address_url(&self, address: Address) -> String { format!("{}/address/{}", self.etherscan_url, address) } /// Return the URL for the given transaction hash pub fn transaction_url(&self, tx_hash: impl AsRef) -> String { format!("{}/tx/{}", self.etherscan_url, tx_hash.as_ref()) } /// Return the URL for the given token hash pub fn token_url(&self, token_hash: impl AsRef) -> String { format!("{}/token/{}", self.etherscan_url, token_hash.as_ref()) } /// Execute an API POST request with a form async fn post_form( &self, form: &Form, ) -> Result> { Ok(self .client .post(self.etherscan_api_url.clone()) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .form(form) .send() .await? .json() .await?) } /// Execute an API GET request with parameters async fn get_json(&self, query: &Q) -> Result> { Ok(self .client .get(self.etherscan_api_url.clone()) .header(header::ACCEPT, "application/json") .query(query) .send() .await? .json() .await?) } fn create_query( &self, module: &'static str, action: &'static str, other: T, ) -> Query { Query { apikey: Cow::Borrowed(&self.api_key), module: Cow::Borrowed(module), action: Cow::Borrowed(action), other, } } } /// The API response type #[derive(Debug, Clone, Deserialize)] pub struct Response { pub status: String, pub message: String, pub result: T, } /// The type that gets serialized as query #[derive(Debug, Serialize)] struct Query<'a, T: Serialize> { apikey: Cow<'a, str>, module: Cow<'a, str>, action: Cow<'a, str>, #[serde(flatten)] other: T, } #[cfg(test)] mod tests { use std::{ future::Future, time::{Duration, SystemTime}, }; use ethers_core::types::Chain; use crate::{Client, EtherscanError}; #[test] fn chain_not_supported() { let err = Client::new_from_env(Chain::XDai).unwrap_err(); assert!(matches!(err, EtherscanError::ChainNotSupported(_))); assert_eq!(err.to_string(), "chain xdai not supported"); } pub async fn run_at_least_duration(duration: Duration, block: impl Future) { let start = SystemTime::now(); block.await; if let Some(sleep) = duration.checked_sub(start.elapsed().unwrap()) { tokio::time::sleep(sleep).await; } } }