use crate::error::{self, Error, Result};
use secrecy::{ExposeSecret, SecretString};
use serde::{de::DeserializeOwned, Serialize};
use std::time::Duration;
use yldfi_common::http::HttpClientConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Network {
EthMainnet,
EthSepolia,
EthHolesky,
PolygonMainnet,
PolygonAmoy,
ArbitrumMainnet,
ArbitrumSepolia,
OptMainnet,
OptSepolia,
BaseMainnet,
BaseSepolia,
ZksyncMainnet,
ZksyncSepolia,
SolanaMainnet,
SolanaDevnet,
LineaMainnet,
ScrollMainnet,
BlastMainnet,
MantleMainnet,
ZoraMainnet,
WorldchainMainnet,
ShapeMainnet,
PolygonZkevmMainnet,
Bnb,
Avalanche,
Fantom,
Gnosis,
}
impl Network {
pub fn slug(&self) -> &'static str {
match self {
Network::EthMainnet => "eth-mainnet",
Network::EthSepolia => "eth-sepolia",
Network::EthHolesky => "eth-holesky",
Network::PolygonMainnet => "polygon-mainnet",
Network::PolygonAmoy => "polygon-amoy",
Network::ArbitrumMainnet => "arb-mainnet",
Network::ArbitrumSepolia => "arb-sepolia",
Network::OptMainnet => "opt-mainnet",
Network::OptSepolia => "opt-sepolia",
Network::BaseMainnet => "base-mainnet",
Network::BaseSepolia => "base-sepolia",
Network::ZksyncMainnet => "zksync-mainnet",
Network::ZksyncSepolia => "zksync-sepolia",
Network::SolanaMainnet => "solana-mainnet",
Network::SolanaDevnet => "solana-devnet",
Network::LineaMainnet => "linea-mainnet",
Network::ScrollMainnet => "scroll-mainnet",
Network::BlastMainnet => "blast-mainnet",
Network::MantleMainnet => "mantle-mainnet",
Network::ZoraMainnet => "zora-mainnet",
Network::WorldchainMainnet => "worldchain-mainnet",
Network::ShapeMainnet => "shape-mainnet",
Network::PolygonZkevmMainnet => "polygonzkevm-mainnet",
Network::Bnb => "bnb-mainnet",
Network::Avalanche => "avax-mainnet",
Network::Fantom => "fantom-mainnet",
Network::Gnosis => "gnosis-mainnet",
}
}
pub fn data_api_name(&self) -> &'static str {
match self {
Network::EthMainnet => "eth-mainnet",
Network::PolygonMainnet => "polygon-mainnet",
Network::ArbitrumMainnet => "arb-mainnet",
Network::OptMainnet => "opt-mainnet",
Network::BaseMainnet => "base-mainnet",
Network::ZksyncMainnet => "zksync-mainnet",
Network::SolanaMainnet => "solana-mainnet",
Network::LineaMainnet => "linea-mainnet",
Network::ScrollMainnet => "scroll-mainnet",
Network::BlastMainnet => "blast-mainnet",
Network::MantleMainnet => "mantle-mainnet",
Network::ZoraMainnet => "zora-mainnet",
Network::WorldchainMainnet => "worldchain-mainnet",
Network::ShapeMainnet => "shape-mainnet",
Network::PolygonZkevmMainnet => "polygonzkevm-mainnet",
Network::Bnb => "bnb-mainnet",
Network::Avalanche => "avax-mainnet",
Network::Fantom => "fantom-mainnet",
Network::Gnosis => "gnosis-mainnet",
Network::EthSepolia => "eth-sepolia",
Network::EthHolesky => "eth-holesky",
Network::PolygonAmoy => "polygon-amoy",
Network::ArbitrumSepolia => "arb-sepolia",
Network::OptSepolia => "opt-sepolia",
Network::BaseSepolia => "base-sepolia",
Network::ZksyncSepolia => "zksync-sepolia",
Network::SolanaDevnet => "solana-devnet",
}
}
}
#[derive(Clone)]
pub struct Config {
pub api_key: SecretString,
pub network: Network,
pub http: HttpClientConfig,
}
impl Config {
pub fn new(api_key: impl Into<String>, network: Network) -> Self {
Self {
api_key: SecretString::from(api_key.into()),
network,
http: HttpClientConfig::default(),
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.http.timeout = timeout;
self
}
#[must_use]
pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {
self.http.proxy = Some(proxy.into());
self
}
#[must_use]
pub fn with_optional_proxy(mut self, proxy: Option<String>) -> Self {
self.http.proxy = proxy;
self
}
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("api_key", &"[REDACTED]")
.field("network", &self.network)
.field("http", &self.http)
.finish()
}
}
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
api_key: SecretString,
network: Network,
}
impl Client {
pub fn new(api_key: impl Into<String>, network: Network) -> Result<Self> {
Self::with_config(Config::new(api_key, network))
}
pub fn with_config(config: Config) -> Result<Self> {
let http = yldfi_common::build_client(&config.http)?;
Ok(Self {
http,
api_key: config.api_key,
network: config.network,
})
}
pub fn from_env(network: Network) -> Result<Self> {
let api_key = std::env::var("ALCHEMY_API_KEY").map_err(|_| error::invalid_api_key())?;
Self::new(api_key, network)
}
pub fn api_key(&self) -> &str {
self.api_key.expose_secret()
}
pub fn network(&self) -> Network {
self.network
}
pub fn http(&self) -> &reqwest::Client {
&self.http
}
pub fn rpc_url(&self) -> String {
format!(
"https://{}.g.alchemy.com/v2/{}",
self.network.slug(),
self.api_key.expose_secret()
)
}
pub fn nft_url(&self) -> String {
format!(
"https://{}.g.alchemy.com/nft/v3/{}",
self.network.slug(),
self.api_key.expose_secret()
)
}
pub fn prices_url(&self) -> String {
format!(
"https://api.g.alchemy.com/prices/v1/{}",
self.api_key.expose_secret()
)
}
pub fn data_url(&self) -> String {
format!(
"https://api.g.alchemy.com/data/v1/{}",
self.api_key.expose_secret()
)
}
pub async fn rpc<P, R>(&self, method: &str, params: P) -> Result<R>
where
P: Serialize,
R: DeserializeOwned,
{
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params
});
let response = self.http.post(self.rpc_url()).json(&request).send().await?;
if response.status() == 429 {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
return Err(Error::rate_limited(retry_after));
}
let result: serde_json::Value = response.json().await?;
if let Some(error) = result.get("error") {
let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
let message = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error")
.to_string();
return Err(error::rpc(code, message));
}
let result = result
.get("result")
.ok_or_else(|| error::rpc(-1, "No result in response"))?
.clone();
Ok(serde_json::from_value(result)?)
}
pub async fn nft_get<R>(&self, path: &str, query: &[(&str, &str)]) -> Result<R>
where
R: DeserializeOwned,
{
let url = format!("{}/{}", self.nft_url(), path);
let response = self.http.get(&url).query(query).send().await?;
self.handle_response(response).await
}
pub async fn nft_post<B, R>(&self, path: &str, body: &B) -> Result<R>
where
B: Serialize,
R: DeserializeOwned,
{
let url = format!("{}/{}", self.nft_url(), path);
let response = self.http.post(&url).json(body).send().await?;
self.handle_response(response).await
}
pub async fn prices_get<R>(&self, path: &str, query: &[(&str, &str)]) -> Result<R>
where
R: DeserializeOwned,
{
let url = format!("{}/{}", self.prices_url(), path);
let response = self.http.get(&url).query(query).send().await?;
self.handle_response(response).await
}
pub async fn prices_post<B, R>(&self, path: &str, body: &B) -> Result<R>
where
B: Serialize,
R: DeserializeOwned,
{
let url = format!("{}/{}", self.prices_url(), path);
let response = self.http.post(&url).json(body).send().await?;
self.handle_response(response).await
}
pub async fn data_post<B, R>(&self, path: &str, body: &B) -> Result<R>
where
B: Serialize,
R: DeserializeOwned,
{
let url = format!("{}/{}", self.data_url(), path);
let response = self.http.post(&url).json(body).send().await?;
self.handle_response(response).await
}
async fn handle_response<R>(&self, response: reqwest::Response) -> Result<R>
where
R: DeserializeOwned,
{
if response.status() == 429 {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
return Err(Error::rate_limited(retry_after));
}
if response.status().is_success() {
Ok(response.json().await?)
} else {
let status = response.status().as_u16();
let message = response.text().await.unwrap_or_default();
Err(Error::api(status, message))
}
}
}