-
Notifications
You must be signed in to change notification settings - Fork 794
feat(etherscan): add caching #1108
Changes from 13 commits
c2ac65d
549d913
8d11bac
b1c5791
05d3c28
bfc28bf
1cc734f
a4179e0
b44ab84
0577072
a625248
d5bf785
18865c0
0a30a7e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -138,8 +138,8 @@ impl Default for CodeFormat { | |
} | ||
|
||
#[derive(Debug, Serialize, Deserialize)] | ||
#[serde(transparent)] | ||
pub struct ContractMetadata { | ||
#[serde(flatten)] | ||
pub items: Vec<Metadata>, | ||
} | ||
|
||
|
@@ -284,12 +284,36 @@ impl Client { | |
/// # } | ||
/// ``` | ||
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 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 let Some(ref cache) = self.cache { | ||
let _ = cache.set_abi(address, None); | ||
} | ||
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 | ||
|
@@ -307,13 +331,34 @@ impl Client { | |
/// # } | ||
/// ``` | ||
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)), | ||
} | ||
} | ||
} | ||
Comment on lines
+335
to
+344
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is really ugly, but the alternative was to cache There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you can prob do something like |
||
|
||
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") { | ||
if let Some(ref cache) = self.cache { | ||
let _ = cache.set_source(address, None); | ||
} | ||
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) | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,21 @@ | ||
//! 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 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; | ||
|
@@ -28,9 +37,92 @@ 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>, | ||
} | ||
|
||
/// 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(|file| std::io::BufWriter::new(file)); | ||
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> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Switched this to an option since we don't really care about the errors:
|
||
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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as above |
||
return None | ||
} | ||
|
||
return Some(inner.data) | ||
} | ||
None | ||
} | ||
} | ||
|
||
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 | ||
pub fn new(chain: Chain, api_key: impl Into<String>) -> Result<Self> { | ||
let (etherscan_api_url, etherscan_url) = match chain { | ||
|
@@ -101,6 +193,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, | ||
}) | ||
} | ||
|
||
|
@@ -180,15 +273,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>( | ||
|
@@ -214,6 +318,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> { | ||
|
@@ -240,7 +351,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] | ||
|
There was a problem hiding this comment.
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 reallyif etherscan would give me access i would make their api better