From 98093d859b18142fafaee03174aafe68960d0079 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 25 Aug 2026 23:06:26 +0200 Subject: [PATCH] v0.2.11-pre.006 --- Cargo.toml | 4 +- .../src/http_client.rs | 18 +- crates/ksp-offchain-transport-lib/src/lib.rs | 22 +- .../src/market_price_dexscreener.rs | 233 +++++++++++++++ .../src/market_price_jupiter.rs | 268 ++++++++++++++++++ .../tests/dependency_boundary.rs | 21 +- .../tests/public_api.rs | 27 +- .../unit_tests/market_price_dexscreener.rs | 105 +++++++ .../unit_tests/market_price_jupiter.rs | 81 ++++++ deltas/0.2.11/pre.006.md | 243 ++++++++++++++++ ...8-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md | 16 +- .../014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md | 89 +++--- 12 files changed, 1079 insertions(+), 48 deletions(-) create mode 100644 crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs create mode 100644 crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs create mode 100644 deltas/0.2.11/pre.006.md diff --git a/Cargo.toml b/Cargo.toml index 268ae6a..381fbe6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 276 +# version: 277 [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.5" +version = "0.2.11-pre.6" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-offchain-transport-lib/src/http_client.rs b/crates/ksp-offchain-transport-lib/src/http_client.rs index ed45049..992b282 100644 --- a/crates/ksp-offchain-transport-lib/src/http_client.rs +++ b/crates/ksp-offchain-transport-lib/src/http_client.rs @@ -1,5 +1,5 @@ // file: crates/ksp-offchain-transport-lib/src/http_client.rs -// version: 3 +// version: 4 //! Crate-wide hardened REST client used internally by off-chain capability adapters. @@ -36,6 +36,22 @@ impl crate::HttpGetRequest { return Self::parse(url, false); } + /// Appends one validated non-secret path segment to a crate-owned official provider base URL. + pub(crate) fn append_path_segment(&mut self, value: &str) -> ksp_core_lib::Result<()> { + let segments_result = self.url.path_segments_mut(); + let mut segments = match segments_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(()) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot accept a path segment") + .with_context("field", "provider_url_path"), + ); + }, + }; + segments.push(value); + return std::result::Result::Ok(()); + } + /// Appends one non-secret query pair using URL encoding. pub(crate) fn append_query_pair(&mut self, name: &'static str, value: &str) { self.url.query_pairs_mut().append_pair(name, value); diff --git a/crates/ksp-offchain-transport-lib/src/lib.rs b/crates/ksp-offchain-transport-lib/src/lib.rs index e76920c..81293bc 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: 7 +// version: 8 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -7,10 +7,10 @@ //! KSP-owned off-chain transport foundation. //! -//! `0.2.11-pre.005` keeps the first capability family deliberately narrow (`market_price`, SOL/USD only) and extends the direct-REST surface with -//! Kraken Spot and Coinbase Exchange. Exchange adapters preserve `ExchangeLastTrade` semantics instead of pretending to be aggregated market prices. -//! Provider wire DTOs remain private, no provider SDK is used, and the crate-wide hardened `http_*` internals remain unavailable as a generic consumer -//! HTTP client. Config integration and generic registry/refresh orchestration remain outside this tranche. +//! `0.2.11-pre.006` keeps the first capability family deliberately narrow (`market_price`, SOL/USD only) and adds Jupiter Price V3 plus DexScreener. +//! Jupiter preserves its Solana heuristic semantics and block provenance without inventing a price timestamp. DexScreener is bound to one explicitly +//! configured Solana pair address, validates that the returned pair is the configured pair with wrapped SOL as base identity, and consumes only `priceUsd`. +//! Provider wire DTOs remain private, no provider SDK is used, and Config plus generic registry/refresh orchestration remain outside this tranche. mod constants; mod error; @@ -24,6 +24,8 @@ mod market_price_coingecko; mod market_price_coinmarketcap; mod market_price_coinpaprika; mod market_price_decimal; +mod market_price_dexscreener; +mod market_price_jupiter; mod market_price_kraken; mod market_price_observation; mod market_price_provider; @@ -95,6 +97,16 @@ pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES; pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_SCALE; /// Exact positive decimal value used by the public market-price contract. pub use self::market_price_decimal::MarketPriceDecimal; +/// DexScreener SOL/USD provider adapter bound to one configured Solana pair. +pub use self::market_price_dexscreener::MarketPriceDexScreenerProvider; +/// DexScreener runtime settings carrying the explicit Solana pair address. +pub use self::market_price_dexscreener::MarketPriceDexScreenerSettings; +/// Jupiter Developer Platform access mode. +pub use self::market_price_jupiter::MarketPriceJupiterAccessMode; +/// Jupiter Price V3 SOL/USD provider adapter. +pub use self::market_price_jupiter::MarketPriceJupiterProvider; +/// Jupiter Price V3 runtime settings. +pub use self::market_price_jupiter::MarketPriceJupiterSettings; /// Kraken Spot SOL/USD provider adapter. pub use self::market_price_kraken::MarketPriceKrakenProvider; /// Kraken Spot runtime settings. diff --git a/crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs b/crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs new file mode 100644 index 0000000..dc12341 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs @@ -0,0 +1,233 @@ +// file: crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs +// version: 1 + +//! DexScreener SOL/USD adapter bound to one explicitly configured Solana pair address. + +const DEXSCREENER_PAIR_BASE_URL: &str = "https://api.dexscreener.com/latest/dex/pairs/solana"; +const DEXSCREENER_PROVIDER_ID: &str = "dexscreener"; +const DEXSCREENER_SOL_MINT: &str = "So11111111111111111111111111111111111111112"; + +/// Runtime settings for the keyless DexScreener market-price adapter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MarketPriceDexScreenerSettings { + common: crate::MarketPriceProviderCommonSettings, + pair_address: ksp_core_lib::Pubkey, +} + +impl crate::MarketPriceDexScreenerSettings { + /// Creates DexScreener settings for one explicit Solana pair address. + pub fn new(enabled: bool, pair_address: impl std::convert::Into) -> ksp_core_lib::Result { + let provider_id = match crate::MarketPriceProviderId::new(DEXSCREENER_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let pair_address = match pair_address.into().parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(provider_settings_error("pair_address")), + }; + let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled); + return std::result::Result::Ok(Self { common, pair_address }); + } + + /// Returns common provider identity and enablement settings. + #[must_use] + pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings { + return &self.common; + } + + /// Returns the explicit Solana pair address used by this adapter. + #[must_use] + pub const fn pair_address(&self) -> &ksp_core_lib::Pubkey { + return &self.pair_address; + } +} + +/// DexScreener SOL/USD provider adapter for one configured pair. +pub struct MarketPriceDexScreenerProvider { + admission: crate::HttpAdmissionController, + descriptor: crate::MarketPriceProviderDescriptor, + http: crate::HttpRestClient, + settings: crate::MarketPriceDexScreenerSettings, +} + +impl crate::MarketPriceDexScreenerProvider { + /// Builds one DexScreener provider from validated runtime settings. + pub fn new(settings: crate::MarketPriceDexScreenerSettings) -> ksp_core_lib::Result { + let descriptor = match descriptor_for(settings.common().provider_id().clone()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings }); + } + + /// Returns the provider-neutral DexScreener capability descriptor. + #[must_use] + pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor { + return &self.descriptor; + } + + /// Returns the validated DexScreener settings including the safe configured pair address. + #[must_use] + pub const fn settings(&self) -> &crate::MarketPriceDexScreenerSettings { + return &self.settings; + } + + /// Fetches one normalized SOL/USD observation from the configured DexScreener pair. + pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result { + if !self.settings.common().enabled() { + return std::result::Result::Err(crate::provider_disabled_error(DEXSCREENER_PROVIDER_ID)); + } + if let std::result::Result::Err(error) = crate::admit_request(DEXSCREENER_PROVIDER_ID, &self.admission) { + return std::result::Result::Err(error); + } + let request_started_at = match crate::current_timestamp() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let request = match build_request(self.settings.pair_address()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let document = match crate::get_json(&self.http, &self.admission, DEXSCREENER_PROVIDER_ID, request).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let received_at = match crate::current_timestamp() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return parse_response( + document.as_bytes(), + self.settings.common().provider_id().clone(), + self.settings.pair_address(), + request_started_at, + received_at, + ); + } +} + +fn build_request(pair_address: &ksp_core_lib::Pubkey) -> ksp_core_lib::Result { + let mut request = match crate::HttpGetRequest::new_https(DEXSCREENER_PAIR_BASE_URL) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let pair_address = pair_address.to_string(); + if let std::result::Result::Err(error) = request.append_path_segment(pair_address.as_str()) { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(request); +} + +fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result { + let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(300, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified) + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::MarketPriceProviderDescriptor::new( + provider_id, + "DexScreener", + crate::MarketPriceSemantics::DexPairUsd, + crate::MarketPriceProviderAuthMode::None, + rate_limit, + std::option::Option::None, + true, + ); +} + +fn parse_response( + bytes: &[u8], + provider_id: crate::MarketPriceProviderId, + configured_pair_address: &ksp_core_lib::Pubkey, + request_started_at: crate::MarketPriceTimestamp, + received_at: crate::MarketPriceTimestamp, +) -> ksp_core_lib::Result { + let wire = match serde_json::from_slice::(bytes) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(crate::invalid_provider_response_with_source(DEXSCREENER_PROVIDER_ID, "response", error)); + }, + }; + let pairs = match wire.pairs { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs")), + }; + if pairs.len() != 1 { + return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs")); + } + let pair = &pairs[0]; + if pair.chain_id != "solana" { + return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.chainId")); + } + let response_pair_address = match pair.pair_address.parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.pairAddress")), + }; + if response_pair_address != *configured_pair_address { + return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.pairAddress")); + } + let base_token = match &pair.base_token { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.baseToken")), + }; + if base_token.address != DEXSCREENER_SOL_MINT { + return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.baseToken.address")); + } + let price_raw = match pair.price_usd.as_deref() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.priceUsd")), + }; + let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provenance = match crate::MarketPriceProvenance::new(std::format!("dexscreener:solana:{configured_pair_address}:priceUsd")) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::MarketPriceObservation::new( + provider_id, + price, + crate::MarketPriceSemantics::DexPairUsd, + request_started_at, + received_at, + std::option::Option::None, + provenance, + ); +} + +fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "DexScreener market-price settings are invalid") + .with_context("provider", DEXSCREENER_PROVIDER_ID) + .with_context("field", field); +} + +#[derive(serde::Deserialize)] +struct DexScreenerWirePair { + #[serde(rename = "baseToken")] + base_token: std::option::Option, + #[serde(rename = "chainId")] + chain_id: std::string::String, + #[serde(rename = "pairAddress")] + pair_address: std::string::String, + #[serde(rename = "priceUsd")] + price_usd: std::option::Option>, +} + +#[derive(serde::Deserialize)] +struct DexScreenerWireResponse { + pairs: std::option::Option>, +} + +#[derive(serde::Deserialize)] +struct DexScreenerWireToken { + address: std::string::String, +} + +#[cfg(test)] +#[path = "../unit_tests/market_price_dexscreener.rs"] +mod tests; diff --git a/crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs b/crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs new file mode 100644 index 0000000..693b743 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs @@ -0,0 +1,268 @@ +// file: crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs +// version: 1 + +//! Jupiter Price V3 SOL/USD adapter using the current Developer Platform REST surface. + +const JUPITER_API_KEY_HEADER: &str = "x-api-key"; +const JUPITER_PRICE_V3_URL: &str = "https://api.jup.ag/price/v3"; +const JUPITER_PROVIDER_ID: &str = "jupiter"; +const JUPITER_SOL_DECIMALS: u8 = 9; +const JUPITER_SOL_MINT: &str = "So11111111111111111111111111111111111111112"; + +/// Jupiter V1 access mode supported by Off-chain Transport. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MarketPriceJupiterAccessMode { + /// Free Developer Platform plan using a provider-issued API key. + Free, + /// Keyless Developer Platform access with the lower documented request cadence. + Keyless, +} + +/// Runtime settings for the Jupiter Price V3 market-price adapter. +pub struct MarketPriceJupiterSettings { + access_mode: crate::MarketPriceJupiterAccessMode, + api_key: std::option::Option, + common: crate::MarketPriceProviderCommonSettings, +} + +impl crate::MarketPriceJupiterSettings { + /// Creates keyless Jupiter settings without accepting a credential. + pub fn keyless(enabled: bool) -> ksp_core_lib::Result { + return Self::new(enabled, crate::MarketPriceJupiterAccessMode::Keyless, std::option::Option::None); + } + + /// Creates Free-plan Jupiter settings. An API key is mandatory while the provider is enabled. + pub fn free(enabled: bool, api_key: std::option::Option) -> ksp_core_lib::Result { + return Self::new(enabled, crate::MarketPriceJupiterAccessMode::Free, api_key); + } + + /// Returns the configured Jupiter access mode. + #[must_use] + pub const fn access_mode(&self) -> crate::MarketPriceJupiterAccessMode { + return self.access_mode; + } + + /// Returns common provider identity and enablement settings. + #[must_use] + pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings { + return &self.common; + } + + fn new(enabled: bool, access_mode: crate::MarketPriceJupiterAccessMode, api_key: std::option::Option) -> ksp_core_lib::Result { + let provider_id = match crate::MarketPriceProviderId::new(JUPITER_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let api_key = match access_mode { + crate::MarketPriceJupiterAccessMode::Keyless => { + if api_key.is_some() { + return std::result::Result::Err(provider_settings_error("api_key")); + } + std::option::Option::None + }, + crate::MarketPriceJupiterAccessMode::Free => match api_key { + std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(JUPITER_PROVIDER_ID, value) { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }, + std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")), + std::option::Option::None => std::option::Option::None, + }, + }; + let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled); + return std::result::Result::Ok(Self { access_mode, api_key, common }); + } + + fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> { + return self.api_key.as_ref(); + } +} + +impl std::fmt::Debug for crate::MarketPriceJupiterSettings { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("MarketPriceJupiterSettings") + .field("access_mode", &self.access_mode) + .field("api_key_present", &self.api_key.is_some()) + .field("common", &self.common) + .finish(); + } +} + +/// Jupiter Price V3 SOL/USD provider adapter. +pub struct MarketPriceJupiterProvider { + admission: crate::HttpAdmissionController, + descriptor: crate::MarketPriceProviderDescriptor, + http: crate::HttpRestClient, + settings: crate::MarketPriceJupiterSettings, +} + +impl crate::MarketPriceJupiterProvider { + /// Builds one Jupiter Price V3 provider from validated runtime settings. + pub fn new(settings: crate::MarketPriceJupiterSettings) -> ksp_core_lib::Result { + let descriptor = match descriptor_for(settings.access_mode(), settings.common().provider_id().clone()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings }); + } + + /// Returns the provider-neutral Jupiter capability descriptor. + #[must_use] + pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor { + return &self.descriptor; + } + + /// Returns the validated Jupiter runtime settings without exposing credential material. + #[must_use] + pub const fn settings(&self) -> &crate::MarketPriceJupiterSettings { + return &self.settings; + } + + /// Fetches one normalized SOL/USD heuristic observation from Jupiter Price V3. + pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result { + if !self.settings.common().enabled() { + return std::result::Result::Err(crate::provider_disabled_error(JUPITER_PROVIDER_ID)); + } + if let std::result::Result::Err(error) = crate::admit_request(JUPITER_PROVIDER_ID, &self.admission) { + return std::result::Result::Err(error); + } + let request_started_at = match crate::current_timestamp() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let request = match build_request(&self.settings) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let document = match crate::get_json(&self.http, &self.admission, JUPITER_PROVIDER_ID, request).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let received_at = match crate::current_timestamp() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at); + } +} + +fn build_request(settings: &crate::MarketPriceJupiterSettings) -> ksp_core_lib::Result { + let mut request = match crate::HttpGetRequest::new_https(JUPITER_PRICE_V3_URL) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + request.append_query_pair("ids", JUPITER_SOL_MINT); + if let std::option::Option::Some(api_key) = settings.api_key() + && let std::result::Result::Err(error) = request.insert_sensitive_header(JUPITER_API_KEY_HEADER, api_key.as_str()) + { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(request); +} + +fn descriptor_for( + access_mode: crate::MarketPriceJupiterAccessMode, + provider_id: crate::MarketPriceProviderId, +) -> ksp_core_lib::Result { + let (auth_mode, rate_limit) = match access_mode { + crate::MarketPriceJupiterAccessMode::Keyless => { + let rate_limit = + match crate::MarketPriceProviderRateLimit::fixed(1, 2, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + (crate::MarketPriceProviderAuthMode::None, rate_limit) + }, + crate::MarketPriceJupiterAccessMode::Free => { + let rate_limit = + match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + (crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit) + }, + }; + return crate::MarketPriceProviderDescriptor::new( + provider_id, + "Jupiter Price V3", + crate::MarketPriceSemantics::SolanaHeuristic, + auth_mode, + rate_limit, + std::option::Option::None, + true, + ); +} + +fn parse_response( + bytes: &[u8], + provider_id: crate::MarketPriceProviderId, + request_started_at: crate::MarketPriceTimestamp, + received_at: crate::MarketPriceTimestamp, +) -> ksp_core_lib::Result { + let wire = match serde_json::from_slice::>(bytes) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(crate::invalid_provider_response_with_source(JUPITER_PROVIDER_ID, "response", error)); + }, + }; + if wire.len() != 1 { + return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "response.identity")); + } + let item = match wire.get(JUPITER_SOL_MINT) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "response.identity")), + }; + if item.decimals != std::option::Option::Some(JUPITER_SOL_DECIMALS) { + return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "decimals")); + } + let price_raw = match item.usd_price.as_deref() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "usdPrice")), + }; + let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let block_id = match item.block_id { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "blockId")), + }; + let provenance = match crate::MarketPriceProvenance::new(std::format!("jupiter:price_v3:{JUPITER_SOL_MINT}:block:{block_id}")) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::MarketPriceObservation::new( + provider_id, + price, + crate::MarketPriceSemantics::SolanaHeuristic, + request_started_at, + received_at, + std::option::Option::None, + provenance, + ); +} + +fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "Jupiter market-price settings are invalid") + .with_context("provider", JUPITER_PROVIDER_ID) + .with_context("field", field); +} + +#[derive(serde::Deserialize)] +struct JupiterWirePrice { + #[serde(rename = "blockId")] + block_id: std::option::Option, + decimals: std::option::Option, + #[serde(rename = "usdPrice")] + usd_price: std::option::Option>, +} + +#[cfg(test)] +#[path = "../unit_tests/market_price_jupiter.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 0f7d79f..85e71a9 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: 6 +// version: 7 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -68,3 +68,22 @@ fn pre_005_provider_modules_are_market_price_scoped_and_fixed_origin() { assert!(!coinpaprika.contains("std::env")); assert!(!kraken.contains("std::env")); } + +#[test] +fn pre_006_jupiter_and_dexscreener_stay_market_price_scoped_fixed_origin_and_discovery_free() { + let crate_root = include_str!("../src/lib.rs"); + assert!(crate_root.contains("mod market_price_dexscreener;")); + assert!(crate_root.contains("mod market_price_jupiter;")); + let dexscreener = include_str!("../src/market_price_dexscreener.rs"); + let jupiter = include_str!("../src/market_price_jupiter.rs"); + assert!(dexscreener.contains("https://api.dexscreener.com/latest/dex/pairs/solana")); + assert!(dexscreener.contains("Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE") || dexscreener.contains("pair_address")); + assert!(!dexscreener.contains("latest/dex/search")); + assert!(!dexscreener.contains("token-pairs/v1")); + assert!(!dexscreener.contains("tokens/v1")); + assert!(jupiter.contains("https://api.jup.ag/price/v3")); + assert!(jupiter.contains("So11111111111111111111111111111111111111112")); + assert!(!jupiter.contains("/quote")); + assert!(!dexscreener.contains("std::env")); + assert!(!jupiter.contains("std::env")); +} diff --git a/crates/ksp-offchain-transport-lib/tests/public_api.rs b/crates/ksp-offchain-transport-lib/tests/public_api.rs index 03814a4..f347d0c 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: 6 +// version: 7 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -122,6 +122,31 @@ fn public_pre_005_exchange_settings_and_adapters_are_available_from_crate_root() return std::result::Result::Ok(()); } +#[test] +fn public_pre_006_jupiter_and_dexscreener_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> { + let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(false) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let jupiter_provider = match ksp_offchain_transport_lib::MarketPriceJupiterProvider::new(jupiter) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(jupiter_provider.descriptor().id().as_str(), "jupiter"); + assert_eq!(jupiter_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic); + let dexscreener = match ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let dexscreener_provider = match ksp_offchain_transport_lib::MarketPriceDexScreenerProvider::new(dexscreener) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(dexscreener_provider.descriptor().id().as_str(), "dexscreener"); + assert_eq!(dexscreener_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd); + return std::result::Result::Ok(()); +} + #[test] fn offchain_error_codes_use_owned_domain() { let codes = [ diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs new file mode 100644 index 0000000..e13391b --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs @@ -0,0 +1,105 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs +// version: 1 + +const TEST_PAIR: &str = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE"; + +#[test] +fn dexscreener_fixture_validates_chain_pair_and_sol_identity_and_maps_exact_pair_usd() -> ksp_core_lib::Result<()> { + let provider_id = match crate::MarketPriceProviderId::new(super::DEXSCREENER_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let pair_address = match TEST_PAIR.parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(super::provider_settings_error("test_pair")), + }; + let fixture = concat!( + r#"{"schemaVersion":"1.0.0","pairs":[{"chainId":"solana","dexId":"orca","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#, + r#""baseToken":{"address":"So11111111111111111111111111111111111111112","name":"Wrapped SOL","symbol":"SOL"},"#, + r#""quoteToken":{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","name":"USD Coin","symbol":"USDC"},"#, + r#""priceNative":"151.987654321012345678","priceUsd":"151.987654321012345678"}]}"# + ) + .as_bytes(); + let started = crate::MarketPriceTimestamp::from_unix_millis(10); + let received = crate::MarketPriceTimestamp::from_unix_millis(20); + let observation = match super::parse_response(fixture, provider_id, &pair_address, started, received) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678"); + assert_eq!(observation.semantics(), crate::MarketPriceSemantics::DexPairUsd); + assert_eq!(observation.provider_timestamp(), std::option::Option::None); + assert_eq!(observation.provenance().as_str(), "dexscreener:solana:Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE:priceUsd"); + return std::result::Result::Ok(()); +} + +#[test] +fn dexscreener_settings_and_descriptor_keep_pair_explicit_and_pair_endpoint_rate_limited() -> ksp_core_lib::Result<()> { + assert!(crate::MarketPriceDexScreenerSettings::new(true, "not-a-pubkey").is_err()); + let settings = match crate::MarketPriceDexScreenerSettings::new(true, TEST_PAIR) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(settings.pair_address().to_string(), TEST_PAIR); + let request = match super::build_request(settings.pair_address()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(request.url_for_test().as_str(), "https://api.dexscreener.com/latest/dex/pairs/solana/Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE"); + let provider = match crate::MarketPriceDexScreenerProvider::new(settings) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None); + assert_eq!(provider.descriptor().semantics(), crate::MarketPriceSemantics::DexPairUsd); + assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(300)); + assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(60)); + assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Unspecified); + return std::result::Result::Ok(()); +} + +#[test] +fn dexscreener_rejects_wrong_chain_pair_base_identity_multiple_pairs_and_missing_price() -> ksp_core_lib::Result<()> { + let provider_id = match crate::MarketPriceProviderId::new(super::DEXSCREENER_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let pair_address = match TEST_PAIR.parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(super::provider_settings_error("test_pair")), + }; + let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10); + let wrong_chain = concat!( + r#"{"pairs":[{"chainId":"ethereum","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#, + r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"}]}"# + ) + .as_bytes(); + assert!(super::parse_response(wrong_chain, provider_id.clone(), &pair_address, timestamp, timestamp).is_err()); + let wrong_pair = concat!( + r#"{"pairs":[{"chainId":"solana","pairAddress":"58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2","#, + r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"}]}"# + ) + .as_bytes(); + assert!(super::parse_response(wrong_pair, provider_id.clone(), &pair_address, timestamp, timestamp).is_err()); + let wrong_base = concat!( + r#"{"pairs":[{"chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#, + r#""baseToken":{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"},"priceUsd":"1.00"}]}"# + ) + .as_bytes(); + assert!(super::parse_response(wrong_base, provider_id.clone(), &pair_address, timestamp, timestamp).is_err()); + let multiple = concat!( + r#"{"pairs":[{"chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#, + r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"},{"#, + r#""chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#, + r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"}]}"# + ) + .as_bytes(); + assert!(super::parse_response(multiple, provider_id.clone(), &pair_address, timestamp, timestamp).is_err()); + let missing_price = concat!( + r#"{"pairs":[{"chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#, + r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":null}]}"# + ) + .as_bytes(); + assert!(super::parse_response(missing_price, provider_id, &pair_address, timestamp, timestamp).is_err()); + return std::result::Result::Ok(()); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs new file mode 100644 index 0000000..815dda7 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs @@ -0,0 +1,81 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs +// version: 1 + +#[test] +fn jupiter_fixture_maps_exact_heuristic_price_and_block_provenance_without_fake_timestamp() -> ksp_core_lib::Result<()> { + let provider_id = match crate::MarketPriceProviderId::new(super::JUPITER_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let fixture = concat!( + r#"{"So11111111111111111111111111111111111111112":{"createdAt":"2024-06-05T08:55:25.527Z","liquidity":621679197.67,"#, + r#""usdPrice":151.987654321012345678,"blockId":348004023,"decimals":9,"priceChange24h":1.29}}"# + ) + .as_bytes(); + let started = crate::MarketPriceTimestamp::from_unix_millis(10); + let received = crate::MarketPriceTimestamp::from_unix_millis(20); + let observation = match super::parse_response(fixture, provider_id, started, received) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678"); + assert_eq!(observation.semantics(), crate::MarketPriceSemantics::SolanaHeuristic); + assert_eq!(observation.provider_timestamp(), std::option::Option::None); + assert_eq!(observation.provenance().as_str(), "jupiter:price_v3:So11111111111111111111111111111111111111112:block:348004023"); + return std::result::Result::Ok(()); +} + +#[test] +fn jupiter_modes_match_current_keyless_and_free_rate_limits_and_redact_key() -> ksp_core_lib::Result<()> { + let keyless = match crate::MarketPriceJupiterSettings::keyless(true) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let keyless_provider = match crate::MarketPriceJupiterProvider::new(keyless) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(keyless_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None); + assert_eq!(keyless_provider.descriptor().semantics(), crate::MarketPriceSemantics::SolanaHeuristic); + assert_eq!(keyless_provider.descriptor().rate_limit().requests(), std::option::Option::Some(1)); + assert_eq!(keyless_provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(2)); + assert_eq!(keyless_provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Unspecified); + let free = match crate::MarketPriceJupiterSettings::free(true, std::option::Option::Some("jup-free-canary-secret".to_owned())) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert!(!std::format!("{free:?}").contains("jup-free-canary-secret")); + let request = match super::build_request(&free) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert!(request.has_header_for_test(super::JUPITER_API_KEY_HEADER)); + assert_eq!(request.url_for_test().query(), std::option::Option::Some("ids=So11111111111111111111111111111111111111112")); + let free_provider = match crate::MarketPriceJupiterProvider::new(free) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(free_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::RequiredApiKey); + assert_eq!(free_provider.descriptor().rate_limit().requests(), std::option::Option::Some(1)); + assert_eq!(free_provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1)); + assert_eq!(free_provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Account); + return std::result::Result::Ok(()); +} + +#[test] +fn jupiter_rejects_keyless_credential_missing_price_wrong_identity_and_wrong_decimals() -> ksp_core_lib::Result<()> { + assert!(crate::MarketPriceJupiterSettings::free(true, std::option::Option::None).is_err()); + assert!(crate::MarketPriceJupiterSettings::keyless(true).is_ok()); + let provider_id = match crate::MarketPriceProviderId::new(super::JUPITER_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10); + let missing_price = br#"{"So11111111111111111111111111111111111111112":{"usdPrice":null,"blockId":348004023,"decimals":9}}"#; + assert!(super::parse_response(missing_price, provider_id.clone(), timestamp, timestamp).is_err()); + let wrong_identity = br#"{"JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN":{"usdPrice":1.0,"blockId":348004023,"decimals":6}}"#; + assert!(super::parse_response(wrong_identity, provider_id.clone(), timestamp, timestamp).is_err()); + let wrong_decimals = br#"{"So11111111111111111111111111111111111111112":{"usdPrice":151.98,"blockId":348004023,"decimals":8}}"#; + assert!(super::parse_response(wrong_decimals, provider_id, timestamp, timestamp).is_err()); + return std::result::Result::Ok(()); +} diff --git a/deltas/0.2.11/pre.006.md b/deltas/0.2.11/pre.006.md new file mode 100644 index 0000000..bdefc33 --- /dev/null +++ b/deltas/0.2.11/pre.006.md @@ -0,0 +1,243 @@ + + + +# Delta `0.2.11-pre.006` — Jupiter Price V3 + DexScreener + +## 1. Base requise + +Cette tranche s'applique exclusivement après `0.2.11-pre.005` accepté par l'opérateur. + +Version Cargo attendue à l'entrée : + +```text +0.2.11-pre.5 +``` + +Version Cargo de sortie : + +```text +0.2.11-pre.6 +``` + +Le gate d'entrée ciblé du `2026-08-25` est PASS : `cargo fmt --all`, audit Rust clean avec 0 candidate export, audit Markdown clean à 119 tables / 110 fichiers, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test -p ksp-offchain-transport-lib` avec 37 unitaires + 3 boundary + 4 public API. `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.005` avant le passage explicite à cette tranche. + +## 2. Objet strict de la tranche + +`pre.006` ajoute uniquement les deux providers prévus par le forecast : + +```text +Jupiter Price V3 +DexScreener +``` + +Le contrat reste : + +```text +SOL/USD uniquement +HTTP REST direct via reqwest +aucun SDK provider +aucune découverte automatique de pool +aucun fallback/consensus/agrégation KSP +pas de registry global +pas de Config +pas de refresh multiple +pas de smoke live revendiqué +``` + +## 3. Jupiter Price V3 + +Ajout de : + +```text +market_price_jupiter.rs +unit_tests/market_price_jupiter.rs +MarketPriceJupiterAccessMode +MarketPriceJupiterSettings +MarketPriceJupiterProvider +``` + +Le réaudit officiel courant confirme le Developer Platform unifié : + +```text +base URL https://api.jup.ag +endpoint /price/v3 +mint SOL So11111111111111111111111111111111111111112 +keyless 0.5 RPS +Free avec API key 1 RPS +header keyed x-api-key +``` + +Les deux modes utilisent la même origine officielle. Le mode keyless n'accepte aucun credential ; le mode Free exige une clé lorsqu'il est activé. La clé reste dans `MarketPriceApiKey`, n'est jamais lue depuis l'environnement par cette crate et n'apparaît pas dans `Debug`, les logs ou les erreurs. + +Price V3 expose `usdPrice` comme prix USD unique. KSP le lit via `serde_json::RawValue` puis `MarketPriceDecimal`, sans passage canonique par `f64`. + +Le contrat V3 expose également `blockId`, qui peut servir à raisonner sur la récence du prix. KSP le conserve uniquement dans la provenance sûre : + +```text +jupiter:price_v3::block: +``` + +`createdAt` décrit la création du token et n'est pas un timestamp de prix. `blockId` n'est pas un timestamp non plus. L'observation conserve donc explicitement : + +```text +provider_timestamp = None +MarketPriceSemantics::SolanaHeuristic +``` + +Le parser exige la clé exacte du mint WSOL, `decimals = 9`, un `usdPrice` présent et positif, et un `blockId` présent. Une réponse sans prix est une réponse provider non exploitable pour une observation réussie ; le registry/availability générique sera matérialisé en `pre.007`. + +## 4. DexScreener + +Ajout de : + +```text +market_price_dexscreener.rs +unit_tests/market_price_dexscreener.rs +MarketPriceDexScreenerSettings +MarketPriceDexScreenerProvider +``` + +Le V1 suit strictement la décision opérateur : un client SOL/USD lié à **une paire Solana explicitement configurée**. + +L'endpoint officiel utilisé est uniquement : + +```text +https://api.dexscreener.com/latest/dex/pairs/solana/{pairId} +``` + +La documentation DexScreener le borne actuellement à : + +```text +300 requêtes / minute +``` + +Le pair address est validé comme `ksp_core_lib::Pubkey` dans les settings. Le client construit le chemin à partir de l'origine officielle fixe et du seul segment Pubkey validé ; aucune base URL configurable n'est introduite. + +Avant d'accepter `priceUsd`, le parser vérifie : + +```text +une seule paire retournée +chainId = solana +pairAddress = pair address configuré +baseToken.address = So11111111111111111111111111111111111111112 +priceUsd présent et exact +``` + +KSP ne consulte pas : + +```text +/latest/dex/search +/token-pairs/v1 +/tokens/v1 +``` + +et ne fait aucun pool discovery, ranking de liquidité, sélection automatique, moyenne ou consensus. + +La provenance conserve le pair address, considéré comme sûr : + +```text +dexscreener:solana::priceUsd +``` + +La sémantique publique reste : + +```text +MarketPriceSemantics::DexPairUsd +provider_timestamp = None +``` + +## 5. HTTP commun + +`HttpGetRequest` gagne uniquement une primitive crate-private : + +```text +append_path_segment +``` + +Elle permet de construire un endpoint provider à origine fixe lorsque le contrat nécessite un segment dynamique validé, ici le Pubkey de la paire DexScreener. Cette évolution ne crée aucune API HTTP publique et ne permet pas au consumer de fournir une URL arbitraire. + +## 6. Façade publique + +La taxonomie reste capability-scoped : + +```text +MarketPriceJupiter* +MarketPriceDexScreener* +``` + +Aucun type wire provider, client HTTP générique ou primitive d'admission n'est exporté aux consumers. + +Le futur `ksp-app-solprices-desk` ne devra pas construire ces settings provider directement ; `pre.009` donnera cette responsabilité à Config et le service générique prévu en `pre.007`/`pre.008` masquera les variantes provider à l'HID. + +## 7. Tests déterministes ajoutés + +Les nouveaux canaries couvrent : + +```text +Jupiter keyless 0.5 RPS +Jupiter Free 1 RPS + API key redacted +query Jupiter fixée au mint WSOL +Jupiter prix exact via RawValue +Jupiter blockId conservé en provenance +Jupiter provider_timestamp absent +Jupiter prix absent rejeté +Jupiter identité/decimals incohérents rejetés +DexScreener pair address Pubkey explicite +URL pair directe construite sans discovery +DexScreener 300 req/min +chainId Solana vérifié +pairAddress retourné vérifié +base token WSOL vérifié +priceUsd exact +réponse multi-pair rejetée +prix absent rejeté +absence des endpoints search/token discovery +façade publique des deux adapters +``` + +## 8. 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 plan enregistre également le réaudit Jupiter/DexScreener courant et distingue explicitement `blockId` Jupiter d'un timestamp provider. + +## 9. Fichiers ajoutés + +```text +crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs +crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs +deltas/0.2.11/pre.006.md +``` + +## 10. Fichiers modifiés + +```text +Cargo.toml +crates/ksp-offchain-transport-lib/src/http_client.rs +crates/ksp-offchain-transport-lib/src/lib.rs +crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs +crates/ksp-offchain-transport-lib/tests/public_api.rs +docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +``` + +## 11. 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.007` ne doit commencer qu'après un gate propre de `pre.006` 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 ed102bd..ca18f1e 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.005` ajoute les deux providers exchange V1 — Kraken Spot et Coinbase Exchange — en SOL/USD keyless via le socle `http_*` existant. Les deux observations conservent `ExchangeLastTrade` : Kraken utilise le dernier trade du ticker `SOLUSD` sans inventer de timestamp provider, tandis que Coinbase Exchange conserve le timestamp RFC3339 de `/products/SOL-USD/ticker`. Le gate opérateur complet de `pre.004-fix.001` est PASS.** +**Statut courant : `0.2.11-pre.006` ajoute Jupiter Price V3 et DexScreener à la famille `market_price`. Jupiter supporte keyless ou Free key sur `api.jup.ag`, conserve `SolanaHeuristic` et le `blockId` comme provenance sans inventer de timestamp de prix. DexScreener utilise exclusivement l'endpoint direct d'une paire Solana explicitement configurée, vérifie `chainId`, pair address et identité WSOL avant d'accepter `priceUsd`. Le gate opérateur ciblé de `pre.005` est PASS ; `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.005`.** ## 1. Base et autorité @@ -224,8 +224,10 @@ https://docs.cdp.coinbase.com/exchange/rest-api/rate-limits https://docs.cdp.coinbase.com/api-reference/exchange-api/rest-api/products/get-product-ticker https://docs.cdp.coinbase.com/api-reference/exchange-api/rest-api/products/get-single-product +https://developers.jup.ag/docs/portal/setup +https://developers.jup.ag/docs/portal/plans https://developers.jup.ag/docs/portal/migration -https://developers.jup.ag/pricing +https://developers.jup.ag/docs/api-reference/price https://developers.jup.ag/docs/price https://docs.birdeye.so/docs/pricing @@ -241,6 +243,8 @@ Les chiffres externes sont une photographie d'audit, pas une constante éternell Réaudit `pre.004` : CoinMarketCap Simple Price est implémenté sur la surface **V2** actuelle. La surface Simple Price V1 est documentée comme deprecated et n'est pas utilisée par KSP. Les deux modes V1 retenus restent distincts : endpoint public keyless V2 sans credential, ou endpoint Pro V2 Basic avec `X-CMC_PRO_API_KEY`. +Réaudit `pre.006` : le Developer Platform Jupiter courant utilise `https://api.jup.ag` pour les accès keyless et API-key. Le keyless est documenté à 0,5 RPS et le plan Free à 1 RPS. Price V3 expose `usdPrice` comme prix USD unique et `blockId` comme information de récence ; `createdAt` décrit la création du token et ne doit pas être transformé en timestamp du prix. DexScreener documente l'endpoint direct `/latest/dex/pairs/{chainId}/{pairId}` à 300 requêtes par minute. KSP sélectionne uniquement `chainId = solana` et interdit dans V1 les endpoints de search ou de découverte de pools. + ### 6.1 Gratuité technique et conditions d'usage Le verdict `IN` signifie ici qu'un accès HTTP gratuit permet techniquement de développer et live-tester le provider dans `0.2.11`. Il ne constitue pas une affirmation de gratuité commerciale permanente. @@ -720,15 +724,15 @@ Correctif strict de `pre.004` : tri alphabétique complet des constantes d'erreu ### `pre.005` — Kraken et Coinbase Exchange -**Statut : réalisé, gate Cargo opérateur à exécuter.** +**Statut : réalisé, gate opérateur ciblé PASS ; workspace complet non rejoué.** Implémentation des deux adapters exchange keyless avec DTOs wire privés et endpoints HTTPS fixes. Kraken utilise le ticker public `SOLUSD`, retient le dernier trade `c[0]`, applique une cadence locale conservatrice de 1 requête par seconde et ne fabrique aucun timestamp provider absent du ticker. Coinbase Exchange utilise `/products/SOL-USD/ticker`, conserve `price` et `time`, et modélise le token bucket public documenté à 10 requêtes par seconde par IP avec burst 15. Les deux adapters exposent `MarketPriceSemantics::ExchangeLastTrade`; aucun registry global, Config, SDK provider ou smoke live n'est avancé. ### `pre.006` — Jupiter et DexScreener -**Statut : planifié.** +**Statut : réalisé, gate Cargo opérateur à exécuter.** -Implémentation des adapters Jupiter et DexScreener. DexScreener reste limité en V1 à la paire SOL/USD explicite configurée et validée. +Implémentation des adapters Jupiter Price V3 et DexScreener avec DTOs wire privés et endpoints HTTPS officiels fixes. Jupiter distingue keyless à 0,5 requête par seconde et Free key à 1 requête par seconde sur le même `api.jup.ag`, utilise uniquement le mint WSOL et conserve `blockId` comme provenance de récence sans le convertir en timestamp. DexScreener exige un pair address Solana explicite dans ses settings, appelle uniquement `/latest/dex/pairs/solana/{pairId}`, vérifie `chainId = solana`, le pair address retourné et le mint WSOL comme base avant de lire `priceUsd`. Aucune découverte de pool, aucun tri de liquidité, aucun consensus et aucun registry global ne sont avancés. ### `pre.007` — Birdeye, registry et availability 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 161f73b..1af6e8b 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 @@ -184,27 +184,52 @@ Le gate opérateur rejoué le `2026-08-25` est intégralement PASS : audit Rust ## 3.8 Gate `0.2.11-pre.005` -| Critère | Statut | Preuve | -|--------------------------------------------------------|---------|----------------------------------------------------| -| version workspace `0.2.11-pre.5` | PASS | `Cargo.toml` racine | -| Kraken Spot SOL/USD adapter public | PASS | `market_price_kraken.rs` | -| Kraken public keyless sans faux credential | PASS | settings + descriptor | -| Kraken dernier trade conservé avec sémantique exchange | PASS | ticker `SOLUSD` -> `c[0]` -> `ExchangeLastTrade` | -| Kraken ne fabrique aucun timestamp provider absent | PASS | `provider_timestamp = None` | -| Kraken cadence locale conservatrice 1 req/s IP | PASS | descriptor + canari unitaire | -| Coinbase Exchange SOL/USD adapter public | PASS | `market_price_coinbase_exchange.rs` | -| Coinbase Exchange public keyless sans faux credential | PASS | settings + descriptor | -| Coinbase dernier trade et timestamp provider conservés | PASS | `price` + `time` -> `ExchangeLastTrade` | -| Coinbase token bucket public 10 req/s IP, burst 15 | PASS | descriptor + canari unitaire | -| DTOs wire Kraken/Coinbase privés | PASS | structs privées dans les modules providers | -| endpoints provider fixes et HTTPS | PASS | constantes privées + boundary canary | -| prix exchange parsés exactement sans passage par `f64` | PASS | `RawValue` -> `MarketPriceDecimal::parse_json_raw` | -| aucun SDK provider, Config ou registry global avancé | PASS | manifest + dependency boundary | -| audit Rust sandbox | PASS | clean, 0 candidate export | -| audit Markdown sandbox | PASS | clean, 119 tables / 110 fichiers | -| gate Cargo complet | PLANNED | à exécuter par l'opérateur | +| Critère | Statut | Preuve | +|--------------------------------------------------------|--------|----------------------------------------------------| +| version workspace `0.2.11-pre.5` | PASS | `Cargo.toml` racine | +| Kraken Spot SOL/USD adapter public | PASS | `market_price_kraken.rs` | +| Kraken public keyless sans faux credential | PASS | settings + descriptor | +| Kraken dernier trade conservé avec sémantique exchange | PASS | ticker `SOLUSD` -> `c[0]` -> `ExchangeLastTrade` | +| Kraken ne fabrique aucun timestamp provider absent | PASS | `provider_timestamp = None` | +| Kraken cadence locale conservatrice 1 req/s IP | PASS | descriptor + canari unitaire | +| Coinbase Exchange SOL/USD adapter public | PASS | `market_price_coinbase_exchange.rs` | +| Coinbase Exchange public keyless sans faux credential | PASS | settings + descriptor | +| Coinbase dernier trade et timestamp provider conservés | PASS | `price` + `time` -> `ExchangeLastTrade` | +| Coinbase token bucket public 10 req/s IP, burst 15 | PASS | descriptor + canari unitaire | +| DTOs wire Kraken/Coinbase privés | PASS | structs privées dans les modules providers | +| endpoints provider fixes et HTTPS | PASS | constantes privées + boundary canary | +| prix exchange parsés exactement sans passage par `f64` | PASS | `RawValue` -> `MarketPriceDecimal::parse_json_raw` | +| aucun SDK provider, Config ou registry global avancé | PASS | manifest + dependency boundary | +| audit Rust sandbox | PASS | clean, 0 candidate export | +| audit Markdown sandbox | PASS | clean, 119 tables / 110 fichiers | +| `cargo check` / Clippy / tests Off-chain | PASS | résultats opérateur du 2026-08-25 | +| `cargo test --workspace` sur état exact `pre.005` | N/R | non rejoué avant passage explicite à `pre.006` | -Les tests déterministes de `pre.005` couvrent les descriptors exchange, la précision décimale, le timestamp Coinbase, l'absence de timestamp Kraken, le rejet d'une erreur/identité Kraken incohérente et le rejet d'un timestamp Coinbase invalide. Aucun smoke réseau n'est revendiqué dans cette tranche. +Les tests déterministes de `pre.005` couvrent les descriptors exchange, la précision décimale, le timestamp Coinbase, l'absence de timestamp Kraken, le rejet d'une erreur/identité Kraken incohérente et le rejet d'un timestamp Coinbase invalide. Le gate opérateur ciblé du `2026-08-25` est PASS : audit Rust clean, audit Markdown clean à 119 tables / 110 fichiers, `cargo check --workspace` PASS, `cargo clippy --workspace --all-targets` PASS et `cargo test -p ksp-offchain-transport-lib` PASS avec 37 unitaires + 3 boundary + 4 public API. `cargo test --workspace` n'a pas été rejoué sur cet état exact. + +## 3.9 Gate `0.2.11-pre.006` + +| Critère | Statut | Preuve | +|-----------------------------------------------------------------|---------|---------------------------------------------------------| +| version workspace `0.2.11-pre.6` | PASS | `Cargo.toml` racine | +| Jupiter Price V3 adapter public | PASS | `market_price_jupiter.rs` | +| Jupiter keyless et Free key distingués | PASS | settings + descriptor + header sensible | +| Jupiter keyless 0,5 RPS et Free 1 RPS représentés | PASS | descriptors + canaries unitaires | +| Jupiter utilise uniquement le mint WSOL | PASS | query `ids` fixe | +| `usdPrice` Jupiter parsé exactement sans `f64` canonique | PASS | `RawValue` -> `MarketPriceDecimal` | +| `blockId` Jupiter conservé en provenance sans faux timestamp | PASS | provenance bornée + `provider_timestamp = None` | +| DexScreener adapter public avec pair address explicite | PASS | `MarketPriceDexScreenerSettings` | +| DexScreener endpoint direct pair uniquement | PASS | `/latest/dex/pairs/solana/{pairId}` | +| DexScreener vérifie `chainId`, pair address et identité WSOL | PASS | parser provider | +| DexScreener utilise uniquement `priceUsd` | PASS | DTO wire privé + parsing exact | +| DexScreener 300 req/min représenté | PASS | descriptor provider | +| aucune découverte/selection/ranking automatique de pool | PASS | dependency boundary + absence search/token-pairs/tokens | +| aucun SDK provider, Config, registry ou refresh multiple avancé | PASS | manifest + scope des modules | +| audit Rust sandbox | PASS | clean, 0 candidate export | +| audit Markdown sandbox | PLANNED | à exécuter après réconciliation documentaire | +| gate Cargo | PLANNED | à exécuter par l'opérateur | + +Les fixtures de `pre.006` couvrent Jupiter keyless/Free, redaction de la clé, prix exact, identité WSOL, `blockId`, absence volontaire de faux timestamp, ainsi que DexScreener avec pair Solana explicite, `priceUsd`, rejet de chain/pair/base invalides, réponse multiple et prix absent. Aucun smoke live n'est revendiqué dans cette tranche. ## 4. Matrice provider prévue @@ -215,9 +240,9 @@ Les tests déterministes de `pre.005` couvrent les descriptors exchange, la pré | CoinPaprika | Oui | Oui | aucune | PASS | PLANNED | IMPLEMENTED | | Kraken | Oui | Oui | aucune | PASS | PLANNED | IMPLEMENTED | | Coinbase Exchange | Oui | Oui | aucune | PASS | PLANNED | IMPLEMENTED | -| Jupiter Price V3 | Oui | Oui | keyless ou Free key | PLANNED | PLANNED | AUDITED | +| Jupiter Price V3 | Oui | Oui | keyless ou Free key | PASS | PLANNED | IMPLEMENTED | | Birdeye | Oui | Oui | API key Standard | PLANNED | PLANNED | AUDITED | -| DexScreener | Oui | Oui | aucune | PLANNED | PLANNED | AUDITED | +| DexScreener | Oui | Oui | aucune | PASS | PLANNED | IMPLEMENTED | `AUDITED` signifie uniquement que la documentation officielle actuelle satisfait le gate d'entrée. Ce statut ne vaut pas preuve d'un smoke réseau KSP. @@ -294,15 +319,15 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d ## 9. DexScreener V1 -| Invariant | Statut | -|----------------------------------------------|---------| -| pair address SOL/USD explicite dans settings | PLANNED | -| `chainId = solana` vérifié | PLANNED | -| identité SOL de la paire vérifiée | PLANNED | -| `priceUsd` utilisé comme prix commun | PLANNED | -| aucune recherche automatique de pool | PLANNED | -| aucun tri automatique par liquidité | PLANNED | -| aucune moyenne/consensus entre pools | PLANNED | +| Invariant | Statut | +|----------------------------------------------|--------| +| pair address SOL/USD explicite dans settings | PASS | +| `chainId = solana` vérifié | PASS | +| identité SOL de la paire vérifiée | PASS | +| `priceUsd` utilisé comme prix commun | PASS | +| aucune recherche automatique de pool | PASS | +| aucun tri automatique par liquidité | PASS | +| aucune moyenne/consensus entre pools | PASS | ## 10. Config -> Off-chain Transport