Files
khadhroony-solana-project/crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs

318 lines
14 KiB
Rust

// 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<crate::MarketPriceApiKey>,
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceCoinMarketCapSettings {
/// Creates keyless CoinMarketCap settings without accepting a credential.
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
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<std::string::String>) -> ksp_core_lib::Result<Self> {
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<std::string::String>,
) -> ksp_core_lib::Result<Self> {
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<Self> {
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<crate::MarketPriceObservation> {
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<crate::HttpGetRequest> {
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<crate::MarketPriceProviderDescriptor> {
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<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<CoinMarketCapWireResponse>(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<serde_json::value::RawValue>,
symbol: std::string::String,
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireItem {
id: u64,
quotes: std::vec::Vec<CoinMarketCapWireQuote>,
symbol: std::string::String,
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireResponse {
data: std::vec::Vec<CoinMarketCapWireItem>,
status: CoinMarketCapWireStatus,
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireStatus {
error_code: std::boxed::Box<serde_json::value::RawValue>,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_coinmarketcap.rs"]
mod tests;