Skip to content
This repository has been archived by the owner on Oct 19, 2024. It is now read-only.

feat(etherscan): add caching #1108

Merged
merged 14 commits into from
Apr 6, 2022
35 changes: 32 additions & 3 deletions ethers-etherscan/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ impl Default for CodeFormat {
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ContractMetadata {
#[serde(flatten)]
pub items: Vec<Metadata>,
}

Expand Down Expand Up @@ -284,12 +284,28 @@ impl Client {
/// # }
/// ```
pub async fn contract_abi(&self, address: Address) -> Result<Abi> {
// apply caching
if let Some(ref cache) = self.cache {
if let Ok(Some(abi)) = cache.get_abi(address) {
return Ok(abi)
}
}

let query = self.create_query("contract", "getabi", HashMap::from([("address", address)]));
let resp: Response<String> = self.get_json(&query).await?;
if resp.result.starts_with("Max rate limit reached") {
Copy link
Collaborator Author

@onbjerg onbjerg Apr 5, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have to dupe the check here cus it deserializes message as a string.. really annoying but no way around it really

if etherscan would give me access i would make their api better

return Err(EtherscanError::RateLimitExceeded)
}
if resp.result.starts_with("Contract source code not verified") {
return Err(EtherscanError::ContractCodeNotVerified(address))
}
Ok(serde_json::from_str(&resp.result)?)
let abi = serde_json::from_str(&resp.result)?;

if let Some(ref cache) = self.cache {
let _ = cache.set_abi(address, &abi);
}

Ok(abi)
}

/// Get Contract Source Code for Verified Contract Source Codes
Expand All @@ -307,13 +323,26 @@ impl Client {
/// # }
/// ```
pub async fn contract_source_code(&self, address: Address) -> Result<ContractMetadata> {
// apply caching
if let Some(ref cache) = self.cache {
if let Ok(Some(src)) = cache.get_source(address) {
return Ok(src)
}
}

let query =
self.create_query("contract", "getsourcecode", HashMap::from([("address", address)]));
let response: Response<Vec<Metadata>> = self.get_json(&query).await?;
if response.result.iter().any(|item| item.abi == "Contract source code not verified") {
return Err(EtherscanError::ContractCodeNotVerified(address))
}
Ok(ContractMetadata { items: response.result })
let res = ContractMetadata { items: response.result };

if let Some(ref cache) = self.cache {
let _ = cache.set_source(address, &res);
}

Ok(res)
}
}

Expand Down
16 changes: 10 additions & 6 deletions ethers-etherscan/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ use std::env::VarError;

#[derive(Debug, thiserror::Error)]
pub enum EtherscanError {
#[error("chain {0} not supported")]
#[error("Chain {0} not supported")]
ChainNotSupported(Chain),
#[error("contract execution call failed: {0}")]
#[error("Contract execution call failed: {0}")]
ExecutionFailed(String),
#[error("balance failed")]
#[error("Balance failed")]
BalanceFailed,
#[error("tx receipt failed")]
#[error("Transaction receipt failed")]
TransactionReceiptFailed,
#[error("gas estimation failed")]
#[error("Gas estimation failed")]
GasEstimationFailed,
#[error("bad status code {0}")]
#[error("Bad status code: {0}")]
BadStatusCode(String),
#[error(transparent)]
EnvVarNotFound(#[from] VarError),
Expand All @@ -23,8 +23,12 @@ pub enum EtherscanError {
Serde(#[from] serde_json::Error),
#[error("Contract source code not verified: {0}")]
ContractCodeNotVerified(Address),
#[error("Rate limit exceeded")]
RateLimitExceeded,
#[error(transparent)]
IO(#[from] std::io::Error),
#[error("Local networks (e.g. ganache, geth --dev) cannot be indexed by etherscan")]
LocalNetworksNotSupported,
#[error("Unknown error: {0}")]
Unknown(String),
}
84 changes: 79 additions & 5 deletions ethers-etherscan/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
//! Bindings for [etherscan.io web api](https://docs.etherscan.io/)

use std::borrow::Cow;
use std::{borrow::Cow, io::Write, path::PathBuf};

use contract::ContractMetadata;
use reqwest::{header, Url};
use serde::{de::DeserializeOwned, Deserialize, Serialize};

use errors::EtherscanError;
use ethers_core::{abi::Address, types::Chain};
use ethers_core::{
abi::{Abi, Address},
types::Chain,
};

pub mod account;
pub mod contract;
Expand All @@ -28,9 +32,60 @@ pub struct Client {
etherscan_api_url: Url,
/// Etherscan base endpoint like <https://etherscan.io>
etherscan_url: Url,
/// Path to where ABI files should be cached
cache: Option<Cache>,
}

#[derive(Clone, Debug)]
// Simple cache for etherscan requests
struct Cache(PathBuf);

impl Cache {
fn get_abi(&self, address: Address) -> Result<Option<ethers_core::abi::Abi>> {
self.get("abi", address)
}

fn set_abi(&self, address: Address, abi: &Abi) -> Result<()> {
self.set("abi", address, abi)
}

fn get_source(&self, address: Address) -> Result<Option<ContractMetadata>> {
self.get("sources", address)
}

fn set_source(&self, address: Address, source: &ContractMetadata) -> Result<()> {
self.set("sources", address, source)
}

fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) -> Result<()> {
let path = self.0.join(prefix).join(format!("{:?}.json", address));
let mut writer = std::io::BufWriter::new(std::fs::File::create(path)?);
serde_json::to_writer(&mut writer, &item)?;
let _ = writer.flush();
Ok(())
}

fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Result<Option<T>> {
let path = self.0.join(prefix).join(format!("{:?}.json", address));
let reader = std::io::BufReader::new(std::fs::File::open(path)?);
if let Ok(inner) = serde_json::from_reader(reader) {
return Ok(Some(inner))
}
Ok(None)
}
}

impl Client {
pub fn new_cached(
chain: Chain,
api_key: impl Into<String>,
cache: Option<PathBuf>,
) -> Result<Self> {
let mut this = Self::new(chain, api_key)?;
this.cache = cache.map(Cache);
Ok(this)
}

/// 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<String>) -> Result<Self> {
let (etherscan_api_url, etherscan_url) = match chain {
Expand Down Expand Up @@ -101,6 +156,7 @@ impl Client {
api_key: api_key.into(),
etherscan_api_url: etherscan_api_url.expect("is valid http"),
etherscan_url: etherscan_url.expect("is valid http"),
cache: None,
})
}

Expand Down Expand Up @@ -180,15 +236,26 @@ impl Client {

/// Execute an API GET request with parameters
async fn get_json<T: DeserializeOwned, Q: Serialize>(&self, query: &Q) -> Result<Response<T>> {
Ok(self
let res: ResponseData<T> = self
.client
.get(self.etherscan_api_url.clone())
.header(header::ACCEPT, "application/json")
.query(query)
.send()
.await?
.json()
.await?)
.await?;

match res {
ResponseData::Error { result, .. } => {
if result.starts_with("Max rate limit reached") {
Err(EtherscanError::RateLimitExceeded)
} else {
Err(EtherscanError::Unknown(result))
}
}
ResponseData::Success(res) => Ok(res),
}
}

fn create_query<T: Serialize>(
Expand All @@ -214,6 +281,13 @@ pub struct Response<T> {
pub result: T,
}

#[derive(Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum ResponseData<T> {
Success(Response<T>),
Error { status: String, message: String, result: String },
}

/// The type that gets serialized as query
#[derive(Debug, Serialize)]
struct Query<'a, T: Serialize> {
Expand All @@ -240,7 +314,7 @@ mod tests {
let err = Client::new_from_env(Chain::XDai).unwrap_err();

assert!(matches!(err, EtherscanError::ChainNotSupported(_)));
assert_eq!(err.to_string(), "chain xdai not supported");
assert_eq!(err.to_string(), "Chain xdai not supported");
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion ethers-etherscan/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ mod tests {
.unwrap_err();

assert!(matches!(err, EtherscanError::ExecutionFailed(_)));
assert_eq!(err.to_string(), "contract execution call failed: Bad jump destination");
assert_eq!(err.to_string(), "Contract execution call failed: Bad jump destination");
})
.await
}
Expand Down