v0.2.11-pre.005

This commit is contained in:
2026-08-25 22:32:07 +02:00
parent 5c97a772be
commit 4021d08541
11 changed files with 746 additions and 20 deletions

View File

@@ -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"

View File

@@ -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.

View File

@@ -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<Self> {
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<Self> {
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral 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<crate::MarketPriceObservation> {
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<crate::HttpGetRequest> {
return crate::HttpGetRequest::new_https(COINBASE_EXCHANGE_SOL_USD_TICKER_URL);
}
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
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<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<CoinbaseExchangeWireTicker>(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<serde_json::value::RawValue>,
time: std::string::String,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_coinbase_exchange.rs"]
mod tests;

View File

@@ -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<Self> {
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<Self> {
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral 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<crate::MarketPriceObservation> {
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<crate::HttpGetRequest> {
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<crate::MarketPriceProviderDescriptor> {
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<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<KrakenWireResponse>(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<std::string::String>,
result: std::collections::BTreeMap<std::string::String, KrakenWireTicker>,
}
#[derive(serde::Deserialize)]
struct KrakenWireTicker {
#[serde(rename = "c")]
last_trade: std::vec::Vec<std::boxed::Box<serde_json::value::RawValue>>,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_kraken.rs"]
mod tests;

View File

@@ -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"));
}

View File

@@ -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 = [

View File

@@ -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(());
}

View File

@@ -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(());
}

202
deltas/0.2.11/pre.005.md Normal file
View File

@@ -0,0 +1,202 @@
<!-- file: deltas/0.2.11/pre.005.md -->
<!-- version: 1 -->
# 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.

View File

@@ -1,9 +1,9 @@
<!-- file: docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md -->
<!-- version: 10 -->
<!-- version: 11 -->
# 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

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md -->
<!-- version: 9 -->
<!-- version: 10 -->
# 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 |