use reqwest::Client as HttpClient;
use std::borrow::Cow;
use std::time::Duration;
use url::Url;
use yldfi_common::http::HttpClientConfig;
use crate::error::{Error, Result};
use crate::types::{LatestPriceResponse, ParsedPriceFeed, PriceFeedId};
const MAX_ERROR_BODY_LEN: usize = 500;
pub mod base_urls {
pub const MAINNET: &str = "https://hermes.pyth.network";
pub const TESTNET: &str = "https://hermes-beta.pyth.network";
}
#[derive(Debug, Clone)]
pub struct Config {
pub base_url: String,
pub http: HttpClientConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
base_url: base_urls::MAINNET.to_string(),
http: HttpClientConfig::default(),
}
}
}
impl Config {
pub fn mainnet() -> Self {
Self::default()
}
pub fn testnet() -> Self {
Self {
base_url: base_urls::TESTNET.to_string(),
http: HttpClientConfig::default(),
}
}
#[must_use]
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
#[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
}
}
#[derive(Debug, Clone)]
pub struct Client {
http: HttpClient,
base_url: String,
}
impl Client {
pub fn new() -> Result<Self> {
Self::with_config(Config::mainnet())
}
pub fn testnet() -> Result<Self> {
Self::with_config(Config::testnet())
}
pub fn with_config(config: Config) -> Result<Self> {
let http = yldfi_common::build_client(&config.http)?;
let _ = Url::parse(&config.base_url)?;
let base_url = config.base_url.trim_end_matches('/').to_string();
Ok(Self { http, base_url })
}
pub async fn get_latest_prices(&self, feed_ids: &[&str]) -> Result<Vec<ParsedPriceFeed>> {
if feed_ids.is_empty() {
return Ok(vec![]);
}
for id in feed_ids {
if !validate_feed_id(id) {
return Err(crate::error::invalid_feed_id(*id));
}
}
let mut url = Url::parse(&format!("{}/v2/updates/price/latest", self.base_url))?;
{
let mut pairs = url.query_pairs_mut();
for id in feed_ids {
pairs.append_pair("ids[]", &normalize_feed_id(id));
}
}
let response: LatestPriceResponse = self.get_url(&url).await?;
Ok(response.parsed)
}
pub async fn get_latest_price(&self, feed_id: &str) -> Result<Option<ParsedPriceFeed>> {
let feeds = self.get_latest_prices(&[feed_id]).await?;
Ok(feeds.into_iter().next())
}
pub async fn get_price_feed_ids(&self) -> Result<Vec<PriceFeedId>> {
self.get("/v2/price_feeds").await
}
pub async fn search_feeds(&self, query: &str) -> Result<Vec<PriceFeedId>> {
let mut url = Url::parse(&format!("{}/v2/price_feeds", self.base_url))?;
url.query_pairs_mut()
.append_pair("query", query)
.append_pair("asset_type", "crypto");
self.get_url(&url).await
}
pub async fn get_feeds_by_asset_type(&self, asset_type: &str) -> Result<Vec<PriceFeedId>> {
let mut url = Url::parse(&format!("{}/v2/price_feeds", self.base_url))?;
url.query_pairs_mut().append_pair("asset_type", asset_type);
self.get_url(&url).await
}
async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
let url = Url::parse(&format!("{}{}", self.base_url, path))?;
self.get_url(&url).await
}
async fn get_url<T: serde::de::DeserializeOwned>(&self, url: &Url) -> Result<T> {
self.get_url_with_retry(url, 3).await
}
async fn get_url_with_retry<T: serde::de::DeserializeOwned>(
&self,
url: &Url,
max_retries: u32,
) -> Result<T> {
let url_str = url.as_str();
let mut last_error: Option<Error> = None;
for attempt in 0..=max_retries {
let response = self.http.get(url_str).send().await?;
let status = response.status().as_u16();
if status == 429 {
if attempt < max_retries {
let backoff = parse_retry_after(&response)
.unwrap_or_else(|| Duration::from_millis(100 * 2u64.pow(attempt)));
tokio::time::sleep(backoff).await;
continue;
}
return Err(Error::api(
429,
"Rate limited - too many requests".to_string(),
));
}
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
let body_truncated = if body.len() > MAX_ERROR_BODY_LEN {
format!("{}...(truncated)", &body[..MAX_ERROR_BODY_LEN])
} else {
body
};
last_error = Some(Error::api(
status,
format!("{} - {}", status, body_truncated),
));
if status >= 500 && attempt < max_retries {
let backoff = Duration::from_millis(100 * 2u64.pow(attempt));
tokio::time::sleep(backoff).await;
continue;
}
return Err(last_error.unwrap());
}
return response
.json()
.await
.map_err(|e| Error::api(status, format!("Parse error: {e}")));
}
Err(last_error.unwrap_or_else(|| Error::api(0, "Unknown error".to_string())))
}
}
fn normalize_feed_id(id: &str) -> Cow<'_, str> {
let id = id.trim();
if id.starts_with("0x")
&& id
.chars()
.skip(2)
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return Cow::Borrowed(id);
}
let lower = id.to_lowercase();
if lower.starts_with("0x") {
Cow::Owned(lower)
} else {
Cow::Owned(format!("0x{}", lower))
}
}
fn validate_feed_id(id: &str) -> bool {
let id = id.trim();
let hex_part = id.strip_prefix("0x").unwrap_or(id);
hex_part.len() == 64 && hex_part.chars().all(|c| c.is_ascii_hexdigit())
}
fn parse_retry_after(response: &reqwest::Response) -> Option<Duration> {
let header_value = response.headers().get("retry-after")?.to_str().ok()?;
let seconds: u64 = header_value.trim().parse().ok()?;
Some(Duration::from_secs(seconds.min(60)))
}
pub mod feed_ids {
pub const BTC_USD: &str = "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43";
pub const ETH_USD: &str = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace";
pub const SOL_USD: &str = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
pub const USDC_USD: &str = "0xeaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a";
pub const USDT_USD: &str = "0x2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b";
pub const LINK_USD: &str = "0x8ac0c70fff57e9aefdf5edf44b51d62c2d433653cbb2cf5cc06bb115af04d221";
pub const ARB_USD: &str = "0x3fa4252848f9f0a1480be62745a4629d9eb1322aebab8a791e344b3b9c1adcf5";
pub const OP_USD: &str = "0x385f64d993f7b77d8182ed5003d97c60aa3361f3cecfe711544d2d59165e9bdf";
pub const AAVE_USD: &str = "0x2b9ab1e972a281585084148ba1389800799bd4be63b957507db1349314e47445";
pub const UNI_USD: &str = "0x78d185a741d07edb3412b09008b7c5cfb9bbbd7d568bf00ba737b456ba171501";
pub const CRV_USD: &str = "0xa19d04ac696c7a6616d291c7e5d1377cc8be437c327b75adb5dc1bad745fcae8";
pub const CVX_USD: &str = "0x6aac625e125ada0d2a6b98316493256ca733a5808cd34ccef79b0e28c64d1e76";
pub const SNX_USD: &str = "0x39d020f60982ed892abbcd4a06a276a9f9b7bfbce003204c110b6e488f502da3";
pub const LDO_USD: &str = "0xc63e2a7f37a04e5e614c07238bedb25dcc38927fba8fe890597a593c0b2fa4ad";
pub const DAI_USD: &str = "0xb0948a5e5313200c632b51bb5ca32f6de0d36e9950a942d19751e833f70dabfd";
pub const DOGE_USD: &str = "0xdcef50dd0a4cd2dcc17e45df1676dcb336a11a61c69df7a0299b0150c672d25c";
pub const AVAX_USD: &str = "0x93da3352f9f1d105fdfe4971cfa80e9dd777bfc5d0f683ebb6e1294b92137bb7";
pub const ATOM_USD: &str = "0xb00b60f88b03a6a625a8d1c048c3f66653edf217439983d037e7222c4e612819";
pub const DOT_USD: &str = "0xca3eed9b267293f6595901c734c7525ce8ef49adafe8284606ceb307afa2ca5b";
}
pub fn symbol_to_feed_id(symbol: &str) -> Option<&'static str> {
match symbol.to_uppercase().as_str() {
"BTC" | "BITCOIN" | "WBTC" => Some(feed_ids::BTC_USD),
"ETH" | "ETHEREUM" | "WETH" => Some(feed_ids::ETH_USD),
"SOL" | "SOLANA" => Some(feed_ids::SOL_USD),
"USDC" => Some(feed_ids::USDC_USD),
"USDT" | "TETHER" => Some(feed_ids::USDT_USD),
"DAI" => Some(feed_ids::DAI_USD),
"LINK" | "CHAINLINK" => Some(feed_ids::LINK_USD),
"AAVE" => Some(feed_ids::AAVE_USD),
"UNI" | "UNISWAP" => Some(feed_ids::UNI_USD),
"CRV" | "CURVE" => Some(feed_ids::CRV_USD),
"CVX" | "CONVEX" => Some(feed_ids::CVX_USD),
"SNX" | "SYNTHETIX" => Some(feed_ids::SNX_USD),
"LDO" | "LIDO" => Some(feed_ids::LDO_USD),
"ARB" | "ARBITRUM" => Some(feed_ids::ARB_USD),
"OP" | "OPTIMISM" => Some(feed_ids::OP_USD),
"DOGE" | "DOGECOIN" => Some(feed_ids::DOGE_USD),
"AVAX" | "AVALANCHE" => Some(feed_ids::AVAX_USD),
"ATOM" | "COSMOS" => Some(feed_ids::ATOM_USD),
"DOT" | "POLKADOT" => Some(feed_ids::DOT_USD),
_ => None,
}
}