264 lines
12 KiB
Rust
264 lines
12 KiB
Rust
// file: crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
|
|
// version: 1
|
|
|
|
//! CoinGecko SOL/USD market-price adapter using the official REST API directly through `reqwest`.
|
|
|
|
const COINGECKO_DEMO_API_KEY_HEADER: &str = "x-cg-demo-api-key";
|
|
const COINGECKO_PROVIDER_ID: &str = "coingecko";
|
|
const COINGECKO_SIMPLE_PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price";
|
|
|
|
/// CoinGecko V1 access mode supported by Off-chain Transport.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum MarketPriceCoinGeckoAccessMode {
|
|
/// Free Demo plan with a provider-issued API key and published allowance.
|
|
Demo,
|
|
/// Shared keyless public API with dynamic IP-based throttling.
|
|
Keyless,
|
|
}
|
|
|
|
/// Runtime settings for the CoinGecko market-price adapter.
|
|
pub struct MarketPriceCoinGeckoSettings {
|
|
access_mode: crate::MarketPriceCoinGeckoAccessMode,
|
|
api_key: std::option::Option<crate::MarketPriceApiKey>,
|
|
common: crate::MarketPriceProviderCommonSettings,
|
|
}
|
|
|
|
impl crate::MarketPriceCoinGeckoSettings {
|
|
/// Creates keyless CoinGecko settings without accepting a credential.
|
|
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
|
|
return Self::new(enabled, crate::MarketPriceCoinGeckoAccessMode::Keyless, std::option::Option::None);
|
|
}
|
|
|
|
/// Creates Demo CoinGecko settings. An API key is mandatory while the provider is enabled.
|
|
pub fn demo(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
|
return Self::new(enabled, crate::MarketPriceCoinGeckoAccessMode::Demo, api_key);
|
|
}
|
|
|
|
/// Returns the configured CoinGecko access mode.
|
|
#[must_use]
|
|
pub const fn access_mode(&self) -> crate::MarketPriceCoinGeckoAccessMode {
|
|
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::MarketPriceCoinGeckoAccessMode, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
|
let provider_id = match crate::MarketPriceProviderId::new(COINGECKO_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::MarketPriceCoinGeckoAccessMode::Keyless => {
|
|
if api_key.is_some() {
|
|
return std::result::Result::Err(provider_settings_error("api_key"));
|
|
}
|
|
std::option::Option::None
|
|
},
|
|
crate::MarketPriceCoinGeckoAccessMode::Demo => match api_key {
|
|
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(COINGECKO_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::MarketPriceCoinGeckoSettings {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
return formatter
|
|
.debug_struct("MarketPriceCoinGeckoSettings")
|
|
.field("access_mode", &self.access_mode)
|
|
.field("api_key_present", &self.api_key.is_some())
|
|
.field("common", &self.common)
|
|
.finish();
|
|
}
|
|
}
|
|
|
|
/// CoinGecko SOL/USD provider adapter.
|
|
pub struct MarketPriceCoinGeckoProvider {
|
|
admission: crate::HttpAdmissionController,
|
|
descriptor: crate::MarketPriceProviderDescriptor,
|
|
http: crate::HttpRestClient,
|
|
settings: crate::MarketPriceCoinGeckoSettings,
|
|
}
|
|
|
|
impl crate::MarketPriceCoinGeckoProvider {
|
|
/// Builds one CoinGecko provider from validated runtime settings.
|
|
pub fn new(settings: crate::MarketPriceCoinGeckoSettings) -> 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 CoinGecko capability descriptor.
|
|
#[must_use]
|
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
|
return &self.descriptor;
|
|
}
|
|
|
|
/// Returns the validated CoinGecko runtime settings without exposing credential material.
|
|
#[must_use]
|
|
pub const fn settings(&self) -> &crate::MarketPriceCoinGeckoSettings {
|
|
return &self.settings;
|
|
}
|
|
|
|
/// Fetches one normalized SOL/USD observation from CoinGecko.
|
|
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(COINGECKO_PROVIDER_ID));
|
|
}
|
|
if let std::result::Result::Err(error) = crate::admit_request(COINGECKO_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, COINGECKO_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::MarketPriceCoinGeckoSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
|
let mut request = match crate::HttpGetRequest::new_https(COINGECKO_SIMPLE_PRICE_URL) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
request.append_query_pair("ids", "solana");
|
|
request.append_query_pair("vs_currencies", "usd");
|
|
request.append_query_pair("include_last_updated_at", "true");
|
|
if let std::option::Option::Some(api_key) = settings.api_key() {
|
|
if let std::result::Result::Err(error) = request.insert_sensitive_header(COINGECKO_DEMO_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::MarketPriceCoinGeckoAccessMode,
|
|
provider_id: crate::MarketPriceProviderId,
|
|
) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
|
let (auth_mode, rate_limit, long_term_quota) = match access_mode {
|
|
crate::MarketPriceCoinGeckoAccessMode::Keyless => (
|
|
crate::MarketPriceProviderAuthMode::None,
|
|
crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip),
|
|
std::option::Option::None,
|
|
),
|
|
crate::MarketPriceCoinGeckoAccessMode::Demo => {
|
|
let rate_limit =
|
|
match crate::MarketPriceProviderRateLimit::fixed(100, 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(
|
|
10_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,
|
|
"CoinGecko",
|
|
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::<CoinGeckoWireResponse>(bytes) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINGECKO_PROVIDER_ID, "response", error));
|
|
},
|
|
};
|
|
let price = match crate::MarketPriceDecimal::parse_json_raw(wire.solana.usd.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_unix_seconds(wire.solana.last_updated_at) {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(crate::invalid_provider_response(COINGECKO_PROVIDER_ID, "last_updated_at"));
|
|
},
|
|
};
|
|
let provenance = match crate::MarketPriceProvenance::new("coingecko:solana:usd") {
|
|
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, "CoinGecko market-price settings are invalid")
|
|
.with_context("provider", COINGECKO_PROVIDER_ID)
|
|
.with_context("field", field);
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct CoinGeckoWireResponse {
|
|
solana: CoinGeckoWireSolana,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct CoinGeckoWireSolana {
|
|
last_updated_at: u64,
|
|
usd: std::boxed::Box<serde_json::value::RawValue>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "../unit_tests/market_price_coingecko.rs"]
|
|
mod tests;
|