#![doc = include_str!("../README.md")]
#![warn(
missing_copy_implementations,
missing_debug_implementations,
unreachable_pub,
rustdoc::all
)]
#![cfg_attr(not(test), warn(unused_crate_dependencies))]
#![deny(unused_must_use, rust_2018_idioms)]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#[macro_use]
extern crate tracing;
use crate::errors::{is_blocked_by_cloudflare_response, is_cloudflare_security_challenge};
use alloy_chains::{Chain, ChainKind, NamedChain};
use alloy_json_abi::JsonAbi;
use alloy_primitives::{Address, B256};
use contract::ContractMetadata;
use errors::EtherscanError;
use reqwest::{header, IntoUrl, Url};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::{
borrow::Cow,
io::Write,
path::PathBuf,
time::{Duration, SystemTime, UNIX_EPOCH},
};
pub mod account;
pub mod block_number;
pub mod blocks;
pub mod contract;
pub mod errors;
pub mod gas;
pub mod serde_helpers;
pub mod source_tree;
mod transaction;
pub mod units;
pub mod utils;
pub mod verify;
pub(crate) type Result<T, E = EtherscanError> = std::result::Result<T, E>;
#[derive(Clone, Debug)]
pub struct Client {
client: reqwest::Client,
api_key: Option<String>,
etherscan_api_url: Url,
etherscan_url: Url,
cache: Option<Cache>,
}
impl Client {
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn new_cached(
chain: Chain,
api_key: impl Into<String>,
cache_root: Option<PathBuf>,
cache_ttl: Duration,
) -> Result<Self> {
let mut this = Self::new(chain, api_key)?;
this.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
Ok(this)
}
pub fn new(chain: Chain, api_key: impl Into<String>) -> Result<Self> {
Client::builder().with_api_key(api_key).chain(chain)?.build()
}
pub fn new_from_env(chain: Chain) -> Result<Self> {
let api_key = match chain.kind() {
ChainKind::Named(named) => match named {
NamedChain::Fantom | NamedChain::FantomTestnet => std::env::var("FMTSCAN_API_KEY")
.or_else(|_| std::env::var("FANTOMSCAN_API_KEY"))
.map_err(Into::into),
NamedChain::Gnosis
| NamedChain::Chiado
| NamedChain::Sepolia
| NamedChain::Rsk
| NamedChain::Sokol
| NamedChain::Poa
| NamedChain::Oasis
| NamedChain::Emerald
| NamedChain::EmeraldTestnet
| NamedChain::Evmos
| NamedChain::EvmosTestnet => Ok(String::new()),
NamedChain::AnvilHardhat | NamedChain::Dev => {
Err(EtherscanError::LocalNetworksNotSupported)
}
_ => named
.etherscan_api_key_name()
.ok_or_else(|| EtherscanError::ChainNotSupported(chain))
.and_then(|key_name| std::env::var(key_name).map_err(Into::into)),
},
ChainKind::Id(_) => Err(EtherscanError::ChainNotSupported(chain)),
}?;
Self::new(chain, api_key)
}
pub fn new_from_opt_env(chain: Chain) -> Result<Self> {
match Self::new_from_env(chain) {
Ok(client) => Ok(client),
Err(EtherscanError::EnvVarNotFound(_)) => {
Self::builder().chain(chain).and_then(|c| c.build())
}
Err(e) => Err(e),
}
}
pub fn set_cache(&mut self, root: impl Into<PathBuf>, ttl: Duration) -> &mut Self {
self.cache = Some(Cache { root: root.into(), ttl });
self
}
pub fn etherscan_api_url(&self) -> &Url {
&self.etherscan_api_url
}
pub fn etherscan_url(&self) -> &Url {
&self.etherscan_url
}
pub fn block_url(&self, block: u64) -> String {
self.etherscan_url.join(&format!("block/{block}")).unwrap().to_string()
}
pub fn address_url(&self, address: Address) -> String {
self.etherscan_url.join(&format!("address/{address:?}")).unwrap().to_string()
}
pub fn transaction_url(&self, tx_hash: B256) -> String {
self.etherscan_url.join(&format!("tx/{tx_hash:?}")).unwrap().to_string()
}
pub fn token_url(&self, token_hash: Address) -> String {
self.etherscan_url.join(&format!("token/{token_hash:?}")).unwrap().to_string()
}
async fn get_json<T: DeserializeOwned, Q: Serialize>(&self, query: &Q) -> Result<Response<T>> {
let res = self.get(query).await?;
self.sanitize_response(res)
}
async fn get<Q: Serialize>(&self, query: &Q) -> Result<String> {
trace!(target: "etherscan", "GET {}", self.etherscan_api_url);
let response = self
.client
.get(self.etherscan_api_url.clone())
.header(header::ACCEPT, "application/json")
.query(query)
.send()
.await?
.text()
.await?;
Ok(response)
}
async fn post_form<T: DeserializeOwned, F: Serialize>(&self, form: &F) -> Result<Response<T>> {
let res = self.post(form).await?;
self.sanitize_response(res)
}
async fn post<F: Serialize>(&self, form: &F) -> Result<String> {
trace!(target: "etherscan", "POST {}", self.etherscan_api_url);
let response = self
.client
.post(self.etherscan_api_url.clone())
.form(form)
.send()
.await?
.text()
.await?;
Ok(response)
}
fn sanitize_response<T: DeserializeOwned>(&self, res: impl AsRef<str>) -> Result<Response<T>> {
let res = res.as_ref();
let res: ResponseData<T> = serde_json::from_str(res).map_err(|err| {
error!(target: "etherscan", ?res, "Failed to deserialize response: {}", err);
if res == "Page not found" {
EtherscanError::PageNotFound
} else if is_blocked_by_cloudflare_response(res) {
EtherscanError::BlockedByCloudflare
} else if is_cloudflare_security_challenge(res) {
EtherscanError::CloudFlareSecurityChallenge
} else {
EtherscanError::Serde(err)
}
})?;
match res {
ResponseData::Error { result, message, status } => {
if let Some(ref result) = result {
if result.starts_with("Max rate limit reached") {
return Err(EtherscanError::RateLimitExceeded);
} else if result.to_lowercase() == "invalid api key" {
return Err(EtherscanError::InvalidApiKey);
}
}
Err(EtherscanError::ErrorResponse { status, message, result })
}
ResponseData::Success(res) => Ok(res),
}
}
fn create_query<T: Serialize>(
&self,
module: &'static str,
action: &'static str,
other: T,
) -> Query<'_, T> {
Query {
apikey: self.api_key.as_deref().map(Cow::Borrowed),
module: Cow::Borrowed(module),
action: Cow::Borrowed(action),
other,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ClientBuilder {
client: Option<reqwest::Client>,
api_key: Option<String>,
etherscan_api_url: Option<Url>,
etherscan_url: Option<Url>,
cache: Option<Cache>,
}
impl ClientBuilder {
pub fn chain(self, chain: Chain) -> Result<Self> {
fn urls(
(api, url): (impl IntoUrl, impl IntoUrl),
) -> (reqwest::Result<Url>, reqwest::Result<Url>) {
(api.into_url(), url.into_url())
}
let (etherscan_api_url, etherscan_url) = chain
.named()
.ok_or_else(|| EtherscanError::ChainNotSupported(chain))?
.etherscan_urls()
.map(urls)
.ok_or_else(|| EtherscanError::ChainNotSupported(chain))?;
self.with_api_url(etherscan_api_url?)?.with_url(etherscan_url?)
}
pub fn with_url(mut self, etherscan_url: impl IntoUrl) -> Result<Self> {
self.etherscan_url = Some(into_url(etherscan_url)?);
Ok(self)
}
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = Some(client);
self
}
pub fn with_api_url(mut self, etherscan_api_url: impl IntoUrl) -> Result<Self> {
self.etherscan_api_url = Some(into_url(etherscan_api_url)?);
Ok(self)
}
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into()).filter(|s| !s.is_empty());
self
}
pub fn with_cache(mut self, cache_root: Option<PathBuf>, cache_ttl: Duration) -> Self {
self.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
self
}
pub fn build(self) -> Result<Client> {
let ClientBuilder { client, api_key, etherscan_api_url, etherscan_url, cache } = self;
let client = Client {
client: client.unwrap_or_default(),
api_key,
etherscan_api_url: etherscan_api_url
.ok_or_else(|| EtherscanError::Builder("etherscan api url".to_string()))?,
etherscan_url: etherscan_url
.ok_or_else(|| EtherscanError::Builder("etherscan url".to_string()))?,
cache,
};
Ok(client)
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct CacheEnvelope<T> {
expiry: u64,
data: T,
}
#[derive(Clone, Debug)]
struct Cache {
root: PathBuf,
ttl: Duration,
}
impl Cache {
fn new(root: PathBuf, ttl: Duration) -> Self {
Self { root, ttl }
}
fn get_abi(&self, address: Address) -> Option<Option<JsonAbi>> {
self.get("abi", address)
}
fn set_abi(&self, address: Address, abi: Option<&JsonAbi>) {
self.set("abi", address, abi)
}
fn get_source(&self, address: Address) -> Option<Option<ContractMetadata>> {
self.get("sources", address)
}
fn set_source(&self, address: Address, source: Option<&ContractMetadata>) {
self.set("sources", address, source)
}
fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) {
let path = self.root.join(prefix).join(format!("{address:?}.json"));
let writer = std::fs::File::create(path).ok().map(std::io::BufWriter::new);
if let Some(mut writer) = writer {
let _ = serde_json::to_writer(
&mut writer,
&CacheEnvelope {
expiry: SystemTime::now()
.checked_add(self.ttl)
.expect("cache ttl overflowed")
.duration_since(UNIX_EPOCH)
.expect("system time is before unix epoch")
.as_secs(),
data: item,
},
);
let _ = writer.flush();
}
}
fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Option<T> {
let path = self.root.join(prefix).join(format!("{address:?}.json"));
let Ok(contents) = std::fs::read_to_string(path) else {
return None;
};
let Ok(inner) = serde_json::from_str::<CacheEnvelope<T>>(&contents) else {
return None;
};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time is before unix epoch")
.checked_sub(Duration::from_secs(inner.expiry))
.map(|_| inner.data)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Response<T> {
pub status: String,
pub message: String,
pub result: T,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum ResponseData<T> {
Success(Response<T>),
Error { status: String, message: String, result: Option<String> },
}
#[derive(Clone, Debug, Serialize)]
struct Query<'a, T: Serialize> {
#[serde(skip_serializing_if = "Option::is_none")]
apikey: Option<Cow<'a, str>>,
module: Cow<'a, str>,
action: Cow<'a, str>,
#[serde(flatten)]
other: T,
}
#[inline]
fn into_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
url.into_url()
}
#[cfg(test)]
mod tests {
use crate::{Client, EtherscanError, ResponseData};
use alloy_chains::Chain;
use alloy_primitives::{Address, B256};
#[test]
fn can_parse_block_scout_err() {
let err = "{\"message\":\"Something went wrong.\",\"result\":null,\"status\":\"0\"}";
let resp: ResponseData<Address> = serde_json::from_str(err).unwrap();
assert!(matches!(resp, ResponseData::Error { .. }));
}
#[test]
fn test_api_paths() {
let client = Client::new(Chain::goerli(), "").unwrap();
assert_eq!(client.etherscan_api_url.as_str(), "https://api-goerli.etherscan.io/api");
assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
}
#[test]
fn stringifies_block_url() {
let etherscan = Client::new(Chain::mainnet(), "").unwrap();
let block: u64 = 1;
let block_url: String = etherscan.block_url(block);
assert_eq!(block_url, format!("https://etherscan.io/block/{block}"));
}
#[test]
fn stringifies_address_url() {
let etherscan = Client::new(Chain::mainnet(), "").unwrap();
let addr: Address = Address::ZERO;
let address_url: String = etherscan.address_url(addr);
assert_eq!(address_url, format!("https://etherscan.io/address/{addr:?}"));
}
#[test]
fn stringifies_transaction_url() {
let etherscan = Client::new(Chain::mainnet(), "").unwrap();
let tx_hash = B256::ZERO;
let tx_url: String = etherscan.transaction_url(tx_hash);
assert_eq!(tx_url, format!("https://etherscan.io/tx/{tx_hash:?}"));
}
#[test]
fn stringifies_token_url() {
let etherscan = Client::new(Chain::mainnet(), "").unwrap();
let token_hash = Address::ZERO;
let token_url: String = etherscan.token_url(token_hash);
assert_eq!(token_url, format!("https://etherscan.io/token/{token_hash:?}"));
}
#[test]
fn local_networks_not_supported() {
let err = Client::new_from_env(Chain::dev()).unwrap_err();
assert!(matches!(err, EtherscanError::LocalNetworksNotSupported));
}
}