v0.2.11-pre.008

This commit is contained in:
2026-08-26 10:23:42 +02:00
parent 5aeff5ca14
commit 87d7314bf4
13 changed files with 972 additions and 63 deletions

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/src/error.rs
// version: 8
// version: 9
/// Stable off-chain transport error for HTTP 401/403 access denial.
pub const ERROR_CODE_HTTP_ACCESS_DENIED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_access_denied");
@@ -41,12 +41,17 @@ pub const ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED: ksp_core_lib::ErrorCode =
/// Stable off-chain transport error for an invalid market-price provider identifier.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_id_invalid");
/// Stable off-chain transport error when a requested provider is absent from the configured service.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_not_found");
/// Stable off-chain transport error when a provider response violates its adapter contract.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_response_invalid");
/// Stable off-chain transport error for invalid common market-price provider settings.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_settings_invalid");
/// Stable off-chain transport error for an invalid generic market-price refresh request.
pub const ERROR_CODE_MARKET_PRICE_REFRESH_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_refresh_invalid");
/// Stable off-chain transport error for an invalid market-price provider registry.
pub const ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_registry_invalid");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/src/lib.rs
// version: 9
// version: 10
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -7,10 +7,10 @@
//! KSP-owned off-chain transport foundation.
//!
//! `0.2.11-pre.007` completes the eight-provider SOL/USD adapter inventory with Birdeye and materializes the provider-neutral runtime registry.
//! Birdeye uses its authenticated Solana Price Single endpoint, retains provider update time, and models both the Standard account cadence and informational
//! compute-unit quota/cost metadata. Registry projections expose descriptors and generic availability without provider-specific branching. Refresh dispatch and
//! Config integration remain outside this tranche.
//! `0.2.11-pre.008` adds the generic SOL/USD refresh service over the complete eight-provider adapter inventory. The service owns provider dispatch,
//! non-blocking rate-limit/cooldown handling, provider-neutral availability transitions and individual/multiple/all refresh operations. Provider-specific setup
//! remains confined to composition while runtime consumers operate only on opaque provider identifiers, registry projections and normalized outcomes. Config
//! integration remains reserved for the next tranche.
mod constants;
mod error;
@@ -31,6 +31,7 @@ mod market_price_kraken;
mod market_price_observation;
mod market_price_provider;
mod market_price_registry;
mod market_price_service;
mod market_price_settings;
/// Stable error code for HTTP access denial.
@@ -69,10 +70,14 @@ pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID;
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED;
/// Stable error code for an invalid provider identifier.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID;
/// Stable error code when a generic refresh targets an unconfigured provider.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND;
/// Stable error code for a provider response that violates its adapter contract.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID;
/// Stable error code for invalid common provider settings.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID;
/// Stable error code for an invalid generic refresh request.
pub use self::error::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID;
/// Stable error code for an invalid provider registry.
pub use self::error::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID;
/// Birdeye Standard SOL/USD provider adapter.
@@ -163,6 +168,12 @@ pub use self::market_price_provider::MarketPriceSemantics;
pub use self::market_price_registry::MarketPriceProviderRegistry;
/// Immutable descriptor-plus-state entry exposed by the configured provider registry.
pub use self::market_price_registry::MarketPriceProviderRegistryEntry;
/// Provider-specific setup accepted once by the generic market-price service.
pub use self::market_price_service::MarketPriceProviderSetup;
/// Generic result of one market-price refresh.
pub use self::market_price_service::MarketPriceRefreshOutcome;
/// Provider-agnostic market-price refresh service.
pub use self::market_price_service::MarketPriceService;
/// Common provider settings shared by provider-specific runtime settings.
pub use self::market_price_settings::MarketPriceProviderCommonSettings;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_provider.rs
// version: 4
// version: 5
/// Maximum UTF-8 byte length of one provider display name.
pub const MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES: usize = 96;
@@ -418,6 +418,18 @@ impl crate::MarketPriceProviderAvailability {
return matches!(self, Self::Ready);
}
/// Reports whether a refresh may be attempted at the supplied wall-clock timestamp.
#[must_use]
pub fn is_refresh_eligible_at(&self, now: crate::MarketPriceTimestamp) -> bool {
return match self {
Self::Ready => true,
Self::CoolingDown { retry_at } => *retry_at <= now,
Self::TemporarilyUnavailable { retry_at: std::option::Option::Some(retry_at) } => *retry_at <= now,
Self::TemporarilyUnavailable { retry_at: std::option::Option::None } => true,
Self::AuthenticationUnavailable | Self::Disabled | Self::Misconfigured | Self::QuotaUnavailable => false,
};
}
/// Returns the known next retry timestamp for cooling-down or temporary states.
#[must_use]
pub const fn retry_at(&self) -> std::option::Option<crate::MarketPriceTimestamp> {
@@ -454,6 +466,11 @@ impl MarketPriceProviderState {
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
return &self.provider_id;
}
/// Replaces the internal availability projection while preserving provider identity.
pub(crate) fn set_availability(&mut self, availability: crate::MarketPriceProviderAvailability) {
self.availability = availability;
}
}
fn provider_descriptor_error() -> ksp_core_lib::Error {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_registry.rs
// version: 1
// version: 2
//! Provider-neutral registry projection for configured market-price providers.
@@ -25,6 +25,11 @@ impl crate::MarketPriceProviderRegistryEntry {
return &self.state;
}
/// Replaces this entry's generic availability projection for the owning runtime service.
pub(crate) fn set_availability(&mut self, availability: crate::MarketPriceProviderAvailability) {
self.state.set_availability(availability);
}
/// Creates one generic registry entry while keeping descriptor and state identity synchronized.
#[must_use]
pub fn new(descriptor: crate::MarketPriceProviderDescriptor, availability: crate::MarketPriceProviderAvailability) -> Self {
@@ -80,6 +85,20 @@ impl crate::MarketPriceProviderRegistry {
return self.entry(provider_id).map(crate::MarketPriceProviderRegistryEntry::state);
}
/// Updates one existing provider availability and reports whether the provider was present.
pub(crate) fn set_availability(&mut self, provider_id: &crate::MarketPriceProviderId, availability: crate::MarketPriceProviderAvailability) -> bool {
let index = self.entries.binary_search_by(|entry| return entry.descriptor().id().cmp(provider_id));
let entry = match index {
std::result::Result::Ok(value) => self.entries.get_mut(value),
std::result::Result::Err(_) => std::option::Option::None,
};
if let std::option::Option::Some(entry) = entry {
entry.set_availability(availability);
return true;
}
return false;
}
/// Builds one bounded deterministic registry and rejects duplicate provider identifiers.
pub fn new(mut entries: std::vec::Vec<crate::MarketPriceProviderRegistryEntry>) -> ksp_core_lib::Result<Self> {
if entries.len() > MARKET_PRICE_PROVIDER_REGISTRY_MAX_ENTRIES {

View File

@@ -0,0 +1,386 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_service.rs
// version: 1
//! Generic market-price refresh service owning provider dispatch and availability transitions.
const MARKET_PRICE_RATE_LIMIT_FALLBACK_MILLIS: u64 = 1_000;
const MARKET_PRICE_REFRESH_MAX_PROVIDERS: usize = 64;
/// Provider-specific runtime setup consumed once by [`crate::MarketPriceService`].
///
/// This enum is intended for composition layers such as Config. Runtime consumers use the generic service methods and never need to branch on provider kinds.
pub enum MarketPriceProviderSetup {
/// Birdeye Standard setup.
Birdeye(crate::MarketPriceBirdeyeSettings),
/// Coinbase Exchange setup.
CoinbaseExchange(crate::MarketPriceCoinbaseExchangeSettings),
/// CoinGecko setup.
CoinGecko(crate::MarketPriceCoinGeckoSettings),
/// CoinMarketCap setup.
CoinMarketCap(crate::MarketPriceCoinMarketCapSettings),
/// CoinPaprika setup.
CoinPaprika(crate::MarketPriceCoinPaprikaSettings),
/// DexScreener setup bound to one explicit Solana pair.
DexScreener(crate::MarketPriceDexScreenerSettings),
/// Jupiter Price V3 setup.
Jupiter(crate::MarketPriceJupiterSettings),
/// Kraken Spot setup.
Kraken(crate::MarketPriceKrakenSettings),
}
/// Generic result of one explicit market-price refresh attempt or eligibility projection.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub struct MarketPriceRefreshOutcome {
observation: std::option::Option<crate::MarketPriceObservation>,
state: crate::MarketPriceProviderState,
}
impl crate::MarketPriceRefreshOutcome {
/// Returns the successful normalized observation when this refresh produced one.
#[must_use]
pub fn observation(&self) -> std::option::Option<&crate::MarketPriceObservation> {
return self.observation.as_ref();
}
/// Returns the provider identifier without exposing provider-specific runtime types.
#[must_use]
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
return self.state.provider_id();
}
/// Reports whether this refresh produced a new observation.
#[must_use]
pub fn refreshed(&self) -> bool {
return self.observation.is_some();
}
/// Returns the resulting provider-neutral runtime state.
#[must_use]
pub const fn state(&self) -> &crate::MarketPriceProviderState {
return &self.state;
}
fn from_state(state: crate::MarketPriceProviderState) -> Self {
return Self { observation: std::option::Option::None, state };
}
fn from_observation(observation: crate::MarketPriceObservation, state: crate::MarketPriceProviderState) -> Self {
return Self { observation: std::option::Option::Some(observation), state };
}
}
/// Provider-agnostic SOL/USD refresh service owning all configured provider adapters.
pub struct MarketPriceService {
providers: std::vec::Vec<MarketPriceProviderRuntime>,
registry: std::sync::Mutex<crate::MarketPriceProviderRegistry>,
}
impl crate::MarketPriceService {
/// Builds the service from provider-specific setup supplied by a composition layer.
///
/// Provider identifiers must be unique. Initial registry state is `Ready` for enabled providers and `Disabled` for disabled providers.
pub fn new(setups: std::vec::Vec<crate::MarketPriceProviderSetup>) -> ksp_core_lib::Result<Self> {
if setups.len() > MARKET_PRICE_REFRESH_MAX_PROVIDERS {
return std::result::Result::Err(refresh_error("provider_count"));
}
let mut providers = std::vec::Vec::with_capacity(setups.len());
let mut entries = std::vec::Vec::with_capacity(setups.len());
for setup in setups {
let runtime = match MarketPriceProviderRuntime::new(setup) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let availability = if runtime.enabled() {
crate::MarketPriceProviderAvailability::Ready
} else {
crate::MarketPriceProviderAvailability::Disabled
};
entries.push(crate::MarketPriceProviderRegistryEntry::new(runtime.descriptor().clone(), availability));
providers.push(runtime);
}
let registry = match crate::MarketPriceProviderRegistry::new(entries) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
providers.sort_by(|left, right| return left.provider_id().cmp(right.provider_id()));
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
provider_count = providers.len(),
"created generic market-price refresh service"
);
return std::result::Result::Ok(Self { providers, registry: std::sync::Mutex::new(registry) });
}
/// Returns a detached provider-neutral registry snapshot suitable for HID projection.
#[must_use]
pub fn registry(&self) -> crate::MarketPriceProviderRegistry {
let guard = lock_registry(&self.registry);
return guard.clone();
}
/// Refreshes one provider by generic provider identifier.
///
/// Non-eligible states are returned without network dispatch. Provider transport/application failures are normalized into availability and returned as an
/// outcome rather than forcing consumers to parse provider-specific errors.
pub async fn refresh(&self, provider_id: &crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceRefreshOutcome> {
let index = match self.provider_index(provider_id) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(provider_not_found_error(provider_id)),
};
let now = current_timestamp_or_zero();
let state = match self.state_snapshot(provider_id) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(provider_not_found_error(provider_id)),
};
if !state.availability().is_refresh_eligible_at(now) {
return std::result::Result::Ok(crate::MarketPriceRefreshOutcome::from_state(state));
}
let result = self.providers[index].fetch_sol_usd().await;
return match result {
std::result::Result::Ok(observation) => {
let state = self.update_availability(provider_id, crate::MarketPriceProviderAvailability::Ready);
std::result::Result::Ok(crate::MarketPriceRefreshOutcome::from_observation(observation, state))
},
std::result::Result::Err(error) => {
let availability = availability_from_error(&error, self.providers[index].descriptor().auth_mode());
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
provider_id = provider_id.as_str(),
error_domain = error.code().domain(),
error_code = error.code().code(),
"classified market-price refresh failure"
);
let state = self.update_availability(provider_id, availability);
std::result::Result::Ok(crate::MarketPriceRefreshOutcome::from_state(state))
},
};
}
/// Refreshes a caller-selected provider set in deterministic request order without fallback or consensus.
///
/// The service never sleeps to wait for a local rate limit. Duplicate or unknown identifiers are rejected before any provider request is attempted.
pub async fn refresh_many(&self, provider_ids: &[crate::MarketPriceProviderId]) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceRefreshOutcome>> {
if provider_ids.len() > MARKET_PRICE_REFRESH_MAX_PROVIDERS {
return std::result::Result::Err(refresh_error("provider_count"));
}
if let std::result::Result::Err(error) = validate_requested_provider_ids(self, provider_ids) {
return std::result::Result::Err(error);
}
let mut outcomes = std::vec::Vec::with_capacity(provider_ids.len());
for provider_id in provider_ids {
let outcome = match self.refresh(provider_id).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
outcomes.push(outcome);
}
return std::result::Result::Ok(outcomes);
}
/// Refreshes every currently eligible configured provider in stable provider-id order.
///
/// Disabled, authentication-unavailable, misconfigured, quota-unavailable and not-yet-expired cooldown states are projected without network dispatch.
pub async fn refresh_all(&self) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceRefreshOutcome>> {
let provider_ids = self.providers.iter().map(|provider| return provider.provider_id().clone()).collect::<std::vec::Vec<_>>();
return self.refresh_many(provider_ids.as_slice()).await;
}
fn provider_index(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<usize> {
let result = self.providers.binary_search_by(|provider| return provider.provider_id().cmp(provider_id));
return match result {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(_) => std::option::Option::None,
};
}
fn state_snapshot(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<crate::MarketPriceProviderState> {
let guard = lock_registry(&self.registry);
return guard.state(provider_id).cloned();
}
fn update_availability(
&self,
provider_id: &crate::MarketPriceProviderId,
availability: crate::MarketPriceProviderAvailability,
) -> crate::MarketPriceProviderState {
let mut guard = lock_registry(&self.registry);
if guard.set_availability(provider_id, availability) {
if let std::option::Option::Some(state) = guard.state(provider_id) {
return state.clone();
}
}
return crate::MarketPriceProviderState::new(provider_id.clone(), availability);
}
}
impl std::fmt::Debug for crate::MarketPriceService {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("MarketPriceService").field("registry", &self.registry()).finish_non_exhaustive();
}
}
enum MarketPriceProviderRuntime {
Birdeye(crate::MarketPriceBirdeyeProvider),
CoinbaseExchange(crate::MarketPriceCoinbaseExchangeProvider),
CoinGecko(crate::MarketPriceCoinGeckoProvider),
CoinMarketCap(crate::MarketPriceCoinMarketCapProvider),
CoinPaprika(crate::MarketPriceCoinPaprikaProvider),
DexScreener(crate::MarketPriceDexScreenerProvider),
Jupiter(crate::MarketPriceJupiterProvider),
Kraken(crate::MarketPriceKrakenProvider),
}
impl MarketPriceProviderRuntime {
fn new(setup: crate::MarketPriceProviderSetup) -> ksp_core_lib::Result<Self> {
return match setup {
crate::MarketPriceProviderSetup::Birdeye(settings) => crate::MarketPriceBirdeyeProvider::new(settings).map(Self::Birdeye),
crate::MarketPriceProviderSetup::CoinbaseExchange(settings) => {
crate::MarketPriceCoinbaseExchangeProvider::new(settings).map(Self::CoinbaseExchange)
},
crate::MarketPriceProviderSetup::CoinGecko(settings) => crate::MarketPriceCoinGeckoProvider::new(settings).map(Self::CoinGecko),
crate::MarketPriceProviderSetup::CoinMarketCap(settings) => crate::MarketPriceCoinMarketCapProvider::new(settings).map(Self::CoinMarketCap),
crate::MarketPriceProviderSetup::CoinPaprika(settings) => crate::MarketPriceCoinPaprikaProvider::new(settings).map(Self::CoinPaprika),
crate::MarketPriceProviderSetup::DexScreener(settings) => crate::MarketPriceDexScreenerProvider::new(settings).map(Self::DexScreener),
crate::MarketPriceProviderSetup::Jupiter(settings) => crate::MarketPriceJupiterProvider::new(settings).map(Self::Jupiter),
crate::MarketPriceProviderSetup::Kraken(settings) => crate::MarketPriceKrakenProvider::new(settings).map(Self::Kraken),
};
}
fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return match self {
Self::Birdeye(provider) => provider.descriptor(),
Self::CoinbaseExchange(provider) => provider.descriptor(),
Self::CoinGecko(provider) => provider.descriptor(),
Self::CoinMarketCap(provider) => provider.descriptor(),
Self::CoinPaprika(provider) => provider.descriptor(),
Self::DexScreener(provider) => provider.descriptor(),
Self::Jupiter(provider) => provider.descriptor(),
Self::Kraken(provider) => provider.descriptor(),
};
}
fn enabled(&self) -> bool {
return match self {
Self::Birdeye(provider) => provider.settings().common().enabled(),
Self::CoinbaseExchange(provider) => provider.settings().common().enabled(),
Self::CoinGecko(provider) => provider.settings().common().enabled(),
Self::CoinMarketCap(provider) => provider.settings().common().enabled(),
Self::CoinPaprika(provider) => provider.settings().common().enabled(),
Self::DexScreener(provider) => provider.settings().common().enabled(),
Self::Jupiter(provider) => provider.settings().common().enabled(),
Self::Kraken(provider) => provider.settings().common().enabled(),
};
}
async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
return match self {
Self::Birdeye(provider) => provider.fetch_sol_usd().await,
Self::CoinbaseExchange(provider) => provider.fetch_sol_usd().await,
Self::CoinGecko(provider) => provider.fetch_sol_usd().await,
Self::CoinMarketCap(provider) => provider.fetch_sol_usd().await,
Self::CoinPaprika(provider) => provider.fetch_sol_usd().await,
Self::DexScreener(provider) => provider.fetch_sol_usd().await,
Self::Jupiter(provider) => provider.fetch_sol_usd().await,
Self::Kraken(provider) => provider.fetch_sol_usd().await,
};
}
fn provider_id(&self) -> &crate::MarketPriceProviderId {
return self.descriptor().id();
}
}
fn availability_from_error(error: &ksp_core_lib::Error, auth_mode: crate::MarketPriceProviderAuthMode) -> crate::MarketPriceProviderAvailability {
let code = error.code();
if code == crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED {
return crate::MarketPriceProviderAvailability::Disabled;
}
if code == crate::ERROR_CODE_HTTP_ACCESS_DENIED {
return match auth_mode {
crate::MarketPriceProviderAuthMode::OptionalApiKey | crate::MarketPriceProviderAuthMode::RequiredApiKey => {
crate::MarketPriceProviderAvailability::AuthenticationUnavailable
},
crate::MarketPriceProviderAuthMode::None => crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None },
};
}
if code == crate::ERROR_CODE_HTTP_ADMISSION_DEFERRED {
let delay = context_u64(error, "retry_after_millis").unwrap_or(MARKET_PRICE_RATE_LIMIT_FALLBACK_MILLIS);
return crate::MarketPriceProviderAvailability::CoolingDown { retry_at: timestamp_after_millis(delay) };
}
if code == crate::ERROR_CODE_HTTP_RATE_LIMITED {
let delay = context_u64(error, "retry_after_seconds")
.and_then(|seconds| return seconds.checked_mul(1_000))
.unwrap_or(MARKET_PRICE_RATE_LIMIT_FALLBACK_MILLIS);
return crate::MarketPriceProviderAvailability::CoolingDown { retry_at: timestamp_after_millis(delay) };
}
if code == crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE {
let retry_at = context_u64(error, "retry_after_seconds").and_then(|seconds| return seconds.checked_mul(1_000)).map(timestamp_after_millis);
return crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at };
}
if code == crate::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED
|| code == crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID
|| code == crate::ERROR_CODE_HTTP_REQUEST_INVALID
|| code == crate::ERROR_CODE_HTTP_SETTINGS_INVALID
|| code == crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID
{
return crate::MarketPriceProviderAvailability::Misconfigured;
}
return crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None };
}
fn context_u64(error: &ksp_core_lib::Error, key: &'static str) -> std::option::Option<u64> {
for context in error.context() {
if context.key() == key {
return match context.value().parse::<u64>() {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(_) => std::option::Option::None,
};
}
}
return std::option::Option::None;
}
fn current_timestamp_or_zero() -> crate::MarketPriceTimestamp {
return match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => crate::MarketPriceTimestamp::from_unix_millis(0),
};
}
fn lock_registry(registry: &std::sync::Mutex<crate::MarketPriceProviderRegistry>) -> std::sync::MutexGuard<'_, crate::MarketPriceProviderRegistry> {
return match registry.lock() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(poisoned) => poisoned.into_inner(),
};
}
fn provider_not_found_error(provider_id: &crate::MarketPriceProviderId) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND, "Market-price provider is not configured")
.with_context("provider_id", provider_id.as_str());
}
fn refresh_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID, "Market-price refresh request is invalid").with_context("field", field);
}
fn timestamp_after_millis(delay_millis: u64) -> crate::MarketPriceTimestamp {
let now = current_timestamp_or_zero().unix_millis();
return crate::MarketPriceTimestamp::from_unix_millis(now.saturating_add(delay_millis));
}
fn validate_requested_provider_ids(service: &crate::MarketPriceService, provider_ids: &[crate::MarketPriceProviderId]) -> ksp_core_lib::Result<()> {
let mut seen = std::collections::BTreeSet::new();
for provider_id in provider_ids {
if service.provider_index(provider_id).is_none() {
return std::result::Result::Err(provider_not_found_error(provider_id));
}
if !seen.insert(provider_id) {
return std::result::Result::Err(refresh_error("provider_id"));
}
}
return std::result::Result::Ok(());
}
#[cfg(test)]
#[path = "../unit_tests/market_price_service.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
// version: 8
// version: 9
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -107,3 +107,21 @@ fn pre_007_birdeye_and_registry_remain_provider_owned_generic_and_fixed_origin()
assert!(!registry.contains("Jupiter"));
assert!(!registry.contains("Kraken"));
}
#[test]
fn pre_008_refresh_service_owns_dispatch_while_registry_and_consumer_surface_stay_generic() {
let crate_root = include_str!("../src/lib.rs");
let registry = include_str!("../src/market_price_registry.rs");
let service = include_str!("../src/market_price_service.rs");
assert!(crate_root.contains("mod market_price_service;"));
assert!(crate_root.contains("pub use self::market_price_service::MarketPriceService;"));
assert!(!registry.contains("MarketPriceProviderSetup::"));
assert!(service.contains("pub async fn refresh("));
assert!(service.contains("pub async fn refresh_many("));
assert!(service.contains("pub async fn refresh_all("));
assert!(service.contains("is_refresh_eligible_at"));
assert!(!service.contains("std::thread::sleep"));
assert!(!service.contains("tokio::time::sleep"));
assert!(!service.contains("fallback_provider"));
assert!(!service.contains("consensus"));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/tests/public_api.rs
// version: 8
// version: 9
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -183,6 +183,29 @@ fn public_pre_007_birdeye_and_provider_registry_are_available_from_crate_root()
return std::result::Result::Ok(());
}
#[tokio::test]
async fn public_pre_008_generic_refresh_service_is_available_without_provider_branching_at_callsite() -> ksp_core_lib::Result<()> {
let settings = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service =
match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(settings),]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = service.registry();
let provider_id = registry.entries()[0].descriptor().id().clone();
let outcome = match service.refresh(&provider_id).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(outcome.provider_id(), &provider_id);
assert!(!outcome.refreshed());
assert_eq!(outcome.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
return std::result::Result::Ok(());
}
#[test]
fn offchain_error_codes_use_owned_domain() {
let codes = [
@@ -203,9 +226,11 @@ fn offchain_error_codes_use_owned_domain() {
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID,
];
for code in codes {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
// version: 4
// version: 5
#[test]
fn provider_id_is_opaque_bounded_and_stable() -> ksp_core_lib::Result<()> {
@@ -130,3 +130,16 @@ fn provider_availability_keeps_cooldown_and_outage_distinct() -> ksp_core_lib::R
assert_ne!(state.availability(), crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(retry_at) });
return std::result::Result::Ok(());
}
#[test]
fn provider_availability_eligibility_at_respects_retry_deadlines_without_consumer_scheduling() {
let now = crate::MarketPriceTimestamp::from_unix_millis(100);
let future = crate::MarketPriceTimestamp::from_unix_millis(200);
let past = crate::MarketPriceTimestamp::from_unix_millis(50);
assert!(crate::MarketPriceProviderAvailability::Ready.is_refresh_eligible_at(now));
assert!(!crate::MarketPriceProviderAvailability::Disabled.is_refresh_eligible_at(now));
assert!(!crate::MarketPriceProviderAvailability::CoolingDown { retry_at: future }.is_refresh_eligible_at(now));
assert!(crate::MarketPriceProviderAvailability::CoolingDown { retry_at: past }.is_refresh_eligible_at(now));
assert!(crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None }.is_refresh_eligible_at(now));
assert!(crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(past) }.is_refresh_eligible_at(now));
}

View File

@@ -0,0 +1,148 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs
// version: 1
#[test]
fn service_registry_orders_all_eight_v1_setups_and_keeps_runtime_projection_generic() -> ksp_core_lib::Result<()> {
let setups = match all_disabled_setups() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service = match crate::MarketPriceService::new(setups) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = service.registry();
assert_eq!(registry.len(), 8);
let ids = registry.entries().iter().map(|entry| return entry.descriptor().id().as_str()).collect::<std::vec::Vec<_>>();
assert_eq!(ids, std::vec!["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken",]);
assert!(registry.entries().iter().all(|entry| return entry.state().availability() == crate::MarketPriceProviderAvailability::Disabled));
return std::result::Result::Ok(());
}
#[tokio::test]
async fn refresh_all_projects_disabled_providers_without_network_dispatch() -> ksp_core_lib::Result<()> {
let setups = match all_disabled_setups() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service = match crate::MarketPriceService::new(setups) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let outcomes = match service.refresh_all().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(outcomes.len(), 8);
assert!(outcomes.iter().all(|outcome| return !outcome.refreshed()));
assert!(outcomes.iter().all(|outcome| return outcome.state().availability() == crate::MarketPriceProviderAvailability::Disabled));
let ids = outcomes.iter().map(|outcome| return outcome.provider_id().as_str()).collect::<std::vec::Vec<_>>();
assert_eq!(ids, std::vec!["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken",]);
return std::result::Result::Ok(());
}
#[tokio::test]
async fn refresh_many_rejects_duplicates_and_unknown_provider_before_dispatch() -> ksp_core_lib::Result<()> {
let settings = match crate::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service = match crate::MarketPriceService::new(std::vec![crate::MarketPriceProviderSetup::CoinPaprika(settings)]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_id = match crate::MarketPriceProviderId::new("coinpaprika") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let duplicate = service.refresh_many(&[provider_id.clone(), provider_id]).await;
let duplicate_error = match duplicate {
std::result::Result::Err(error) => error,
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("duplicate")),
};
assert_eq!(duplicate_error.code(), crate::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID);
let unknown_id = match crate::MarketPriceProviderId::new("unknown") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let unknown = service.refresh(&unknown_id).await;
let unknown_error = match unknown {
std::result::Result::Err(error) => error,
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("unknown")),
};
assert_eq!(unknown_error.code(), crate::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND);
return std::result::Result::Ok(());
}
#[test]
fn refresh_error_classification_is_provider_neutral_and_preserves_known_retry_time() {
let auth = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_ACCESS_DENIED, "test");
assert_eq!(
super::availability_from_error(&auth, crate::MarketPriceProviderAuthMode::RequiredApiKey),
crate::MarketPriceProviderAvailability::AuthenticationUnavailable
);
assert!(matches!(
super::availability_from_error(&auth, crate::MarketPriceProviderAuthMode::None),
crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None }
));
let rate = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMITED, "test").with_context("retry_after_seconds", "2");
assert!(matches!(
super::availability_from_error(&rate, crate::MarketPriceProviderAuthMode::None),
crate::MarketPriceProviderAvailability::CoolingDown { .. }
));
let temporary = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE, "test").with_context("retry_after_seconds", "3");
let availability = super::availability_from_error(&temporary, crate::MarketPriceProviderAuthMode::None);
assert!(matches!(availability, crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(_) }));
let local = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "test");
assert_eq!(super::availability_from_error(&local, crate::MarketPriceProviderAuthMode::None), crate::MarketPriceProviderAvailability::Misconfigured);
}
fn all_disabled_setups() -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderSetup>> {
let birdeye = match crate::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinbase = match crate::MarketPriceCoinbaseExchangeSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coingecko = match crate::MarketPriceCoinGeckoSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap = match crate::MarketPriceCoinMarketCapSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinpaprika = match crate::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let dexscreener = match crate::MarketPriceDexScreenerSettings::new(false, "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let jupiter = match crate::MarketPriceJupiterSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kraken = match crate::MarketPriceKrakenSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(std::vec![
crate::MarketPriceProviderSetup::Birdeye(birdeye),
crate::MarketPriceProviderSetup::CoinbaseExchange(coinbase),
crate::MarketPriceProviderSetup::CoinGecko(coingecko),
crate::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
crate::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
crate::MarketPriceProviderSetup::DexScreener(dexscreener),
crate::MarketPriceProviderSetup::Jupiter(jupiter),
crate::MarketPriceProviderSetup::Kraken(kraken),
]);
}
fn test_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID, "Market-price service test expectation failed")
.with_context("field", field);
}