v0.2.11-pre.004

This commit is contained in:
2026-08-25 21:47:47 +02:00
parent f4413ebbb0
commit 60afb51451
23 changed files with 1862 additions and 117 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 273
# version: 274
[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.3.fix.1"
version = "0.2.11-pre.4"
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/Cargo.toml
# version: 3
# version: 4
[package]
name = "ksp-offchain-transport-lib"
@@ -8,11 +8,12 @@ edition.workspace = true
repository.workspace = true
[dependencies]
chrono = { workspace = true, features = ["std"] }
ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" }
reqwest = { workspace = true, features = ["rustls"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
[dev-dependencies]
tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "time"] }

View File

@@ -1,6 +1,8 @@
// file: crates/ksp-offchain-transport-lib/src/error.rs
// version: 5
// version: 6
/// Stable off-chain transport error when local provider admission defers a request.
pub const ERROR_CODE_HTTP_ADMISSION_DEFERRED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_admission_deferred");
/// Stable off-chain transport error for HTTP 401/403 access denial.
pub const ERROR_CODE_HTTP_ACCESS_DENIED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_access_denied");
/// Stable off-chain transport error when the hardened reqwest client cannot be initialized.
@@ -27,6 +29,9 @@ pub const ERROR_CODE_HTTP_TEMPORARY_FAILURE: ksp_core_lib::ErrorCode = ksp_core_
pub const ERROR_CODE_HTTP_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_timeout");
/// Stable off-chain transport error for an invalid exact market-price decimal.
pub const ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_decimal_invalid");
/// Stable off-chain transport error when a disabled market-price provider is invoked directly.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_disabled");
/// Stable off-chain transport error for an invalid normalized market-price observation.
pub const ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_observation_invalid");
@@ -36,6 +41,9 @@ pub const ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID: ksp_core_lib::Err
/// Stable off-chain transport error for an invalid market-price provider identifier.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_id_invalid");
/// Stable off-chain transport error when a provider response violates its adapter contract.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_response_invalid");
/// Stable off-chain transport error for invalid common market-price provider settings.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_settings_invalid");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/src/http_admission.rs
// version: 2
// version: 3
//! Provider-neutral local request admission and rate-limit cooldown primitives.
@@ -16,8 +16,6 @@ pub(crate) enum HttpAdmissionPolicy {
Dynamic,
/// Enforce a smooth token bucket for a documented request budget and window.
Fixed { requests: u32, window: std::time::Duration, burst: u32 },
/// No local cadence limit is configured; provider-driven cooldown still applies.
Unlimited,
}
impl crate::HttpAdmissionPolicy {
@@ -49,7 +47,6 @@ pub(crate) enum HttpAdmissionDecision {
pub(crate) struct HttpAdmissionController {
cooldown_until: std::sync::Mutex<std::option::Option<std::time::Instant>>,
fallback_cooldown: std::time::Duration,
policy: crate::HttpAdmissionPolicy,
token_bucket: std::sync::Mutex<std::option::Option<HttpTokenBucketState>>,
}
@@ -70,22 +67,15 @@ impl crate::HttpAdmissionController {
crate::HttpAdmissionPolicy::Fixed { requests, window, burst } => {
std::option::Option::Some(HttpTokenBucketState::new(requests, window, burst, std::time::Instant::now()))
},
crate::HttpAdmissionPolicy::Dynamic | crate::HttpAdmissionPolicy::Unlimited => std::option::Option::None,
crate::HttpAdmissionPolicy::Dynamic => std::option::Option::None,
};
return std::result::Result::Ok(Self {
cooldown_until: std::sync::Mutex::new(std::option::Option::None),
fallback_cooldown,
policy,
token_bucket: std::sync::Mutex::new(token_bucket),
});
}
/// Returns the configured local policy.
#[must_use]
pub(crate) const fn policy(&self) -> crate::HttpAdmissionPolicy {
return self.policy;
}
/// Tries to admit one request immediately without sleeping.
pub(crate) fn try_admit(&self) -> crate::HttpAdmissionDecision {
return self.try_admit_at(std::time::Instant::now());
@@ -108,7 +98,8 @@ impl crate::HttpAdmissionController {
return effective;
}
/// Returns the remaining provider cooldown, if any.
/// Returns the remaining provider cooldown for deterministic limiter tests.
#[cfg(test)]
pub(crate) fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
return self.cooldown_remaining_at(std::time::Instant::now());
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/src/http_client.rs
// version: 2
// version: 3
//! Crate-wide hardened REST client used internally by off-chain capability adapters.
@@ -71,12 +71,24 @@ impl crate::HttpGetRequest {
return std::result::Result::Ok(());
}
/// Reports whether a named header is present without exposing its value in tests.
#[cfg(test)]
pub(crate) fn has_header_for_test(&self, name: &'static str) -> bool {
return self.headers.contains_key(name);
}
/// Creates a plain-HTTP request for loopback-only deterministic unit tests.
#[cfg(test)]
pub(crate) fn new_test_http(url: &str) -> ksp_core_lib::Result<Self> {
return Self::parse(url, true);
}
/// Returns the constructed URL only to deterministic in-crate tests; production diagnostics remain redacted.
#[cfg(test)]
pub(crate) fn url_for_test(&self) -> &reqwest::Url {
return &self.url;
}
fn parse(url: &str, allow_http_for_tests: bool) -> ksp_core_lib::Result<Self> {
let parsed_result = reqwest::Url::parse(url);
let parsed = match parsed_result {

View File

@@ -1,13 +1,16 @@
// file: crates/ksp-offchain-transport-lib/src/http_settings.rs
// version: 2
// version: 3
//! Crate-wide bounded HTTP runtime settings shared by off-chain capability families.
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 1_048_576;
const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[cfg(test)]
const MAX_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[cfg(test)]
const MAX_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
#[cfg(test)]
const MAX_RESPONSE_BODY_BYTES: usize = 4_194_304;
/// Crate-internal HTTP client settings with defensive hard bounds.
@@ -19,7 +22,8 @@ pub(crate) struct HttpClientSettings {
}
impl crate::HttpClientSettings {
/// Creates one validated HTTP settings value.
/// Creates one validated HTTP settings value for deterministic settings tests until runtime configuration consumes this constructor.
#[cfg(test)]
pub(crate) fn new(
connect_timeout: std::time::Duration,
request_timeout: std::time::Duration,
@@ -69,6 +73,7 @@ impl std::default::Default for crate::HttpClientSettings {
}
}
#[cfg(test)]
fn invalid_http_settings(message: &str, field: &'static str) -> ksp_core_lib::Result<crate::HttpClientSettings> {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, "rejected invalid off-chain HTTP client settings");
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_SETTINGS_INVALID, message).with_context("field", field));

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/src/lib.rs
// version: 5
// version: 6
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -7,20 +7,21 @@
//! KSP-owned off-chain transport foundation.
//!
//! `0.2.11-pre.003` keeps the first capability family deliberately narrow (`market_price`, SOL/USD only) while staging crate-wide hardened HTTP REST
//! primitives and non-blocking local admission/cooldown machinery under `cfg(test)` until the first production adapter consumes them in `pre.004`.
//! These `http_*` internals are shared transport mechanics for future off-chain capability families such as `swap_quote_*`; they are intentionally not
//! re-exported as a generic consumer HTTP client. Provider wire DTOs, provider adapters, Config integration and refresh orchestration remain outside this
//! tranche.
//! `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.
mod constants;
mod error;
#[cfg(test)] // RUST-API-008: staged until the first production adapter consumes the HTTP admission path in pre.004.
mod http_admission;
#[cfg(test)] // RUST-API-008: staged until the first production adapter consumes the HTTP client path in pre.004.
mod http_client;
#[cfg(test)] // RUST-API-008: staged until the first production adapter consumes the HTTP settings path in pre.004.
mod http_settings;
mod market_price_adapter;
mod market_price_api_key;
mod market_price_coingecko;
mod market_price_coinmarketcap;
mod market_price_coinpaprika;
mod market_price_decimal;
mod market_price_observation;
mod market_price_provider;
@@ -28,6 +29,8 @@ mod market_price_settings;
/// Stable error code for HTTP access denial.
pub use self::error::ERROR_CODE_HTTP_ACCESS_DENIED;
/// Stable error code for local HTTP request deferral.
pub use self::error::ERROR_CODE_HTTP_ADMISSION_DEFERRED;
/// Stable error code for hardened HTTP client initialization failure.
pub use self::error::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED;
/// Stable error code for an off-chain HTTP connection failure.
@@ -56,10 +59,30 @@ pub use self::error::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID;
pub use self::error::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID;
/// Stable error code for an invalid provider descriptor.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID;
/// Stable error code returned when a disabled provider is invoked directly.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED;
/// Stable error code for an invalid provider identifier.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID;
/// Stable error code for a provider response that violates its adapter contract.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID;
/// Stable error code for invalid common provider settings.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID;
/// CoinGecko V1 access mode.
pub use self::market_price_coingecko::MarketPriceCoinGeckoAccessMode;
/// CoinGecko SOL/USD provider adapter.
pub use self::market_price_coingecko::MarketPriceCoinGeckoProvider;
/// CoinGecko runtime settings.
pub use self::market_price_coingecko::MarketPriceCoinGeckoSettings;
/// CoinMarketCap V1 access mode.
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapAccessMode;
/// CoinMarketCap SOL/USD provider adapter.
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapProvider;
/// CoinMarketCap runtime settings.
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapSettings;
/// CoinPaprika SOL/USD provider adapter.
pub use self::market_price_coinpaprika::MarketPriceCoinPaprikaProvider;
/// CoinPaprika runtime settings.
pub use self::market_price_coinpaprika::MarketPriceCoinPaprikaSettings;
/// Maximum accepted byte length for one textual decimal input.
pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES;
/// Maximum decimal scale retained by the canonical SOL/USD price representation.
@@ -110,26 +133,38 @@ pub use self::market_price_settings::MarketPriceProviderCommonSettings;
/// Owning tracing target for events emitted by Off-chain Transport.
pub(crate) use self::constants::TRACING_TARGET;
/// Maximum provider-directed cooldown accepted from a server `Retry-After` value.
#[cfg(test)]
pub(crate) use self::http_admission::HTTP_MAX_RETRY_AFTER;
/// Crate-internal non-blocking request-admission controller.
#[cfg(test)]
pub(crate) use self::http_admission::HttpAdmissionController;
/// Crate-internal result of one immediate request-admission attempt.
#[cfg(test)]
pub(crate) use self::http_admission::HttpAdmissionDecision;
/// Crate-internal provider-neutral local request-admission policy.
#[cfg(test)]
pub(crate) use self::http_admission::HttpAdmissionPolicy;
/// Crate-internal fixed-origin GET request with redacted diagnostics.
#[cfg(test)]
pub(crate) use self::http_client::HttpGetRequest;
/// Crate-internal bounded syntactically valid JSON response document.
#[cfg(test)]
pub(crate) use self::http_client::HttpJsonDocument;
/// Crate-internal hardened REST client shared by capability adapters.
#[cfg(test)]
pub(crate) use self::http_client::HttpRestClient;
/// Crate-internal bounded HTTP runtime settings.
#[cfg(test)]
pub(crate) use self::http_settings::HttpClientSettings;
/// Applies one market-price request admission decision.
pub(crate) use self::market_price_adapter::admit_request;
/// Captures the current market-price wall-clock timestamp.
pub(crate) use self::market_price_adapter::current_timestamp;
/// Executes one market-price HTTP GET with rate-limit feedback.
pub(crate) use self::market_price_adapter::get_json;
/// Builds one safe invalid-provider-response error.
pub(crate) use self::market_price_adapter::invalid_provider_response;
/// Builds one safe invalid-provider-response error with parser source.
pub(crate) use self::market_price_adapter::invalid_provider_response_with_source;
/// Parses one RFC 3339 provider market-price timestamp.
pub(crate) use self::market_price_adapter::market_price_timestamp_from_rfc3339;
/// Converts whole Unix seconds into a market-price timestamp.
pub(crate) use self::market_price_adapter::market_price_timestamp_from_unix_seconds;
/// Builds the disabled-provider error used by direct adapters.
pub(crate) use self::market_price_adapter::provider_disabled_error;
/// Builds common HTTP runtime primitives for one market-price provider.
pub(crate) use self::market_price_adapter::provider_http_runtime;
/// Crate-internal redacted holder for provider API keys.
pub(crate) use self::market_price_api_key::MarketPriceApiKey;

View File

@@ -0,0 +1,155 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
// version: 1
//! Shared market-price adapter mechanics layered over crate-wide HTTP primitives.
/// Applies one non-blocking market-price admission decision and maps deferral to a stable KSP error.
pub(crate) fn admit_request(provider: &'static str, admission: &crate::HttpAdmissionController) -> ksp_core_lib::Result<()> {
return match admission.try_admit() {
crate::HttpAdmissionDecision::Ready => std::result::Result::Ok(()),
crate::HttpAdmissionDecision::Deferred(delay) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_ADMISSION_DEFERRED, "Off-chain provider request is locally deferred")
.with_context("provider", provider)
.with_context("retry_after_millis", duration_millis_u64(delay).to_string()),
),
};
}
/// Captures the current UTC wall clock as a bounded market-price timestamp.
pub(crate) fn current_timestamp() -> ksp_core_lib::Result<crate::MarketPriceTimestamp> {
let duration = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID, "System clock cannot produce a market-price timestamp")
.with_source(error),
);
},
};
let millis = match u64::try_from(duration.as_millis()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID, "System clock exceeds market-price timestamp bounds")
.with_source(error),
);
},
};
return std::result::Result::Ok(crate::MarketPriceTimestamp::from_unix_millis(millis));
}
/// Executes one provider GET and feeds any HTTP 429 cooldown back into the provider admission controller.
pub(crate) async fn get_json(
http: &crate::HttpRestClient,
admission: &crate::HttpAdmissionController,
provider: &'static str,
request: crate::HttpGetRequest,
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
let result = http.get_json(provider, "sol_usd", request).await;
if let std::result::Result::Err(error) = &result
&& error.code() == crate::ERROR_CODE_HTTP_RATE_LIMITED
{
let retry_after = retry_after_from_error(error);
admission.record_rate_limited(retry_after);
}
return result;
}
/// Builds one safe provider-response contract error without copying remote payload data.
pub(crate) fn invalid_provider_response(provider: &'static str, field: &'static str) -> ksp_core_lib::Error {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, provider = provider, field = field, "rejected invalid market-price provider response");
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID, "Off-chain provider returned an invalid market-price response")
.with_context("provider", provider)
.with_context("field", field);
}
/// Builds one safe provider-response contract error and attaches a parser source that contains no remote payload copy.
pub(crate) fn invalid_provider_response_with_source<E>(provider: &'static str, field: &'static str, source: E) -> ksp_core_lib::Error
where
E: std::error::Error + std::marker::Send + std::marker::Sync + 'static,
{
return invalid_provider_response(provider, field).with_source(source);
}
/// Converts one provider RFC 3339 timestamp into the public millisecond timestamp contract.
pub(crate) fn market_price_timestamp_from_rfc3339(source: &str) -> std::option::Option<crate::MarketPriceTimestamp> {
let parsed = match chrono::DateTime::parse_from_rfc3339(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
let millis = parsed.timestamp_millis();
if millis < 0 {
return std::option::Option::None;
}
return match u64::try_from(millis) {
std::result::Result::Ok(value) => std::option::Option::Some(crate::MarketPriceTimestamp::from_unix_millis(value)),
std::result::Result::Err(_) => std::option::Option::None,
};
}
/// Converts whole Unix seconds into the public millisecond timestamp contract with overflow checking.
pub(crate) fn market_price_timestamp_from_unix_seconds(seconds: u64) -> std::option::Option<crate::MarketPriceTimestamp> {
return seconds.checked_mul(1_000).map(crate::MarketPriceTimestamp::from_unix_millis);
}
/// Builds the stable error returned when a disabled provider is invoked directly.
pub(crate) fn provider_disabled_error(provider: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED, "Off-chain market-price provider is disabled")
.with_context("provider", provider);
}
/// Builds hardened HTTP and admission runtime primitives from one provider-neutral rate-limit descriptor.
pub(crate) fn provider_http_runtime(
rate_limit: crate::MarketPriceProviderRateLimit,
) -> ksp_core_lib::Result<(crate::HttpRestClient, crate::HttpAdmissionController)> {
let policy = match rate_limit.kind() {
crate::MarketPriceProviderRateLimitKind::Dynamic => crate::HttpAdmissionPolicy::Dynamic,
crate::MarketPriceProviderRateLimitKind::Fixed => {
let requests = match rate_limit.requests() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(invalid_rate_limit_bridge()),
};
let window_seconds = match rate_limit.window_seconds() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(invalid_rate_limit_bridge()),
};
match crate::HttpAdmissionPolicy::fixed(requests, std::time::Duration::from_secs(u64::from(window_seconds)), rate_limit.burst()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
};
let admission = match crate::HttpAdmissionController::new(policy, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let http = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok((http, admission));
}
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
return match u64::try_from(duration.as_millis()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => u64::MAX,
};
}
fn invalid_rate_limit_bridge() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "Market-price rate-limit descriptor cannot map to HTTP admission policy");
}
fn retry_after_from_error(error: &ksp_core_lib::Error) -> std::option::Option<std::time::Duration> {
for context in error.context() {
if context.key() == "retry_after_seconds" {
let seconds = match context.value().parse::<u64>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
return std::option::Option::Some(std::time::Duration::from_secs(seconds));
}
}
return std::option::Option::None;
}

View File

@@ -0,0 +1,35 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_api_key.rs
// version: 1
//! Secret API-key holder shared by keyed market-price adapters.
const MARKET_PRICE_API_KEY_MAX_BYTES: usize = 512;
/// Redacted bounded API-key holder used by keyed market-price adapters.
pub(crate) struct MarketPriceApiKey(std::boxed::Box<str>);
impl crate::MarketPriceApiKey {
/// Creates one validated API-key holder without logging or exposing the credential.
pub(crate) fn new(provider: &'static str, value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
let value = value.into();
if value.is_empty() || value.len() > MARKET_PRICE_API_KEY_MAX_BYTES || value.trim() != value || value.chars().any(char::is_control) {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "Market-price provider API key is invalid")
.with_context("provider", provider)
.with_context("field", "api_key"),
);
}
return std::result::Result::Ok(Self(value.into_boxed_str()));
}
/// Returns the credential only to the provider request builder that owns the corresponding secret header.
pub(crate) fn as_str(&self) -> &str {
return self.0.as_ref();
}
}
impl std::fmt::Debug for crate::MarketPriceApiKey {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("MarketPriceApiKey(<redacted>)");
}
}

View File

@@ -0,0 +1,263 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
// version: 1
//! CoinGecko SOL/USD market-price adapter using the official REST API directly through `reqwest`.
const COINGECKO_DEMO_API_KEY_HEADER: &str = "x-cg-demo-api-key";
const COINGECKO_PROVIDER_ID: &str = "coingecko";
const COINGECKO_SIMPLE_PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price";
/// CoinGecko 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 MarketPriceCoinGeckoAccessMode {
/// Free Demo plan with a provider-issued API key and published allowance.
Demo,
/// Shared keyless public API with dynamic IP-based throttling.
Keyless,
}
/// Runtime settings for the CoinGecko market-price adapter.
pub struct MarketPriceCoinGeckoSettings {
access_mode: crate::MarketPriceCoinGeckoAccessMode,
api_key: std::option::Option<crate::MarketPriceApiKey>,
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceCoinGeckoSettings {
/// Creates keyless CoinGecko settings without accepting a credential.
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceCoinGeckoAccessMode::Keyless, std::option::Option::None);
}
/// Creates Demo CoinGecko settings. An API key is mandatory while the provider is enabled.
pub fn demo(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceCoinGeckoAccessMode::Demo, api_key);
}
/// Returns the configured CoinGecko access mode.
#[must_use]
pub const fn access_mode(&self) -> crate::MarketPriceCoinGeckoAccessMode {
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::MarketPriceCoinGeckoAccessMode, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(COINGECKO_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::MarketPriceCoinGeckoAccessMode::Keyless => {
if api_key.is_some() {
return std::result::Result::Err(provider_settings_error("api_key"));
}
std::option::Option::None
},
crate::MarketPriceCoinGeckoAccessMode::Demo => match api_key {
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(COINGECKO_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::MarketPriceCoinGeckoSettings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("MarketPriceCoinGeckoSettings")
.field("access_mode", &self.access_mode)
.field("api_key_present", &self.api_key.is_some())
.field("common", &self.common)
.finish();
}
}
/// CoinGecko SOL/USD provider adapter.
pub struct MarketPriceCoinGeckoProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceCoinGeckoSettings,
}
impl crate::MarketPriceCoinGeckoProvider {
/// Builds one CoinGecko provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceCoinGeckoSettings) -> ksp_core_lib::Result<Self> {
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 CoinGecko capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated CoinGecko runtime settings without exposing credential material.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceCoinGeckoSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD observation from CoinGecko.
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(COINGECKO_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(COINGECKO_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, COINGECKO_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::MarketPriceCoinGeckoSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
let mut request = match crate::HttpGetRequest::new_https(COINGECKO_SIMPLE_PRICE_URL) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("ids", "solana");
request.append_query_pair("vs_currencies", "usd");
request.append_query_pair("include_last_updated_at", "true");
if let std::option::Option::Some(api_key) = settings.api_key() {
if let std::result::Result::Err(error) = request.insert_sensitive_header(COINGECKO_DEMO_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::MarketPriceCoinGeckoAccessMode,
provider_id: crate::MarketPriceProviderId,
) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let (auth_mode, rate_limit, long_term_quota) = match access_mode {
crate::MarketPriceCoinGeckoAccessMode::Keyless => (
crate::MarketPriceProviderAuthMode::None,
crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip),
std::option::Option::None,
),
crate::MarketPriceCoinGeckoAccessMode::Demo => {
let rate_limit =
match crate::MarketPriceProviderRateLimit::fixed(100, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let quota = match crate::MarketPriceProviderLongTermQuota::new(
10_000,
crate::MarketPriceProviderQuotaPeriod::Month,
crate::MarketPriceProviderQuotaUnit::Credits,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
(crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit, std::option::Option::Some(quota))
},
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"CoinGecko",
crate::MarketPriceSemantics::AggregatedMarket,
auth_mode,
rate_limit,
long_term_quota,
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::<CoinGeckoWireResponse>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINGECKO_PROVIDER_ID, "response", error));
},
};
let price = match crate::MarketPriceDecimal::parse_json_raw(wire.solana.usd.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_unix_seconds(wire.solana.last_updated_at) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::invalid_provider_response(COINGECKO_PROVIDER_ID, "last_updated_at"));
},
};
let provenance = match crate::MarketPriceProvenance::new("coingecko:solana:usd") {
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::AggregatedMarket,
request_started_at,
received_at,
std::option::Option::Some(provider_timestamp),
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, "CoinGecko market-price settings are invalid")
.with_context("provider", COINGECKO_PROVIDER_ID)
.with_context("field", field);
}
#[derive(serde::Deserialize)]
struct CoinGeckoWireResponse {
solana: CoinGeckoWireSolana,
}
#[derive(serde::Deserialize)]
struct CoinGeckoWireSolana {
last_updated_at: u64,
usd: std::boxed::Box<serde_json::value::RawValue>,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_coingecko.rs"]
mod tests;

View File

@@ -0,0 +1,316 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs
// version: 1
//! CoinMarketCap SOL/USD market-price adapter using the current Simple Price V2 REST surface.
const COINMARKETCAP_API_KEY_HEADER: &str = "x-cmc_pro_api_key";
const COINMARKETCAP_BASIC_URL: &str = "https://pro-api.coinmarketcap.com/v2/simple/price";
const COINMARKETCAP_KEYLESS_URL: &str = "https://pro-api.coinmarketcap.com/public-api/v2/simple/price";
const COINMARKETCAP_PROVIDER_ID: &str = "coinmarketcap";
const COINMARKETCAP_SOL_ID: u64 = 5_426;
/// CoinMarketCap 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 MarketPriceCoinMarketCapAccessMode {
/// Free authenticated Basic plan with a provider-issued API key.
Basic,
/// Keyless public API intended for evaluation and low-volume use.
Keyless,
}
/// Runtime settings for the CoinMarketCap market-price adapter.
pub struct MarketPriceCoinMarketCapSettings {
access_mode: crate::MarketPriceCoinMarketCapAccessMode,
api_key: std::option::Option<crate::MarketPriceApiKey>,
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceCoinMarketCapSettings {
/// Creates keyless CoinMarketCap settings without accepting a credential.
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceCoinMarketCapAccessMode::Keyless, std::option::Option::None);
}
/// Creates Basic CoinMarketCap settings. An API key is mandatory while the provider is enabled.
pub fn basic(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceCoinMarketCapAccessMode::Basic, api_key);
}
/// Returns the configured CoinMarketCap access mode.
#[must_use]
pub const fn access_mode(&self) -> crate::MarketPriceCoinMarketCapAccessMode {
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::MarketPriceCoinMarketCapAccessMode,
api_key: std::option::Option<std::string::String>,
) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(COINMARKETCAP_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::MarketPriceCoinMarketCapAccessMode::Keyless => {
if api_key.is_some() {
return std::result::Result::Err(provider_settings_error("api_key"));
}
std::option::Option::None
},
crate::MarketPriceCoinMarketCapAccessMode::Basic => match api_key {
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(COINMARKETCAP_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::MarketPriceCoinMarketCapSettings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("MarketPriceCoinMarketCapSettings")
.field("access_mode", &self.access_mode)
.field("api_key_present", &self.api_key.is_some())
.field("common", &self.common)
.finish();
}
}
/// CoinMarketCap SOL/USD provider adapter.
pub struct MarketPriceCoinMarketCapProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceCoinMarketCapSettings,
}
impl crate::MarketPriceCoinMarketCapProvider {
/// Builds one CoinMarketCap provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceCoinMarketCapSettings) -> ksp_core_lib::Result<Self> {
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 CoinMarketCap capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated CoinMarketCap runtime settings without exposing credential material.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceCoinMarketCapSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD observation from CoinMarketCap.
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(COINMARKETCAP_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(COINMARKETCAP_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, COINMARKETCAP_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::MarketPriceCoinMarketCapSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
let url = match settings.access_mode() {
crate::MarketPriceCoinMarketCapAccessMode::Basic => COINMARKETCAP_BASIC_URL,
crate::MarketPriceCoinMarketCapAccessMode::Keyless => COINMARKETCAP_KEYLESS_URL,
};
let mut request = match crate::HttpGetRequest::new_https(url) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("ids", "5426");
request.append_query_pair("convert", "USD");
request.append_query_pair("include_last_updated", "true");
if let std::option::Option::Some(api_key) = settings.api_key() {
if let std::result::Result::Err(error) = request.insert_sensitive_header(COINMARKETCAP_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::MarketPriceCoinMarketCapAccessMode,
provider_id: crate::MarketPriceProviderId,
) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let (auth_mode, rate_limit, long_term_quota) = match access_mode {
crate::MarketPriceCoinMarketCapAccessMode::Keyless => (
crate::MarketPriceProviderAuthMode::None,
crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip),
std::option::Option::None,
),
crate::MarketPriceCoinMarketCapAccessMode::Basic => {
let rate_limit =
match crate::MarketPriceProviderRateLimit::fixed(50, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let quota = match crate::MarketPriceProviderLongTermQuota::new(
15_000,
crate::MarketPriceProviderQuotaPeriod::Month,
crate::MarketPriceProviderQuotaUnit::Credits,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
(crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit, std::option::Option::Some(quota))
},
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"CoinMarketCap",
crate::MarketPriceSemantics::AggregatedMarket,
auth_mode,
rate_limit,
long_term_quota,
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::<CoinMarketCapWireResponse>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINMARKETCAP_PROVIDER_ID, "response", error));
},
};
if !raw_status_is_zero(wire.status.error_code.as_ref()) {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "status.error_code"));
}
if wire.data.len() != 1 {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data"));
}
let item = &wire.data[0];
if item.id != COINMARKETCAP_SOL_ID || item.symbol != "SOL" {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.identity"));
}
let mut usd_quote = std::option::Option::None;
for quote in &item.quotes {
if quote.symbol == "USD" {
if usd_quote.is_some() {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes"));
}
usd_quote = std::option::Option::Some(quote);
}
}
let quote = match usd_quote {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes.USD"));
},
};
let price = match crate::MarketPriceDecimal::parse_json_raw(quote.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(quote.last_updated.as_str()) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes.last_updated"));
},
};
let provenance = match crate::MarketPriceProvenance::new("coinmarketcap:5426:usd:v2") {
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::AggregatedMarket,
request_started_at,
received_at,
std::option::Option::Some(provider_timestamp),
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, "CoinMarketCap market-price settings are invalid")
.with_context("provider", COINMARKETCAP_PROVIDER_ID)
.with_context("field", field);
}
fn raw_status_is_zero(raw: &serde_json::value::RawValue) -> bool {
return raw.get() == "0" || raw.get() == "\"0\"";
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireQuote {
last_updated: std::string::String,
price: std::boxed::Box<serde_json::value::RawValue>,
symbol: std::string::String,
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireItem {
id: u64,
quotes: std::vec::Vec<CoinMarketCapWireQuote>,
symbol: std::string::String,
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireResponse {
data: std::vec::Vec<CoinMarketCapWireItem>,
status: CoinMarketCapWireStatus,
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireStatus {
error_code: std::boxed::Box<serde_json::value::RawValue>,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_coinmarketcap.rs"]
mod tests;

View File

@@ -0,0 +1,187 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_coinpaprika.rs
// version: 1
//! CoinPaprika SOL/USD market-price adapter using the free official REST API directly through `reqwest`.
const COINPAPRIKA_PROVIDER_ID: &str = "coinpaprika";
const COINPAPRIKA_SOL_ID: &str = "sol-solana";
const COINPAPRIKA_SOL_TICKER_URL: &str = "https://api.coinpaprika.com/v1/tickers/sol-solana";
/// Runtime settings for the keyless CoinPaprika market-price adapter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MarketPriceCoinPaprikaSettings {
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceCoinPaprikaSettings {
/// Creates CoinPaprika settings for the free keyless REST surface.
pub fn new(enabled: bool) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(COINPAPRIKA_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;
}
}
/// CoinPaprika SOL/USD provider adapter.
pub struct MarketPriceCoinPaprikaProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceCoinPaprikaSettings,
}
impl crate::MarketPriceCoinPaprikaProvider {
/// Builds one CoinPaprika provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceCoinPaprikaSettings) -> 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 CoinPaprika capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated CoinPaprika runtime settings.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceCoinPaprikaSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD observation from CoinPaprika.
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(COINPAPRIKA_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(COINPAPRIKA_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, COINPAPRIKA_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(COINPAPRIKA_SOL_TICKER_URL) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("quotes", "USD");
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(10, 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),
};
let quota =
match crate::MarketPriceProviderLongTermQuota::new(20_000, crate::MarketPriceProviderQuotaPeriod::Month, crate::MarketPriceProviderQuotaUnit::Requests)
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"CoinPaprika",
crate::MarketPriceSemantics::AggregatedMarket,
crate::MarketPriceProviderAuthMode::None,
rate_limit,
std::option::Option::Some(quota),
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::<CoinPaprikaWireResponse>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINPAPRIKA_PROVIDER_ID, "response", error));
},
};
if wire.id != COINPAPRIKA_SOL_ID || wire.symbol != "SOL" {
return std::result::Result::Err(crate::invalid_provider_response(COINPAPRIKA_PROVIDER_ID, "identity"));
}
let price = match crate::MarketPriceDecimal::parse_json_raw(wire.quotes.usd.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.last_updated.as_str()) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::invalid_provider_response(COINPAPRIKA_PROVIDER_ID, "last_updated"));
},
};
let provenance = match crate::MarketPriceProvenance::new("coinpaprika:sol-solana:usd") {
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::AggregatedMarket,
request_started_at,
received_at,
std::option::Option::Some(provider_timestamp),
provenance,
);
}
#[derive(serde::Deserialize)]
struct CoinPaprikaWireQuote {
price: std::boxed::Box<serde_json::value::RawValue>,
}
#[derive(serde::Deserialize)]
struct CoinPaprikaWireQuotes {
#[serde(rename = "USD")]
usd: CoinPaprikaWireQuote,
}
#[derive(serde::Deserialize)]
struct CoinPaprikaWireResponse {
id: std::string::String,
last_updated: std::string::String,
quotes: CoinPaprikaWireQuotes,
symbol: std::string::String,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_coinpaprika.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
// version: 3
// version: 4
/// Maximum accepted UTF-8 byte length for one textual decimal input.
pub const MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES: usize = 96;
@@ -63,6 +63,19 @@ impl MarketPriceDecimal {
return std::result::Result::Ok(Self { coefficient, scale });
}
/// Parses one provider JSON number or string while preserving the original numeric lexeme.
pub(crate) fn parse_json_raw(raw: &serde_json::value::RawValue) -> ksp_core_lib::Result<Self> {
let source = raw.get();
if source.starts_with('"') {
let decoded = match serde_json::from_str::<std::string::String>(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
};
return crate::MarketPriceDecimal::parse(decoded.as_str());
}
return crate::MarketPriceDecimal::parse(source);
}
/// Returns the normalized integer coefficient.
#[must_use]
pub const fn coefficient(&self) -> u128 {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
// version: 4
// version: 5
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -8,45 +8,54 @@
//! Dependency, observability, hardened HTTP and module-taxonomy canaries for Off-chain Transport.
#[test]
fn pre_003_manifest_adds_only_shared_http_runtime_dependencies() {
fn pre_004_manifest_uses_only_generic_runtime_crates_and_no_provider_sdk() {
let manifest = include_str!("../Cargo.toml");
assert!(manifest.contains("chrono = { workspace = true, features = [\"std\"] }"));
assert!(manifest.contains("ksp-core-lib"));
assert!(manifest.contains("ksp-logging-lib"));
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
assert!(manifest.contains("serde"));
assert!(manifest.contains("serde_json.workspace = true"));
assert!(manifest.contains("serde_json = { workspace = true, features = [\"raw_value\"] }"));
assert!(!manifest.contains("ksp-config-lib"));
assert!(!manifest.contains("coingecko"));
assert!(!manifest.contains("coinmarketcap"));
assert!(!manifest.contains("coinpaprika"));
assert!(!manifest.contains("jupiter"));
assert!(!manifest.contains("birdeye"));
assert!(!manifest.contains("dexscreener"));
assert!(!manifest.lines().any(|line| return line.trim_start().starts_with("tracing =")));
}
#[test]
fn pre_004_http_runtime_is_production_active_but_not_a_public_generic_client() {
let crate_root = include_str!("../src/lib.rs");
assert!(crate_root.contains("mod http_admission;"));
assert!(crate_root.contains("mod http_client;"));
assert!(crate_root.contains("mod http_settings;"));
assert!(crate_root.contains("mod market_price_decimal;"));
assert!(crate_root.contains("mod market_price_observation;"));
assert!(crate_root.contains("mod market_price_provider;"));
assert!(crate_root.contains("mod market_price_settings;"));
assert!(!crate_root.contains("#[cfg(test)] // RUST-API-008: staged"));
assert!(!crate_root.contains("pub use self::http_client::HttpRestClient"));
let constants = include_str!("../src/constants.rs");
assert!(constants.contains("pub(crate) const TRACING_TARGET: &str = \"ksp-offchain-transport-lib\";"));
}
#[test]
fn pre_003_http_client_hardening_is_explicit_and_provider_neutral() {
let client = include_str!("../src/http_client.rs");
assert!(client.contains(".redirect(reqwest::redirect::Policy::none())"));
assert!(client.contains(".referer(false)"));
assert!(client.contains(".retry(reqwest::retry::never())"));
assert!(client.contains(".no_proxy()"));
assert!(client.contains("error.without_url()"));
assert!(client.contains("response.chunk().await"));
assert!(!client.contains("coingecko"));
assert!(!client.contains("coinmarketcap"));
assert!(!client.contains("jupiter"));
assert!(!client.contains("birdeye"));
assert!(!client.contains("dexscreener"));
}
#[test]
fn pre_004_provider_modules_are_market_price_scoped_and_fixed_origin() {
let crate_root = include_str!("../src/lib.rs");
assert!(crate_root.contains("mod market_price_coingecko;"));
assert!(crate_root.contains("mod market_price_coinmarketcap;"));
assert!(crate_root.contains("mod market_price_coinpaprika;"));
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");
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!(!coingecko.contains("std::env"));
assert!(!coinmarketcap.contains("std::env"));
assert!(!coinpaprika.contains("std::env"));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/tests/public_api.rs
// version: 4
// version: 5
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -65,10 +65,43 @@ fn public_pre_002_market_price_foundation_is_available_from_crate_root() -> ksp_
return std::result::Result::Ok(());
}
#[test]
fn public_pre_004_aggregator_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coingecko_provider = match ksp_offchain_transport_lib::MarketPriceCoinGeckoProvider::new(coingecko) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(coingecko_provider.descriptor().id().as_str(), "coingecko");
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap_provider = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapProvider::new(coinmarketcap) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(coinmarketcap_provider.descriptor().id().as_str(), "coinmarketcap");
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinpaprika_provider = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaProvider::new(coinpaprika) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(coinpaprika_provider.descriptor().id().as_str(), "coinpaprika");
return std::result::Result::Ok(());
}
#[test]
fn offchain_error_codes_use_owned_domain() {
let codes = [
ksp_offchain_transport_lib::ERROR_CODE_HTTP_ACCESS_DENIED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_ADMISSION_DEFERRED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CONNECTION_FAILED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_INVALID_JSON,
@@ -82,7 +115,9 @@ fn offchain_error_codes_use_owned_domain() {
ksp_offchain_transport_lib::ERROR_CODE_HTTP_TIMEOUT,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID,
];

View File

@@ -55,7 +55,6 @@ fn provider_retry_after_extends_but_cannot_pathologically_lock_cooldown() -> ksp
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(controller.policy(), crate::HttpAdmissionPolicy::Dynamic);
let applied = controller.record_rate_limited(std::option::Option::Some(std::time::Duration::from_secs(99_999)));
assert_eq!(applied, crate::HTTP_MAX_RETRY_AFTER);
let remaining = controller.cooldown_remaining();
@@ -69,5 +68,4 @@ fn admission_policy_rejects_zero_values_and_invalid_fallback() {
assert!(crate::HttpAdmissionPolicy::fixed(0, std::time::Duration::from_secs(1), std::option::Option::None).is_err());
assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::ZERO, std::option::Option::None).is_err());
assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::from_secs(1), std::option::Option::Some(0)).is_err());
assert!(crate::HttpAdmissionController::new(crate::HttpAdmissionPolicy::Unlimited, std::option::Option::Some(std::time::Duration::ZERO)).is_err());
}

View File

@@ -0,0 +1,71 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coingecko.rs
// version: 1
#[test]
fn coingecko_modes_map_exact_free_capabilities_and_redact_demo_key() -> ksp_core_lib::Result<()> {
let keyless = match crate::MarketPriceCoinGeckoSettings::keyless(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let keyless_provider = match crate::MarketPriceCoinGeckoProvider::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().rate_limit().kind(), crate::MarketPriceProviderRateLimitKind::Dynamic);
assert_eq!(keyless_provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
assert_eq!(keyless_provider.descriptor().long_term_quota(), std::option::Option::None);
let demo = match crate::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::Some("demo-secret-canary".to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!format!("{demo:?}").contains("demo-secret-canary"));
let request = match super::build_request(&demo) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(request.url_for_test().host_str(), std::option::Option::Some("api.coingecko.com"));
assert!(request.url_for_test().as_str().contains("ids=solana"));
assert!(request.url_for_test().as_str().contains("vs_currencies=usd"));
assert!(request.has_header_for_test(super::COINGECKO_DEMO_API_KEY_HEADER));
let demo_provider = match crate::MarketPriceCoinGeckoProvider::new(demo) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(demo_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::RequiredApiKey);
assert_eq!(demo_provider.descriptor().rate_limit().requests(), std::option::Option::Some(100));
assert_eq!(demo_provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(60));
let quota = match demo_provider.descriptor().long_term_quota() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response("coingecko", "quota")),
};
assert_eq!(quota.amount(), 10_000);
return std::result::Result::Ok(());
}
#[test]
fn coingecko_fixture_maps_exact_price_and_real_provider_timestamp() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("coingecko") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let start = crate::MarketPriceTimestamp::from_unix_millis(1_800_000_000_000);
let received = crate::MarketPriceTimestamp::from_unix_millis(1_800_000_000_100);
let observation = match super::parse_response(br#"{"solana":{"usd":151.123456789012345678,"last_updated_at":1800000000}}"#, provider_id, start, 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.123456789012345678");
assert_eq!(observation.provider_timestamp(), std::option::Option::Some(start));
assert_eq!(observation.provenance().as_str(), "coingecko:solana:usd");
return std::result::Result::Ok(());
}
#[test]
fn coingecko_keyless_rejects_credential_and_enabled_demo_requires_one() {
let keyless =
crate::MarketPriceCoinGeckoSettings::new(true, crate::MarketPriceCoinGeckoAccessMode::Keyless, std::option::Option::Some("unexpected".to_owned()));
assert!(keyless.is_err());
assert!(crate::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::None).is_err());
assert!(crate::MarketPriceCoinGeckoSettings::demo(false, std::option::Option::None).is_ok());
}

View File

@@ -0,0 +1,89 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coinmarketcap.rs
// version: 1
#[test]
fn coinmarketcap_modes_use_v2_and_map_exact_free_capabilities() -> ksp_core_lib::Result<()> {
let keyless = match crate::MarketPriceCoinMarketCapSettings::keyless(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match super::build_request(&keyless) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(request.url_for_test().path(), "/public-api/v2/simple/price");
assert!(!request.has_header_for_test(super::COINMARKETCAP_API_KEY_HEADER));
let keyless_provider = match crate::MarketPriceCoinMarketCapProvider::new(keyless) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(keyless_provider.descriptor().rate_limit().kind(), crate::MarketPriceProviderRateLimitKind::Dynamic);
let basic = match crate::MarketPriceCoinMarketCapSettings::basic(true, std::option::Option::Some("cmc-secret-canary".to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!format!("{basic:?}").contains("cmc-secret-canary"));
let request = match super::build_request(&basic) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(request.url_for_test().path(), "/v2/simple/price");
assert!(request.has_header_for_test(super::COINMARKETCAP_API_KEY_HEADER));
let basic_provider = match crate::MarketPriceCoinMarketCapProvider::new(basic) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(basic_provider.descriptor().rate_limit().requests(), std::option::Option::Some(50));
let quota = match basic_provider.descriptor().long_term_quota() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response("coinmarketcap", "quota")),
};
assert_eq!(quota.amount(), 15_000);
return std::result::Result::Ok(());
}
#[test]
fn coinmarketcap_v2_fixture_normalizes_string_status_and_exact_price() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("coinmarketcap") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let start = crate::MarketPriceTimestamp::from_unix_millis(1_775_000_000_000);
let received = crate::MarketPriceTimestamp::from_unix_millis(1_775_000_000_100);
let observation = match super::parse_response(
concat!(
r#"{"data":[{"id":5426,"symbol":"SOL","quotes":[{"symbol":"USD","price":151.987654321012345678,"#,
r#"last_updated":"2026-04-01T00:00:00.000Z"}]}],"status":{"error_code":"0"}}"#,
)
.as_bytes(),
provider_id,
start,
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.provenance().as_str(), "coinmarketcap:5426:usd:v2");
assert!(observation.provider_timestamp().is_some());
return std::result::Result::Ok(());
}
#[test]
fn coinmarketcap_rejects_nonzero_status_and_wrong_identity() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("coinmarketcap") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(1);
let nonzero = super::parse_response(br#"{"data":[],"status":{"error_code":1001}}"#, provider_id.clone(), timestamp, timestamp);
assert!(nonzero.is_err());
let wrong = super::parse_response(
br#"{"data":[{"id":1,"symbol":"BTC","quotes":[{"symbol":"USD","price":1,"last_updated":"2026-04-01T00:00:00Z"}]}],"status":{"error_code":0}}"#,
provider_id,
timestamp,
timestamp,
);
assert!(wrong.is_err());
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,60 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coinpaprika.rs
// version: 1
#[test]
fn coinpaprika_free_descriptor_maps_ip_rate_and_monthly_request_quota() -> ksp_core_lib::Result<()> {
let settings = match crate::MarketPriceCoinPaprikaSettings::new(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match super::build_request() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(request.url_for_test().host_str(), std::option::Option::Some("api.coinpaprika.com"));
assert_eq!(request.url_for_test().path(), "/v1/tickers/sol-solana");
assert!(request.url_for_test().as_str().contains("quotes=USD"));
let provider = match crate::MarketPriceCoinPaprikaProvider::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().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().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
let quota = match provider.descriptor().long_term_quota() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response("coinpaprika", "quota")),
};
assert_eq!(quota.amount(), 20_000);
assert_eq!(quota.unit(), crate::MarketPriceProviderQuotaUnit::Requests);
return std::result::Result::Ok(());
}
#[test]
fn coinpaprika_fixture_validates_sol_identity_and_preserves_timestamp() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("coinpaprika") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(1_775_000_000_000);
let observation = match super::parse_response(
br#"{"id":"sol-solana","symbol":"SOL","last_updated":"2026-04-01T00:00:00Z","quotes":{"USD":{"price":151.010203040506070809}}}"#,
provider_id.clone(),
timestamp,
timestamp,
) {
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.010203040506070809");
assert_eq!(observation.provenance().as_str(), "coinpaprika:sol-solana:usd");
let wrong = super::parse_response(
br#"{"id":"btc-bitcoin","symbol":"BTC","last_updated":"2026-04-01T00:00:00Z","quotes":{"USD":{"price":1}}}"#,
provider_id,
timestamp,
timestamp,
);
assert!(wrong.is_err());
return std::result::Result::Ok(());
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
// version: 3
// version: 4
#[test]
fn decimal_normalizes_fractional_and_scientific_forms_without_f64() -> ksp_core_lib::Result<()> {
@@ -57,3 +57,30 @@ fn decimal_serde_is_canonical_string_and_round_trips_exactly() -> ksp_core_lib::
assert!(serde_json::from_str::<crate::MarketPriceDecimal>("123.45").is_err());
return std::result::Result::Ok(());
}
#[test]
fn decimal_parses_raw_json_number_and_string_without_f64_round_trip() -> ksp_core_lib::Result<()> {
let number = match serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>("151.123456789012345678") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test raw number failed"));
},
};
let parsed = match crate::MarketPriceDecimal::parse_json_raw(number.as_ref()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(parsed.to_canonical_string(), "151.123456789012345678");
let string = match serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>(r#""151.2300""#) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test raw string failed"));
},
};
let parsed = match crate::MarketPriceDecimal::parse_json_raw(string.as_ref()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(parsed.to_canonical_string(), "151.23");
return std::result::Result::Ok(());
}

402
deltas/0.2.11/pre.004.md Normal file
View File

@@ -0,0 +1,402 @@
<!-- file: deltas/0.2.11/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.004` — CoinGecko, CoinMarketCap et CoinPaprika
## 1. Base requise
Cette tranche s'applique exclusivement après :
```text
v0.2.10
+ 0.2.11-pre.001
+ 0.2.11-pre.001-fix.001
+ 0.2.11-pre.002
+ 0.2.11-pre.002-fix.001
+ 0.2.11-pre.002-fix.002
+ 0.2.11-pre.002-fix.003
+ 0.2.11-pre.003
+ 0.2.11-pre.003-fix.001
```
La version Cargo attendue à l'entrée est :
```text
0.2.11-pre.3.fix.1
```
La version Cargo de sortie est :
```text
0.2.11-pre.4
```
## 2. Gate d'entrée et reprise volontaire de `RUST-FMT-108`
L'opérateur a validé `0.2.11-pre.003-fix.001` le `2026-08-25` avec :
```text
cargo fmt --all exécuté
python3 scripts/audit_rust_workspace_rules.py 1 violation RUST-FMT-108 uniquement
python3 scripts/audit_markdown_tables.py clean, 117 tables / 107 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test -p ksp-offchain-transport-lib PASS, 22 unit + 2 boundary + 2 public API
```
`RUST-FMT-108` provenait uniquement du staging `#[cfg(test)]` des modules `http_*` imposé en `pre.003-fix.001` tant qu'aucun consumer de production n'existait. L'opérateur a explicitement demandé de ne pas créer un fix artificiel supplémentaire puisque `pre.004` est précisément la tranche qui active ces modules avec leurs premiers consumers runtime.
`pre.004` retire donc le staging crate-root de :
```text
http_admission
http_client
http_settings
```
La cause structurelle de `RUST-FMT-108` disparaît sans exception ni `allow`.
## 3. Objectif
Implémenter les trois premiers providers SOL/USD de la famille `market_price_*` :
```text
CoinGecko
CoinMarketCap
CoinPaprika
```
Le contrat reste :
```text
SOL/USD uniquement
reqwest direct
aucun SDK provider
endpoints officiels fixes
DTOs wire privés
prix public exact, aucun canon f64
credentials absents des Debug/logs/errors
aucun Config dans Off-chain Transport
aucun registry global ni refresh multiple avant leurs tranches dédiées
```
## 4. Pont HTTP -> Market Price
`market_price_adapter.rs` matérialise uniquement les mécanismes réellement partagés par les adapters de prix :
```text
admission non bloquante
capture des timestamps KSP
GET JSON borné via le client HTTP commun
feedback 429 / Retry-After vers le cooldown local
conversion RFC3339 ou Unix seconds vers MarketPriceTimestamp
erreurs de schéma provider sûres sans copie du payload distant
construction du runtime HTTP + admission depuis le descriptor provider
```
Les primitives `http_*` restent crate-wide et crate-private afin d'être réutilisables plus tard par d'autres familles telles que `swap_quote_*`, sans devenir un client HTTP générique public pour les consumers.
## 5. Credential market-price partagé
`market_price_api_key.rs` ajoute un holder privé `MarketPriceApiKey` :
```text
longueur maximale 512 octets
valeur vide rejetée
whitespace externe rejeté
caractères de contrôle rejetés
Debug toujours redacted
aucun logging de la valeur
```
Ce holder ne lit aucun environnement. Config reste l'unique futur propriétaire de la résolution des secrets.
## 6. CoinGecko
Le provider public est :
```text
MarketPriceCoinGeckoProvider
MarketPriceCoinGeckoSettings
MarketPriceCoinGeckoAccessMode
```
Modes V1 :
```text
Keyless
Demo
```
La configuration respecte les capacités réelles :
```text
Keyless -> aucun credential accepté
Demo activé -> API key obligatoire
Demo désactivé -> credential optionnel pour permettre une config inactive
```
Le request contract est fixe :
```text
GET https://api.coingecko.com/api/v3/simple/price
ids=solana
vs_currencies=usd
include_last_updated_at=true
header x-cg-demo-api-key uniquement en mode Demo
```
Le descriptor conserve :
```text
Keyless -> auth none, rate limit dynamique scope IP
Demo -> API key requise, 100 requêtes / 60 s scope account, 10k credits / mois
semantics -> AggregatedMarket
```
La réponse exige `solana.usd` et `solana.last_updated_at`. Le prix est lu via `RawValue` puis `MarketPriceDecimal`; le timestamp provider Unix est conservé.
## 7. CoinMarketCap
Le provider public est :
```text
MarketPriceCoinMarketCapProvider
MarketPriceCoinMarketCapSettings
MarketPriceCoinMarketCapAccessMode
```
Modes V1 :
```text
Keyless
Basic
```
La configuration suit la même discipline : keyless refuse un credential et Basic activé exige une API key.
Le réaudit `pre.004` retient la surface **Simple Price V2 actuelle** et n'introduit pas la surface V1 deprecated.
Requests fixes :
```text
Keyless -> GET https://pro-api.coinmarketcap.com/public-api/v2/simple/price
Basic -> GET https://pro-api.coinmarketcap.com/v2/simple/price
ids=5426
convert=USD
include_last_updated=true
header x-cmc_pro_api_key uniquement en mode Basic
```
Le descriptor conserve :
```text
Keyless -> auth none, rate limit dynamique scope IP
Basic -> API key requise, 50 requêtes / 60 s scope account, 15k credits / mois
semantics -> AggregatedMarket
```
Le parser vérifie :
```text
status.error_code = 0
un seul item SOL id 5426
symbol = SOL
une seule quote USD
price exact via RawValue
last_updated RFC3339 valide
```
La provenance normalisée conserve explicitement la génération `v2`.
## 8. CoinPaprika
Le provider public est :
```text
MarketPriceCoinPaprikaProvider
MarketPriceCoinPaprikaSettings
```
Il est keyless uniquement : aucun faux champ credential n'est introduit.
Request fixe :
```text
GET https://api.coinpaprika.com/v1/tickers/sol-solana
quotes=USD
```
Le descriptor conserve :
```text
auth none
10 requêtes / seconde scope IP
20k requests / mois
semantics -> AggregatedMarket
```
Le parser vérifie `id = sol-solana`, `symbol = SOL`, lit `quotes.USD.price` exactement via `RawValue` et conserve `last_updated` RFC3339.
## 9. Numeric safety
`MarketPriceDecimal` gagne un parser crate-private de `serde_json::value::RawValue`.
Il accepte :
```text
nombre JSON exact
ou chaîne JSON contenant un décimal valide
```
Il ne passe jamais par `f64`. Les tests couvrent notamment une mantisse dont les derniers chiffres seraient perdus par une conversion flottante intermédiaire.
## 10. Availability et rate limiting dans cette tranche
Chaque provider possède son `HttpAdmissionController` local construit depuis son descriptor.
Le chemin runtime :
```text
fetch_sol_usd
-> enabled check
-> try_admit non bloquant
-> request timestamp
-> GET provider
-> si 429 : Retry-After enregistré dans le cooldown
-> receive timestamp
-> parser typed provider
-> MarketPriceObservation
```
Un provider localement non admissible retourne `http_admission_deferred` avec un délai safe ; aucun sleep n'est introduit dans l'adapter.
Le registry global et la projection générique d'availability restent réservés à `pre.007`, et l'orchestration multiple à `pre.008`.
## 11. Tests ajoutés ou étendus
Les tests déterministes couvrent :
```text
modes keyless/keyed et credentials requis/interdits
Debug settings sans secret
headers sensibles présents uniquement dans le bon mode
URLs et query fixes
capabilities descriptors et quotas
réponses SOL/USD valides
prix JSON exact sans f64
provider timestamps
identités provider incorrectes rejetées
schema/provider status incohérents rejetés
endpoint CMC V2 exact
absence de SDK provider
absence de Config et tracing direct
HTTP production actif mais non exporté publiquement
```
Aucun smoke réseau n'est ajouté dans cette tranche.
## 12. Fichiers ajoutés
```text
crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
crates/ksp-offchain-transport-lib/src/market_price_api_key.rs
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs
crates/ksp-offchain-transport-lib/src/market_price_coinpaprika.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_coingecko.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinmarketcap.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinpaprika.rs
deltas/0.2.11/pre.004.md
```
## 13. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/Cargo.toml
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/http_admission.rs
crates/ksp-offchain-transport-lib/src/http_client.rs
crates/ksp-offchain-transport-lib/src/http_settings.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 14. Fichiers supprimés
```text
aucun
```
## 15. Fichiers volontairement inchangés
```text
CHANGELOG.md
ROADMAP.md
README.md
.env.example
config/**
crates/ksp-config-lib/**
crates/ksp-onchain-transport-lib/**
prompts/**
```
`ROADMAP.md` et `CHANGELOG.md` restent hors de cette prerelease conformément à leur ownership de release.
## 16. Validations exécutées dans le sandbox
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
clean, 118 tables / 108 files
contrôle lignes Rust > 160 sur ksp-offchain-transport-lib
PASS, 0 ligne
```
Le sandbox de génération ne possède pas Cargo/Rust. `cargo fmt`, `cargo check`, `cargo clippy` et les tests Cargo ne sont donc pas déclarés exécutés ici.
## 17. Gate opérateur demandé
```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
```
Les smokes live restent hors de `pre.004`.
## 18. Scope non avancé
```text
Kraken
Coinbase Exchange
Jupiter
DexScreener
Birdeye
registry provider global
availability service global
refresh single/multiple générique
Config std.offchain_transport
.env.example secrets provider
ksp-app-solprices-desk
fallback automatique
consensus de prix
SOL/EUR
prix SPL générique
```

View File

@@ -1,9 +1,9 @@
<!-- file: docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md -->
<!-- version: 8 -->
<!-- version: 9 -->
# Plan `0.2.11` — Off-chain price transport SOL/USD multi-provider
**Statut courant : `0.2.11-pre.003-fix.001` corrige la conformité crate-root/ordre Rust de la fondation HTTP, autorise les bursts documentés supérieurs à la cadence moyenne et stage les helpers `http_*` sous `cfg(test)` conformément à `RUST-API-008` jusqu'à leur première consommation de production en `pre.004`. `market_price_*` reste la seule famille métier actuelle et demeure limitée à SOL/USD.**
**Statut courant : `0.2.11-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.**
## 1. Base et autorité
@@ -208,6 +208,8 @@ https://www.coingecko.com/en/api/pricing
https://coinmarketcap.com/api/documentation/pro-api-reference/keyless-public-api
https://coinmarketcap.com/api/documentation/pro-api-reference/cryptocurrency
https://coinmarketcap.com/api/documentation/pro-api-reference/cryptocurrency/simple-price-v2
https://coinmarketcap.com/api/documentation/pro-api-reference/cryptocurrency/simple-price-v1
https://coinmarketcap.com/api/pricing
https://docs.coinpaprika.com/api-reference/rest-api/introduction
@@ -237,6 +239,8 @@ https://docs.dexscreener.com/
Les chiffres externes sont une photographie d'audit, pas une constante éternelle. Les adapters doivent rester capables de classer `429` et les informations serveur sans supposer que la documentation ne changera jamais.
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`.
### 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.
@@ -698,15 +702,15 @@ Client HTTP REST commun crate-private, classification d'erreurs HTTP/reqwest, bo
#### `pre.003-fix.001` — Façade crate-root, staging et burst documenté
**Statut : réalisé, gate Cargo opérateur à rejouer.**
**Statut : réalisé ; gate Cargo/Clippy/tests PASS, `RUST-FMT-108` structurel repris par `pre.004`.**
Correction de l'ordre alphabétique des constantes d'erreur, utilisation systématique de la façade crate-root pour les items `http_*` partagés (`impl crate::...` et signatures crate-wide), suppression des `#[allow(dead_code)]` préparatoires au profit de `#[cfg(test)]` conformément à `RUST-API-008`, et correction du contrat de burst : un burst documenté peut dépasser la cadence moyenne de la fenêtre, comme le cas Coinbase `10 req/s, burst 15`. La version technique workspace devient `0.2.11-pre.3.fix.1`.
### `pre.004` — CoinGecko, CoinMarketCap et CoinPaprika
**Statut : planifié.**
**Statut : réalisé, gate Cargo opérateur à exécuter.**
Implémentation des trois adapters d'agrégateurs avec leurs DTOs wire privés et tests dédiés.
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.005` — Kraken et Coinbase Exchange

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md -->
<!-- version: 7 -->
<!-- version: 8 -->
# Validation `0.2.11` — Off-chain price transport SOL/USD
@@ -105,24 +105,25 @@ Cette correction reste dans la responsabilité de `pre.002` : elle stabilise la
## 3.4 Gate `0.2.11-pre.003`
| Critère | Statut | Preuve |
|-----------------------------------------------------------------------|---------|-----------------------------------------------------|
| version workspace `0.2.11-pre.3` | PASS | `Cargo.toml` racine |
| `reqwest` utilisé directement, sans SDK provider | PASS | manifest `ksp-offchain-transport-lib` |
| primitives HTTP communes sous modules `http_*` crate-private | PASS | `http_client`, `http_settings`, `http_admission` |
| client HTTP générique non exporté aux consumers | PASS | façade `lib.rs` |
| redirects, Referer, proxy système et retries reqwest désactivés | PASS | construction `HttpRestClient` |
| connect/request timeouts bornés | PASS | `HttpClientSettings` |
| body borné pendant lecture chunked avant désérialisation | PASS | `HttpRestClient::get_json` |
| JSON syntaxiquement validé sans normalisation numérique intermédiaire | PASS | `serde::de::IgnoredAny` sur bytes bruts |
| 401/403, 429, 408/5xx et autres statuts distingués | PASS | codes d'erreur HTTP dédiés |
| URL reqwest supprimée des sources d'erreur | PASS | `reqwest::Error::without_url` |
| body remote non recopié dans les erreurs | PASS | erreurs construites uniquement depuis metadata safe |
| limiter fixe lissé et burst absent traité conservativement | PASS | `HttpAdmissionController` |
| limite dynamique n'invente pas de cadence locale | PASS | `HttpAdmissionPolicy::Dynamic` |
| cooldown `Retry-After` borné et admission non bloquante | PASS | `record_rate_limited` + `try_admit` |
| adapters CoinGecko/Jupiter/etc. ajoutés | N/A | explicitement réservés à `pre.004+` |
| gate Cargo complet sur `pre.003-fix.001` | PENDING | à rejouer par l'opérateur après le correctif |
| Critère | Statut | Preuve |
|-----------------------------------------------------------------------|--------|-----------------------------------------------------|
| version workspace `0.2.11-pre.3` | PASS | `Cargo.toml` racine |
| `reqwest` utilisé directement, sans SDK provider | PASS | manifest `ksp-offchain-transport-lib` |
| primitives HTTP communes sous modules `http_*` crate-private | PASS | `http_client`, `http_settings`, `http_admission` |
| client HTTP générique non exporté aux consumers | PASS | façade `lib.rs` |
| redirects, Referer, proxy système et retries reqwest désactivés | PASS | construction `HttpRestClient` |
| connect/request timeouts bornés | PASS | `HttpClientSettings` |
| body borné pendant lecture chunked avant désérialisation | PASS | `HttpRestClient::get_json` |
| JSON syntaxiquement validé sans normalisation numérique intermédiaire | PASS | `serde::de::IgnoredAny` sur bytes bruts |
| 401/403, 429, 408/5xx et autres statuts distingués | PASS | codes d'erreur HTTP dédiés |
| URL reqwest supprimée des sources d'erreur | PASS | `reqwest::Error::without_url` |
| body remote non recopié dans les erreurs | PASS | erreurs construites uniquement depuis metadata safe |
| limiter fixe lissé et burst absent traité conservativement | PASS | `HttpAdmissionController` |
| limite dynamique n'invente pas de cadence locale | PASS | `HttpAdmissionPolicy::Dynamic` |
| cooldown `Retry-After` borné et admission non bloquante | PASS | `record_rate_limited` + `try_admit` |
| adapters CoinGecko/Jupiter/etc. ajoutés | N/A | explicitement réservés à `pre.004+` |
| gate Cargo `check`/`clippy`/tests Off-chain sur `pre.003-fix.001` | PASS | résultats opérateur du 2026-08-25 |
| audit Rust final de `pre.003-fix.001` | CARRY | seul `RUST-FMT-108`, cause supprimée en `pre.004` |
Les tests déterministes de `pre.003` couvrent en particulier le body chunked dépassant la limite, le refus des redirects, un `429` avec `Retry-After`, la non-propagation d'un body distant canari, le JSON invalide et le refill/cooldown du limiter.
@@ -141,15 +142,43 @@ contrat limiter : burst documenté indépendant de la cadence moyenne et autoris
Le cas `1 req/s` avec `burst 2` devient un canari déterministe ; il protège notamment la représentabilité du cas Coinbase documenté dans le plan à `10 req/s, burst 15`. Les modules `http_*` restent donc testés et prêts en `pre.003`, mais leur compilation normale sera activée en `pre.004` lorsque les premiers adapters de production les consommeront réellement.
La version Cargo du correctif est `0.2.11-pre.3.fix.1`. Les audits statiques du sandbox sont propres ; le gate Cargo complet doit être rejoué par l'opérateur.
La version Cargo du correctif est `0.2.11-pre.3.fix.1`. Le gate opérateur du `2026-08-25` confirme `cargo check --workspace` PASS, `cargo clippy --workspace --all-targets` PASS et `cargo test -p ksp-offchain-transport-lib` PASS avec 22 unitaires, 2 boundary et 2 public API. L'audit Rust signale uniquement `RUST-FMT-108` dans `src/lib.rs`, conséquence directe du staging `#[cfg(test)]` des modules `http_*`. L'opérateur a explicitement décidé de ne pas publier un fix artificiel pour ce point puisque `pre.004` retire ce staging avec les premiers consumers de production.
## 3.6 Gate `0.2.11-pre.004`
| Critère | Statut | Preuve |
|------------------------------------------------------------------------|---------|-----------------------------------------------------------|
| version workspace `0.2.11-pre.4` | PASS | `Cargo.toml` racine |
| modules `http_*` actifs en production, sans staging crate-root test | PASS | `src/lib.rs` |
| cause `RUST-FMT-108` de `pre.003-fix.001` supprimée | PASS | modules production avant tests |
| CoinGecko SOL/USD adapter public | PASS | `market_price_coingecko.rs` |
| CoinGecko keyless et Demo key distingués | PASS | settings + descriptor + header sensible |
| CoinMarketCap SOL/USD adapter public | PASS | `market_price_coinmarketcap.rs` |
| CoinMarketCap Simple Price V2 utilisé, V1 deprecated absent du runtime | PASS | endpoints V2 fixes + canari dependency boundary |
| CoinMarketCap keyless et Basic key distingués | PASS | settings + descriptor + header sensible |
| CoinPaprika SOL/USD adapter public | PASS | `market_price_coinpaprika.rs` |
| CoinPaprika sans faux credential | PASS | settings keyless uniquement |
| prix JSON provider parsé exactement sans passage par `f64` | PASS | `RawValue` -> `MarketPriceDecimal::parse_json_raw` |
| timestamps provider conservés lorsqu'ils existent | PASS | Unix seconds CoinGecko, RFC3339 CoinMarketCap/CoinPaprika |
| DTOs wire provider privés | PASS | structs privées dans chaque module provider |
| endpoints provider fixes et HTTPS | PASS | constantes privées + `HttpGetRequest::new_https` |
| API keys absentes de `Debug` et diagnostics | PASS | `MarketPriceApiKey` redacted + tests settings |
| `429` alimente le cooldown local partagé | PASS | `get_json` -> `record_rate_limited` |
| 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 | à recalculer après finalisation documentaire |
| gate Cargo complet `pre.004` | PENDING | opérateur |
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.
## 4. Matrice provider prévue
| Provider | SOL/USD V1 | Gratuit V1 | Mode auth prévu | Test déterministe | Smoke live | Statut courant |
|-------------------|------------|------------|----------------------|-------------------|------------|----------------|
| CoinGecko | Oui | Oui | keyless ou Demo key | PLANNED | PLANNED | AUDITED |
| CoinMarketCap | Oui | Oui | keyless ou Basic key | PLANNED | PLANNED | AUDITED |
| CoinPaprika | Oui | Oui | aucune | PLANNED | PLANNED | AUDITED |
| 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 |
| Jupiter Price V3 | Oui | Oui | keyless ou Free key | PLANNED | PLANNED | AUDITED |
@@ -185,8 +214,8 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d
| cadence locale fixe représentable | PASS |
| burst documenté supérieur à la cadence moyenne représentable | PASS |
| limite dynamique keyless représentable | PASS |
| `429` classé | PLANNED |
| `Retry-After` honoré lorsqu'exploitable | PLANNED |
| `429` classé | PASS |
| `Retry-After` honoré lorsqu'exploitable | PASS |
| cooldown expose prochain instant admissible | PLANNED |
| refresh all ne lance que les providers éligibles | PLANNED |
| provider en cooldown ne bloque pas les autres | PLANNED |
@@ -197,36 +226,36 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d
## 7. Numeric safety
| Cas | Statut |
|-----------------------------------------------------|---------|
| décimal string valide | PASS |
| JSON number valide sans passage canonique par `f64` | PLANNED |
| notation scientifique bornée | PASS |
| coefficient/scale overflow rejeté | PASS |
| valeur négative rejetée | PASS |
| zéro rejeté pour une observation réussie | PASS |
| NaN/Inf impossibles dans le type canonique | PASS |
| serialization canonique sans perte | PASS |
| round-trip public déterministe | PASS |
| Cas | Statut |
|-----------------------------------------------------|--------|
| décimal string valide | PASS |
| JSON number valide sans passage canonique par `f64` | PASS |
| notation scientifique bornée | PASS |
| coefficient/scale overflow rejeté | PASS |
| valeur négative rejetée | PASS |
| zéro rejeté pour une observation réussie | PASS |
| NaN/Inf impossibles dans le type canonique | PASS |
| serialization canonique sans perte | PASS |
| round-trip public déterministe | PASS |
## 8. HTTP et sécurité
| Invariant | Statut |
|------------------------------------------------|---------|
| `reqwest` seul client provider | PLANNED |
| aucun SDK provider | PLANNED |
| endpoint provider officiel fixe en V1 | PLANNED |
| redirects désactivés | PLANNED |
| proxy implicite désactivé selon policy KSP | PLANNED |
| connect timeout borné | PLANNED |
| request timeout borné | PLANNED |
| body réponse borné avant parsing | PLANNED |
| URL retirée des erreurs reqwest | PLANNED |
| réponse remote brute absente des KspError/logs | PLANNED |
| API key absente de Debug/log/error | PLANNED |
| 401/403 classés auth/access | PLANNED |
| 429 classé rate limit | PLANNED |
| 5xx classé transient | PLANNED |
| `reqwest` seul client provider | PASS |
| aucun SDK provider | PASS |
| endpoint provider officiel fixe en V1 | PASS |
| redirects désactivés | PASS |
| proxy implicite désactivé selon policy KSP | PASS |
| connect timeout borné | PASS |
| request timeout borné | PASS |
| body réponse borné avant parsing | PASS |
| URL retirée des erreurs reqwest | PASS |
| réponse remote brute absente des KspError/logs | PASS |
| API key absente de Debug/log/error | PASS |
| 401/403 classés auth/access | PASS |
| 429 classé rate limit | PASS |
| 5xx classé transient | PASS |
| schema drift classé provider protocol | PLANNED |
## 9. DexScreener V1