v0.2.11-pre.004
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 273
|
# version: 274
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
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"]
|
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]
|
[workspace.package]
|
||||||
version = "0.2.11-pre.3.fix.1"
|
version = "0.2.11-pre.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-offchain-transport-lib/Cargo.toml
|
# file: crates/ksp-offchain-transport-lib/Cargo.toml
|
||||||
# version: 3
|
# version: 4
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-offchain-transport-lib"
|
name = "ksp-offchain-transport-lib"
|
||||||
@@ -8,11 +8,12 @@ edition.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { workspace = true, features = ["std"] }
|
||||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
reqwest = { workspace = true, features = ["rustls"] }
|
reqwest = { workspace = true, features = ["rustls"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json = { workspace = true, features = ["raw_value"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "time"] }
|
tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "time"] }
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// file: crates/ksp-offchain-transport-lib/src/error.rs
|
// 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.
|
/// 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");
|
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.
|
/// 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");
|
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.
|
/// 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");
|
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.
|
/// Stable off-chain transport error for an invalid normalized market-price observation.
|
||||||
pub const ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID: ksp_core_lib::ErrorCode =
|
pub const ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID: ksp_core_lib::ErrorCode =
|
||||||
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_observation_invalid");
|
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.
|
/// 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 =
|
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");
|
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.
|
/// 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 =
|
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");
|
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_settings_invalid");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-offchain-transport-lib/src/http_admission.rs
|
// file: crates/ksp-offchain-transport-lib/src/http_admission.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
//! Provider-neutral local request admission and rate-limit cooldown primitives.
|
//! Provider-neutral local request admission and rate-limit cooldown primitives.
|
||||||
|
|
||||||
@@ -16,8 +16,6 @@ pub(crate) enum HttpAdmissionPolicy {
|
|||||||
Dynamic,
|
Dynamic,
|
||||||
/// Enforce a smooth token bucket for a documented request budget and window.
|
/// Enforce a smooth token bucket for a documented request budget and window.
|
||||||
Fixed { requests: u32, window: std::time::Duration, burst: u32 },
|
Fixed { requests: u32, window: std::time::Duration, burst: u32 },
|
||||||
/// No local cadence limit is configured; provider-driven cooldown still applies.
|
|
||||||
Unlimited,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::HttpAdmissionPolicy {
|
impl crate::HttpAdmissionPolicy {
|
||||||
@@ -49,7 +47,6 @@ pub(crate) enum HttpAdmissionDecision {
|
|||||||
pub(crate) struct HttpAdmissionController {
|
pub(crate) struct HttpAdmissionController {
|
||||||
cooldown_until: std::sync::Mutex<std::option::Option<std::time::Instant>>,
|
cooldown_until: std::sync::Mutex<std::option::Option<std::time::Instant>>,
|
||||||
fallback_cooldown: std::time::Duration,
|
fallback_cooldown: std::time::Duration,
|
||||||
policy: crate::HttpAdmissionPolicy,
|
|
||||||
token_bucket: std::sync::Mutex<std::option::Option<HttpTokenBucketState>>,
|
token_bucket: std::sync::Mutex<std::option::Option<HttpTokenBucketState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,22 +67,15 @@ impl crate::HttpAdmissionController {
|
|||||||
crate::HttpAdmissionPolicy::Fixed { requests, window, burst } => {
|
crate::HttpAdmissionPolicy::Fixed { requests, window, burst } => {
|
||||||
std::option::Option::Some(HttpTokenBucketState::new(requests, window, burst, std::time::Instant::now()))
|
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 {
|
return std::result::Result::Ok(Self {
|
||||||
cooldown_until: std::sync::Mutex::new(std::option::Option::None),
|
cooldown_until: std::sync::Mutex::new(std::option::Option::None),
|
||||||
fallback_cooldown,
|
fallback_cooldown,
|
||||||
policy,
|
|
||||||
token_bucket: std::sync::Mutex::new(token_bucket),
|
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.
|
/// Tries to admit one request immediately without sleeping.
|
||||||
pub(crate) fn try_admit(&self) -> crate::HttpAdmissionDecision {
|
pub(crate) fn try_admit(&self) -> crate::HttpAdmissionDecision {
|
||||||
return self.try_admit_at(std::time::Instant::now());
|
return self.try_admit_at(std::time::Instant::now());
|
||||||
@@ -108,7 +98,8 @@ impl crate::HttpAdmissionController {
|
|||||||
return effective;
|
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> {
|
pub(crate) fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
|
||||||
return self.cooldown_remaining_at(std::time::Instant::now());
|
return self.cooldown_remaining_at(std::time::Instant::now());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-offchain-transport-lib/src/http_client.rs
|
// 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.
|
//! Crate-wide hardened REST client used internally by off-chain capability adapters.
|
||||||
|
|
||||||
@@ -71,12 +71,24 @@ impl crate::HttpGetRequest {
|
|||||||
return std::result::Result::Ok(());
|
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.
|
/// Creates a plain-HTTP request for loopback-only deterministic unit tests.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn new_test_http(url: &str) -> ksp_core_lib::Result<Self> {
|
pub(crate) fn new_test_http(url: &str) -> ksp_core_lib::Result<Self> {
|
||||||
return Self::parse(url, true);
|
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> {
|
fn parse(url: &str, allow_http_for_tests: bool) -> ksp_core_lib::Result<Self> {
|
||||||
let parsed_result = reqwest::Url::parse(url);
|
let parsed_result = reqwest::Url::parse(url);
|
||||||
let parsed = match parsed_result {
|
let parsed = match parsed_result {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
// file: crates/ksp-offchain-transport-lib/src/http_settings.rs
|
// 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.
|
//! 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_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 1_048_576;
|
const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 1_048_576;
|
||||||
const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
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);
|
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);
|
const MAX_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
#[cfg(test)]
|
||||||
const MAX_RESPONSE_BODY_BYTES: usize = 4_194_304;
|
const MAX_RESPONSE_BODY_BYTES: usize = 4_194_304;
|
||||||
|
|
||||||
/// Crate-internal HTTP client settings with defensive hard bounds.
|
/// Crate-internal HTTP client settings with defensive hard bounds.
|
||||||
@@ -19,7 +22,8 @@ pub(crate) struct HttpClientSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl crate::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(
|
pub(crate) fn new(
|
||||||
connect_timeout: std::time::Duration,
|
connect_timeout: std::time::Duration,
|
||||||
request_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> {
|
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");
|
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));
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_SETTINGS_INVALID, message).with_context("field", field));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-offchain-transport-lib/src/lib.rs
|
// file: crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
// version: 5
|
// version: 6
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
@@ -7,20 +7,21 @@
|
|||||||
|
|
||||||
//! KSP-owned off-chain transport foundation.
|
//! 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
|
//! `0.2.11-pre.004` keeps the first capability family deliberately narrow (`market_price`, SOL/USD only), activates the crate-wide hardened `http_*`
|
||||||
//! primitives and non-blocking local admission/cooldown machinery under `cfg(test)` until the first production adapter consumes them in `pre.004`.
|
//! primitives in production, and introduces the first three direct-REST adapters: CoinGecko, CoinMarketCap and CoinPaprika. Provider wire DTOs remain
|
||||||
//! These `http_*` internals are shared transport mechanics for future off-chain capability families such as `swap_quote_*`; they are intentionally not
|
//! private, no provider SDK is used, and the `http_*` internals are not re-exported as a generic consumer HTTP client. Config integration and generic
|
||||||
//! re-exported as a generic consumer HTTP client. Provider wire DTOs, provider adapters, Config integration and refresh orchestration remain outside this
|
//! registry/refresh orchestration remain outside this tranche.
|
||||||
//! tranche.
|
|
||||||
|
|
||||||
mod constants;
|
mod constants;
|
||||||
mod error;
|
mod error;
|
||||||
#[cfg(test)] // RUST-API-008: staged until the first production adapter consumes the HTTP admission path in pre.004.
|
|
||||||
mod http_admission;
|
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;
|
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 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_decimal;
|
||||||
mod market_price_observation;
|
mod market_price_observation;
|
||||||
mod market_price_provider;
|
mod market_price_provider;
|
||||||
@@ -28,6 +29,8 @@ mod market_price_settings;
|
|||||||
|
|
||||||
/// Stable error code for HTTP access denial.
|
/// Stable error code for HTTP access denial.
|
||||||
pub use self::error::ERROR_CODE_HTTP_ACCESS_DENIED;
|
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.
|
/// Stable error code for hardened HTTP client initialization failure.
|
||||||
pub use self::error::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED;
|
pub use self::error::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED;
|
||||||
/// Stable error code for an off-chain HTTP connection failure.
|
/// 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;
|
pub use self::error::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID;
|
||||||
/// Stable error code for an invalid provider descriptor.
|
/// Stable error code for an invalid provider descriptor.
|
||||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID;
|
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.
|
/// Stable error code for an invalid provider identifier.
|
||||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID;
|
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.
|
/// Stable error code for invalid common provider settings.
|
||||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID;
|
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.
|
/// Maximum accepted byte length for one textual decimal input.
|
||||||
pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES;
|
pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES;
|
||||||
/// Maximum decimal scale retained by the canonical SOL/USD price representation.
|
/// 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.
|
/// Owning tracing target for events emitted by Off-chain Transport.
|
||||||
pub(crate) use self::constants::TRACING_TARGET;
|
pub(crate) use self::constants::TRACING_TARGET;
|
||||||
/// Maximum provider-directed cooldown accepted from a server `Retry-After` value.
|
/// Maximum provider-directed cooldown accepted from a server `Retry-After` value.
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use self::http_admission::HTTP_MAX_RETRY_AFTER;
|
pub(crate) use self::http_admission::HTTP_MAX_RETRY_AFTER;
|
||||||
/// Crate-internal non-blocking request-admission controller.
|
/// Crate-internal non-blocking request-admission controller.
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use self::http_admission::HttpAdmissionController;
|
pub(crate) use self::http_admission::HttpAdmissionController;
|
||||||
/// Crate-internal result of one immediate request-admission attempt.
|
/// Crate-internal result of one immediate request-admission attempt.
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use self::http_admission::HttpAdmissionDecision;
|
pub(crate) use self::http_admission::HttpAdmissionDecision;
|
||||||
/// Crate-internal provider-neutral local request-admission policy.
|
/// Crate-internal provider-neutral local request-admission policy.
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use self::http_admission::HttpAdmissionPolicy;
|
pub(crate) use self::http_admission::HttpAdmissionPolicy;
|
||||||
/// Crate-internal fixed-origin GET request with redacted diagnostics.
|
/// Crate-internal fixed-origin GET request with redacted diagnostics.
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use self::http_client::HttpGetRequest;
|
pub(crate) use self::http_client::HttpGetRequest;
|
||||||
/// Crate-internal bounded syntactically valid JSON response document.
|
/// Crate-internal bounded syntactically valid JSON response document.
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use self::http_client::HttpJsonDocument;
|
pub(crate) use self::http_client::HttpJsonDocument;
|
||||||
/// Crate-internal hardened REST client shared by capability adapters.
|
/// Crate-internal hardened REST client shared by capability adapters.
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use self::http_client::HttpRestClient;
|
pub(crate) use self::http_client::HttpRestClient;
|
||||||
/// Crate-internal bounded HTTP runtime settings.
|
/// Crate-internal bounded HTTP runtime settings.
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use self::http_settings::HttpClientSettings;
|
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;
|
||||||
|
|||||||
155
crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
Normal file
155
crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>)");
|
||||||
|
}
|
||||||
|
}
|
||||||
263
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
Normal file
263
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
|
// 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.
|
/// Maximum accepted UTF-8 byte length for one textual decimal input.
|
||||||
pub const MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES: usize = 96;
|
pub const MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES: usize = 96;
|
||||||
@@ -63,6 +63,19 @@ impl MarketPriceDecimal {
|
|||||||
return std::result::Result::Ok(Self { coefficient, scale });
|
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.
|
/// Returns the normalized integer coefficient.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn coefficient(&self) -> u128 {
|
pub const fn coefficient(&self) -> u128 {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
// file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
// version: 4
|
// version: 5
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
@@ -8,45 +8,54 @@
|
|||||||
//! Dependency, observability, hardened HTTP and module-taxonomy canaries for Off-chain Transport.
|
//! Dependency, observability, hardened HTTP and module-taxonomy canaries for Off-chain Transport.
|
||||||
|
|
||||||
#[test]
|
#[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");
|
let manifest = include_str!("../Cargo.toml");
|
||||||
|
assert!(manifest.contains("chrono = { workspace = true, features = [\"std\"] }"));
|
||||||
assert!(manifest.contains("ksp-core-lib"));
|
assert!(manifest.contains("ksp-core-lib"));
|
||||||
assert!(manifest.contains("ksp-logging-lib"));
|
assert!(manifest.contains("ksp-logging-lib"));
|
||||||
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||||
assert!(manifest.contains("serde"));
|
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("ksp-config-lib"));
|
||||||
assert!(!manifest.contains("coingecko"));
|
assert!(!manifest.contains("coingecko"));
|
||||||
assert!(!manifest.contains("coinmarketcap"));
|
assert!(!manifest.contains("coinmarketcap"));
|
||||||
|
assert!(!manifest.contains("coinpaprika"));
|
||||||
assert!(!manifest.contains("jupiter"));
|
assert!(!manifest.contains("jupiter"));
|
||||||
assert!(!manifest.contains("birdeye"));
|
assert!(!manifest.contains("birdeye"));
|
||||||
assert!(!manifest.contains("dexscreener"));
|
assert!(!manifest.contains("dexscreener"));
|
||||||
assert!(!manifest.lines().any(|line| return line.trim_start().starts_with("tracing =")));
|
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");
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
assert!(crate_root.contains("mod http_admission;"));
|
assert!(crate_root.contains("mod http_admission;"));
|
||||||
assert!(crate_root.contains("mod http_client;"));
|
assert!(crate_root.contains("mod http_client;"));
|
||||||
assert!(crate_root.contains("mod http_settings;"));
|
assert!(crate_root.contains("mod http_settings;"));
|
||||||
assert!(crate_root.contains("mod market_price_decimal;"));
|
assert!(!crate_root.contains("#[cfg(test)] // RUST-API-008: staged"));
|
||||||
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("pub use self::http_client::HttpRestClient"));
|
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");
|
let client = include_str!("../src/http_client.rs");
|
||||||
assert!(client.contains(".redirect(reqwest::redirect::Policy::none())"));
|
assert!(client.contains(".redirect(reqwest::redirect::Policy::none())"));
|
||||||
assert!(client.contains(".referer(false)"));
|
assert!(client.contains(".referer(false)"));
|
||||||
assert!(client.contains(".retry(reqwest::retry::never())"));
|
assert!(client.contains(".retry(reqwest::retry::never())"));
|
||||||
assert!(client.contains(".no_proxy()"));
|
assert!(client.contains(".no_proxy()"));
|
||||||
assert!(client.contains("error.without_url()"));
|
assert!(client.contains("error.without_url()"));
|
||||||
assert!(client.contains("response.chunk().await"));
|
}
|
||||||
assert!(!client.contains("coingecko"));
|
|
||||||
assert!(!client.contains("coinmarketcap"));
|
#[test]
|
||||||
assert!(!client.contains("jupiter"));
|
fn pre_004_provider_modules_are_market_price_scoped_and_fixed_origin() {
|
||||||
assert!(!client.contains("birdeye"));
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
assert!(!client.contains("dexscreener"));
|
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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-offchain-transport-lib/tests/public_api.rs
|
// file: crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
// version: 4
|
// version: 5
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![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(());
|
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]
|
#[test]
|
||||||
fn offchain_error_codes_use_owned_domain() {
|
fn offchain_error_codes_use_owned_domain() {
|
||||||
let codes = [
|
let codes = [
|
||||||
ksp_offchain_transport_lib::ERROR_CODE_HTTP_ACCESS_DENIED,
|
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_CLIENT_BUILD_FAILED,
|
||||||
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CONNECTION_FAILED,
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CONNECTION_FAILED,
|
||||||
ksp_offchain_transport_lib::ERROR_CODE_HTTP_INVALID_JSON,
|
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_HTTP_TIMEOUT,
|
||||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID,
|
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_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_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_OBSERVATION_INVALID,
|
||||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID,
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ fn provider_retry_after_extends_but_cannot_pathologically_lock_cooldown() -> ksp
|
|||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
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)));
|
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);
|
assert_eq!(applied, crate::HTTP_MAX_RETRY_AFTER);
|
||||||
let remaining = controller.cooldown_remaining();
|
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(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::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::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());
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
|
||||||
// version: 3
|
// version: 4
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decimal_normalizes_fractional_and_scientific_forms_without_f64() -> ksp_core_lib::Result<()> {
|
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());
|
assert!(serde_json::from_str::<crate::MarketPriceDecimal>("123.45").is_err());
|
||||||
return std::result::Result::Ok(());
|
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
402
deltas/0.2.11/pre.004.md
Normal 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
|
||||||
|
```
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- file: docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md -->
|
<!-- 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
|
# 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é
|
## 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/keyless-public-api
|
||||||
https://coinmarketcap.com/api/documentation/pro-api-reference/cryptocurrency
|
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://coinmarketcap.com/api/pricing
|
||||||
|
|
||||||
https://docs.coinpaprika.com/api-reference/rest-api/introduction
|
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.
|
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
|
### 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.
|
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é
|
#### `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`.
|
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
|
### `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
|
### `pre.005` — Kraken et Coinbase Exchange
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md -->
|
<!-- 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
|
# 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`
|
## 3.4 Gate `0.2.11-pre.003`
|
||||||
|
|
||||||
| Critère | Statut | Preuve |
|
| Critère | Statut | Preuve |
|
||||||
|-----------------------------------------------------------------------|---------|-----------------------------------------------------|
|
|-----------------------------------------------------------------------|--------|-----------------------------------------------------|
|
||||||
| version workspace `0.2.11-pre.3` | PASS | `Cargo.toml` racine |
|
| version workspace `0.2.11-pre.3` | PASS | `Cargo.toml` racine |
|
||||||
| `reqwest` utilisé directement, sans SDK provider | PASS | manifest `ksp-offchain-transport-lib` |
|
| `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` |
|
| 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` |
|
| 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` |
|
| redirects, Referer, proxy système et retries reqwest désactivés | PASS | construction `HttpRestClient` |
|
||||||
| connect/request timeouts bornés | PASS | `HttpClientSettings` |
|
| connect/request timeouts bornés | PASS | `HttpClientSettings` |
|
||||||
| body borné pendant lecture chunked avant désérialisation | PASS | `HttpRestClient::get_json` |
|
| 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 |
|
| 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 |
|
| 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` |
|
| 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 |
|
| body remote non recopié dans les erreurs | PASS | erreurs construites uniquement depuis metadata safe |
|
||||||
| limiter fixe lissé et burst absent traité conservativement | PASS | `HttpAdmissionController` |
|
| limiter fixe lissé et burst absent traité conservativement | PASS | `HttpAdmissionController` |
|
||||||
| limite dynamique n'invente pas de cadence locale | PASS | `HttpAdmissionPolicy::Dynamic` |
|
| 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` |
|
| 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+` |
|
| 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 |
|
| 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.
|
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.
|
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
|
## 4. Matrice provider prévue
|
||||||
|
|
||||||
| Provider | SOL/USD V1 | Gratuit V1 | Mode auth prévu | Test déterministe | Smoke live | Statut courant |
|
| 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 |
|
| CoinGecko | Oui | Oui | keyless ou Demo key | PASS | PLANNED | IMPLEMENTED |
|
||||||
| CoinMarketCap | Oui | Oui | keyless ou Basic key | PLANNED | PLANNED | AUDITED |
|
| CoinMarketCap | Oui | Oui | keyless ou Basic key | PASS | PLANNED | IMPLEMENTED |
|
||||||
| CoinPaprika | Oui | Oui | aucune | PLANNED | PLANNED | AUDITED |
|
| CoinPaprika | Oui | Oui | aucune | PASS | PLANNED | IMPLEMENTED |
|
||||||
| Kraken | Oui | Oui | aucune | PLANNED | PLANNED | AUDITED |
|
| Kraken | Oui | Oui | aucune | PLANNED | PLANNED | AUDITED |
|
||||||
| Coinbase Exchange | 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 |
|
| 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 |
|
| cadence locale fixe représentable | PASS |
|
||||||
| burst documenté supérieur à la cadence moyenne représentable | PASS |
|
| burst documenté supérieur à la cadence moyenne représentable | PASS |
|
||||||
| limite dynamique keyless représentable | PASS |
|
| limite dynamique keyless représentable | PASS |
|
||||||
| `429` classé | PLANNED |
|
| `429` classé | PASS |
|
||||||
| `Retry-After` honoré lorsqu'exploitable | PLANNED |
|
| `Retry-After` honoré lorsqu'exploitable | PASS |
|
||||||
| cooldown expose prochain instant admissible | PLANNED |
|
| cooldown expose prochain instant admissible | PLANNED |
|
||||||
| refresh all ne lance que les providers éligibles | PLANNED |
|
| refresh all ne lance que les providers éligibles | PLANNED |
|
||||||
| provider en cooldown ne bloque pas les autres | 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
|
## 7. Numeric safety
|
||||||
|
|
||||||
| Cas | Statut |
|
| Cas | Statut |
|
||||||
|-----------------------------------------------------|---------|
|
|-----------------------------------------------------|--------|
|
||||||
| décimal string valide | PASS |
|
| décimal string valide | PASS |
|
||||||
| JSON number valide sans passage canonique par `f64` | PLANNED |
|
| JSON number valide sans passage canonique par `f64` | PASS |
|
||||||
| notation scientifique bornée | PASS |
|
| notation scientifique bornée | PASS |
|
||||||
| coefficient/scale overflow rejeté | PASS |
|
| coefficient/scale overflow rejeté | PASS |
|
||||||
| valeur négative rejetée | PASS |
|
| valeur négative rejetée | PASS |
|
||||||
| zéro rejeté pour une observation réussie | PASS |
|
| zéro rejeté pour une observation réussie | PASS |
|
||||||
| NaN/Inf impossibles dans le type canonique | PASS |
|
| NaN/Inf impossibles dans le type canonique | PASS |
|
||||||
| serialization canonique sans perte | PASS |
|
| serialization canonique sans perte | PASS |
|
||||||
| round-trip public déterministe | PASS |
|
| round-trip public déterministe | PASS |
|
||||||
|
|
||||||
## 8. HTTP et sécurité
|
## 8. HTTP et sécurité
|
||||||
|
|
||||||
| Invariant | Statut |
|
| Invariant | Statut |
|
||||||
|------------------------------------------------|---------|
|
|------------------------------------------------|---------|
|
||||||
| `reqwest` seul client provider | PLANNED |
|
| `reqwest` seul client provider | PASS |
|
||||||
| aucun SDK provider | PLANNED |
|
| aucun SDK provider | PASS |
|
||||||
| endpoint provider officiel fixe en V1 | PLANNED |
|
| endpoint provider officiel fixe en V1 | PASS |
|
||||||
| redirects désactivés | PLANNED |
|
| redirects désactivés | PASS |
|
||||||
| proxy implicite désactivé selon policy KSP | PLANNED |
|
| proxy implicite désactivé selon policy KSP | PASS |
|
||||||
| connect timeout borné | PLANNED |
|
| connect timeout borné | PASS |
|
||||||
| request timeout borné | PLANNED |
|
| request timeout borné | PASS |
|
||||||
| body réponse borné avant parsing | PLANNED |
|
| body réponse borné avant parsing | PASS |
|
||||||
| URL retirée des erreurs reqwest | PLANNED |
|
| URL retirée des erreurs reqwest | PASS |
|
||||||
| réponse remote brute absente des KspError/logs | PLANNED |
|
| réponse remote brute absente des KspError/logs | PASS |
|
||||||
| API key absente de Debug/log/error | PLANNED |
|
| API key absente de Debug/log/error | PASS |
|
||||||
| 401/403 classés auth/access | PLANNED |
|
| 401/403 classés auth/access | PASS |
|
||||||
| 429 classé rate limit | PLANNED |
|
| 429 classé rate limit | PASS |
|
||||||
| 5xx classé transient | PLANNED |
|
| 5xx classé transient | PASS |
|
||||||
| schema drift classé provider protocol | PLANNED |
|
| schema drift classé provider protocol | PLANNED |
|
||||||
|
|
||||||
## 9. DexScreener V1
|
## 9. DexScreener V1
|
||||||
|
|||||||
Reference in New Issue
Block a user