feat(etherscan): add ClientBuilder type (#1193)
* style: clean up order * feat(etherscan): add client builder * fix: set correct field
This commit is contained in:
parent
c81254a8b6
commit
281913b187
|
@ -31,4 +31,6 @@ pub enum EtherscanError {
|
||||||
LocalNetworksNotSupported,
|
LocalNetworksNotSupported,
|
||||||
#[error("Unknown error: {0}")]
|
#[error("Unknown error: {0}")]
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
|
#[error("Missing field: {0}")]
|
||||||
|
Builder(String),
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use contract::ContractMetadata;
|
use contract::ContractMetadata;
|
||||||
use reqwest::{header, Url};
|
use reqwest::{header, IntoUrl, Url};
|
||||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
|
|
||||||
|
@ -42,82 +42,22 @@ pub struct Client {
|
||||||
cache: Option<Cache>,
|
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()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("system time is before unix epoch")
|
|
||||||
.checked_sub(Duration::from_secs(inner.expiry))
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return None
|
|
||||||
}
|
|
||||||
|
|
||||||
return Some(inner.data)
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
|
/// Creates a `ClientBuilder` to configure a `Client`.
|
||||||
|
/// This is the same as `ClientBuilder::default()`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use ethers_core::types::Chain;
|
||||||
|
/// use ethers_etherscan::Client;
|
||||||
|
/// let client = Client::builder().with_api_key("<API KEY>").chain(Chain::Mainnet).unwrap().build().unwrap();
|
||||||
|
/// ```
|
||||||
|
pub fn builder() -> ClientBuilder {
|
||||||
|
ClientBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new instance that caches etherscan requests
|
||||||
pub fn new_cached(
|
pub fn new_cached(
|
||||||
chain: Chain,
|
chain: Chain,
|
||||||
api_key: impl Into<String>,
|
api_key: impl Into<String>,
|
||||||
|
@ -131,76 +71,7 @@ impl Client {
|
||||||
|
|
||||||
/// 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 {
|
Client::builder().with_api_key(api_key).chain(chain)?.build()
|
||||||
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/api"), Url::parse("https://ftmscan.com"))
|
|
||||||
}
|
|
||||||
Chain::FantomTestnet => (
|
|
||||||
Url::parse("https://api-testnet.ftmscan.com/api"),
|
|
||||||
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::Cronos => {
|
|
||||||
(Url::parse("https://api.cronoscan.com/api"), Url::parse("https://cronoscan.com"))
|
|
||||||
}
|
|
||||||
Chain::Dev => return Err(EtherscanError::LocalNetworksNotSupported),
|
|
||||||
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"),
|
|
||||||
cache: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new client with the correct endpoints based on the chain and API key
|
/// Create a new client with the correct endpoints based on the chain and API key
|
||||||
|
@ -319,6 +190,216 @@ impl Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct ClientBuilder {
|
||||||
|
/// Client that executes HTTP requests
|
||||||
|
client: Option<reqwest::Client>,
|
||||||
|
/// Etherscan API key
|
||||||
|
api_key: Option<String>,
|
||||||
|
/// Etherscan API endpoint like <https://api(-chain).etherscan.io/api>
|
||||||
|
etherscan_api_url: Option<Url>,
|
||||||
|
/// Etherscan base endpoint like <https://etherscan.io>
|
||||||
|
etherscan_url: Option<Url>,
|
||||||
|
/// Path to where ABI files should be cached
|
||||||
|
cache: Option<Cache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// === impl ClientBuilder ===
|
||||||
|
|
||||||
|
impl ClientBuilder {
|
||||||
|
/// Configures the etherscan url and api url for the given chain
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Fails if the chain is not supported by etherscan
|
||||||
|
pub fn chain(self, chain: Chain) -> Result<Self> {
|
||||||
|
fn urls(
|
||||||
|
api: impl IntoUrl,
|
||||||
|
url: impl IntoUrl,
|
||||||
|
) -> (reqwest::Result<Url>, reqwest::Result<Url>) {
|
||||||
|
(api.into_url(), url.into_url())
|
||||||
|
}
|
||||||
|
|
||||||
|
let (etherscan_api_url, etherscan_url) = match chain {
|
||||||
|
Chain::Mainnet => urls("https://api.etherscan.io/api", "https://etherscan.io"),
|
||||||
|
Chain::Ropsten | Chain::Kovan | Chain::Rinkeby | Chain::Goerli => {
|
||||||
|
let chain_name = chain.to_string().to_lowercase();
|
||||||
|
urls(
|
||||||
|
format!("https://api-{}.etherscan.io/api", chain_name),
|
||||||
|
format!("https://{}.etherscan.io", chain_name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Chain::Polygon => urls("https://api.polygonscan.com/api", "https://polygonscan.com"),
|
||||||
|
Chain::PolygonMumbai => {
|
||||||
|
urls("https://api-testnet.polygonscan.com/api", "https://mumbai.polygonscan.com")
|
||||||
|
}
|
||||||
|
Chain::Avalanche => urls("https://api.snowtrace.io/api", "https://snowtrace.io"),
|
||||||
|
Chain::AvalancheFuji => {
|
||||||
|
urls("https://api-testnet.snowtrace.io/api", "https://testnet.snowtrace.io")
|
||||||
|
}
|
||||||
|
Chain::Optimism => {
|
||||||
|
urls("https://api-optimistic.etherscan.io/api", "https://optimistic.etherscan.io")
|
||||||
|
}
|
||||||
|
Chain::OptimismKovan => urls(
|
||||||
|
"https://api-kovan-optimistic.etherscan.io/api",
|
||||||
|
"https://kovan-optimistic.etherscan.io",
|
||||||
|
),
|
||||||
|
Chain::Fantom => urls("https://api.ftmscan.com/api", "https://ftmscan.com"),
|
||||||
|
Chain::FantomTestnet => {
|
||||||
|
urls("https://api-testnet.ftmscan.com/api", "https://testnet.ftmscan.com")
|
||||||
|
}
|
||||||
|
Chain::BinanceSmartChain => urls("https://api.bscscan.com/api", "https://bscscan.com"),
|
||||||
|
Chain::BinanceSmartChainTestnet => {
|
||||||
|
urls("https://api-testnet.bscscan.com/api", "https://testnet.bscscan.com")
|
||||||
|
}
|
||||||
|
Chain::Arbitrum => urls("https://api.arbiscan.io/api", "https://arbiscan.io"),
|
||||||
|
Chain::ArbitrumTestnet => {
|
||||||
|
urls("https://api-testnet.arbiscan.io/api", "https://testnet.arbiscan.io")
|
||||||
|
}
|
||||||
|
Chain::Cronos => urls("https://api.cronoscan.com/api", "https://cronoscan.com"),
|
||||||
|
Chain::Dev => return Err(EtherscanError::LocalNetworksNotSupported),
|
||||||
|
chain => return Err(EtherscanError::ChainNotSupported(chain)),
|
||||||
|
};
|
||||||
|
self.with_api_url(etherscan_api_url?)?.with_url(etherscan_url?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures the etherscan url
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Fails if the `etherscan_url` is not a valid `Url`
|
||||||
|
pub fn with_url(mut self, etherscan_url: impl IntoUrl) -> Result<Self> {
|
||||||
|
self.etherscan_url = Some(etherscan_url.into_url()?);
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures the `reqwest::Client`
|
||||||
|
pub fn with_client(mut self, client: reqwest::Client) -> Self {
|
||||||
|
self.client = Some(client);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures the etherscan api url
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Fails if the `etherscan_api_url` is not a valid `Url`
|
||||||
|
pub fn with_api_url(mut self, etherscan_api_url: impl IntoUrl) -> Result<Self> {
|
||||||
|
self.etherscan_api_url = Some(etherscan_api_url.into_url()?);
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures the etherscan api key
|
||||||
|
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
|
||||||
|
self.api_key = Some(api_key.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures cache for etherscan request
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a Client that uses this ClientBuilder configuration.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// if required fields are missing:
|
||||||
|
/// - `api_key`
|
||||||
|
/// - `etherscan_api_url`
|
||||||
|
/// - `etherscan_url`
|
||||||
|
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: api_key
|
||||||
|
.ok_or_else(|| EtherscanError::Builder("etherscan api key".to_string()))?,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time is before unix epoch")
|
||||||
|
.checked_sub(Duration::from_secs(inner.expiry))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return None
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some(inner.data)
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The API response type
|
/// The API response type
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct Response<T> {
|
pub struct Response<T> {
|
||||||
|
|
Loading…
Reference in New Issue