feat(etherscan): add caching (#1108)

* feat(etherscan): add caching

* feat: write to cache if not exists

* fix: flush `BufWriter`

* fix: fix serialization

* fix: read cache without truncating the file

* chore: remove comments

* feat: rate limit errors

* test: fix tests

* test: fix tests

* fix: don't fail if cache doesn't exist

* fix: catch all rate limits

* feat: add ttl

* feat: also cache when contracts are not verified

* chore: clippy

Co-authored-by: Georgios Konstantopoulos <me@gakonst.com>
This commit is contained in:
Bjerg 2022-04-06 04:01:44 +02:00 committed by GitHub
parent 23e45e8531
commit c436d19a9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 175 additions and 15 deletions

View File

@ -138,8 +138,8 @@ impl Default for CodeFormat {
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ContractMetadata { pub struct ContractMetadata {
#[serde(flatten)]
pub items: Vec<Metadata>, pub items: Vec<Metadata>,
} }
@ -284,12 +284,36 @@ impl Client {
/// # } /// # }
/// ``` /// ```
pub async fn contract_abi(&self, address: Address) -> Result<Abi> { pub async fn contract_abi(&self, address: Address) -> Result<Abi> {
// apply caching
if let Some(ref cache) = self.cache {
// If this is None, then we have a cache miss
if let Some(src) = cache.get_abi(address) {
// If this is None, then the contract is not verified
return match src {
Some(src) => Ok(src),
None => Err(EtherscanError::ContractCodeNotVerified(address)),
}
}
}
let query = self.create_query("contract", "getabi", HashMap::from([("address", address)])); let query = self.create_query("contract", "getabi", HashMap::from([("address", address)]));
let resp: Response<String> = self.get_json(&query).await?; let resp: Response<String> = self.get_json(&query).await?;
if resp.result.starts_with("Max rate limit reached") {
return Err(EtherscanError::RateLimitExceeded)
}
if resp.result.starts_with("Contract source code not verified") { if resp.result.starts_with("Contract source code not verified") {
if let Some(ref cache) = self.cache {
let _ = cache.set_abi(address, None);
}
return Err(EtherscanError::ContractCodeNotVerified(address)) 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, Some(&abi));
}
Ok(abi)
} }
/// Get Contract Source Code for Verified Contract Source Codes /// Get Contract Source Code for Verified Contract Source Codes
@ -307,13 +331,34 @@ impl Client {
/// # } /// # }
/// ``` /// ```
pub async fn contract_source_code(&self, address: Address) -> Result<ContractMetadata> { pub async fn contract_source_code(&self, address: Address) -> Result<ContractMetadata> {
// apply caching
if let Some(ref cache) = self.cache {
// If this is None, then we have a cache miss
if let Some(src) = cache.get_source(address) {
// If this is None, then the contract is not verified
return match src {
Some(src) => Ok(src),
None => Err(EtherscanError::ContractCodeNotVerified(address)),
}
}
}
let query = let query =
self.create_query("contract", "getsourcecode", HashMap::from([("address", address)])); self.create_query("contract", "getsourcecode", HashMap::from([("address", address)]));
let response: Response<Vec<Metadata>> = self.get_json(&query).await?; let response: Response<Vec<Metadata>> = self.get_json(&query).await?;
if response.result.iter().any(|item| item.abi == "Contract source code not verified") { if response.result.iter().any(|item| item.abi == "Contract source code not verified") {
if let Some(ref cache) = self.cache {
let _ = cache.set_source(address, None);
}
return Err(EtherscanError::ContractCodeNotVerified(address)) 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, Some(&res));
}
Ok(res)
} }
} }

View File

@ -3,17 +3,17 @@ use std::env::VarError;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum EtherscanError { pub enum EtherscanError {
#[error("chain {0} not supported")] #[error("Chain {0} not supported")]
ChainNotSupported(Chain), ChainNotSupported(Chain),
#[error("contract execution call failed: {0}")] #[error("Contract execution call failed: {0}")]
ExecutionFailed(String), ExecutionFailed(String),
#[error("balance failed")] #[error("Balance failed")]
BalanceFailed, BalanceFailed,
#[error("tx receipt failed")] #[error("Transaction receipt failed")]
TransactionReceiptFailed, TransactionReceiptFailed,
#[error("gas estimation failed")] #[error("Gas estimation failed")]
GasEstimationFailed, GasEstimationFailed,
#[error("bad status code {0}")] #[error("Bad status code: {0}")]
BadStatusCode(String), BadStatusCode(String),
#[error(transparent)] #[error(transparent)]
EnvVarNotFound(#[from] VarError), EnvVarNotFound(#[from] VarError),
@ -23,8 +23,12 @@ pub enum EtherscanError {
Serde(#[from] serde_json::Error), Serde(#[from] serde_json::Error),
#[error("Contract source code not verified: {0}")] #[error("Contract source code not verified: {0}")]
ContractCodeNotVerified(Address), ContractCodeNotVerified(Address),
#[error("Rate limit exceeded")]
RateLimitExceeded,
#[error(transparent)] #[error(transparent)]
IO(#[from] std::io::Error), IO(#[from] std::io::Error),
#[error("Local networks (e.g. ganache, geth --dev) cannot be indexed by etherscan")] #[error("Local networks (e.g. ganache, geth --dev) cannot be indexed by etherscan")]
LocalNetworksNotSupported, LocalNetworksNotSupported,
#[error("Unknown error: {0}")]
Unknown(String),
} }

View File

@ -1,12 +1,21 @@
//! Bindings for [etherscan.io web api](https://docs.etherscan.io/) //! Bindings for [etherscan.io web api](https://docs.etherscan.io/)
use std::borrow::Cow; use std::{
borrow::Cow,
io::Write,
path::PathBuf,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use contract::ContractMetadata;
use reqwest::{header, Url}; use reqwest::{header, Url};
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use errors::EtherscanError; use errors::EtherscanError;
use ethers_core::{abi::Address, types::Chain}; use ethers_core::{
abi::{Abi, Address},
types::Chain,
};
pub mod account; pub mod account;
pub mod contract; pub mod contract;
@ -28,9 +37,92 @@ pub struct Client {
etherscan_api_url: Url, etherscan_api_url: Url,
/// Etherscan base endpoint like <https://etherscan.io> /// Etherscan base endpoint like <https://etherscan.io>
etherscan_url: Url, etherscan_url: Url,
/// Path to where ABI files should be cached
cache: Option<Cache>,
}
/// A wrapper around an Etherscan cache object with an expiry
#[derive(Clone, Debug, Deserialize, Serialize)]
struct CacheEnvelope<T> {
expiry: u64,
data: T,
}
/// Simple cache for etherscan requests
#[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<ethers_core::abi::Abi>> {
self.get("abi", address)
}
fn set_abi(&self, address: Address, abi: Option<&Abi>) {
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!("{:?}.json", address));
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!("{:?}.json", address));
let reader = std::io::BufReader::new(std::fs::File::open(path).ok()?);
if let Ok(inner) = serde_json::from_reader::<_, CacheEnvelope<T>>(reader) {
// If this does not return None then we have passed the expiry
if SystemTime::now().checked_sub(Duration::from_secs(inner.expiry)).is_some() {
return None
}
return Some(inner.data)
}
None
}
} }
impl Client { impl Client {
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)
}
/// Create a new client with the correct endpoints based on the chain and provided API key /// 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> { pub fn new(chain: Chain, api_key: impl Into<String>) -> Result<Self> {
let (etherscan_api_url, etherscan_url) = match chain { let (etherscan_api_url, etherscan_url) = match chain {
@ -101,6 +193,7 @@ impl Client {
api_key: api_key.into(), api_key: api_key.into(),
etherscan_api_url: etherscan_api_url.expect("is valid http"), etherscan_api_url: etherscan_api_url.expect("is valid http"),
etherscan_url: etherscan_url.expect("is valid http"), etherscan_url: etherscan_url.expect("is valid http"),
cache: None,
}) })
} }
@ -180,7 +273,7 @@ impl Client {
/// Execute an API GET request with parameters /// Execute an API GET request with parameters
async fn get_json<T: DeserializeOwned, Q: Serialize>(&self, query: &Q) -> Result<Response<T>> { async fn get_json<T: DeserializeOwned, Q: Serialize>(&self, query: &Q) -> Result<Response<T>> {
Ok(self let res: ResponseData<T> = self
.client .client
.get(self.etherscan_api_url.clone()) .get(self.etherscan_api_url.clone())
.header(header::ACCEPT, "application/json") .header(header::ACCEPT, "application/json")
@ -188,7 +281,18 @@ impl Client {
.send() .send()
.await? .await?
.json() .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>( fn create_query<T: Serialize>(
@ -214,6 +318,13 @@ pub struct Response<T> {
pub result: 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 /// The type that gets serialized as query
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct Query<'a, T: Serialize> { struct Query<'a, T: Serialize> {
@ -240,7 +351,7 @@ mod tests {
let err = Client::new_from_env(Chain::XDai).unwrap_err(); let err = Client::new_from_env(Chain::XDai).unwrap_err();
assert!(matches!(err, EtherscanError::ChainNotSupported(_))); assert!(matches!(err, EtherscanError::ChainNotSupported(_)));
assert_eq!(err.to_string(), "chain xdai not supported"); assert_eq!(err.to_string(), "Chain xdai not supported");
} }
#[test] #[test]

View File

@ -91,7 +91,7 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(matches!(err, EtherscanError::ExecutionFailed(_))); 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 .await
} }