use std::borrow::Cow;
use std::time::Duration;
use url::Url;
use yldfi_common::api::{ApiConfig, BaseClient};
use yldfi_common::{with_retry, RetryConfig};
use crate::error::{DomainError, 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 {
inner: ApiConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
inner: ApiConfig::new(base_urls::MAINNET),
}
}
}
impl Config {
#[must_use]
pub fn mainnet() -> Self {
Self::default()
}
#[must_use]
pub fn testnet() -> Self {
Self {
inner: ApiConfig::new(base_urls::TESTNET),
}
}
#[must_use]
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.inner.base_url = url.into();
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.inner.http.timeout = timeout;
self
}
#[must_use]
pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {
self.inner.http.proxy = Some(proxy.into());
self
}
pub fn validate(&self) -> Result<()> {
self.inner.validate().map_err(|e| match e {
yldfi_common::api::ConfigValidationError::InsecureScheme => {
crate::error::insecure_scheme()
}
yldfi_common::api::ConfigValidationError::InvalidUrl(msg) => {
crate::error::invalid_url(msg)
}
})
}
}
#[derive(Debug, Clone)]
pub struct Client {
base: BaseClient,
retry_config: RetryConfig,
}
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> {
config.validate()?;
let base = BaseClient::new(config.inner)
.map_err(yldfi_common::api::ApiError::<DomainError>::HttpBuild)?;
Ok(Self {
base,
retry_config: RetryConfig::new(3)
.with_initial_delay(Duration::from_millis(100))
.with_max_delay(Duration::from_secs(5)),
})
}
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(&self.base.url("/v2/updates/price/latest"))?;
{
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(&self.base.url("/v2/price_feeds"))?;
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(&self.base.url("/v2/price_feeds"))?;
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(&self.base.url(path))?;
self.get_url(&url).await
}
async fn get_url<T: serde::de::DeserializeOwned>(&self, url: &Url) -> Result<T> {
let url_str = url.to_string();
let http = self.base.http().clone();
let result = with_retry(&self.retry_config, || {
let http = http.clone();
let url_str = url_str.clone();
async move {
let response = http.get(&url_str).send().await?;
let status = response.status().as_u16();
if response.status().is_success() {
response
.json()
.await
.map_err(|e| Error::api(status, format!("Parse error: {e}")))
} else {
let retry_after = yldfi_common::api::extract_retry_after(response.headers());
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
};
Err(Error::from_response(status, &body_truncated, retry_after))
}
}
})
.await;
result.map_err(yldfi_common::RetryError::into_inner)
}
}
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())
}
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";
}
#[must_use]
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,
}
}