From 4021d085413c09ff3b027fd4c3cb53c6de60e574 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 25 Aug 2026 22:32:07 +0200 Subject: [PATCH] v0.2.11-pre.005 --- Cargo.toml | 4 +- crates/ksp-offchain-transport-lib/src/lib.rs | 20 +- .../src/market_price_coinbase_exchange.rs | 159 ++++++++++++++ .../src/market_price_kraken.rs | 179 ++++++++++++++++ .../tests/dependency_boundary.rs | 13 +- .../tests/public_api.rs | 27 ++- .../market_price_coinbase_exchange.rs | 59 +++++ .../unit_tests/market_price_kraken.rs | 57 +++++ deltas/0.2.11/pre.005.md | 202 ++++++++++++++++++ ...8-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md | 12 +- .../014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md | 34 ++- 11 files changed, 746 insertions(+), 20 deletions(-) create mode 100644 crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs create mode 100644 crates/ksp-offchain-transport-lib/src/market_price_kraken.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/market_price_coinbase_exchange.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs create mode 100644 deltas/0.2.11/pre.005.md diff --git a/Cargo.toml b/Cargo.toml index 284cff6..268ae6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 275 +# version: 276 [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.4.fix.1" +version = "0.2.11-pre.5" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-offchain-transport-lib/src/lib.rs b/crates/ksp-offchain-transport-lib/src/lib.rs index b806dde..e76920c 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: 6 +// version: 7 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -7,10 +7,10 @@ //! KSP-owned off-chain transport foundation. //! -//! `0.2.11-pre.004` keeps the first capability family deliberately narrow (`market_price`, SOL/USD only), activates the crate-wide hardened `http_*` -//! primitives in production, and introduces the first three direct-REST adapters: CoinGecko, CoinMarketCap and CoinPaprika. Provider wire DTOs remain -//! private, no provider SDK is used, and the `http_*` internals are not re-exported as a generic consumer HTTP client. Config integration and generic -//! registry/refresh orchestration remain outside this tranche. +//! `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. mod constants; mod error; @@ -19,10 +19,12 @@ mod http_client; mod http_settings; mod market_price_adapter; mod market_price_api_key; +mod market_price_coinbase_exchange; mod market_price_coingecko; mod market_price_coinmarketcap; mod market_price_coinpaprika; mod market_price_decimal; +mod market_price_kraken; mod market_price_observation; mod market_price_provider; mod market_price_settings; @@ -67,6 +69,10 @@ pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID; 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; +/// Coinbase Exchange SOL/USD provider adapter. +pub use self::market_price_coinbase_exchange::MarketPriceCoinbaseExchangeProvider; +/// Coinbase Exchange runtime settings. +pub use self::market_price_coinbase_exchange::MarketPriceCoinbaseExchangeSettings; /// CoinGecko V1 access mode. pub use self::market_price_coingecko::MarketPriceCoinGeckoAccessMode; /// CoinGecko SOL/USD provider adapter. @@ -89,6 +95,10 @@ 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; +/// Kraken Spot SOL/USD provider adapter. +pub use self::market_price_kraken::MarketPriceKrakenProvider; +/// Kraken Spot runtime settings. +pub use self::market_price_kraken::MarketPriceKrakenSettings; /// Maximum safe provenance length attached to one normalized observation. pub use self::market_price_observation::MARKET_PRICE_PROVENANCE_MAX_BYTES; /// Public V1 SOL/USD observation normalized by Off-chain Transport. diff --git a/crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs b/crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs new file mode 100644 index 0000000..01aa6fc --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs @@ -0,0 +1,159 @@ +// file: crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs +// version: 1 + +//! Coinbase Exchange SOL/USD market-price adapter using the public product ticker directly through `reqwest`. + +const COINBASE_EXCHANGE_PROVIDER_ID: &str = "coinbase_exchange"; +const COINBASE_EXCHANGE_SOL_USD_TICKER_URL: &str = "https://api.exchange.coinbase.com/products/SOL-USD/ticker"; + +/// Runtime settings for the keyless Coinbase Exchange market-price adapter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MarketPriceCoinbaseExchangeSettings { + common: crate::MarketPriceProviderCommonSettings, +} + +impl crate::MarketPriceCoinbaseExchangeSettings { + /// Creates Coinbase Exchange settings for the public keyless REST surface. + pub fn new(enabled: bool) -> ksp_core_lib::Result { + let provider_id = match crate::MarketPriceProviderId::new(COINBASE_EXCHANGE_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(Self { common: crate::MarketPriceProviderCommonSettings::new(provider_id, enabled) }); + } + + /// Returns common provider identity and enablement settings. + #[must_use] + pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings { + return &self.common; + } +} + +/// Coinbase Exchange SOL/USD provider adapter. +pub struct MarketPriceCoinbaseExchangeProvider { + admission: crate::HttpAdmissionController, + descriptor: crate::MarketPriceProviderDescriptor, + http: crate::HttpRestClient, + settings: crate::MarketPriceCoinbaseExchangeSettings, +} + +impl crate::MarketPriceCoinbaseExchangeProvider { + /// Builds one Coinbase Exchange provider from validated runtime settings. + pub fn new(settings: crate::MarketPriceCoinbaseExchangeSettings) -> 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 Coinbase Exchange capability descriptor. + #[must_use] + pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor { + return &self.descriptor; + } + + /// Returns the validated Coinbase Exchange runtime settings. + #[must_use] + pub const fn settings(&self) -> &crate::MarketPriceCoinbaseExchangeSettings { + return &self.settings; + } + + /// Fetches one normalized SOL/USD last-trade observation from Coinbase Exchange. + 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(COINBASE_EXCHANGE_PROVIDER_ID)); + } + if let std::result::Result::Err(error) = crate::admit_request(COINBASE_EXCHANGE_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() { + 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, COINBASE_EXCHANGE_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() -> ksp_core_lib::Result { + return crate::HttpGetRequest::new_https(COINBASE_EXCHANGE_SOL_USD_TICKER_URL); +} + +fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result { + let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(10, 1, std::option::Option::Some(15), crate::MarketPriceProviderRateLimitScope::Ip) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::MarketPriceProviderDescriptor::new( + provider_id, + "Coinbase Exchange", + crate::MarketPriceSemantics::ExchangeLastTrade, + crate::MarketPriceProviderAuthMode::None, + 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(COINBASE_EXCHANGE_PROVIDER_ID, "response", error)); + }, + }; + let price = match crate::MarketPriceDecimal::parse_json_raw(wire.price.as_ref()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provider_timestamp = match crate::market_price_timestamp_from_rfc3339(wire.time.as_str()) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(crate::invalid_provider_response(COINBASE_EXCHANGE_PROVIDER_ID, "time")); + }, + }; + let provenance = match crate::MarketPriceProvenance::new("coinbase_exchange:SOL-USD:last_trade") { + 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::ExchangeLastTrade, + request_started_at, + received_at, + std::option::Option::Some(provider_timestamp), + provenance, + ); +} + +#[derive(serde::Deserialize)] +struct CoinbaseExchangeWireTicker { + price: std::boxed::Box, + time: std::string::String, +} + +#[cfg(test)] +#[path = "../unit_tests/market_price_coinbase_exchange.rs"] +mod tests; diff --git a/crates/ksp-offchain-transport-lib/src/market_price_kraken.rs b/crates/ksp-offchain-transport-lib/src/market_price_kraken.rs new file mode 100644 index 0000000..09019ea --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/market_price_kraken.rs @@ -0,0 +1,179 @@ +// file: crates/ksp-offchain-transport-lib/src/market_price_kraken.rs +// version: 1 + +//! Kraken Spot SOL/USD market-price adapter using the public REST ticker directly through `reqwest`. + +const KRAKEN_PROVIDER_ID: &str = "kraken"; +const KRAKEN_SOL_USD_PAIR: &str = "SOLUSD"; +const KRAKEN_TICKER_URL: &str = "https://api.kraken.com/0/public/Ticker"; + +/// Runtime settings for the keyless Kraken Spot market-price adapter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MarketPriceKrakenSettings { + common: crate::MarketPriceProviderCommonSettings, +} + +impl crate::MarketPriceKrakenSettings { + /// Creates Kraken Spot settings for the public keyless REST surface. + pub fn new(enabled: bool) -> ksp_core_lib::Result { + let provider_id = match crate::MarketPriceProviderId::new(KRAKEN_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(Self { common: crate::MarketPriceProviderCommonSettings::new(provider_id, enabled) }); + } + + /// Returns common provider identity and enablement settings. + #[must_use] + pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings { + return &self.common; + } +} + +/// Kraken Spot SOL/USD provider adapter. +pub struct MarketPriceKrakenProvider { + admission: crate::HttpAdmissionController, + descriptor: crate::MarketPriceProviderDescriptor, + http: crate::HttpRestClient, + settings: crate::MarketPriceKrakenSettings, +} + +impl crate::MarketPriceKrakenProvider { + /// Builds one Kraken Spot provider from validated runtime settings. + pub fn new(settings: crate::MarketPriceKrakenSettings) -> 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 Kraken Spot capability descriptor. + #[must_use] + pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor { + return &self.descriptor; + } + + /// Returns the validated Kraken Spot runtime settings. + #[must_use] + pub const fn settings(&self) -> &crate::MarketPriceKrakenSettings { + return &self.settings; + } + + /// Fetches one normalized SOL/USD last-trade observation from Kraken Spot. + 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(KRAKEN_PROVIDER_ID)); + } + if let std::result::Result::Err(error) = crate::admit_request(KRAKEN_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() { + 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, KRAKEN_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() -> ksp_core_lib::Result { + let mut request = match crate::HttpGetRequest::new_https(KRAKEN_TICKER_URL) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + request.append_query_pair("pair", KRAKEN_SOL_USD_PAIR); + return std::result::Result::Ok(request); +} + +fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result { + let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::MarketPriceProviderDescriptor::new( + provider_id, + "Kraken", + crate::MarketPriceSemantics::ExchangeLastTrade, + crate::MarketPriceProviderAuthMode::None, + 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(KRAKEN_PROVIDER_ID, "response", error)); + }, + }; + if !wire.error.is_empty() { + return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "error")); + } + if wire.result.len() != 1 { + return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "result")); + } + let ticker = match wire.result.get(KRAKEN_SOL_USD_PAIR) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "identity")), + }; + let last_trade = match ticker.last_trade.first() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "last_trade")), + }; + let price = match crate::MarketPriceDecimal::parse_json_raw(last_trade.as_ref()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provenance = match crate::MarketPriceProvenance::new("kraken:SOLUSD:last_trade") { + 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::ExchangeLastTrade, + request_started_at, + received_at, + std::option::Option::None, + provenance, + ); +} + +#[derive(serde::Deserialize)] +struct KrakenWireResponse { + error: std::vec::Vec, + result: std::collections::BTreeMap, +} + +#[derive(serde::Deserialize)] +struct KrakenWireTicker { + #[serde(rename = "c")] + last_trade: std::vec::Vec>, +} + +#[cfg(test)] +#[path = "../unit_tests/market_price_kraken.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 6d7b8cd..0f7d79f 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: 5 +// version: 6 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -43,19 +43,28 @@ fn pre_004_http_runtime_is_production_active_but_not_a_public_generic_client() { } #[test] -fn pre_004_provider_modules_are_market_price_scoped_and_fixed_origin() { +fn pre_005_provider_modules_are_market_price_scoped_and_fixed_origin() { let crate_root = include_str!("../src/lib.rs"); + assert!(crate_root.contains("mod market_price_coinbase_exchange;")); assert!(crate_root.contains("mod market_price_coingecko;")); assert!(crate_root.contains("mod market_price_coinmarketcap;")); assert!(crate_root.contains("mod market_price_coinpaprika;")); + assert!(crate_root.contains("mod market_price_kraken;")); + let coinbase = include_str!("../src/market_price_coinbase_exchange.rs"); let coingecko = include_str!("../src/market_price_coingecko.rs"); let coinmarketcap = include_str!("../src/market_price_coinmarketcap.rs"); let coinpaprika = include_str!("../src/market_price_coinpaprika.rs"); + let kraken = include_str!("../src/market_price_kraken.rs"); + assert!(coinbase.contains("https://api.exchange.coinbase.com/products/SOL-USD/ticker")); assert!(coingecko.contains("https://api.coingecko.com/api/v3/simple/price")); assert!(coinmarketcap.contains("https://pro-api.coinmarketcap.com/public-api/v2/simple/price")); assert!(coinmarketcap.contains("https://pro-api.coinmarketcap.com/v2/simple/price")); assert!(coinpaprika.contains("https://api.coinpaprika.com/v1/tickers/sol-solana")); + assert!(kraken.contains("https://api.kraken.com/0/public/Ticker")); + assert!(kraken.contains("SOLUSD")); + assert!(!coinbase.contains("std::env")); assert!(!coingecko.contains("std::env")); assert!(!coinmarketcap.contains("std::env")); assert!(!coinpaprika.contains("std::env")); + assert!(!kraken.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 1fbdcf8..03814a4 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: 5 +// version: 6 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -97,6 +97,31 @@ fn public_pre_004_aggregator_settings_and_adapters_are_available_from_crate_root return std::result::Result::Ok(()); } +#[test] +fn public_pre_005_exchange_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> { + let kraken = match ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(false) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let kraken_provider = match ksp_offchain_transport_lib::MarketPriceKrakenProvider::new(kraken) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(kraken_provider.descriptor().id().as_str(), "kraken"); + assert_eq!(kraken_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade); + let coinbase = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(false) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let coinbase_provider = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeProvider::new(coinbase) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(coinbase_provider.descriptor().id().as_str(), "coinbase_exchange"); + assert_eq!(coinbase_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade); + 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_coinbase_exchange.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_coinbase_exchange.rs new file mode 100644 index 0000000..fac804f --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_coinbase_exchange.rs @@ -0,0 +1,59 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coinbase_exchange.rs +// version: 1 + +#[test] +fn coinbase_exchange_fixture_maps_exact_last_trade_and_provider_timestamp() -> ksp_core_lib::Result<()> { + let provider_id = match crate::MarketPriceProviderId::new(super::COINBASE_EXCHANGE_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let fixture = concat!( + r#"{"trade_id":86326522,"price":"151.987654321012345678","size":"0.25","#, + r#""time":"2026-08-25T19:00:01.123Z","bid":"151.98","ask":"151.99","volume":"12345.67"}"# + ) + .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::ExchangeLastTrade); + assert_eq!(observation.provider_timestamp().map(|value| return value.unix_millis()), std::option::Option::Some(1_787_684_401_123)); + assert_eq!(observation.provenance().as_str(), "coinbase_exchange:SOL-USD:last_trade"); + return std::result::Result::Ok(()); +} + +#[test] +fn coinbase_exchange_descriptor_matches_public_token_bucket() -> ksp_core_lib::Result<()> { + let settings = match crate::MarketPriceCoinbaseExchangeSettings::new(true) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provider = match crate::MarketPriceCoinbaseExchangeProvider::new(settings) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(provider.descriptor().id().as_str(), super::COINBASE_EXCHANGE_PROVIDER_ID); + assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None); + assert_eq!(provider.descriptor().semantics(), crate::MarketPriceSemantics::ExchangeLastTrade); + assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(10)); + assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1)); + assert_eq!(provider.descriptor().rate_limit().burst(), std::option::Option::Some(15)); + assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip); + assert_eq!(provider.descriptor().long_term_quota(), std::option::Option::None); + return std::result::Result::Ok(()); +} + +#[test] +fn coinbase_exchange_rejects_invalid_timestamp() -> ksp_core_lib::Result<()> { + let provider_id = match crate::MarketPriceProviderId::new(super::COINBASE_EXCHANGE_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let fixture = br#"{"price":"151.98","time":"not-a-timestamp"}"#; + let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10); + assert!(super::parse_response(fixture, provider_id, timestamp, timestamp).is_err()); + return std::result::Result::Ok(()); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs new file mode 100644 index 0000000..52d49ca --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs @@ -0,0 +1,57 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs +// version: 1 + +#[test] +fn kraken_fixture_maps_exact_last_trade_without_inventing_provider_timestamp() -> ksp_core_lib::Result<()> { + let provider_id = match crate::MarketPriceProviderId::new(super::KRAKEN_PROVIDER_ID) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let fixture = br#"{"error":[],"result":{"SOLUSD":{"a":["152.10","1","1.0"],"b":["152.00","1","1.0"],"c":["152.012345678901234567","0.25"]}}}"#; + 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(), "152.012345678901234567"); + assert_eq!(observation.semantics(), crate::MarketPriceSemantics::ExchangeLastTrade); + assert_eq!(observation.provider_timestamp(), std::option::Option::None); + assert_eq!(observation.provenance().as_str(), "kraken:SOLUSD:last_trade"); + return std::result::Result::Ok(()); +} + +#[test] +fn kraken_descriptor_is_keyless_exchange_last_trade_with_conservative_public_cadence() -> ksp_core_lib::Result<()> { + let settings = match crate::MarketPriceKrakenSettings::new(true) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provider = match crate::MarketPriceKrakenProvider::new(settings) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(provider.descriptor().id().as_str(), super::KRAKEN_PROVIDER_ID); + assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None); + assert_eq!(provider.descriptor().semantics(), crate::MarketPriceSemantics::ExchangeLastTrade); + assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(1)); + assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1)); + assert_eq!(provider.descriptor().rate_limit().burst(), std::option::Option::None); + assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip); + assert_eq!(provider.descriptor().long_term_quota(), std::option::Option::None); + return std::result::Result::Ok(()); +} + +#[test] +fn kraken_rejects_provider_error_and_wrong_result_identity() -> ksp_core_lib::Result<()> { + let provider_id = match crate::MarketPriceProviderId::new(super::KRAKEN_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 provider_error = br#"{"error":["EGeneral:Temporary lockout"],"result":{}}"#; + assert!(super::parse_response(provider_error, provider_id.clone(), timestamp, timestamp).is_err()); + let wrong_identity = br#"{"error":[],"result":{"SOLEUR":{"c":["152.01","0.25"]}}}"#; + assert!(super::parse_response(wrong_identity, provider_id, timestamp, timestamp).is_err()); + return std::result::Result::Ok(()); +} diff --git a/deltas/0.2.11/pre.005.md b/deltas/0.2.11/pre.005.md new file mode 100644 index 0000000..39191fb --- /dev/null +++ b/deltas/0.2.11/pre.005.md @@ -0,0 +1,202 @@ + + + +# Delta `0.2.11-pre.005` — Kraken + Coinbase Exchange + +## 1. Base requise + +Cette tranche s'applique exclusivement après `0.2.11-pre.004-fix.001` validé par l'opérateur. + +Version Cargo attendue à l'entrée : + +```text +0.2.11-pre.4.fix.1 +``` + +Version Cargo de sortie : + +```text +0.2.11-pre.5 +``` + +Le gate d'entrée du `2026-08-25` est intégralement PASS : audit Rust clean, audit Markdown clean, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-offchain-transport-lib` et `cargo test --workspace`. + +## 2. Objet strict de la tranche + +`pre.005` ajoute uniquement les deux providers exchange prévus par le forecast : + +```text +Kraken Spot +Coinbase Exchange +``` + +Le contrat reste : + +```text +SOL/USD uniquement +HTTP REST direct via reqwest +aucun SDK provider +aucun credential provider pour Kraken/Coinbase Exchange +MarketPriceSemantics::ExchangeLastTrade +pas de registry global +pas de Config +pas de refresh multiple +pas de smoke live revendiqué +``` + +## 3. Kraken Spot + +Ajout de : + +```text +market_price_kraken.rs +unit_tests/market_price_kraken.rs +MarketPriceKrakenSettings +MarketPriceKrakenProvider +``` + +Le provider est keyless et utilise l'endpoint public fixe : + +```text +https://api.kraken.com/0/public/Ticker?pair=SOLUSD +``` + +Le dernier trade est lu dans `c[0]` et normalisé par `MarketPriceDecimal::parse_json_raw` sans passage par `f64`. + +Le ticker public Kraken ne porte pas de timestamp du dernier trade dans cette forme. KSP conserve donc : + +```text +provider_timestamp = None +``` + +et n'invente aucune heure provider à partir de l'heure de réception locale. + +La sémantique reste : + +```text +MarketPriceSemantics::ExchangeLastTrade +``` + +La documentation Kraken indique qu'une cadence de `1 req/s` ou moins reste dans les limites des endpoints REST publics. Le descriptor V1 retient donc une limite locale conservatrice : + +```text +1 req/s +scope IP +burst non documenté +``` + +## 4. Coinbase Exchange + +Ajout de : + +```text +market_price_coinbase_exchange.rs +unit_tests/market_price_coinbase_exchange.rs +MarketPriceCoinbaseExchangeSettings +MarketPriceCoinbaseExchangeProvider +``` + +Le provider est keyless et utilise l'endpoint public fixe : + +```text +https://api.exchange.coinbase.com/products/SOL-USD/ticker +``` + +KSP conserve : + +```text +price +provider timestamp RFC3339 time +``` + +Le prix est parsé exactement via `RawValue` et le timestamp est converti dans le contrat millisecondes UTC existant. + +Le descriptor conserve le token bucket public documenté par Coinbase Exchange : + +```text +10 req/s par IP +burst 15 +``` + +La sémantique reste : + +```text +MarketPriceSemantics::ExchangeLastTrade +``` + +## 5. Façade publique + +La taxonomie publique reste capability-scoped : + +```text +MarketPriceKraken* +MarketPriceCoinbaseExchange* +``` + +Le nom `CoinbaseExchange` est volontairement explicite afin de ne pas confondre cette surface avec Advanced Trade ou d'autres APIs Coinbase futures. + +Aucun type wire provider n'est exporté. Les primitives `http_*` restent crate-private. + +## 6. Tests déterministes ajoutés + +Les nouveaux canaries couvrent : + +```text +Kraken exact last trade decimal +Kraken provider_timestamp absent +Kraken descriptor keyless + 1 req/s IP +Kraken provider error rejetée +Kraken résultat d'une autre paire rejeté +Coinbase exact last trade decimal +Coinbase timestamp RFC3339 conservé +Coinbase descriptor keyless + 10 req/s IP + burst 15 +Coinbase timestamp invalide rejeté +façade publique des deux adapters +endpoints fixes et absence de std::env +``` + +## 7. 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 marque `pre.004-fix.001` comme gate opérateur complet PASS et `pre.005` comme réalisé en attente de son gate Cargo. + +## 8. Fichiers ajoutés + +```text +crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs +crates/ksp-offchain-transport-lib/src/market_price_kraken.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_coinbase_exchange.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs +deltas/0.2.11/pre.005.md +``` + +## 9. Fichiers modifiés + +```text +Cargo.toml +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 +``` + +## 10. 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.006` ne doit commencer qu'après un gate propre de `pre.005` 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 42f39f5..ed102bd 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.004-fix.001` corrige le gate statique/Clippy et la fixture CoinMarketCap V2 de `pre.004`; `pre.004` active les primitives `http_*` en production et implémente les trois premiers adapters `market_price_*` — CoinGecko, CoinMarketCap et CoinPaprika — en SOL/USD uniquement, sans SDK provider. Le staging `cfg(test)` de `pre.003-fix.001` disparaît donc structurellement avec leurs premiers consumers de production. CoinMarketCap utilise la surface Simple Price V2 actuelle ; la surface V1 deprecated n'est pas introduite.** +**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.** ## 1. Base et autorité @@ -708,21 +708,21 @@ Correction de l'ordre alphabétique des constantes d'erreur, utilisation systém ### `pre.004` — CoinGecko, CoinMarketCap et CoinPaprika -**Statut : réalisé, gate Cargo opérateur à exécuter.** +**Statut : réalisé.** Activation en production du socle `http_*` et implémentation des trois adapters d'agrégateurs avec DTOs wire privés, parsing décimal exact via `RawValue`, timestamps provider lorsque fournis, endpoints officiels fixes, auth correspondant aux capacités réelles et limites provider décrites dans leurs descriptors. CoinGecko supporte keyless ou Demo key, CoinMarketCap keyless ou Basic key sur Simple Price V2, CoinPaprika reste keyless. Aucun registry global, Config ou smoke live n'est avancé. #### `pre.004-fix.001` — Ordre des constantes, Clippy et fixture CoinMarketCap V2 -**Statut : réalisé, gate Cargo opérateur à rejouer.** +**Statut : réalisé, gate opérateur complet PASS.** Correctif strict de `pre.004` : tri alphabétique complet des constantes d'erreur, suppression des deux warnings `clippy::collapsible_if` dans les builders CoinGecko/CoinMarketCap, et correction de la fixture CoinMarketCap V2 dont le JSON concaténé omettait le guillemet ouvrant de `last_updated`. Aucun contrat provider, endpoint, parsing runtime, rate limit ou scope de release n'est modifié. La version technique workspace devient `0.2.11-pre.4.fix.1`. ### `pre.005` — Kraken et Coinbase Exchange -**Statut : planifié.** +**Statut : réalisé, gate Cargo opérateur à exécuter.** -Implémentation des deux adapters exchange, conservation de leur sémantique de marché propre et tests dédiés. +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 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 4bab94e..161f73b 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 @@ -167,7 +167,7 @@ La version Cargo du correctif est `0.2.11-pre.3.fix.1`. Le gate opérateur du `2 | aucun SDK provider | PASS | manifest + boundary tests | | Config, registry global et refresh multiple non avancés | PASS | scopes réservés à `pre.007+` / `pre.009` | | audit Rust sandbox | PASS | clean, 0 candidate export | -| audit Markdown sandbox | PASS | clean, 118 tables / 109 files | +| audit Markdown sandbox | PASS | clean, 119 tables / 110 fichiers | | gate Cargo complet `pre.004` | FAIL | audit Rust : 1 x RUST-FMT-104 ; Clippy : 2 warnings ; 1 fixture CMC KO | Les tests déterministes de `pre.004` couvrent les modes d'auth, les headers sans fuite de credential, les URLs/query fixes, le parsing des réponses SOL/USD, le rejet d'identités ou schémas incohérents et le parsing exact des nombres JSON. Aucun smoke réseau n'est revendiqué dans cette tranche. @@ -180,6 +180,32 @@ L'échec CoinMarketCap provenait uniquement de la fixture : la concaténation pr La version Cargo du correctif est `0.2.11-pre.4.fix.1`. Aucun endpoint, mode d'auth, descriptor, limite provider ou contrat public n'est modifié. +Le gate opérateur rejoué le `2026-08-25` est intégralement PASS : audit Rust clean avec 0 candidate export, audit Markdown clean à 118 tables / 109 fichiers, `cargo check --workspace` PASS, `cargo clippy --workspace --all-targets` PASS, `cargo test -p ksp-offchain-transport-lib` PASS avec 31 unitaires + 3 boundary + 3 public API, puis `cargo test --workspace` PASS avec uniquement les smokes/timing explicitement ignorés. + +## 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 | + +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. + ## 4. Matrice provider prévue | Provider | SOL/USD V1 | Gratuit V1 | Mode auth prévu | Test déterministe | Smoke live | Statut courant | @@ -187,8 +213,8 @@ La version Cargo du correctif est `0.2.11-pre.4.fix.1`. Aucun endpoint, mode d'a | CoinGecko | Oui | Oui | keyless ou Demo key | PASS | PLANNED | IMPLEMENTED | | CoinMarketCap | Oui | Oui | keyless ou Basic key | PASS | PLANNED | IMPLEMENTED | | CoinPaprika | Oui | Oui | aucune | PASS | PLANNED | IMPLEMENTED | -| Kraken | Oui | Oui | aucune | PLANNED | PLANNED | AUDITED | -| Coinbase Exchange | Oui | Oui | aucune | PLANNED | PLANNED | AUDITED | +| 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 | | Birdeye | Oui | Oui | API key Standard | PLANNED | PLANNED | AUDITED | | DexScreener | Oui | Oui | aucune | PLANNED | PLANNED | AUDITED |