239 lines
11 KiB
Rust
239 lines
11 KiB
Rust
// file: crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs
|
|
// version: 2
|
|
|
|
//! DexScreener SOL/USD adapter bound to one explicitly configured Solana pair address.
|
|
|
|
const DEXSCREENER_PAIR_BASE_URL: &str = "https://api.dexscreener.com/latest/dex/pairs/solana";
|
|
const DEXSCREENER_PROVIDER_ID: &str = "dexscreener";
|
|
const DEXSCREENER_SOL_MINT: &str = "So11111111111111111111111111111111111111112";
|
|
|
|
/// Runtime settings for the keyless DexScreener market-price adapter.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct MarketPriceDexScreenerSettings {
|
|
common: crate::MarketPriceProviderCommonSettings,
|
|
pair_address: std::option::Option<ksp_core_lib::Pubkey>,
|
|
}
|
|
|
|
impl crate::MarketPriceDexScreenerSettings {
|
|
/// Creates DexScreener settings for one optional explicit Solana pair address.
|
|
///
|
|
/// An enabled provider requires a valid pair address. A disabled provider may omit it so Config does not need to fabricate provider data merely to keep the
|
|
/// provider visible in the generic registry.
|
|
pub fn new(enabled: bool, pair_address: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
|
let provider_id = match crate::MarketPriceProviderId::new(DEXSCREENER_PROVIDER_ID) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let pair_address = match pair_address {
|
|
std::option::Option::Some(value) => match value.parse::<ksp_core_lib::Pubkey>() {
|
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
|
std::result::Result::Err(_) => return std::result::Result::Err(provider_settings_error("pair_address")),
|
|
},
|
|
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("pair_address")),
|
|
std::option::Option::None => std::option::Option::None,
|
|
};
|
|
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
|
|
return std::result::Result::Ok(Self { common, pair_address });
|
|
}
|
|
|
|
/// Returns common provider identity and enablement settings.
|
|
#[must_use]
|
|
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
|
return &self.common;
|
|
}
|
|
|
|
/// Returns the explicit Solana pair address when one is configured.
|
|
#[must_use]
|
|
pub const fn pair_address(&self) -> std::option::Option<&ksp_core_lib::Pubkey> {
|
|
return self.pair_address.as_ref();
|
|
}
|
|
}
|
|
|
|
/// DexScreener SOL/USD provider adapter for one configured pair.
|
|
pub struct MarketPriceDexScreenerProvider {
|
|
admission: crate::HttpAdmissionController,
|
|
descriptor: crate::MarketPriceProviderDescriptor,
|
|
http: crate::HttpRestClient,
|
|
settings: crate::MarketPriceDexScreenerSettings,
|
|
}
|
|
|
|
impl crate::MarketPriceDexScreenerProvider {
|
|
/// Builds one DexScreener provider from validated runtime settings.
|
|
pub fn new(settings: crate::MarketPriceDexScreenerSettings) -> ksp_core_lib::Result<Self> {
|
|
let descriptor = match descriptor_for(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 DexScreener capability descriptor.
|
|
#[must_use]
|
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
|
return &self.descriptor;
|
|
}
|
|
|
|
/// Returns the validated DexScreener settings including the safe configured pair address.
|
|
#[must_use]
|
|
pub const fn settings(&self) -> &crate::MarketPriceDexScreenerSettings {
|
|
return &self.settings;
|
|
}
|
|
|
|
/// Fetches one normalized SOL/USD observation from the configured DexScreener pair.
|
|
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(DEXSCREENER_PROVIDER_ID));
|
|
}
|
|
if let std::result::Result::Err(error) = crate::admit_request(DEXSCREENER_PROVIDER_ID, &self.admission) {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let pair_address = match self.settings.pair_address() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return std::result::Result::Err(provider_settings_error("pair_address")),
|
|
};
|
|
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(pair_address) {
|
|
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, DEXSCREENER_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(), pair_address, request_started_at, received_at);
|
|
}
|
|
}
|
|
|
|
fn build_request(pair_address: &ksp_core_lib::Pubkey) -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
|
let mut request = match crate::HttpGetRequest::new_https(DEXSCREENER_PAIR_BASE_URL) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let pair_address = pair_address.to_string();
|
|
if let std::result::Result::Err(error) = request.append_path_segment(pair_address.as_str()) {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
return std::result::Result::Ok(request);
|
|
}
|
|
|
|
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
|
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(300, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified)
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
return crate::MarketPriceProviderDescriptor::new(
|
|
provider_id,
|
|
"DexScreener",
|
|
crate::MarketPriceSemantics::DexPairUsd,
|
|
crate::MarketPriceProviderAuthMode::None,
|
|
rate_limit,
|
|
std::option::Option::None,
|
|
true,
|
|
);
|
|
}
|
|
|
|
fn parse_response(
|
|
bytes: &[u8],
|
|
provider_id: crate::MarketPriceProviderId,
|
|
configured_pair_address: &ksp_core_lib::Pubkey,
|
|
request_started_at: crate::MarketPriceTimestamp,
|
|
received_at: crate::MarketPriceTimestamp,
|
|
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
|
let wire = match serde_json::from_slice::<DexScreenerWireResponse>(bytes) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(DEXSCREENER_PROVIDER_ID, "response", error));
|
|
},
|
|
};
|
|
let pairs = match wire.pairs {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs")),
|
|
};
|
|
if pairs.len() != 1 {
|
|
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs"));
|
|
}
|
|
let pair = &pairs[0];
|
|
if pair.chain_id != "solana" {
|
|
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.chainId"));
|
|
}
|
|
let response_pair_address = match pair.pair_address.parse::<ksp_core_lib::Pubkey>() {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.pairAddress")),
|
|
};
|
|
if response_pair_address != *configured_pair_address {
|
|
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.pairAddress"));
|
|
}
|
|
let base_token = match &pair.base_token {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.baseToken")),
|
|
};
|
|
if base_token.address != DEXSCREENER_SOL_MINT {
|
|
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.baseToken.address"));
|
|
}
|
|
let price_raw = match pair.price_usd.as_deref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.priceUsd")),
|
|
};
|
|
let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let provenance = match crate::MarketPriceProvenance::new(std::format!("dexscreener:solana:{configured_pair_address}:priceUsd")) {
|
|
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::DexPairUsd,
|
|
request_started_at,
|
|
received_at,
|
|
std::option::Option::None,
|
|
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, "DexScreener market-price settings are invalid")
|
|
.with_context("provider", DEXSCREENER_PROVIDER_ID)
|
|
.with_context("field", field);
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct DexScreenerWirePair {
|
|
#[serde(rename = "baseToken")]
|
|
base_token: std::option::Option<DexScreenerWireToken>,
|
|
#[serde(rename = "chainId")]
|
|
chain_id: std::string::String,
|
|
#[serde(rename = "pairAddress")]
|
|
pair_address: std::string::String,
|
|
#[serde(rename = "priceUsd")]
|
|
price_usd: std::option::Option<std::boxed::Box<serde_json::value::RawValue>>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct DexScreenerWireResponse {
|
|
pairs: std::option::Option<std::vec::Vec<DexScreenerWirePair>>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct DexScreenerWireToken {
|
|
address: std::string::String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "../unit_tests/market_price_dexscreener.rs"]
|
|
mod tests;
|