From 87d7314bf4fa38355130a4c86507615b428362d8 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 26 Aug 2026 10:23:42 +0200 Subject: [PATCH] v0.2.11-pre.008 --- Cargo.toml | 4 +- .../ksp-offchain-transport-lib/src/error.rs | 7 +- crates/ksp-offchain-transport-lib/src/lib.rs | 21 +- .../src/market_price_provider.rs | 19 +- .../src/market_price_registry.rs | 21 +- .../src/market_price_service.rs | 386 ++++++++++++++++++ .../tests/dependency_boundary.rs | 20 +- .../tests/public_api.rs | 27 +- .../unit_tests/market_price_provider.rs | 15 +- .../unit_tests/market_price_service.rs | 148 +++++++ deltas/0.2.11/pre.008.md | 242 +++++++++++ ...8-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md | 14 +- .../014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md | 111 +++-- 13 files changed, 972 insertions(+), 63 deletions(-) create mode 100644 crates/ksp-offchain-transport-lib/src/market_price_service.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs create mode 100644 deltas/0.2.11/pre.008.md diff --git a/Cargo.toml b/Cargo.toml index ba3fba8..f03ca45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 278 +# version: 279 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.2.11-pre.7" +version = "0.2.11-pre.8" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-offchain-transport-lib/src/error.rs b/crates/ksp-offchain-transport-lib/src/error.rs index 771f01d..ba8976f 100644 --- a/crates/ksp-offchain-transport-lib/src/error.rs +++ b/crates/ksp-offchain-transport-lib/src/error.rs @@ -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"); diff --git a/crates/ksp-offchain-transport-lib/src/lib.rs b/crates/ksp-offchain-transport-lib/src/lib.rs index beec862..30d0ce1 100644 --- a/crates/ksp-offchain-transport-lib/src/lib.rs +++ b/crates/ksp-offchain-transport-lib/src/lib.rs @@ -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; diff --git a/crates/ksp-offchain-transport-lib/src/market_price_provider.rs b/crates/ksp-offchain-transport-lib/src/market_price_provider.rs index b2e0a94..e60631a 100644 --- a/crates/ksp-offchain-transport-lib/src/market_price_provider.rs +++ b/crates/ksp-offchain-transport-lib/src/market_price_provider.rs @@ -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 { @@ -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 { diff --git a/crates/ksp-offchain-transport-lib/src/market_price_registry.rs b/crates/ksp-offchain-transport-lib/src/market_price_registry.rs index af1918f..2ae8dbb 100644 --- a/crates/ksp-offchain-transport-lib/src/market_price_registry.rs +++ b/crates/ksp-offchain-transport-lib/src/market_price_registry.rs @@ -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) -> ksp_core_lib::Result { if entries.len() > MARKET_PRICE_PROVIDER_REGISTRY_MAX_ENTRIES { diff --git a/crates/ksp-offchain-transport-lib/src/market_price_service.rs b/crates/ksp-offchain-transport-lib/src/market_price_service.rs new file mode 100644 index 0000000..0c41c34 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/market_price_service.rs @@ -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, + 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, + registry: std::sync::Mutex, +} + +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) -> ksp_core_lib::Result { + 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 { + 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> { + 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> { + let provider_ids = self.providers.iter().map(|provider| return provider.provider_id().clone()).collect::>(); + return self.refresh_many(provider_ids.as_slice()).await; + } + + fn provider_index(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option { + 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 { + 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 { + 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 { + 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 { + for context in error.context() { + if context.key() == key { + return match context.value().parse::() { + 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) -> 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; diff --git a/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs b/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs index f8e47c3..837a9be 100644 --- a/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs +++ b/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs @@ -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")); +} diff --git a/crates/ksp-offchain-transport-lib/tests/public_api.rs b/crates/ksp-offchain-transport-lib/tests/public_api.rs index 1b5099e..445f13f 100644 --- a/crates/ksp-offchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-offchain-transport-lib/tests/public_api.rs @@ -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 { diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs index f67230d..2748463 100644 --- a/crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs @@ -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)); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs new file mode 100644 index 0000000..5551c06 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs @@ -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::>(); + 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::>(); + 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> { + 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); +} diff --git a/deltas/0.2.11/pre.008.md b/deltas/0.2.11/pre.008.md new file mode 100644 index 0000000..bd35d36 --- /dev/null +++ b/deltas/0.2.11/pre.008.md @@ -0,0 +1,242 @@ + + + +# Delta `0.2.11-pre.008` — Refresh individuel + multiple/all + +## 1. Base requise + +Cette tranche s'applique exclusivement après `0.2.11-pre.007` accepté par l'opérateur. + +Version Cargo attendue à l'entrée : + +```text +0.2.11-pre.7 +``` + +Version Cargo de sortie : + +```text +0.2.11-pre.8 +``` + +Le gate opérateur du `2026-08-26` est entièrement PASS : `cargo fmt --all`, audit Rust clean avec 0 candidate export, audit Markdown clean à 121 tables / 112 fichiers, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-offchain-transport-lib` avec 49 unitaires + 5 boundary + 6 public API, puis `cargo test --workspace` complet PASS avec uniquement les smokes/diagnostics explicitement ignorés. + +## 2. Objet strict de la tranche + +`pre.008` matérialise l'orchestrateur générique de la famille `market_price` : + +```text +MarketPriceService +MarketPriceProviderSetup +MarketPriceRefreshOutcome +refresh(provider_id) +refresh_many(provider_ids) +refresh_all() +transitions runtime d'availability +``` + +La tranche n'ajoute pas : + +```text +Config Off-chain Transport +lecture directe d'environnement +scheduler périodique +fallback provider +consensus / moyenne / agrégation de prix +smoke live provider +nouvelle famille off-chain +``` + +## 3. Frontière composition / runtime + +`MarketPriceProviderSetup` est l'unique enum provider-specific introduit par l'orchestrateur. Il consomme les settings déjà publics des huit adapters V1 et est destiné à la couche de composition, notamment au futur adapter Config de `pre.009`. + +Une fois le service construit, les opérations runtime ne prennent plus aucun type provider-specific : + +```text +registry() +refresh(&MarketPriceProviderId) +refresh_many(&[MarketPriceProviderId]) +refresh_all() +``` + +La future HID peut donc rester indépendante de CoinGecko, Birdeye, Kraken, Jupiter ou de tout autre provider concret. + +## 4. Registry runtime et availability + +`MarketPriceService` possède les adapters concrets et une copie mutable interne du `MarketPriceProviderRegistry`. + +Le registry public reste une projection détachable `descriptor + state`. Deux helpers crate-private permettent au service de remplacer l'availability sans modifier l'identité du provider. + +`MarketPriceProviderAvailability` gagne : + +```text +is_refresh_eligible_at(now) +``` + +Cette méthode autorise : + +```text +Ready +CoolingDown dont retry_at est expiré +TemporarilyUnavailable sans retry_at +TemporarilyUnavailable dont retry_at est expiré +``` + +et bloque : + +```text +AuthenticationUnavailable +Disabled +Misconfigured +QuotaUnavailable +CoolingDown non expiré +TemporarilyUnavailable avec retry_at futur +``` + +Aucun timer/sleep n'est lancé pour rendre un provider éligible : l'état est simplement réévalué lors du prochain appel explicite. + +## 5. Classification générique des erreurs + +Le service absorbe les erreurs provider/HTTP après dispatch et retourne un `MarketPriceRefreshOutcome` générique. + +Les classes principales sont : + +```text +provider disabled -> Disabled +401/403 sur mode avec API key -> AuthenticationUnavailable +401/403 sur mode keyless -> TemporarilyUnavailable +admission locale deferred -> CoolingDown +HTTP 429 -> CoolingDown +HTTP 408/5xx -> TemporarilyUnavailable +client/settings/request localement invalides -> Misconfigured +autre erreur provider/transport -> TemporarilyUnavailable +``` + +Un `Retry-After` exploitable ou un délai local de defer est converti en `retry_at` absolu. Aucun message/body provider n'est projeté au consumer ; le code d'erreur est seulement journalisé via `ksp-logging-lib` avec le provider id validé. + +`QuotaUnavailable` reste une catégorie publique mais n'est pas inventée automatiquement lorsque le provider ne fournit pas une preuve exploitable de quota épuisé. + +## 6. Refresh individuel + +`MarketPriceService::refresh` : + +```text +résout le provider par identifiant opaque +projette immédiatement les états non éligibles +n'effectue aucun sleep +appelle uniquement l'adapter propriétaire du provider +met l'état à Ready après succès +classe un échec dans l'availability générique +retourne toujours une outcome provider-neutral pour un provider configuré +``` + +Un provider inconnu retourne `ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND` avant tout réseau. + +## 7. Refresh multiple / all + +`refresh_many` conserve l'ordre fourni par le caller et valide la liste complète avant dispatch : + +```text +maximum 64 providers +doublons interdits +provider inconnu interdit +``` + +Chaque provider configuré retourne son outcome propre. Une erreur réseau/provider normalisée ne court-circuite donc pas les providers suivants. + +`refresh_all` utilise l'ordre stable des provider ids du service. Les providers non éligibles sont inclus comme projections d'état mais ne déclenchent pas de requête réseau. + +V1 reste volontairement séquentielle et déterministe. Cette décision ne constitue pas un scheduler : aucun provider n'est attendu par sleep et aucun fallback ou consensus n'est exécuté. + +## 8. Façade publique + +Ajouts publics : + +```text +MarketPriceProviderSetup +MarketPriceRefreshOutcome +MarketPriceService +ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND +ERROR_CODE_MARKET_PRICE_REFRESH_INVALID +MarketPriceProviderAvailability::is_refresh_eligible_at +``` + +`MarketPriceRefreshOutcome` expose uniquement : + +```text +provider_id() +state() +observation() +refreshed() +``` + +Le consumer n'a pas accès au dispatcher interne, aux clients HTTP, aux limiters ou aux DTOs wire. + +## 9. Tests déterministes ajoutés + +Les nouveaux canaries couvrent : + +```text +construction du service avec les huit setups V1 désactivés +registry de service trié et provider-neutral +refresh_all de huit providers désactivés sans réseau +ordre déterministe des outcomes +doublon dans refresh_many rejeté avant dispatch +provider inconnu rejeté avant dispatch +401/403 keyed vs keyless +429 -> CoolingDown +5xx + retry_after -> TemporarilyUnavailable daté +erreur locale de request -> Misconfigured +éligibilité avant/après retry_at +absence de sleep / fallback / consensus dans le service +façade publique générique depuis la crate root +``` + +## 10. Documentation réconciliée + +Mise à jour de : + +```text +docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +``` + +Le gate complet de `pre.007` fourni par l'opérateur est enregistré comme PASS. + +## 11. Fichiers ajoutés + +```text +crates/ksp-offchain-transport-lib/src/market_price_service.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs +deltas/0.2.11/pre.008.md +``` + +## 12. Fichiers modifiés + +```text +Cargo.toml +crates/ksp-offchain-transport-lib/src/error.rs +crates/ksp-offchain-transport-lib/src/lib.rs +crates/ksp-offchain-transport-lib/src/market_price_provider.rs +crates/ksp-offchain-transport-lib/src/market_price_registry.rs +crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs +crates/ksp-offchain-transport-lib/tests/public_api.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs +docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +``` + +## 13. Validation attendue + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-offchain-transport-lib +cargo test --workspace +``` + +`pre.009` ne doit commencer qu'après un gate propre de `pre.008` ou un delta fix explicite. diff --git a/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md b/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md index 1fba481..221ade6 100644 --- a/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +++ b/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md @@ -1,9 +1,9 @@ - + # Plan `0.2.11` — Off-chain price transport SOL/USD multi-provider -**Statut courant : `0.2.11-pre.007` complète les huit adapters `market_price` avec Birdeye Standard et matérialise le registry provider-neutral. Birdeye exige `X-API-KEY`, utilise `/defi/price` sur Solana/WSOL, modélise 1 RPS par compte, 30 000 compute units de quota informatif et 3 compute units par requête SOL/USD. Le registry expose uniquement descriptor + état générique en ordre déterministe ; le dispatch/refresh reste réservé à `pre.008`. Le gate opérateur ciblé de `pre.006` est PASS ; `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.006`.** +**Statut courant : `0.2.11-pre.008` matérialise le service générique de refresh SOL/USD au-dessus des huit adapters V1. `MarketPriceService` possède le dispatch provider, le registry runtime et les transitions d'availability ; les consumers utilisent uniquement les provider ids opaques, `registry()`, `refresh`, `refresh_many` et `refresh_all`. Les limites/cooldowns restent non bloquants et aucun fallback, consensus ou scheduler provider n'est introduit. `MarketPriceProviderSetup` reste la frontière provider-specific destinée à la composition/Config de `pre.009`. Le gate opérateur complet de `pre.007` est PASS, y compris `cargo test --workspace`.** ## 1. Base et autorité @@ -436,7 +436,7 @@ misconfigured disabled ``` -`MarketPriceProviderAvailability` et `MarketPriceProviderState` matérialisent cette projection générique dès `pre.002`. `pre.007` ajoute `MarketPriceProviderRegistry` et `MarketPriceProviderRegistryEntry` : le registry est borné, trié de façon déterministe par `provider_id`, rejette les doublons et n'expose que `descriptor + state`. Son module ne contient aucun dispatch par nom de provider. Le registry reste une projection générique sans méthode de dispatch ni transition réseau ; le futur service `pre.008` possédera les mutations runtime et republiera les états mis à jour. +`MarketPriceProviderAvailability` et `MarketPriceProviderState` matérialisent cette projection générique dès `pre.002`. `pre.007` ajoute `MarketPriceProviderRegistry` et `MarketPriceProviderRegistryEntry` : le registry est borné, trié de façon déterministe par `provider_id`, rejette les doublons et n'expose que `descriptor + state`. Son module ne contient aucun dispatch par nom de provider. `pre.008` ajoute `MarketPriceService`, propriétaire du dispatch réel et des mutations d'availability, tout en conservant le registry comme projection générique détachable. `MarketPriceProviderSetup` contient uniquement la sélection provider-specific nécessaire à la composition initiale ; elle ne traverse pas les opérations runtime destinées à la HID. `MarketPriceProviderAvailability` expose aussi génériquement l'éligibilité immédiate au refresh et un `retry_at` lorsqu'il est réellement connu. `AuthenticationUnavailable`, `QuotaUnavailable`, `Misconfigured`, `Disabled`, `CoolingDown`, `TemporarilyUnavailable` et `Ready` restent distincts sans parsing provider dans les consumers. @@ -516,7 +516,7 @@ Il n'existe pas d'URL provider arbitraire dans la Config V1. Cela évite SSRF, r Les primitives `http_*` sont **crate-private** : elles ne créent pas un client HTTP générique public contournant les capacités métier. Les adapters `market_price_*`, puis de futures familles telles que `swap_quote_*`, les consomment derrière leur propre contrat. -Le limiter `pre.003` est non bloquant. Une cadence fixe est matérialisée par un token bucket lissé ; lorsqu'aucun burst n'est documenté, la capacité locale initiale reste volontairement `1`. Lorsqu'un burst est documenté, sa capacité est indépendante du nombre moyen de requêtes de la fenêtre et peut donc lui être supérieure. Une limite dynamique n'invente aucune cadence locale et apprend seulement des réponses provider, notamment `429`. Un `Retry-After` serveur peut prolonger le cooldown mais ne peut pas dépasser une borne défensive d'une heure. Aucune primitive HTTP commune ne dort en attendant la disponibilité : elle expose un délai de defer que l'orchestrateur `pre.008` pourra projeter provider par provider. +Le limiter `pre.003` est non bloquant. Une cadence fixe est matérialisée par un token bucket lissé ; lorsqu'aucun burst n'est documenté, la capacité locale initiale reste volontairement `1`. Lorsqu'un burst est documenté, sa capacité est indépendante du nombre moyen de requêtes de la fenêtre et peut donc lui être supérieure. Une limite dynamique n'invente aucune cadence locale et apprend seulement des réponses provider, notamment `429`. Un `Retry-After` serveur peut prolonger le cooldown mais ne peut pas dépasser une borne défensive d'une heure. `pre.008` projette ces délais dans `MarketPriceProviderAvailability` et ne dort jamais : `refresh`, `refresh_many` et `refresh_all` retournent immédiatement un état non éligible lorsqu'un prochain instant admissible est encore dans le futur. ## 15. Config provider-capability-aware @@ -740,15 +740,15 @@ Implémentation des adapters Jupiter Price V3 et DexScreener avec DTOs wire priv ### `pre.007` — Birdeye, registry et availability -**Statut : réalisé, gate Cargo opérateur à exécuter.** +**Statut : réalisé, gate opérateur complet PASS.** Implémentation de Birdeye Standard avec `X-API-KEY`, endpoint Price Single Solana/WSOL, prix exact et timestamp provider réel. Le descriptor modélise 1 RPS par compte, 30 000 compute units de quota informatif et 3 compute units par refresh SOL/USD. `MarketPriceProviderRegistry` finalise l'inventaire runtime provider-neutral des huit adapters sous forme d'entrées `descriptor + state`, avec ordre déterministe et doublons rejetés. Les transitions runtime et le dispatch restent réservés à `pre.008`. Aucun dispatch de refresh, Config ou smoke live n'est avancé. ### `pre.008` — Refresh individuel et multiple -**Statut : planifié.** +**Statut : réalisé, gate Cargo opérateur à exécuter.** -Service de refresh single/multiple, orchestration rate-limit-aware et tests cross-provider sans scheduling provider dans les consumers. +`MarketPriceService` possède les huit adapters configurés, le registry runtime et le dispatch provider-specific interne. La construction accepte `MarketPriceProviderSetup` pour la future couche de composition, puis l'usage devient intégralement provider-agnostic via `registry()`, `refresh(provider_id)`, `refresh_many(...)` et `refresh_all()`. Les échecs provider sont classés en availability générique ; un résultat en erreur n'interrompt pas les autres providers d'un batch. Les états non éligibles sont projetés sans requête réseau, les cooldowns expirés redeviennent tentables, et aucune opération ne dort pour attendre un rate limit. Aucun fallback, consensus, agrégation, Config ou scheduling périodique n'est avancé. ### `pre.009` — Config Off-chain Transport diff --git a/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md b/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md index 14ae9e8..63da910 100644 --- a/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +++ b/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md @@ -1,5 +1,5 @@ - + # Validation `0.2.11` — Off-chain price transport SOL/USD @@ -234,30 +234,55 @@ Les fixtures de `pre.006` couvrent Jupiter keyless/Free, redaction de la clé, p ## 3.10 Gate `0.2.11-pre.007` -| Critère | Statut | Preuve | -|------------------------------------------------------------------|---------|---------------------------------------| -| version workspace `0.2.11-pre.7` | PASS | `Cargo.toml` racine | -| Birdeye Standard SOL/USD adapter public | PASS | `market_price_birdeye.rs` | -| Birdeye exige `X-API-KEY` lorsqu'activé | PASS | settings + header sensible | -| Birdeye fixe `x-chain = solana` et le mint WSOL | PASS | request builder | -| Birdeye Price Single `value` parsé exactement | PASS | `RawValue` -> `MarketPriceDecimal` | -| Birdeye `updateUnixTime` conservé comme timestamp provider | PASS | parser provider | -| Birdeye Standard 1 RPS compte modélisé | PASS | descriptor provider | -| quota Birdeye 30 000 compute units informatif | PASS | `MarketPriceProviderLongTermQuota` | -| coût Price Single 3 compute units informatif | PASS | `MarketPriceProviderRequestCost` | -| registry provider-neutral matérialisé | PASS | `MarketPriceProviderRegistry` | -| huit providers V1 présents dans le canari de complétude registry | PASS | test `market_price_registry` | -| registry trié par provider id et doublons rejetés | PASS | constructeur public générique + tests | -| entry publique limitée à descriptor + état générique | PASS | `MarketPriceProviderRegistryEntry` | -| availability expose éligibilité et `retry_at` génériques | PASS | `MarketPriceProviderAvailability` | -| registry sans mutation ou dispatch réseau | PASS | projection générique publique | -| registry ne contient aucun dispatch/name provider | PASS | dependency boundary | -| refresh single/multiple, Config et smoke live non avancés | PASS | scope réservé à `pre.008+` | -| audit Rust sandbox | PASS | clean, 0 candidate export | -| audit Markdown sandbox | PASS | clean, 121 tables / 112 fichiers | -| gate Cargo | PLANNED | à exécuter par l'opérateur | +| Critère | Statut | Preuve | +|------------------------------------------------------------------|--------|---------------------------------------| +| version workspace `0.2.11-pre.7` | PASS | `Cargo.toml` racine | +| Birdeye Standard SOL/USD adapter public | PASS | `market_price_birdeye.rs` | +| Birdeye exige `X-API-KEY` lorsqu'activé | PASS | settings + header sensible | +| Birdeye fixe `x-chain = solana` et le mint WSOL | PASS | request builder | +| Birdeye Price Single `value` parsé exactement | PASS | `RawValue` -> `MarketPriceDecimal` | +| Birdeye `updateUnixTime` conservé comme timestamp provider | PASS | parser provider | +| Birdeye Standard 1 RPS compte modélisé | PASS | descriptor provider | +| quota Birdeye 30 000 compute units informatif | PASS | `MarketPriceProviderLongTermQuota` | +| coût Price Single 3 compute units informatif | PASS | `MarketPriceProviderRequestCost` | +| registry provider-neutral matérialisé | PASS | `MarketPriceProviderRegistry` | +| huit providers V1 présents dans le canari de complétude registry | PASS | test `market_price_registry` | +| registry trié par provider id et doublons rejetés | PASS | constructeur public générique + tests | +| entry publique limitée à descriptor + état générique | PASS | `MarketPriceProviderRegistryEntry` | +| availability expose éligibilité et `retry_at` génériques | PASS | `MarketPriceProviderAvailability` | +| registry sans mutation ou dispatch réseau | PASS | projection générique publique | +| registry ne contient aucun dispatch/name provider | PASS | dependency boundary | +| refresh single/multiple, Config et smoke live non avancés | PASS | scope réservé à `pre.008+` | +| audit Rust sandbox | PASS | clean, 0 candidate export | +| audit Markdown sandbox | PASS | clean, 121 tables / 112 fichiers | +| gate Cargo | PASS | gate opérateur complet du 2026-08-26 | -Les tests déterministes de `pre.007` couvrent l'auth Birdeye et sa redaction, le descriptor 1 RPS / 30 000 CU / 3 CU, le parsing exact du prix et du timestamp, les réponses invalides, ainsi que le registry complet des huit providers, son ordre déterministe, le rejet de doublons et les projections d'availability. Aucun refresh réseau générique ni smoke live n'est revendiqué dans cette tranche. +Les tests déterministes de `pre.007` couvrent l'auth Birdeye et sa redaction, le descriptor 1 RPS / 30 000 CU / 3 CU, le parsing exact du prix et du timestamp, les réponses invalides, ainsi que le registry complet des huit providers, son ordre déterministe, le rejet de doublons et les projections d'availability. Le gate opérateur du 2026-08-26 est intégralement PASS : audits Rust/Markdown, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests ciblés Off-chain et `cargo test --workspace`. Aucun smoke live provider n'est revendiqué. + +## 3.11 Gate `0.2.11-pre.008` + +| Critère | Statut | Preuve | +|-------------------------------------------------------------------------|---------|--------------------------------------------| +| version workspace `0.2.11-pre.8` | PASS | `Cargo.toml` racine | +| `MarketPriceService` provider-agnostic public | PASS | `market_price_service.rs` | +| setup provider-specific limité à la composition | PASS | `MarketPriceProviderSetup` | +| registry détachable sans dispatch provider | PASS | `MarketPriceService::registry` | +| refresh individuel par `MarketPriceProviderId` | PASS | `MarketPriceService::refresh` | +| refresh multiple par liste d'identifiants opaques | PASS | `MarketPriceService::refresh_many` | +| refresh all en ordre provider déterministe | PASS | `MarketPriceService::refresh_all` | +| doublons / provider inconnu rejetés avant dispatch | PASS | tests `market_price_service` | +| provider non éligible projeté sans requête réseau | PASS | eligibility + test huit providers disabled | +| `429` / defer local projetés en `CoolingDown` | PASS | classification générique | +| `401/403` keyed projetés en `AuthenticationUnavailable` | PASS | classification auth-aware | +| transport temporaire projeté sans rendre le service global inutilisable | PASS | outcome provider-neutral | +| aucune attente/sleep de rate limit dans le service | PASS | dependency boundary | +| aucun fallback, consensus ou agrégation multi-provider | PASS | scope + dependency boundary | +| Config, scheduling périodique et smoke live non avancés | PASS | scope réservé à `pre.009+` | +| audit Rust sandbox | PASS | clean, 0 candidate export | +| audit Markdown sandbox | PASS | clean, 122 tables / 113 fichiers | +| gate Cargo | PLANNED | à exécuter par l'opérateur | + +Les tests déterministes de `pre.008` vérifient le registry de service sur les huit setups V1, `refresh_all` sans réseau lorsque tous les providers sont désactivés, le rejet pré-dispatch des doublons/providers inconnus, les classes auth/rate-limit/temporaire/local misconfiguration et l'éligibilité temporelle des cooldowns. Les méthodes multiples restent séquentielles et déterministes dans V1 ; elles ne dorment jamais pour attendre un provider et ne font aucun fallback/consensus. ## 4. Matrice provider prévue @@ -278,21 +303,21 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d ## 5. Contrat public prévu -| Invariant | Statut | -|------------------------------------------------------------------------|---------| -| surface publique limitée à SOL/USD dans V1 | PASS | -| identifiants provider opaques pour les consumers | PASS | -| provenance provider toujours observable | PASS | -| sémantique de prix observable sans prétendre à une équivalence | PASS | -| timestamp requête/réception KSP présents | PASS | -| timestamp provider optionnel seulement lorsqu'il est réellement fourni | PASS | -| absence de prix distincte de zéro | PASS | -| `f64` non utilisé comme canon public | PASS | -| aucune structure wire provider exportée | PASS | -| aucune URL/header/asset id provider requis côté app | PASS | -| registry provider et état runtime possédés par Off-chain Transport | PASS | -| refresh provider par identifiant générique | PLANNED | -| refresh multiple sans connaissance provider côté consumer | PLANNED | +| Invariant | Statut | +|------------------------------------------------------------------------|--------| +| surface publique limitée à SOL/USD dans V1 | PASS | +| identifiants provider opaques pour les consumers | PASS | +| provenance provider toujours observable | PASS | +| sémantique de prix observable sans prétendre à une équivalence | PASS | +| timestamp requête/réception KSP présents | PASS | +| timestamp provider optionnel seulement lorsqu'il est réellement fourni | PASS | +| absence de prix distincte de zéro | PASS | +| `f64` non utilisé comme canon public | PASS | +| aucune structure wire provider exportée | PASS | +| aucune URL/header/asset id provider requis côté app | PASS | +| registry provider et état runtime possédés par Off-chain Transport | PASS | +| refresh provider par identifiant générique | PASS | +| refresh multiple sans connaissance provider côté consumer | PASS | ## 6. Rate limiting et availability @@ -304,10 +329,10 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d | `429` classé | PASS | | `Retry-After` honoré lorsqu'exploitable | PASS | | cooldown expose prochain instant admissible | PASS | -| refresh all ne lance que les providers éligibles | PLANNED | -| provider en cooldown ne bloque pas les autres | PLANNED | -| provider down ne rend pas le service global inutilisable | PLANNED | -| auth manquante distinguée d'un transport down | PLANNED | +| refresh all ne lance que les providers éligibles | PASS | +| provider en cooldown ne bloque pas les autres | PASS | +| provider down ne rend pas le service global inutilisable | PASS | +| auth rejetée distinguée d'un transport down | PASS | | quota indisponible distingué d'un transport down | PLANNED | | quota mensuel local non présenté comme compteur authoritative | PASS |