v0.2.11-pre.004
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-offchain-transport-lib/Cargo.toml
|
||||
# version: 3
|
||||
# version: 4
|
||||
|
||||
[package]
|
||||
name = "ksp-offchain-transport-lib"
|
||||
@@ -8,11 +8,12 @@ edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true, features = ["std"] }
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
reqwest = { workspace = true, features = ["rustls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "time"] }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/error.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
/// Stable off-chain transport error when local provider admission defers a request.
|
||||
pub const ERROR_CODE_HTTP_ADMISSION_DEFERRED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_admission_deferred");
|
||||
/// Stable off-chain transport error for HTTP 401/403 access denial.
|
||||
pub const ERROR_CODE_HTTP_ACCESS_DENIED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_access_denied");
|
||||
/// Stable off-chain transport error when the hardened reqwest client cannot be initialized.
|
||||
@@ -27,6 +29,9 @@ pub const ERROR_CODE_HTTP_TEMPORARY_FAILURE: ksp_core_lib::ErrorCode = ksp_core_
|
||||
pub const ERROR_CODE_HTTP_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_timeout");
|
||||
/// Stable off-chain transport error for an invalid exact market-price decimal.
|
||||
pub const ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_decimal_invalid");
|
||||
/// Stable off-chain transport error when a disabled market-price provider is invoked directly.
|
||||
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_disabled");
|
||||
/// Stable off-chain transport error for an invalid normalized market-price observation.
|
||||
pub const ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_observation_invalid");
|
||||
@@ -36,6 +41,9 @@ pub const ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID: ksp_core_lib::Err
|
||||
/// Stable off-chain transport error for an invalid market-price provider identifier.
|
||||
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_id_invalid");
|
||||
/// Stable off-chain transport error when a provider response violates its adapter contract.
|
||||
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_response_invalid");
|
||||
/// Stable off-chain transport error for invalid common market-price provider settings.
|
||||
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_settings_invalid");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/http_admission.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Provider-neutral local request admission and rate-limit cooldown primitives.
|
||||
|
||||
@@ -16,8 +16,6 @@ pub(crate) enum HttpAdmissionPolicy {
|
||||
Dynamic,
|
||||
/// Enforce a smooth token bucket for a documented request budget and window.
|
||||
Fixed { requests: u32, window: std::time::Duration, burst: u32 },
|
||||
/// No local cadence limit is configured; provider-driven cooldown still applies.
|
||||
Unlimited,
|
||||
}
|
||||
|
||||
impl crate::HttpAdmissionPolicy {
|
||||
@@ -49,7 +47,6 @@ pub(crate) enum HttpAdmissionDecision {
|
||||
pub(crate) struct HttpAdmissionController {
|
||||
cooldown_until: std::sync::Mutex<std::option::Option<std::time::Instant>>,
|
||||
fallback_cooldown: std::time::Duration,
|
||||
policy: crate::HttpAdmissionPolicy,
|
||||
token_bucket: std::sync::Mutex<std::option::Option<HttpTokenBucketState>>,
|
||||
}
|
||||
|
||||
@@ -70,22 +67,15 @@ impl crate::HttpAdmissionController {
|
||||
crate::HttpAdmissionPolicy::Fixed { requests, window, burst } => {
|
||||
std::option::Option::Some(HttpTokenBucketState::new(requests, window, burst, std::time::Instant::now()))
|
||||
},
|
||||
crate::HttpAdmissionPolicy::Dynamic | crate::HttpAdmissionPolicy::Unlimited => std::option::Option::None,
|
||||
crate::HttpAdmissionPolicy::Dynamic => std::option::Option::None,
|
||||
};
|
||||
return std::result::Result::Ok(Self {
|
||||
cooldown_until: std::sync::Mutex::new(std::option::Option::None),
|
||||
fallback_cooldown,
|
||||
policy,
|
||||
token_bucket: std::sync::Mutex::new(token_bucket),
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the configured local policy.
|
||||
#[must_use]
|
||||
pub(crate) const fn policy(&self) -> crate::HttpAdmissionPolicy {
|
||||
return self.policy;
|
||||
}
|
||||
|
||||
/// Tries to admit one request immediately without sleeping.
|
||||
pub(crate) fn try_admit(&self) -> crate::HttpAdmissionDecision {
|
||||
return self.try_admit_at(std::time::Instant::now());
|
||||
@@ -108,7 +98,8 @@ impl crate::HttpAdmissionController {
|
||||
return effective;
|
||||
}
|
||||
|
||||
/// Returns the remaining provider cooldown, if any.
|
||||
/// Returns the remaining provider cooldown for deterministic limiter tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
|
||||
return self.cooldown_remaining_at(std::time::Instant::now());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/http_client.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Crate-wide hardened REST client used internally by off-chain capability adapters.
|
||||
|
||||
@@ -71,12 +71,24 @@ impl crate::HttpGetRequest {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Reports whether a named header is present without exposing its value in tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_header_for_test(&self, name: &'static str) -> bool {
|
||||
return self.headers.contains_key(name);
|
||||
}
|
||||
|
||||
/// Creates a plain-HTTP request for loopback-only deterministic unit tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_test_http(url: &str) -> ksp_core_lib::Result<Self> {
|
||||
return Self::parse(url, true);
|
||||
}
|
||||
|
||||
/// Returns the constructed URL only to deterministic in-crate tests; production diagnostics remain redacted.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn url_for_test(&self) -> &reqwest::Url {
|
||||
return &self.url;
|
||||
}
|
||||
|
||||
fn parse(url: &str, allow_http_for_tests: bool) -> ksp_core_lib::Result<Self> {
|
||||
let parsed_result = reqwest::Url::parse(url);
|
||||
let parsed = match parsed_result {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/http_settings.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Crate-wide bounded HTTP runtime settings shared by off-chain capability families.
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 1_048_576;
|
||||
const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
#[cfg(test)]
|
||||
const MAX_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
#[cfg(test)]
|
||||
const MAX_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
#[cfg(test)]
|
||||
const MAX_RESPONSE_BODY_BYTES: usize = 4_194_304;
|
||||
|
||||
/// Crate-internal HTTP client settings with defensive hard bounds.
|
||||
@@ -19,7 +22,8 @@ pub(crate) struct HttpClientSettings {
|
||||
}
|
||||
|
||||
impl crate::HttpClientSettings {
|
||||
/// Creates one validated HTTP settings value.
|
||||
/// Creates one validated HTTP settings value for deterministic settings tests until runtime configuration consumes this constructor.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(
|
||||
connect_timeout: std::time::Duration,
|
||||
request_timeout: std::time::Duration,
|
||||
@@ -69,6 +73,7 @@ impl std::default::Default for crate::HttpClientSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn invalid_http_settings(message: &str, field: &'static str) -> ksp_core_lib::Result<crate::HttpClientSettings> {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, "rejected invalid off-chain HTTP client settings");
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_SETTINGS_INVALID, message).with_context("field", field));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/lib.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -7,20 +7,21 @@
|
||||
|
||||
//! KSP-owned off-chain transport foundation.
|
||||
//!
|
||||
//! `0.2.11-pre.003` keeps the first capability family deliberately narrow (`market_price`, SOL/USD only) while staging crate-wide hardened HTTP REST
|
||||
//! primitives and non-blocking local admission/cooldown machinery under `cfg(test)` until the first production adapter consumes them in `pre.004`.
|
||||
//! These `http_*` internals are shared transport mechanics for future off-chain capability families such as `swap_quote_*`; they are intentionally not
|
||||
//! re-exported as a generic consumer HTTP client. Provider wire DTOs, provider adapters, Config integration and refresh orchestration remain outside this
|
||||
//! tranche.
|
||||
//! `0.2.11-pre.004` keeps the first capability family deliberately narrow (`market_price`, SOL/USD only), activates the crate-wide hardened `http_*`
|
||||
//! primitives in production, and introduces the first three direct-REST adapters: CoinGecko, CoinMarketCap and CoinPaprika. Provider wire DTOs remain
|
||||
//! private, no provider SDK is used, and the `http_*` internals are not re-exported as a generic consumer HTTP client. Config integration and generic
|
||||
//! registry/refresh orchestration remain outside this tranche.
|
||||
|
||||
mod constants;
|
||||
mod error;
|
||||
#[cfg(test)] // RUST-API-008: staged until the first production adapter consumes the HTTP admission path in pre.004.
|
||||
mod http_admission;
|
||||
#[cfg(test)] // RUST-API-008: staged until the first production adapter consumes the HTTP client path in pre.004.
|
||||
mod http_client;
|
||||
#[cfg(test)] // RUST-API-008: staged until the first production adapter consumes the HTTP settings path in pre.004.
|
||||
mod http_settings;
|
||||
mod market_price_adapter;
|
||||
mod market_price_api_key;
|
||||
mod market_price_coingecko;
|
||||
mod market_price_coinmarketcap;
|
||||
mod market_price_coinpaprika;
|
||||
mod market_price_decimal;
|
||||
mod market_price_observation;
|
||||
mod market_price_provider;
|
||||
@@ -28,6 +29,8 @@ mod market_price_settings;
|
||||
|
||||
/// Stable error code for HTTP access denial.
|
||||
pub use self::error::ERROR_CODE_HTTP_ACCESS_DENIED;
|
||||
/// Stable error code for local HTTP request deferral.
|
||||
pub use self::error::ERROR_CODE_HTTP_ADMISSION_DEFERRED;
|
||||
/// Stable error code for hardened HTTP client initialization failure.
|
||||
pub use self::error::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED;
|
||||
/// Stable error code for an off-chain HTTP connection failure.
|
||||
@@ -56,10 +59,30 @@ pub use self::error::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID;
|
||||
pub use self::error::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID;
|
||||
/// Stable error code for an invalid provider descriptor.
|
||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID;
|
||||
/// Stable error code returned when a disabled provider is invoked directly.
|
||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED;
|
||||
/// Stable error code for an invalid provider identifier.
|
||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID;
|
||||
/// Stable error code for a provider response that violates its adapter contract.
|
||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID;
|
||||
/// Stable error code for invalid common provider settings.
|
||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID;
|
||||
/// CoinGecko V1 access mode.
|
||||
pub use self::market_price_coingecko::MarketPriceCoinGeckoAccessMode;
|
||||
/// CoinGecko SOL/USD provider adapter.
|
||||
pub use self::market_price_coingecko::MarketPriceCoinGeckoProvider;
|
||||
/// CoinGecko runtime settings.
|
||||
pub use self::market_price_coingecko::MarketPriceCoinGeckoSettings;
|
||||
/// CoinMarketCap V1 access mode.
|
||||
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapAccessMode;
|
||||
/// CoinMarketCap SOL/USD provider adapter.
|
||||
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapProvider;
|
||||
/// CoinMarketCap runtime settings.
|
||||
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapSettings;
|
||||
/// CoinPaprika SOL/USD provider adapter.
|
||||
pub use self::market_price_coinpaprika::MarketPriceCoinPaprikaProvider;
|
||||
/// CoinPaprika runtime settings.
|
||||
pub use self::market_price_coinpaprika::MarketPriceCoinPaprikaSettings;
|
||||
/// Maximum accepted byte length for one textual decimal input.
|
||||
pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES;
|
||||
/// Maximum decimal scale retained by the canonical SOL/USD price representation.
|
||||
@@ -110,26 +133,38 @@ pub use self::market_price_settings::MarketPriceProviderCommonSettings;
|
||||
/// Owning tracing target for events emitted by Off-chain Transport.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Maximum provider-directed cooldown accepted from a server `Retry-After` value.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::http_admission::HTTP_MAX_RETRY_AFTER;
|
||||
/// Crate-internal non-blocking request-admission controller.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::http_admission::HttpAdmissionController;
|
||||
/// Crate-internal result of one immediate request-admission attempt.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::http_admission::HttpAdmissionDecision;
|
||||
/// Crate-internal provider-neutral local request-admission policy.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::http_admission::HttpAdmissionPolicy;
|
||||
/// Crate-internal fixed-origin GET request with redacted diagnostics.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::http_client::HttpGetRequest;
|
||||
/// Crate-internal bounded syntactically valid JSON response document.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::http_client::HttpJsonDocument;
|
||||
/// Crate-internal hardened REST client shared by capability adapters.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::http_client::HttpRestClient;
|
||||
/// Crate-internal bounded HTTP runtime settings.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::http_settings::HttpClientSettings;
|
||||
/// Applies one market-price request admission decision.
|
||||
pub(crate) use self::market_price_adapter::admit_request;
|
||||
/// Captures the current market-price wall-clock timestamp.
|
||||
pub(crate) use self::market_price_adapter::current_timestamp;
|
||||
/// Executes one market-price HTTP GET with rate-limit feedback.
|
||||
pub(crate) use self::market_price_adapter::get_json;
|
||||
/// Builds one safe invalid-provider-response error.
|
||||
pub(crate) use self::market_price_adapter::invalid_provider_response;
|
||||
/// Builds one safe invalid-provider-response error with parser source.
|
||||
pub(crate) use self::market_price_adapter::invalid_provider_response_with_source;
|
||||
/// Parses one RFC 3339 provider market-price timestamp.
|
||||
pub(crate) use self::market_price_adapter::market_price_timestamp_from_rfc3339;
|
||||
/// Converts whole Unix seconds into a market-price timestamp.
|
||||
pub(crate) use self::market_price_adapter::market_price_timestamp_from_unix_seconds;
|
||||
/// Builds the disabled-provider error used by direct adapters.
|
||||
pub(crate) use self::market_price_adapter::provider_disabled_error;
|
||||
/// Builds common HTTP runtime primitives for one market-price provider.
|
||||
pub(crate) use self::market_price_adapter::provider_http_runtime;
|
||||
/// Crate-internal redacted holder for provider API keys.
|
||||
pub(crate) use self::market_price_api_key::MarketPriceApiKey;
|
||||
|
||||
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
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Maximum accepted UTF-8 byte length for one textual decimal input.
|
||||
pub const MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES: usize = 96;
|
||||
@@ -63,6 +63,19 @@ impl MarketPriceDecimal {
|
||||
return std::result::Result::Ok(Self { coefficient, scale });
|
||||
}
|
||||
|
||||
/// Parses one provider JSON number or string while preserving the original numeric lexeme.
|
||||
pub(crate) fn parse_json_raw(raw: &serde_json::value::RawValue) -> ksp_core_lib::Result<Self> {
|
||||
let source = raw.get();
|
||||
if source.starts_with('"') {
|
||||
let decoded = match serde_json::from_str::<std::string::String>(source) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
|
||||
};
|
||||
return crate::MarketPriceDecimal::parse(decoded.as_str());
|
||||
}
|
||||
return crate::MarketPriceDecimal::parse(source);
|
||||
}
|
||||
|
||||
/// Returns the normalized integer coefficient.
|
||||
#[must_use]
|
||||
pub const fn coefficient(&self) -> u128 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -8,45 +8,54 @@
|
||||
//! Dependency, observability, hardened HTTP and module-taxonomy canaries for Off-chain Transport.
|
||||
|
||||
#[test]
|
||||
fn pre_003_manifest_adds_only_shared_http_runtime_dependencies() {
|
||||
fn pre_004_manifest_uses_only_generic_runtime_crates_and_no_provider_sdk() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
assert!(manifest.contains("chrono = { workspace = true, features = [\"std\"] }"));
|
||||
assert!(manifest.contains("ksp-core-lib"));
|
||||
assert!(manifest.contains("ksp-logging-lib"));
|
||||
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||
assert!(manifest.contains("serde"));
|
||||
assert!(manifest.contains("serde_json.workspace = true"));
|
||||
assert!(manifest.contains("serde_json = { workspace = true, features = [\"raw_value\"] }"));
|
||||
assert!(!manifest.contains("ksp-config-lib"));
|
||||
assert!(!manifest.contains("coingecko"));
|
||||
assert!(!manifest.contains("coinmarketcap"));
|
||||
assert!(!manifest.contains("coinpaprika"));
|
||||
assert!(!manifest.contains("jupiter"));
|
||||
assert!(!manifest.contains("birdeye"));
|
||||
assert!(!manifest.contains("dexscreener"));
|
||||
assert!(!manifest.lines().any(|line| return line.trim_start().starts_with("tracing =")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_http_runtime_is_production_active_but_not_a_public_generic_client() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(crate_root.contains("mod http_admission;"));
|
||||
assert!(crate_root.contains("mod http_client;"));
|
||||
assert!(crate_root.contains("mod http_settings;"));
|
||||
assert!(crate_root.contains("mod market_price_decimal;"));
|
||||
assert!(crate_root.contains("mod market_price_observation;"));
|
||||
assert!(crate_root.contains("mod market_price_provider;"));
|
||||
assert!(crate_root.contains("mod market_price_settings;"));
|
||||
assert!(!crate_root.contains("#[cfg(test)] // RUST-API-008: staged"));
|
||||
assert!(!crate_root.contains("pub use self::http_client::HttpRestClient"));
|
||||
let constants = include_str!("../src/constants.rs");
|
||||
assert!(constants.contains("pub(crate) const TRACING_TARGET: &str = \"ksp-offchain-transport-lib\";"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_http_client_hardening_is_explicit_and_provider_neutral() {
|
||||
let client = include_str!("../src/http_client.rs");
|
||||
assert!(client.contains(".redirect(reqwest::redirect::Policy::none())"));
|
||||
assert!(client.contains(".referer(false)"));
|
||||
assert!(client.contains(".retry(reqwest::retry::never())"));
|
||||
assert!(client.contains(".no_proxy()"));
|
||||
assert!(client.contains("error.without_url()"));
|
||||
assert!(client.contains("response.chunk().await"));
|
||||
assert!(!client.contains("coingecko"));
|
||||
assert!(!client.contains("coinmarketcap"));
|
||||
assert!(!client.contains("jupiter"));
|
||||
assert!(!client.contains("birdeye"));
|
||||
assert!(!client.contains("dexscreener"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_provider_modules_are_market_price_scoped_and_fixed_origin() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(crate_root.contains("mod market_price_coingecko;"));
|
||||
assert!(crate_root.contains("mod market_price_coinmarketcap;"));
|
||||
assert!(crate_root.contains("mod market_price_coinpaprika;"));
|
||||
let coingecko = include_str!("../src/market_price_coingecko.rs");
|
||||
let coinmarketcap = include_str!("../src/market_price_coinmarketcap.rs");
|
||||
let coinpaprika = include_str!("../src/market_price_coinpaprika.rs");
|
||||
assert!(coingecko.contains("https://api.coingecko.com/api/v3/simple/price"));
|
||||
assert!(coinmarketcap.contains("https://pro-api.coinmarketcap.com/public-api/v2/simple/price"));
|
||||
assert!(coinmarketcap.contains("https://pro-api.coinmarketcap.com/v2/simple/price"));
|
||||
assert!(coinpaprika.contains("https://api.coinpaprika.com/v1/tickers/sol-solana"));
|
||||
assert!(!coingecko.contains("std::env"));
|
||||
assert!(!coinmarketcap.contains("std::env"));
|
||||
assert!(!coinpaprika.contains("std::env"));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -65,10 +65,43 @@ fn public_pre_002_market_price_foundation_is_available_from_crate_root() -> ksp_
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_004_aggregator_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
|
||||
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coingecko_provider = match ksp_offchain_transport_lib::MarketPriceCoinGeckoProvider::new(coingecko) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(coingecko_provider.descriptor().id().as_str(), "coingecko");
|
||||
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinmarketcap_provider = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapProvider::new(coinmarketcap) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(coinmarketcap_provider.descriptor().id().as_str(), "coinmarketcap");
|
||||
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinpaprika_provider = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaProvider::new(coinpaprika) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(coinpaprika_provider.descriptor().id().as_str(), "coinpaprika");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offchain_error_codes_use_owned_domain() {
|
||||
let codes = [
|
||||
ksp_offchain_transport_lib::ERROR_CODE_HTTP_ACCESS_DENIED,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_HTTP_ADMISSION_DEFERRED,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CONNECTION_FAILED,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_HTTP_INVALID_JSON,
|
||||
@@ -82,7 +115,9 @@ fn offchain_error_codes_use_owned_domain() {
|
||||
ksp_offchain_transport_lib::ERROR_CODE_HTTP_TIMEOUT,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID,
|
||||
];
|
||||
|
||||
@@ -55,7 +55,6 @@ fn provider_retry_after_extends_but_cannot_pathologically_lock_cooldown() -> ksp
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(controller.policy(), crate::HttpAdmissionPolicy::Dynamic);
|
||||
let applied = controller.record_rate_limited(std::option::Option::Some(std::time::Duration::from_secs(99_999)));
|
||||
assert_eq!(applied, crate::HTTP_MAX_RETRY_AFTER);
|
||||
let remaining = controller.cooldown_remaining();
|
||||
@@ -69,5 +68,4 @@ fn admission_policy_rejects_zero_values_and_invalid_fallback() {
|
||||
assert!(crate::HttpAdmissionPolicy::fixed(0, std::time::Duration::from_secs(1), std::option::Option::None).is_err());
|
||||
assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::ZERO, std::option::Option::None).is_err());
|
||||
assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::from_secs(1), std::option::Option::Some(0)).is_err());
|
||||
assert!(crate::HttpAdmissionController::new(crate::HttpAdmissionPolicy::Unlimited, std::option::Option::Some(std::time::Duration::ZERO)).is_err());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
#[test]
|
||||
fn decimal_normalizes_fractional_and_scientific_forms_without_f64() -> ksp_core_lib::Result<()> {
|
||||
@@ -57,3 +57,30 @@ fn decimal_serde_is_canonical_string_and_round_trips_exactly() -> ksp_core_lib::
|
||||
assert!(serde_json::from_str::<crate::MarketPriceDecimal>("123.45").is_err());
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_parses_raw_json_number_and_string_without_f64_round_trip() -> ksp_core_lib::Result<()> {
|
||||
let number = match serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>("151.123456789012345678") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test raw number failed"));
|
||||
},
|
||||
};
|
||||
let parsed = match crate::MarketPriceDecimal::parse_json_raw(number.as_ref()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(parsed.to_canonical_string(), "151.123456789012345678");
|
||||
let string = match serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>(r#""151.2300""#) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test raw string failed"));
|
||||
},
|
||||
};
|
||||
let parsed = match crate::MarketPriceDecimal::parse_json_raw(string.as_ref()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(parsed.to_canonical_string(), "151.23");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user