From 4f92fb03f178d1dd369f820c051b1c04425dbc64 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 25 Aug 2026 19:45:06 +0200 Subject: [PATCH] v0.2.11-pre.002 --- Cargo.toml | 6 +- crates/ksp-offchain-transport-lib/Cargo.toml | 18 + .../ksp-offchain-transport-lib/src/decimal.rs | 225 ++++++++++ .../ksp-offchain-transport-lib/src/error.rs | 13 + crates/ksp-offchain-transport-lib/src/lib.rs | 75 ++++ .../src/observation.rs | 169 ++++++++ .../src/provider.rs | 401 ++++++++++++++++++ .../src/settings.rs | 36 ++ .../tests/dependency_boundary.rs | 20 + .../tests/public_api.rs | 78 ++++ .../unit_tests/decimal.rs | 59 +++ .../unit_tests/observation.rs | 69 +++ .../unit_tests/provider.rs | 77 ++++ .../unit_tests/settings.rs | 23 + deltas/0.2.11/pre.002.md | 278 ++++++++++++ ...8-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md | 27 +- .../014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md | 80 ++-- 17 files changed, 1610 insertions(+), 44 deletions(-) create mode 100644 crates/ksp-offchain-transport-lib/Cargo.toml create mode 100644 crates/ksp-offchain-transport-lib/src/decimal.rs create mode 100644 crates/ksp-offchain-transport-lib/src/error.rs create mode 100644 crates/ksp-offchain-transport-lib/src/lib.rs create mode 100644 crates/ksp-offchain-transport-lib/src/observation.rs create mode 100644 crates/ksp-offchain-transport-lib/src/provider.rs create mode 100644 crates/ksp-offchain-transport-lib/src/settings.rs create mode 100644 crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs create mode 100644 crates/ksp-offchain-transport-lib/tests/public_api.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/decimal.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/observation.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/provider.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/settings.rs create mode 100644 deltas/0.2.11/pre.002.md diff --git a/Cargo.toml b/Cargo.toml index 94ebc19..73dc0ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 269 +# version: 270 [workspace] resolver = "3" -members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-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] -version = "0.2.11-pre.1" +version = "0.2.11-pre.2" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-offchain-transport-lib/Cargo.toml b/crates/ksp-offchain-transport-lib/Cargo.toml new file mode 100644 index 0000000..05d8783 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/Cargo.toml @@ -0,0 +1,18 @@ +# file: crates/ksp-offchain-transport-lib/Cargo.toml +# version: 1 + +[package] +name = "ksp-offchain-transport-lib" +version.workspace = true +edition.workspace = true +repository.workspace = true + +[dependencies] +ksp-core-lib = { path = "../ksp-core-lib" } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/ksp-offchain-transport-lib/src/decimal.rs b/crates/ksp-offchain-transport-lib/src/decimal.rs new file mode 100644 index 0000000..e8634a7 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/decimal.rs @@ -0,0 +1,225 @@ +// file: crates/ksp-offchain-transport-lib/src/decimal.rs +// version: 1 + +/// Maximum accepted UTF-8 byte length for one textual decimal input. +pub const PRICE_DECIMAL_MAX_INPUT_BYTES: usize = 96; +/// Maximum scale retained by the canonical exact decimal representation. +pub const PRICE_DECIMAL_MAX_SCALE: u8 = 18; + +/// Exact positive decimal value used for one successful V1 SOL/USD observation. +/// +/// The value is represented as a positive `u128` coefficient plus a bounded decimal scale. Trailing fractional zeroes are removed during construction, and +/// Serde serialization always emits the canonical decimal string instead of an IEEE-754 number. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PriceDecimal { + coefficient: u128, + scale: u8, +} + +impl PriceDecimal { + /// Parses a positive decimal or bounded scientific-notation value without converting through `f64`. + pub fn parse(source: &str) -> ksp_core_lib::Result { + if source.is_empty() || source.len() > crate::PRICE_DECIMAL_MAX_INPUT_BYTES || source.trim() != source { + return std::result::Result::Err(invalid_decimal_error()); + } + let (significand, exponent) = match split_exponent(source) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(()) => return std::result::Result::Err(invalid_decimal_error()), + }; + let (digits, fractional_digits) = match significand_digits(significand) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(()) => return std::result::Result::Err(invalid_decimal_error()), + }; + let mut coefficient = match digits.parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()), + }; + if coefficient == 0 { + return std::result::Result::Err(invalid_decimal_error()); + } + let mut effective_scale = i32::from(fractional_digits) - exponent; + if effective_scale < 0 { + let multiplication_power = match u32::try_from(-effective_scale) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()), + }; + coefficient = match checked_multiply_power_of_ten(coefficient, multiplication_power) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(invalid_decimal_error()), + }; + effective_scale = 0; + } + if effective_scale > i32::from(crate::PRICE_DECIMAL_MAX_SCALE) { + return std::result::Result::Err(invalid_decimal_error()); + } + let mut scale = match u8::try_from(effective_scale) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()), + }; + while scale > 0 && coefficient % 10 == 0 { + coefficient /= 10; + scale -= 1; + } + return std::result::Result::Ok(Self { coefficient, scale }); + } + + /// Returns the normalized integer coefficient. + #[must_use] + pub const fn coefficient(&self) -> u128 { + return self.coefficient; + } + + /// Returns the normalized decimal scale. + #[must_use] + pub const fn scale(&self) -> u8 { + return self.scale; + } + + /// Returns the canonical non-scientific decimal representation. + #[must_use] + pub fn to_canonical_string(&self) -> std::string::String { + let digits = self.coefficient.to_string(); + if self.scale == 0 { + return digits; + } + let scale = usize::from(self.scale); + if digits.len() > scale { + let split = digits.len() - scale; + return std::format!("{}.{}", &digits[..split], &digits[split..]); + } + let zero_count = scale - digits.len(); + return std::format!("0.{}{}", "0".repeat(zero_count), digits); + } +} + +impl std::fmt::Display for PriceDecimal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.write_str(self.to_canonical_string().as_str()); + } +} + +impl std::str::FromStr for PriceDecimal { + type Err = ksp_core_lib::Error; + + fn from_str(source: &str) -> std::result::Result { + return crate::PriceDecimal::parse(source); + } +} + +impl serde::Serialize for PriceDecimal { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + return serializer.serialize_str(self.to_canonical_string().as_str()); + } +} + +impl<'de> serde::Deserialize<'de> for PriceDecimal { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + return deserializer.deserialize_str(PriceDecimalVisitor); + } +} + +struct PriceDecimalVisitor; + +impl<'de> serde::de::Visitor<'de> for PriceDecimalVisitor { + type Value = crate::PriceDecimal; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.write_str("a canonicalizable positive decimal string"); + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + return match crate::PriceDecimal::parse(value) { + std::result::Result::Ok(decimal) => std::result::Result::Ok(decimal), + std::result::Result::Err(_) => std::result::Result::Err(E::custom("invalid KSP price decimal")), + }; + } +} + +fn checked_multiply_power_of_ten(mut value: u128, exponent: u32) -> std::option::Option { + let mut remaining = exponent; + while remaining > 0 { + value = match value.checked_mul(10) { + std::option::Option::Some(next) => next, + std::option::Option::None => return std::option::Option::None, + }; + remaining -= 1; + } + return std::option::Option::Some(value); +} + +fn invalid_decimal_error() -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_PRICE_DECIMAL_INVALID, "invalid exact price decimal"); +} + +fn significand_digits(significand: &str) -> std::result::Result<(std::string::String, u8), ()> { + if significand.is_empty() || significand.starts_with('-') || significand.starts_with('+') { + return std::result::Result::Err(()); + } + let mut digits = std::string::String::with_capacity(significand.len()); + let mut fractional_digits: usize = 0; + let mut decimal_seen = false; + let mut digit_seen = false; + for character in significand.chars() { + if character.is_ascii_digit() { + digits.push(character); + digit_seen = true; + if decimal_seen { + fractional_digits += 1; + } + } else if character == '.' && !decimal_seen { + decimal_seen = true; + } else { + return std::result::Result::Err(()); + } + } + if !digit_seen || significand.ends_with('.') || significand.starts_with('.') { + return std::result::Result::Err(()); + } + let fractional_digits = match u8::try_from(fractional_digits) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(()), + }; + return std::result::Result::Ok((digits, fractional_digits)); +} + +fn split_exponent(source: &str) -> std::result::Result<(&str, i32), ()> { + let mut separator_index = std::option::Option::None; + for (index, character) in source.char_indices() { + if character == 'e' || character == 'E' { + if separator_index.is_some() { + return std::result::Result::Err(()); + } + separator_index = std::option::Option::Some(index); + } + } + let index = match separator_index { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Ok((source, 0)), + }; + let significand = &source[..index]; + let exponent_source = &source[index + 1..]; + if exponent_source.is_empty() || exponent_source.len() > 4 { + return std::result::Result::Err(()); + } + let exponent = match exponent_source.parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(()), + }; + if !(-128..=128).contains(&exponent) { + return std::result::Result::Err(()); + } + return std::result::Result::Ok((significand, exponent)); +} + +#[cfg(test)] +#[path = "../unit_tests/decimal.rs"] +mod tests; diff --git a/crates/ksp-offchain-transport-lib/src/error.rs b/crates/ksp-offchain-transport-lib/src/error.rs new file mode 100644 index 0000000..7d3d849 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/error.rs @@ -0,0 +1,13 @@ +// file: crates/ksp-offchain-transport-lib/src/error.rs +// version: 1 + +/// Stable off-chain transport error for an invalid exact decimal price. +pub const ERROR_CODE_PRICE_DECIMAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "price_decimal_invalid"); +/// Stable off-chain transport error for an invalid provider descriptor. +pub const ERROR_CODE_PROVIDER_DESCRIPTOR_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "provider_descriptor_invalid"); +/// Stable off-chain transport error for an invalid provider identifier. +pub const ERROR_CODE_PROVIDER_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "provider_id_invalid"); +/// Stable off-chain transport error for an invalid normalized observation. +pub const ERROR_CODE_PROVIDER_OBSERVATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "provider_observation_invalid"); +/// Stable off-chain transport error for invalid common provider settings. +pub const ERROR_CODE_PROVIDER_SETTINGS_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "provider_settings_invalid"); diff --git a/crates/ksp-offchain-transport-lib/src/lib.rs b/crates/ksp-offchain-transport-lib/src/lib.rs new file mode 100644 index 0000000..faf42a1 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/lib.rs @@ -0,0 +1,75 @@ +// file: crates/ksp-offchain-transport-lib/src/lib.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! KSP-owned off-chain transport foundation. +//! +//! `0.2.11-pre.002` opens the first deliberately narrow public surface: exact SOL/USD price values, provider-neutral observations, provider descriptors, +//! common provider settings and generic availability/rate-limit metadata. No provider wire type, HTTP client, provider SDK, Config dependency or active +//! rate limiter is introduced by this tranche. + +mod decimal; +mod error; +mod observation; +mod provider; +mod settings; + +/// Maximum accepted byte length for one textual decimal input. +pub use self::decimal::PRICE_DECIMAL_MAX_INPUT_BYTES; +/// Maximum decimal scale retained by the canonical SOL/USD price representation. +pub use self::decimal::PRICE_DECIMAL_MAX_SCALE; +/// Exact positive decimal value used by the public off-chain price contract. +pub use self::decimal::PriceDecimal; +/// Stable error code for an invalid exact decimal price. +pub use self::error::ERROR_CODE_PRICE_DECIMAL_INVALID; +/// Stable error code for an invalid provider descriptor. +pub use self::error::ERROR_CODE_PROVIDER_DESCRIPTOR_INVALID; +/// Stable error code for an invalid provider identifier. +pub use self::error::ERROR_CODE_PROVIDER_ID_INVALID; +/// Stable error code for an invalid provider-neutral observation. +pub use self::error::ERROR_CODE_PROVIDER_OBSERVATION_INVALID; +/// Stable error code for invalid common provider settings. +pub use self::error::ERROR_CODE_PROVIDER_SETTINGS_INVALID; +/// Maximum safe provenance length attached to one normalized observation. +pub use self::observation::PRICE_PROVENANCE_MAX_BYTES; +/// Safe bounded provenance supplied by one provider adapter. +pub use self::observation::PriceProvenance; +/// Millisecond UTC timestamp used for request, receipt, provider and cooldown projections. +pub use self::observation::PriceTimestamp; +/// Public V1 SOL/USD observation normalized by Off-chain Transport. +pub use self::observation::SolUsdPriceObservation; +/// Maximum provider display-name length accepted by descriptors. +pub use self::provider::PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES; +/// Maximum opaque provider identifier length accepted by the public contract. +pub use self::provider::PRICE_PROVIDER_ID_MAX_BYTES; +/// Only price pair exposed by the `0.2.11` V1 public contract. +pub use self::provider::PricePair; +/// Generic authentication capability exposed by a configured provider descriptor. +pub use self::provider::PriceProviderAuthMode; +/// Generic runtime availability state exposed without provider-specific error parsing. +pub use self::provider::PriceProviderAvailability; +/// Provider capability and presentation descriptor consumed by provider-agnostic callers. +pub use self::provider::PriceProviderDescriptor; +/// Opaque validated provider identifier owned by Off-chain Transport. +pub use self::provider::PriceProviderId; +/// Long-term provider quota descriptor that is informational rather than an authoritative local counter. +pub use self::provider::PriceProviderLongTermQuota; +/// Period used by a documented long-term provider quota. +pub use self::provider::PriceProviderQuotaPeriod; +/// Unit used by a documented long-term provider quota. +pub use self::provider::PriceProviderQuotaUnit; +/// Generic provider request-limit capability. +pub use self::provider::PriceProviderRateLimit; +/// Shape of one generic provider request-limit capability. +pub use self::provider::PriceProviderRateLimitKind; +/// Scope to which a provider documents one request limit. +pub use self::provider::PriceProviderRateLimitScope; +/// Current provider-neutral runtime state projection. +pub use self::provider::PriceProviderState; +/// Price semantics retained so consumers never assume all providers report equivalent market values. +pub use self::provider::PriceSemantics; +/// Common provider settings shared by provider-specific runtime settings. +pub use self::settings::PriceProviderCommonSettings; diff --git a/crates/ksp-offchain-transport-lib/src/observation.rs b/crates/ksp-offchain-transport-lib/src/observation.rs new file mode 100644 index 0000000..b553196 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/observation.rs @@ -0,0 +1,169 @@ +// file: crates/ksp-offchain-transport-lib/src/observation.rs +// version: 1 + +/// Maximum UTF-8 byte length accepted for safe provider provenance. +pub const PRICE_PROVENANCE_MAX_BYTES: usize = 256; + +/// Millisecond UTC timestamp used by provider-neutral public projections. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)] +pub struct PriceTimestamp { + unix_millis: u64, +} + +impl PriceTimestamp { + /// Creates a UTC timestamp from whole milliseconds since Unix epoch. + #[must_use] + pub const fn from_unix_millis(unix_millis: u64) -> Self { + return Self { unix_millis }; + } + + /// Returns whole milliseconds since Unix epoch. + #[must_use] + pub const fn unix_millis(&self) -> u64 { + return self.unix_millis; + } +} + +/// Safe bounded provenance supplied by one provider adapter. +#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(try_from = "std::string::String", into = "std::string::String")] +pub struct PriceProvenance(std::string::String); + +impl PriceProvenance { + /// Creates bounded non-empty provenance without accepting control characters. + pub fn new(value: impl std::convert::Into) -> ksp_core_lib::Result { + let value = value.into(); + if !valid_provenance(value.as_str()) { + return std::result::Result::Err(observation_error()); + } + return std::result::Result::Ok(Self(value)); + } + + /// Returns the safe provider provenance. + #[must_use] + pub fn as_str(&self) -> &str { + return self.0.as_str(); + } +} + +impl std::convert::TryFrom for PriceProvenance { + type Error = ksp_core_lib::Error; + + fn try_from(value: std::string::String) -> std::result::Result { + return crate::PriceProvenance::new(value); + } +} + +impl std::convert::From for std::string::String { + fn from(value: PriceProvenance) -> Self { + return value.0; + } +} + +/// Public V1 SOL/USD observation normalized by Off-chain Transport. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +pub struct SolUsdPriceObservation { + pair: crate::PricePair, + price: crate::PriceDecimal, + provider_id: crate::PriceProviderId, + provider_timestamp: std::option::Option, + provenance: crate::PriceProvenance, + received_at: crate::PriceTimestamp, + request_started_at: crate::PriceTimestamp, + semantics: crate::PriceSemantics, +} + +impl SolUsdPriceObservation { + /// Creates one successful normalized SOL/USD observation. + pub fn new( + provider_id: crate::PriceProviderId, + price: crate::PriceDecimal, + semantics: crate::PriceSemantics, + request_started_at: crate::PriceTimestamp, + received_at: crate::PriceTimestamp, + provider_timestamp: std::option::Option, + provenance: crate::PriceProvenance, + ) -> ksp_core_lib::Result { + if received_at < request_started_at { + return std::result::Result::Err(observation_error()); + } + return std::result::Result::Ok(Self { + pair: crate::PricePair::SolUsd, + price, + provider_id, + provider_timestamp, + provenance, + received_at, + request_started_at, + semantics, + }); + } + + /// Returns the only V1 pair represented by this observation. + #[must_use] + pub const fn pair(&self) -> crate::PricePair { + return self.pair; + } + + /// Returns the exact positive SOL/USD price. + #[must_use] + pub const fn price(&self) -> crate::PriceDecimal { + return self.price; + } + + /// Returns the opaque provider identifier. + #[must_use] + pub const fn provider_id(&self) -> &crate::PriceProviderId { + return &self.provider_id; + } + + /// Returns a provider timestamp only when the provider adapter has a real price-time field. + #[must_use] + pub const fn provider_timestamp(&self) -> std::option::Option { + return self.provider_timestamp; + } + + /// Returns safe provider provenance. + #[must_use] + pub const fn provenance(&self) -> &crate::PriceProvenance { + return &self.provenance; + } + + /// Returns the KSP wall-clock receipt timestamp. + #[must_use] + pub const fn received_at(&self) -> crate::PriceTimestamp { + return self.received_at; + } + + /// Returns the KSP wall-clock request-start timestamp. + #[must_use] + pub const fn request_started_at(&self) -> crate::PriceTimestamp { + return self.request_started_at; + } + + /// Returns the provider-specific semantic class retained by the normalized observation. + #[must_use] + pub const fn semantics(&self) -> crate::PriceSemantics { + return self.semantics; + } +} + +fn observation_error() -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_PROVIDER_OBSERVATION_INVALID, "invalid off-chain price observation"); +} + +fn valid_provenance(value: &str) -> bool { + if value.is_empty() || value.len() > crate::PRICE_PROVENANCE_MAX_BYTES || value.trim() != value { + return false; + } + for character in value.chars() { + if character.is_control() { + return false; + } + } + return true; +} + +#[cfg(test)] +#[path = "../unit_tests/observation.rs"] +mod tests; diff --git a/crates/ksp-offchain-transport-lib/src/provider.rs b/crates/ksp-offchain-transport-lib/src/provider.rs new file mode 100644 index 0000000..8fbc2ca --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/provider.rs @@ -0,0 +1,401 @@ +// file: crates/ksp-offchain-transport-lib/src/provider.rs +// version: 1 + +/// Maximum UTF-8 byte length of one provider display name. +pub const PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES: usize = 96; +/// Maximum byte length of one opaque provider identifier. +pub const PRICE_PROVIDER_ID_MAX_BYTES: usize = 64; + +/// Only price pair exposed by the `0.2.11` V1 public contract. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PricePair { + /// Native SOL quoted directly in US dollars according to one provider's documented semantics. + SolUsd, +} + +impl PricePair { + /// Returns the stable human-readable pair code. + #[must_use] + pub const fn code(&self) -> &'static str { + return match self { + Self::SolUsd => "SOL/USD", + }; + } +} + +/// Price semantics retained so normalized observations do not imply cross-provider equivalence. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PriceSemantics { + /// Aggregated market price produced by a multi-market data provider. + AggregatedMarket, + /// USD price associated with one explicitly configured DEX pair. + DexPairUsd, + /// Last-trade price reported by one centralized exchange market. + ExchangeLastTrade, + /// Heuristic USD price derived from Solana swap/liquidity activity. + SolanaHeuristic, + /// Direct Solana-oriented spot price supplied by an on-chain market data provider. + SolanaSpot, +} + +/// Generic authentication capability exposed by one configured provider. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PriceProviderAuthMode { + /// No credential is required for the configured access mode. + None, + /// Provider accepts an API key but also supports an unauthenticated mode selected by configuration. + OptionalApiKey, + /// An API key is required for the configured access mode. + RequiredApiKey, +} + +/// Scope to which a provider documents a request limit. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PriceProviderRateLimitScope { + /// Limit is associated with the configured account or API key. + Account, + /// Limit is associated with the source IP address. + Ip, + /// Limit is associated with an organization or project wider than one key. + Organization, + /// Provider documentation does not expose a stronger stable scope. + Unspecified, +} + +/// Shape of one generic provider request-limit capability. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PriceProviderRateLimitKind { + /// Dynamic or server-driven limit that cannot be represented as one safe fixed local cadence. + Dynamic, + /// Locally enforceable fixed request budget over a documented window. + Fixed, +} + +/// Generic provider request-limit capability with validated fixed-limit values. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)] +pub struct PriceProviderRateLimit { + burst: std::option::Option, + kind: crate::PriceProviderRateLimitKind, + requests: std::option::Option, + scope: crate::PriceProviderRateLimitScope, + window_seconds: std::option::Option, +} + +impl PriceProviderRateLimit { + /// Creates a validated fixed request limit. + pub fn fixed(requests: u32, window_seconds: u32, burst: std::option::Option, scope: crate::PriceProviderRateLimitScope) -> ksp_core_lib::Result { + if requests == 0 || window_seconds == 0 || burst == std::option::Option::Some(0) { + return std::result::Result::Err(provider_descriptor_error()); + } + return std::result::Result::Ok(Self { + burst, + kind: crate::PriceProviderRateLimitKind::Fixed, + requests: std::option::Option::Some(requests), + scope, + window_seconds: std::option::Option::Some(window_seconds), + }); + } + + /// Creates a dynamic/server-driven request-limit descriptor. + #[must_use] + pub const fn dynamic(scope: crate::PriceProviderRateLimitScope) -> Self { + return Self { + burst: std::option::Option::None, + kind: crate::PriceProviderRateLimitKind::Dynamic, + requests: std::option::Option::None, + scope, + window_seconds: std::option::Option::None, + }; + } + + /// Returns optional documented burst capacity. + #[must_use] + pub const fn burst(&self) -> std::option::Option { + return self.burst; + } + + /// Returns whether the limit is fixed or dynamic/server-driven. + #[must_use] + pub const fn kind(&self) -> crate::PriceProviderRateLimitKind { + return self.kind; + } + + /// Returns the request budget for a fixed limit, or `None` for a dynamic limit. + #[must_use] + pub const fn requests(&self) -> std::option::Option { + return self.requests; + } + + /// Returns the documented limit scope. + #[must_use] + pub const fn scope(&self) -> crate::PriceProviderRateLimitScope { + return self.scope; + } + + /// Returns the fixed window duration in seconds, or `None` for a dynamic limit. + #[must_use] + pub const fn window_seconds(&self) -> std::option::Option { + return self.window_seconds; + } +} + +/// Period used by one documented long-term provider quota. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PriceProviderQuotaPeriod { + /// Quota resets on a provider-defined daily period. + Day, + /// Quota resets on a provider-defined monthly period. + Month, +} + +/// Unit used by one documented long-term provider quota. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PriceProviderQuotaUnit { + /// Provider-specific credits, not assumed to equal HTTP requests. + Credits, + /// HTTP/API requests. + Requests, +} + +/// Long-term provider quota descriptor exposed as non-authoritative capability metadata. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)] +pub struct PriceProviderLongTermQuota { + amount: u64, + period: crate::PriceProviderQuotaPeriod, + unit: crate::PriceProviderQuotaUnit, +} + +impl PriceProviderLongTermQuota { + /// Creates a non-zero documented quota descriptor. + pub fn new(amount: u64, period: crate::PriceProviderQuotaPeriod, unit: crate::PriceProviderQuotaUnit) -> ksp_core_lib::Result { + if amount == 0 { + return std::result::Result::Err(provider_descriptor_error()); + } + return std::result::Result::Ok(Self { amount, period, unit }); + } + + /// Returns the documented amount without treating it as a local remaining counter. + #[must_use] + pub const fn amount(&self) -> u64 { + return self.amount; + } + + /// Returns the provider-defined quota period. + #[must_use] + pub const fn period(&self) -> crate::PriceProviderQuotaPeriod { + return self.period; + } + + /// Returns the documented quota unit. + #[must_use] + pub const fn unit(&self) -> crate::PriceProviderQuotaUnit { + return self.unit; + } +} + +/// Opaque validated provider identifier owned by Off-chain Transport. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)] +#[serde(try_from = "std::string::String", into = "std::string::String")] +pub struct PriceProviderId(std::string::String); + +impl PriceProviderId { + /// Creates one bounded stable provider identifier. + pub fn new(value: impl std::convert::Into) -> ksp_core_lib::Result { + let value = value.into(); + if !valid_provider_id(value.as_str()) { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_PROVIDER_ID_INVALID, "invalid off-chain provider identifier")); + } + return std::result::Result::Ok(Self(value)); + } + + /// Returns the opaque identifier as a stable string. + #[must_use] + pub fn as_str(&self) -> &str { + return self.0.as_str(); + } +} + +impl std::fmt::Display for PriceProviderId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.write_str(self.0.as_str()); + } +} + +impl std::convert::TryFrom for PriceProviderId { + type Error = ksp_core_lib::Error; + + fn try_from(value: std::string::String) -> std::result::Result { + return crate::PriceProviderId::new(value); + } +} + +impl std::convert::From for std::string::String { + fn from(value: PriceProviderId) -> Self { + return value.0; + } +} + +/// Provider capability and presentation descriptor consumed by provider-agnostic callers. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +pub struct PriceProviderDescriptor { + auth_mode: crate::PriceProviderAuthMode, + display_name: std::string::String, + id: crate::PriceProviderId, + long_term_quota: std::option::Option, + rate_limit: crate::PriceProviderRateLimit, + semantics: crate::PriceSemantics, + supports_sol_usd: bool, +} + +impl PriceProviderDescriptor { + /// Creates a validated provider-neutral descriptor. + pub fn new( + id: crate::PriceProviderId, + display_name: impl std::convert::Into, + semantics: crate::PriceSemantics, + auth_mode: crate::PriceProviderAuthMode, + rate_limit: crate::PriceProviderRateLimit, + long_term_quota: std::option::Option, + supports_sol_usd: bool, + ) -> ksp_core_lib::Result { + let display_name = display_name.into(); + if !valid_display_name(display_name.as_str()) { + return std::result::Result::Err(provider_descriptor_error()); + } + return std::result::Result::Ok(Self { auth_mode, display_name, id, long_term_quota, rate_limit, semantics, supports_sol_usd }); + } + + /// Returns the configured authentication capability. + #[must_use] + pub const fn auth_mode(&self) -> crate::PriceProviderAuthMode { + return self.auth_mode; + } + + /// Returns the safe display name. + #[must_use] + pub fn display_name(&self) -> &str { + return self.display_name.as_str(); + } + + /// Returns the opaque provider identifier. + #[must_use] + pub const fn id(&self) -> &crate::PriceProviderId { + return &self.id; + } + + /// Returns optional long-term quota metadata without exposing a local remaining counter. + #[must_use] + pub const fn long_term_quota(&self) -> std::option::Option { + return self.long_term_quota; + } + + /// Returns the configured request-limit capability. + #[must_use] + pub const fn rate_limit(&self) -> crate::PriceProviderRateLimit { + return self.rate_limit; + } + + /// Returns the documented price semantics. + #[must_use] + pub const fn semantics(&self) -> crate::PriceSemantics { + return self.semantics; + } + + /// Reports whether this descriptor can serve the V1 SOL/USD pair. + #[must_use] + pub const fn supports_sol_usd(&self) -> bool { + return self.supports_sol_usd; + } +} + +/// Generic runtime availability state exposed without provider-specific error parsing. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum PriceProviderAvailability { + /// Required authentication material is unavailable or rejected. + AuthenticationUnavailable, + /// Provider is locally cooling down until the supplied timestamp. + CoolingDown { + /// Earliest known wall-clock timestamp at which a new attempt may be admitted. + retry_at: crate::PriceTimestamp, + }, + /// Provider is disabled by runtime configuration. + Disabled, + /// Runtime settings do not satisfy the provider adapter contract. + Misconfigured, + /// Provider-reported quota prevents current use. + QuotaUnavailable, + /// Provider is eligible for a new request. + Ready, + /// Transport/provider failure is transient; retry time is present only when actually known. + TemporarilyUnavailable { + /// Optional next retry timestamp derived from safe runtime/provider information. + retry_at: std::option::Option, + }, +} + +/// Current provider-neutral runtime state projection. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +pub struct PriceProviderState { + availability: crate::PriceProviderAvailability, + provider_id: crate::PriceProviderId, +} + +impl PriceProviderState { + /// Creates one generic state projection for a configured provider. + #[must_use] + pub fn new(provider_id: crate::PriceProviderId, availability: crate::PriceProviderAvailability) -> Self { + return Self { availability, provider_id }; + } + + /// Returns the generic availability classification. + #[must_use] + pub const fn availability(&self) -> crate::PriceProviderAvailability { + return self.availability; + } + + /// Returns the opaque provider identifier. + #[must_use] + pub const fn provider_id(&self) -> &crate::PriceProviderId { + return &self.provider_id; + } +} + +fn provider_descriptor_error() -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_PROVIDER_DESCRIPTOR_INVALID, "invalid off-chain provider descriptor"); +} + +fn valid_display_name(value: &str) -> bool { + if value.is_empty() || value.len() > crate::PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES || value.trim() != value { + return false; + } + for character in value.chars() { + if character.is_control() { + return false; + } + } + return true; +} + +fn valid_provider_id(value: &str) -> bool { + if value.is_empty() || value.len() > crate::PRICE_PROVIDER_ID_MAX_BYTES { + return false; + } + for byte in value.bytes() { + if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_') { + return false; + } + } + return true; +} + +#[cfg(test)] +#[path = "../unit_tests/provider.rs"] +mod tests; diff --git a/crates/ksp-offchain-transport-lib/src/settings.rs b/crates/ksp-offchain-transport-lib/src/settings.rs new file mode 100644 index 0000000..f6c11aa --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/settings.rs @@ -0,0 +1,36 @@ +// file: crates/ksp-offchain-transport-lib/src/settings.rs +// version: 1 + +/// Common provider settings embedded by future provider-specific runtime settings. +/// +/// Provider-specific credentials, pair selectors and access modes intentionally do not live here because providers without those capabilities must not be +/// forced into artificial fields. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +pub struct PriceProviderCommonSettings { + enabled: bool, + provider_id: crate::PriceProviderId, +} + +impl PriceProviderCommonSettings { + /// Creates common settings for one uniquely identified runtime provider instance. + #[must_use] + pub fn new(provider_id: crate::PriceProviderId, enabled: bool) -> Self { + return Self { enabled, provider_id }; + } + + /// Reports whether this provider instance is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + return self.enabled; + } + + /// Returns the opaque runtime provider identifier. + #[must_use] + pub const fn provider_id(&self) -> &crate::PriceProviderId { + return &self.provider_id; + } +} + +#[cfg(test)] +#[path = "../unit_tests/settings.rs"] +mod tests; diff --git a/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs b/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs new file mode 100644 index 0000000..e1545c7 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs @@ -0,0 +1,20 @@ +// file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +#[test] +fn pre_002_manifest_keeps_foundation_provider_and_config_independent() { + let manifest = include_str!("../Cargo.toml"); + assert!(manifest.contains("ksp-core-lib")); + assert!(manifest.contains("serde")); + assert!(!manifest.contains("ksp-config-lib")); + assert!(!manifest.contains("reqwest")); + assert!(!manifest.contains("coingecko")); + assert!(!manifest.contains("coinmarketcap")); + assert!(!manifest.contains("jupiter")); + assert!(!manifest.contains("birdeye")); + assert!(!manifest.contains("dexscreener")); +} diff --git a/crates/ksp-offchain-transport-lib/tests/public_api.rs b/crates/ksp-offchain-transport-lib/tests/public_api.rs new file mode 100644 index 0000000..45d3355 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/tests/public_api.rs @@ -0,0 +1,78 @@ +// file: crates/ksp-offchain-transport-lib/tests/public_api.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +#[test] +fn public_pre_002_price_foundation_is_available_from_crate_root() -> ksp_core_lib::Result<()> { + assert_eq!(ksp_offchain_transport_lib::PricePair::SolUsd.code(), "SOL/USD"); + assert_eq!(ksp_offchain_transport_lib::PRICE_DECIMAL_MAX_SCALE, 18); + let price = match ksp_offchain_transport_lib::PriceDecimal::parse("201.2500") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(price.to_canonical_string(), "201.25"); + let id = match ksp_offchain_transport_lib::PriceProviderId::new("provider-canary") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let common = ksp_offchain_transport_lib::PriceProviderCommonSettings::new(id.clone(), true); + assert_eq!(common.provider_id(), &id); + let rate_limit = match ksp_offchain_transport_lib::PriceProviderRateLimit::fixed( + 1, + 1, + std::option::Option::None, + ksp_offchain_transport_lib::PriceProviderRateLimitScope::Ip, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let descriptor = match ksp_offchain_transport_lib::PriceProviderDescriptor::new( + id.clone(), + "Provider Canary", + ksp_offchain_transport_lib::PriceSemantics::AggregatedMarket, + ksp_offchain_transport_lib::PriceProviderAuthMode::None, + rate_limit, + std::option::Option::None, + true, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert!(descriptor.supports_sol_usd()); + let provenance = match ksp_offchain_transport_lib::PriceProvenance::new("canary") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let timestamp = ksp_offchain_transport_lib::PriceTimestamp::from_unix_millis(1); + let observation = match ksp_offchain_transport_lib::SolUsdPriceObservation::new( + id, + price, + ksp_offchain_transport_lib::PriceSemantics::AggregatedMarket, + timestamp, + timestamp, + std::option::Option::None, + provenance, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(observation.price(), price); + return std::result::Result::Ok(()); +} + +#[test] +fn offchain_error_codes_use_owned_domain() { + let codes = [ + ksp_offchain_transport_lib::ERROR_CODE_PRICE_DECIMAL_INVALID, + ksp_offchain_transport_lib::ERROR_CODE_PROVIDER_DESCRIPTOR_INVALID, + ksp_offchain_transport_lib::ERROR_CODE_PROVIDER_ID_INVALID, + ksp_offchain_transport_lib::ERROR_CODE_PROVIDER_OBSERVATION_INVALID, + ksp_offchain_transport_lib::ERROR_CODE_PROVIDER_SETTINGS_INVALID, + ]; + for code in codes { + assert_eq!(code.domain(), "offchain_transport"); + } +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/decimal.rs b/crates/ksp-offchain-transport-lib/unit_tests/decimal.rs new file mode 100644 index 0000000..7545a84 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/decimal.rs @@ -0,0 +1,59 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/decimal.rs +// version: 1 + +#[test] +fn decimal_normalizes_fractional_and_scientific_forms_without_f64() -> ksp_core_lib::Result<()> { + let cases = [ + ("123.4500", "123.45", 12_345_u128, 2_u8), + ("1.2345e2", "123.45", 12_345, 2), + ("12345e-2", "123.45", 12_345, 2), + ("1e3", "1000", 1_000, 0), + ("1e-3", "0.001", 1, 3), + ]; + for (source, expected, coefficient, scale) in cases { + let value = match crate::PriceDecimal::parse(source) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(value.to_canonical_string(), expected); + assert_eq!(value.coefficient(), coefficient); + assert_eq!(value.scale(), scale); + } + return std::result::Result::Ok(()); +} + +#[test] +fn decimal_rejects_zero_negative_nonfinite_excessive_scale_and_overflow() { + let invalid = ["0", "0.000", "-1", "+1", "NaN", "inf", "1e-19", "1e129", "340282366920938463463374607431768211456", " 1", "1 ", ".1", "1."]; + for source in invalid { + let result = crate::PriceDecimal::parse(source); + assert!(result.is_err()); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_PRICE_DECIMAL_INVALID); + } + } +} + +#[test] +fn decimal_serde_is_canonical_string_and_round_trips_exactly() -> ksp_core_lib::Result<()> { + let value = match crate::PriceDecimal::parse("123.4500") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let encoded = match serde_json::to_string(&value) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_PRICE_DECIMAL_INVALID, "test serialization failed")); + }, + }; + assert_eq!(encoded, "\"123.45\""); + let decoded: crate::PriceDecimal = match serde_json::from_str(encoded.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_PRICE_DECIMAL_INVALID, "test deserialization failed")); + }, + }; + assert_eq!(decoded, value); + assert!(serde_json::from_str::("123.45").is_err()); + return std::result::Result::Ok(()); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/observation.rs b/crates/ksp-offchain-transport-lib/unit_tests/observation.rs new file mode 100644 index 0000000..53ab971 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/observation.rs @@ -0,0 +1,69 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/observation.rs +// version: 1 + +#[test] +fn observation_preserves_pair_exact_price_semantics_timestamps_and_safe_provenance() -> ksp_core_lib::Result<()> { + let provider_id = match crate::PriceProviderId::new("kraken") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let price = match crate::PriceDecimal::parse("204.125") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provenance = match crate::PriceProvenance::new("market=SOL/USD") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let started = crate::PriceTimestamp::from_unix_millis(10_000); + let received = crate::PriceTimestamp::from_unix_millis(10_250); + let observation = match crate::SolUsdPriceObservation::new( + provider_id, + price, + crate::PriceSemantics::ExchangeLastTrade, + started, + received, + std::option::Option::None, + provenance, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(observation.pair(), crate::PricePair::SolUsd); + assert_eq!(observation.pair().code(), "SOL/USD"); + assert_eq!(observation.price(), price); + assert_eq!(observation.semantics(), crate::PriceSemantics::ExchangeLastTrade); + assert_eq!(observation.request_started_at(), started); + assert_eq!(observation.received_at(), received); + assert_eq!(observation.provider_timestamp(), std::option::Option::None); + assert_eq!(observation.provenance().as_str(), "market=SOL/USD"); + return std::result::Result::Ok(()); +} + +#[test] +fn observation_rejects_reversed_ksp_timestamps_and_unsafe_provenance() -> ksp_core_lib::Result<()> { + assert!(crate::PriceProvenance::new("line\nbreak").is_err()); + let provider_id = match crate::PriceProviderId::new("coingecko") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let price = match crate::PriceDecimal::parse("200") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provenance = match crate::PriceProvenance::new("asset=solana") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = crate::SolUsdPriceObservation::new( + provider_id, + price, + crate::PriceSemantics::AggregatedMarket, + crate::PriceTimestamp::from_unix_millis(2), + crate::PriceTimestamp::from_unix_millis(1), + std::option::Option::None, + provenance, + ); + assert!(result.is_err()); + return std::result::Result::Ok(()); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/provider.rs b/crates/ksp-offchain-transport-lib/unit_tests/provider.rs new file mode 100644 index 0000000..6601d84 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/provider.rs @@ -0,0 +1,77 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/provider.rs +// version: 1 + +#[test] +fn provider_id_is_opaque_bounded_and_stable() -> ksp_core_lib::Result<()> { + let id = match crate::PriceProviderId::new("coingecko-main") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(id.as_str(), "coingecko-main"); + assert_eq!(id.to_string(), "coingecko-main"); + for invalid in ["", "CoinGecko", "coin gecko", "coin/gecko", "é"] { + assert!(crate::PriceProviderId::new(invalid).is_err()); + } + return std::result::Result::Ok(()); +} + +#[test] +fn provider_descriptor_preserves_semantics_auth_limits_and_informational_quota() -> ksp_core_lib::Result<()> { + let id = match crate::PriceProviderId::new("provider-a") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let rate_limit = match crate::PriceProviderRateLimit::fixed(1, 2, std::option::Option::None, crate::PriceProviderRateLimitScope::Ip) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let quota = match crate::PriceProviderLongTermQuota::new(10_000, crate::PriceProviderQuotaPeriod::Month, crate::PriceProviderQuotaUnit::Credits) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let descriptor = match crate::PriceProviderDescriptor::new( + id, + "Provider A", + crate::PriceSemantics::AggregatedMarket, + crate::PriceProviderAuthMode::OptionalApiKey, + rate_limit, + std::option::Option::Some(quota), + true, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(descriptor.display_name(), "Provider A"); + assert_eq!(descriptor.semantics(), crate::PriceSemantics::AggregatedMarket); + assert_eq!(descriptor.auth_mode(), crate::PriceProviderAuthMode::OptionalApiKey); + assert_eq!(descriptor.rate_limit(), rate_limit); + assert_eq!(descriptor.long_term_quota(), std::option::Option::Some(quota)); + assert!(descriptor.supports_sol_usd()); + return std::result::Result::Ok(()); +} + +#[test] +fn provider_limit_descriptors_reject_zero_and_model_dynamic_scope() { + assert!(crate::PriceProviderRateLimit::fixed(0, 1, std::option::Option::None, crate::PriceProviderRateLimitScope::Ip).is_err()); + assert!(crate::PriceProviderRateLimit::fixed(1, 0, std::option::Option::None, crate::PriceProviderRateLimitScope::Ip).is_err()); + assert!(crate::PriceProviderRateLimit::fixed(1, 1, std::option::Option::Some(0), crate::PriceProviderRateLimitScope::Ip).is_err()); + let dynamic = crate::PriceProviderRateLimit::dynamic(crate::PriceProviderRateLimitScope::Ip); + assert_eq!(dynamic.kind(), crate::PriceProviderRateLimitKind::Dynamic); + assert_eq!(dynamic.scope(), crate::PriceProviderRateLimitScope::Ip); + assert_eq!(dynamic.requests(), std::option::Option::None); + assert_eq!(dynamic.window_seconds(), std::option::Option::None); + assert_eq!(dynamic.burst(), std::option::Option::None); +} + +#[test] +fn provider_availability_keeps_cooldown_and_outage_distinct() -> ksp_core_lib::Result<()> { + let id = match crate::PriceProviderId::new("jupiter") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let retry_at = crate::PriceTimestamp::from_unix_millis(1_777_777_777_000); + let state = crate::PriceProviderState::new(id, crate::PriceProviderAvailability::CoolingDown { retry_at }); + assert_eq!(state.availability(), crate::PriceProviderAvailability::CoolingDown { retry_at }); + assert_ne!(state.availability(), crate::PriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(retry_at) }); + return std::result::Result::Ok(()); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/settings.rs b/crates/ksp-offchain-transport-lib/unit_tests/settings.rs new file mode 100644 index 0000000..0c70bec --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/settings.rs @@ -0,0 +1,23 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/settings.rs +// version: 1 + +#[test] +fn common_settings_contain_only_generic_identity_and_enablement() -> ksp_core_lib::Result<()> { + let provider_id = match crate::PriceProviderId::new("coinpaprika") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let settings = crate::PriceProviderCommonSettings::new(provider_id, true); + assert!(settings.enabled()); + assert_eq!(settings.provider_id().as_str(), "coinpaprika"); + let serialized = match serde_json::to_value(&settings) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_PROVIDER_SETTINGS_INVALID, "test serialization failed")); + }, + }; + assert!(serialized.get("api_key").is_none()); + assert!(serialized.get("endpoint").is_none()); + assert!(serialized.get("rate_limit").is_none()); + return std::result::Result::Ok(()); +} diff --git a/deltas/0.2.11/pre.002.md b/deltas/0.2.11/pre.002.md new file mode 100644 index 0000000..02fb8e5 --- /dev/null +++ b/deltas/0.2.11/pre.002.md @@ -0,0 +1,278 @@ + + + +# Delta `0.2.11-pre.002` — fondation Off-chain Transport SOL/USD + +## 1. Base requise + +Cette tranche s'applique exclusivement sur : + +```text +v0.2.10 ++ 0.2.11-pre.001 ++ 0.2.11-pre.001-fix.001 +``` + +La version Cargo d'entrée attendue est : + +```text +0.2.11-pre.1 +``` + +La version Cargo de sortie est : + +```text +0.2.11-pre.2 +``` + +## 2. Objectif + +Créer la fondation de `ksp-offchain-transport-lib` sans avancer le client HTTP commun ni aucun adapter provider. + +La tranche matérialise uniquement : + +```text +surface publique SOL/USD V1 +PriceDecimal exact sans f64 canonique +observation/provenance/timestamps provider-neutral +identifiant/descripteur provider opaque +sémantique de prix explicite +auth/rate-limit/quota descriptifs +settings communs minimaux +availability/state provider-neutral +``` + +`reqwest`, le limiter actif, les credentials provider et les DTOs wire restent réservés aux prereleases suivantes. + +## 3. Décisions matérialisées + +### 3.1 Décimal exact + +`PriceDecimal` possède : + +```text +coefficient positif u128 +scale maximale 18 +entrée textuelle maximale 96 octets +normalisation des zéros fractionnaires terminaux +notation scientifique bornée +serialization Serde canonique sous forme de string décimale +zéro, négatif, NaN/Inf, overflow et scale excessive rejetés +``` + +Le type ne passe jamais par `f64` comme vérité canonique. + +Le zéro étant invalide pour un prix réussi, l'absence de prix ne peut pas être silencieusement transformée en `0`. + +### 3.2 Paire et observation V1 + +La seule paire publique reste : + +```text +PricePair::SolUsd +``` + +`SolUsdPriceObservation` conserve : + +```text +provider_id opaque +PriceDecimal +PriceSemantics +request_started_at +received_at +provider_timestamp optionnel +PriceProvenance sûre et bornée +``` + +Les timestamps sont projetés en millisecondes UTC depuis Unix epoch via `PriceTimestamp`. + +Le constructeur rejette une réception antérieure au départ de requête. Aucun timestamp provider n'est obligatoire ou inventé. + +### 3.3 Provider-neutral descriptors + +`PriceProviderId` est un identifiant opaque borné. Il n'expose pas les identifiants wire propriétaires des providers. + +`PriceProviderDescriptor` conserve uniquement des informations génériques utiles aux consumers : + +```text +id +nom d'affichage +PriceSemantics +PriceProviderAuthMode +PriceProviderRateLimit +PriceProviderLongTermQuota optionnel +support SOL/USD +``` + +Les limites fixes exigent un nombre de requêtes et une fenêtre non nuls. Une limite dynamique/server-driven est représentée séparément. + +Les quotas longs termes sont informatifs : aucun compteur local ne prétend connaître le quota restant réel d'un compte partagé ou consommé par un autre processus. + +### 3.4 Settings communs minimaux + +`PriceProviderCommonSettings` ne contient que : + +```text +provider_id +enabled +``` + +Il ne contient volontairement ni endpoint, ni API key, ni cadence configurable. + +Les settings propres à CoinGecko, CoinMarketCap, Jupiter, Birdeye ou DexScreener seront ajoutés uniquement avec leurs adapters afin que chaque configuration corresponde aux capacités réelles du provider. + +### 3.5 Availability générique + +`PriceProviderAvailability` peut représenter : + +```text +authentication unavailable +cooling down + retry_at +disabled +misconfigured +quota unavailable +ready +temporarily unavailable + retry_at optionnel +``` + +`PriceProviderState` associe cette projection à un `PriceProviderId` sans demander au consumer de parser une erreur provider. + +Le registry et les transitions runtime effectives restent prévus en `pre.007`. + +## 4. Tests ajoutés + +La crate contient des tests unitaires physiquement séparés pour : + +```text +parsing/normalisation/scientific notation PriceDecimal +rejets numeric safety +round-trip Serde PriceDecimal +validation PriceProviderId +descriptors auth/rate-limit/quota +availability générique +observation SOL/USD et ordre des timestamps +provenance sûre +settings communs sans endpoint/API key/rate-limit +``` + +Deux tests d'intégration vérifient : + +```text +surface publique pre.002 au crate-root +error codes domaine offchain_transport +absence de dépendance Config +absence de reqwest/provider SDK dans le manifest pre.002 +``` + +## 5. Fichiers ajoutés + +```text +crates/ksp-offchain-transport-lib/Cargo.toml +crates/ksp-offchain-transport-lib/src/decimal.rs +crates/ksp-offchain-transport-lib/src/error.rs +crates/ksp-offchain-transport-lib/src/lib.rs +crates/ksp-offchain-transport-lib/src/observation.rs +crates/ksp-offchain-transport-lib/src/provider.rs +crates/ksp-offchain-transport-lib/src/settings.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/decimal.rs +crates/ksp-offchain-transport-lib/unit_tests/observation.rs +crates/ksp-offchain-transport-lib/unit_tests/provider.rs +crates/ksp-offchain-transport-lib/unit_tests/settings.rs +deltas/0.2.11/pre.002.md +``` + +## 6. Fichiers modifiés + +```text +Cargo.toml +docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +``` + +## 7. Fichiers supprimés + +```text +aucun +``` + +## 8. 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 tranche conformément à leur ownership de release. + +## 9. 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 docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md + clean / 14 tables / 2 files + +contrôle lignes Rust > 160 sur la nouvelle crate + PASS / aucune + +inspection manifest nouvelle crate + ksp-config-lib absent + reqwest absent en pre.002 + SDK provider absent +``` + +## 10. Validations non exécutées dans le sandbox + +Le sandbox de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`. + +Les validations suivantes doivent donc être exécutées par l'opérateur après application du delta : + +```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 validations Cargo fournies par l'opérateur pour `pre.001` étaient vertes avant cette tranche ; elles constituent une baseline d'entrée mais ne sont pas comptées comme validation du nouveau code `pre.002`. + +## 11. Questions ouvertes + +Aucune question de design ne bloque `pre.003`. + +Les points volontairement différés restent : + +```text +HTTP reqwest commun +conversion JSON number/string provider vers PriceDecimal +classification HTTP/transport +Retry-After +limiter/cooldown actif +credentials provider +adapters provider +registry runtime effectif +``` + +## 12. Suite prévue + +La tranche suivante reste : + +```text +0.2.11-pre.003 — HTTP REST commun et rate limiting +``` + +Elle pourra ajouter `reqwest` et `ksp-logging-lib` à la nouvelle crate, les bornes HTTP, la redaction, la classification commune des erreurs et le limiter/cooldown générique, sans encore implémenter les adapters provider de `pre.004+`. diff --git a/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md b/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md index f298bd4..322474c 100644 --- a/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +++ b/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md @@ -1,9 +1,9 @@ - + # Plan `0.2.11` — Off-chain price transport SOL/USD multi-provider -**Statut courant : `0.2.11-pre.001-fix.001` conserve intégralement le cadrage technique de `pre.001` et corrige uniquement la forme du forecast souple afin de rendre chaque tranche éditable par statut et extensible par sous-sections `####` pour ses fixes. Aucun client provider n'est encore implémenté. Le scope V1 reste limité à SOL/USD via HTTP REST `reqwest`, sans SDK provider, avec huit providers gratuits retenus pour implémentation progressive.** +**Statut courant : `0.2.11-pre.002` matérialise la fondation de `ksp-offchain-transport-lib` sans encore ajouter de client HTTP ou d'adapter provider. Le contrat public V1 possède désormais un décimal exact SOL/USD, des observations/provenances/timestamps sûrs, des identifiants/descriptors provider opaques, des capacités auth/rate-limit/quota descriptives, des settings communs minimaux et des états d'availability provider-neutral.** ## 1. Base et autorité @@ -304,15 +304,16 @@ Le choix d'une paire explicite évite d'introduire silencieusement une politique Le gate rejette `f64` comme représentation canonique publique du prix. -Direction retenue pour `pre.002` : type décimal KSP borné, sérialisable sans perte, construit depuis la représentation textuelle du provider. +Décision matérialisée en `pre.002` : `PriceDecimal` est le type décimal KSP borné, sérialisable sans perte et construit depuis la représentation textuelle du provider. -Forme de travail : +Contrat V1 exact : ```text coefficient positif u128 -scale bornée +scale bornée à 18 décimales +entrée textuelle bornée à 96 octets normalisation des zéros terminaux -parsing décimal et notation scientifique bornés +parsing décimal et notation scientifique bornés sans passage par f64 aucun NaN aucun Inf overflow rejeté @@ -322,6 +323,8 @@ serialization canonique décimale en chaîne Le wire d'un provider peut être JSON number ou string. L'adapter le convertit vers le type KSP sans utiliser `as_f64()` comme vérité canonique. +`PriceDecimal` sérialise toujours sa valeur canonique sous forme de chaîne décimale non scientifique. Le zéro est invalide pour ce type puisqu'il représente exclusivement un prix réussi ; l'absence de prix reste donc hors de la valeur numérique elle-même. + Cette solution évite d'ajouter une crate decimal uniquement pour V1 et prépare le passage frontend sans perte IEEE-754. ## 11. Observation commune et fraîcheur @@ -338,6 +341,8 @@ instant provider optionnel lorsqu'il existe réellement provenance provider sûre et bornée ``` +`pre.002` matérialise ce contrat avec `SolUsdPriceObservation`, `PriceTimestamp` (millisecondes UTC depuis Unix epoch) et `PriceProvenance` bornée à 256 octets sans caractères de contrôle. Le constructeur d'observation rejette un instant de réception antérieur au départ de requête. + Règles : ```text @@ -370,6 +375,8 @@ limitation générique support SOL/USD ``` +`pre.002` fixe `PriceProviderId`, `PriceProviderDescriptor`, `PriceProviderAuthMode`, `PriceProviderRateLimit`, `PriceProviderLongTermQuota` et leurs enums de scope/période/unité. Les limites fixes exigent un budget et une fenêtre non nuls ; les limites dynamiques restent explicitement distinctes. Les quotas longs termes sont descriptifs et ne deviennent jamais un compteur local de quota restant. + L'état runtime peut représenter au minimum : ```text @@ -382,7 +389,9 @@ misconfigured disabled ``` -Les noms Rust finaux sont à fixer avec le code, mais aucun consumer ne doit parser des strings d'erreur pour connaître cet état. +`PriceProviderAvailability` et `PriceProviderState` matérialisent cette projection générique dès `pre.002`; le registry et les transitions runtime effectives restent prévus en `pre.007`. + +Les noms Rust de cette fondation sont désormais matérialisés ; aucun consumer ne doit parser des strings d'erreur pour connaître cet état. ## 13. Rate limiting et refresh multiple @@ -616,9 +625,9 @@ Remplacement du forecast tabulaire par des sous-sections éditables, clarificati ### `pre.002` — Fondation de `ksp-offchain-transport-lib` -**Statut : planifié.** +**Statut : réalisé.** -Création de la crate, modèle SOL/USD, type décimal KSP, descriptors, settings et états provider-neutral. +Création de la crate et de sa façade publique initiale : `PriceDecimal`, paire SOL/USD, observation/provenance/timestamps, identifiants/descriptors provider, capacités auth/rate-limit/quota, settings communs minimaux et états d'availability provider-neutral. Aucun HTTP, limiter actif, credential provider ou adapter wire n'est avancé depuis `pre.003+`. ### `pre.003` — HTTP REST commun et rate limiting diff --git a/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md b/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md index 54923da..356b831 100644 --- a/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +++ b/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md @@ -1,5 +1,5 @@ - + # Validation `0.2.11` — Off-chain price transport SOL/USD @@ -44,7 +44,23 @@ Les statuts `PLANNED` restent non validés tant que la tranche correspondante n' | forecast prereleases établi | PASS | plan 018 | | code runtime provider ajouté en `pre.001` | N/A | interdit par le gate, aucune crate créée | -## 3. Matrice provider prévue +## 3. Gate `0.2.11-pre.002` + +| Critère | Statut | Preuve | +|----------------------------------------------------|--------|----------------------------------------------------| +| crate `ksp-offchain-transport-lib` créée | PASS | membre workspace + package dédié | +| version workspace synchronisée sur `0.2.11-pre.2` | PASS | `Cargo.toml` racine | +| dépendance inverse vers Config absente | PASS | manifest crate | +| `reqwest` et SDK providers absents de la fondation | PASS | manifest crate | +| `PriceDecimal` exact sans canon `f64` | PASS | coefficient `u128`, scale 18, sérialisation string | +| SOL/USD seule paire publique V1 | PASS | `PricePair::SolUsd` + `SolUsdPriceObservation` | +| identifiant/descripteur provider opaques | PASS | `PriceProviderId` + `PriceProviderDescriptor` | +| auth/rate-limit/quota représentables génériquement | PASS | types provider-neutral dédiés | +| settings communs sans faux endpoint/API key | PASS | `PriceProviderCommonSettings` | +| availability provider-neutral représentable | PASS | `PriceProviderAvailability` + `PriceProviderState` | +| HTTP actif ou adapter provider ajouté | N/A | explicitement réservé à `pre.003+` | + +## 4. Matrice provider prévue | Provider | SOL/USD V1 | Gratuit V1 | Mode auth prévu | Test déterministe | Smoke live | Statut courant | |-------------------|------------|------------|----------------------|-------------------|------------|----------------| @@ -61,31 +77,31 @@ Les statuts `PLANNED` restent non validés tant que la tranche correspondante n' Avant la stable, les conditions d'usage et la persistance des offres gratuites doivent être réauditées ; un provider peut rester techniquement supporté tout en nécessitant un plan différent pour certains usages. -## 4. Contrat public prévu +## 5. Contrat public prévu | Invariant | Statut | |------------------------------------------------------------------------|---------| -| surface publique limitée à SOL/USD dans V1 | PLANNED | -| identifiants provider opaques pour les consumers | PLANNED | -| provenance provider toujours observable | PLANNED | -| sémantique de prix observable sans prétendre à une équivalence | PLANNED | -| timestamp requête/réception KSP présents | PLANNED | -| timestamp provider optionnel seulement lorsqu'il est réellement fourni | PLANNED | -| absence de prix distincte de zéro | PLANNED | -| `f64` non utilisé comme canon public | PLANNED | -| aucune structure wire provider exportée | PLANNED | -| aucune URL/header/asset id provider requis côté app | PLANNED | +| surface publique limitée à SOL/USD dans V1 | PASS | +| identifiants provider opaques pour les consumers | PASS | +| provenance provider toujours observable | PASS | +| sémantique de prix observable sans prétendre à une équivalence | PASS | +| timestamp requête/réception KSP présents | PASS | +| timestamp provider optionnel seulement lorsqu'il est réellement fourni | PASS | +| absence de prix distincte de zéro | PASS | +| `f64` non utilisé comme canon public | PASS | +| aucune structure wire provider exportée | PASS | +| aucune URL/header/asset id provider requis côté app | PASS | | registry provider et état runtime possédés par Off-chain Transport | PLANNED | | refresh provider par identifiant générique | PLANNED | | refresh multiple sans connaissance provider côté consumer | PLANNED | -## 5. Rate limiting et availability +## 6. Rate limiting et availability | Cas | Statut | |---------------------------------------------------------------|---------| -| cadence locale fixe représentable | PLANNED | -| burst documenté représentable | PLANNED | -| limite dynamique keyless représentable | PLANNED | +| cadence locale fixe représentable | PASS | +| burst documenté représentable | PASS | +| limite dynamique keyless représentable | PASS | | `429` classé | PLANNED | | `Retry-After` honoré lorsqu'exploitable | PLANNED | | cooldown expose prochain instant admissible | PLANNED | @@ -96,21 +112,21 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d | quota indisponible distingué d'un transport down | PLANNED | | quota mensuel local non présenté comme compteur authoritative | PLANNED | -## 6. Numeric safety +## 7. Numeric safety | Cas | Statut | |-----------------------------------------------------|---------| -| décimal string valide | PLANNED | +| décimal string valide | PASS | | JSON number valide sans passage canonique par `f64` | PLANNED | -| notation scientifique bornée | PLANNED | -| coefficient/scale overflow rejeté | PLANNED | -| valeur négative rejetée | PLANNED | -| zéro rejeté pour une observation réussie | PLANNED | -| NaN/Inf impossibles dans le type canonique | PLANNED | -| serialization canonique sans perte | PLANNED | -| round-trip public déterministe | PLANNED | +| notation scientifique bornée | PASS | +| coefficient/scale overflow rejeté | PASS | +| valeur négative rejetée | PASS | +| zéro rejeté pour une observation réussie | PASS | +| NaN/Inf impossibles dans le type canonique | PASS | +| serialization canonique sans perte | PASS | +| round-trip public déterministe | PASS | -## 7. HTTP et sécurité +## 8. HTTP et sécurité | Invariant | Statut | |------------------------------------------------|---------| @@ -130,7 +146,7 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d | 5xx classé transient | PLANNED | | schema drift classé provider protocol | PLANNED | -## 8. DexScreener V1 +## 9. DexScreener V1 | Invariant | Statut | |----------------------------------------------|---------| @@ -142,7 +158,7 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d | aucun tri automatique par liquidité | PLANNED | | aucune moyenne/consensus entre pools | PLANNED | -## 9. Config -> Off-chain Transport +## 10. Config -> Off-chain Transport | Invariant | Statut | |------------------------------------------------------------------|---------| @@ -159,7 +175,7 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d | `.env.example` synchronisé pour les secrets réellement ajoutés | PLANNED | | aucune cadence provider configurable au-dessus de la limite sûre | PLANNED | -## 10. Frontière future `ksp-app-solprices-desk` +## 11. Frontière future `ksp-app-solprices-desk` | Invariant | Statut | |--------------------------------------------------------------------|---------| @@ -175,7 +191,7 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d Cette section valide l'architecture préparée par `0.2.11`; l'application elle-même reste hors scope et sera testée en `0.2.12`. -## 11. Non-régression et gates finaux prévus +## 12. Non-régression et gates finaux prévus | Gate | Statut | |-------------------------------------------------|---------| @@ -193,7 +209,7 @@ Cette section valide l'architecture préparée par `0.2.11`; l'application elle- | smokes keyed gratuits disponibles à l'opérateur | PLANNED | | réconciliation README/USAGE/plan/validation | PLANNED | -## 12. Hors scope validé +## 13. Hors scope validé ```text SOL/EUR et autres quotes