// file: crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs // version: 4 //! CoinMarketCap SOL/USD market-price adapter using the current Simple Price V2 REST surface. const COINMARKETCAP_API_KEY_HEADER: &str = "x-cmc_pro_api_key"; const COINMARKETCAP_BASIC_URL: &str = "https://pro-api.coinmarketcap.com/v2/simple/price"; const COINMARKETCAP_KEYLESS_URL: &str = "https://pro-api.coinmarketcap.com/public-api/v2/simple/price"; const COINMARKETCAP_PROVIDER_ID: &str = "coinmarketcap"; const COINMARKETCAP_SOL_ID: u64 = 5_426; /// CoinMarketCap V1 access mode supported by Off-chain Transport. #[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum MarketPriceCoinMarketCapAccessMode { /// Free authenticated Basic plan with a provider-issued API key. Basic, /// Keyless public API intended for evaluation and low-volume use. Keyless, } /// Runtime settings for the CoinMarketCap market-price adapter. pub struct MarketPriceCoinMarketCapSettings { access_mode: crate::MarketPriceCoinMarketCapAccessMode, api_key: std::option::Option, common: crate::MarketPriceProviderCommonSettings, } impl crate::MarketPriceCoinMarketCapSettings { /// Creates keyless CoinMarketCap settings without accepting a credential. pub fn keyless(enabled: bool) -> ksp_core_lib::Result { return Self::new(enabled, crate::MarketPriceCoinMarketCapAccessMode::Keyless, std::option::Option::None); } /// Creates Basic CoinMarketCap settings. An API key is mandatory while the provider is enabled. pub fn basic(enabled: bool, api_key: std::option::Option) -> ksp_core_lib::Result { return Self::new(enabled, crate::MarketPriceCoinMarketCapAccessMode::Basic, api_key); } /// Returns the configured CoinMarketCap access mode. #[must_use] pub const fn access_mode(&self) -> crate::MarketPriceCoinMarketCapAccessMode { return self.access_mode; } /// Returns common provider identity and enablement settings. #[must_use] pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings { return &self.common; } fn new( enabled: bool, access_mode: crate::MarketPriceCoinMarketCapAccessMode, api_key: std::option::Option, ) -> ksp_core_lib::Result { let provider_id = match crate::MarketPriceProviderId::new(COINMARKETCAP_PROVIDER_ID) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let api_key = match access_mode { crate::MarketPriceCoinMarketCapAccessMode::Keyless => { if api_key.is_some() { return std::result::Result::Err(provider_settings_error("api_key")); } std::option::Option::None }, crate::MarketPriceCoinMarketCapAccessMode::Basic => match api_key { std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(COINMARKETCAP_PROVIDER_ID, value) { std::result::Result::Ok(value) => std::option::Option::Some(value), std::result::Result::Err(error) => return std::result::Result::Err(error), }, std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")), std::option::Option::None => std::option::Option::None, }, }; let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled); return std::result::Result::Ok(Self { access_mode, api_key, common }); } fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> { return self.api_key.as_ref(); } } impl std::fmt::Debug for crate::MarketPriceCoinMarketCapSettings { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { return formatter .debug_struct("MarketPriceCoinMarketCapSettings") .field("access_mode", &self.access_mode) .field("api_key_present", &self.api_key.is_some()) .field("common", &self.common) .finish(); } } /// CoinMarketCap SOL/USD provider adapter. pub struct MarketPriceCoinMarketCapProvider { admission: crate::HttpAdmissionController, descriptor: crate::MarketPriceProviderDescriptor, http: crate::HttpRestClient, settings: crate::MarketPriceCoinMarketCapSettings, } impl crate::MarketPriceCoinMarketCapProvider { /// Builds one CoinMarketCap provider from validated runtime settings. pub fn new(settings: crate::MarketPriceCoinMarketCapSettings) -> ksp_core_lib::Result { let descriptor = match descriptor_for(settings.access_mode(), settings.common().provider_id().clone()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings }); } /// Returns the provider-neutral CoinMarketCap capability descriptor. #[must_use] pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor { return &self.descriptor; } /// Returns the validated CoinMarketCap runtime settings without exposing credential material. #[must_use] pub const fn settings(&self) -> &crate::MarketPriceCoinMarketCapSettings { return &self.settings; } /// Fetches one normalized SOL/USD observation from CoinMarketCap. pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result { if !self.settings.common().enabled() { return std::result::Result::Err(crate::provider_disabled_error(COINMARKETCAP_PROVIDER_ID)); } if let std::result::Result::Err(error) = crate::admit_request(COINMARKETCAP_PROVIDER_ID, &self.admission) { return std::result::Result::Err(error); } let request_started_at = match crate::current_timestamp() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let request = match build_request(&self.settings) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let document = match crate::get_json(&self.http, &self.admission, COINMARKETCAP_PROVIDER_ID, request).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let received_at = match crate::current_timestamp() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at); } } fn build_request(settings: &crate::MarketPriceCoinMarketCapSettings) -> ksp_core_lib::Result { let url = match settings.access_mode() { crate::MarketPriceCoinMarketCapAccessMode::Basic => COINMARKETCAP_BASIC_URL, crate::MarketPriceCoinMarketCapAccessMode::Keyless => COINMARKETCAP_KEYLESS_URL, }; let mut request = match crate::HttpGetRequest::new_https(url) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; request.append_query_pair("id", "5426"); request.append_query_pair("convert", "USD"); request.append_query_pair("include_last_updated", "true"); if let std::option::Option::Some(api_key) = settings.api_key() && let std::result::Result::Err(error) = request.insert_sensitive_header(COINMARKETCAP_API_KEY_HEADER, api_key.as_str()) { return std::result::Result::Err(error); } return std::result::Result::Ok(request); } fn descriptor_for( access_mode: crate::MarketPriceCoinMarketCapAccessMode, provider_id: crate::MarketPriceProviderId, ) -> ksp_core_lib::Result { let (auth_mode, rate_limit, long_term_quota) = match access_mode { crate::MarketPriceCoinMarketCapAccessMode::Keyless => ( crate::MarketPriceProviderAuthMode::None, crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip), std::option::Option::None, ), crate::MarketPriceCoinMarketCapAccessMode::Basic => { let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(50, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let quota = match crate::MarketPriceProviderLongTermQuota::new( 15_000, crate::MarketPriceProviderQuotaPeriod::Month, crate::MarketPriceProviderQuotaUnit::Credits, ) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; (crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit, std::option::Option::Some(quota)) }, }; return crate::MarketPriceProviderDescriptor::new( provider_id, "CoinMarketCap", crate::MarketPriceSemantics::AggregatedMarket, auth_mode, rate_limit, long_term_quota, true, ); } fn parse_response( bytes: &[u8], provider_id: crate::MarketPriceProviderId, request_started_at: crate::MarketPriceTimestamp, received_at: crate::MarketPriceTimestamp, ) -> ksp_core_lib::Result { let wire = match serde_json::from_slice::(bytes) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(crate::invalid_provider_response_with_source(COINMARKETCAP_PROVIDER_ID, "response", error)); }, }; if !raw_status_is_zero(wire.status.error_code.as_ref()) { return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "status.error_code")); } if wire.data.len() != 1 { return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data")); } let item = &wire.data[0]; if item.id != COINMARKETCAP_SOL_ID || item.symbol != "SOL" { return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.identity")); } let mut usd_quote = std::option::Option::None; for quote in &item.quotes { if quote.symbol == "USD" { if usd_quote.is_some() { return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes")); } usd_quote = std::option::Option::Some(quote); } } let quote = match usd_quote { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes.USD")); }, }; let price = match crate::MarketPriceDecimal::parse_json_raw(quote.price.as_ref()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let provider_timestamp = match crate::market_price_timestamp_from_rfc3339(quote.last_updated.as_str()) { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes.last_updated")); }, }; let provenance = match crate::MarketPriceProvenance::new("coinmarketcap:5426:usd:v2") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return crate::MarketPriceObservation::new( provider_id, price, crate::MarketPriceSemantics::AggregatedMarket, request_started_at, received_at, std::option::Option::Some(provider_timestamp), provenance, ); } fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "CoinMarketCap market-price settings are invalid") .with_context("provider", COINMARKETCAP_PROVIDER_ID) .with_context("field", field); } fn raw_status_is_zero(raw: &serde_json::value::RawValue) -> bool { return raw.get() == "0" || raw.get() == "\"0\""; } #[derive(serde::Deserialize)] struct CoinMarketCapWireQuote { last_updated: std::string::String, price: std::boxed::Box, symbol: std::string::String, } #[derive(serde::Deserialize)] struct CoinMarketCapWireItem { id: u64, quotes: std::vec::Vec, symbol: std::string::String, } #[derive(serde::Deserialize)] struct CoinMarketCapWireResponse { data: std::vec::Vec, status: CoinMarketCapWireStatus, } #[derive(serde::Deserialize)] struct CoinMarketCapWireStatus { error_code: std::boxed::Box, } #[cfg(test)] #[path = "../unit_tests/market_price_coinmarketcap.rs"] mod tests;