v0.2.11-pre.002

This commit is contained in:
2026-08-25 19:45:06 +02:00
parent f42fa8f4f1
commit 4f92fb03f1
17 changed files with 1610 additions and 44 deletions

View File

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

View File

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

View File

@@ -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<Self> {
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::<u128>() {
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<Self, Self::Err> {
return crate::PriceDecimal::parse(source);
}
}
impl serde::Serialize for PriceDecimal {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
return serializer.serialize_str(self.to_canonical_string().as_str());
}
}
impl<'de> serde::Deserialize<'de> for PriceDecimal {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
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<E>(self, value: &str) -> std::result::Result<Self::Value, E>
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<u128> {
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::<i32>() {
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;

View File

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

View File

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

View File

@@ -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<std::string::String>) -> ksp_core_lib::Result<Self> {
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<std::string::String> for PriceProvenance {
type Error = ksp_core_lib::Error;
fn try_from(value: std::string::String) -> std::result::Result<Self, Self::Error> {
return crate::PriceProvenance::new(value);
}
}
impl std::convert::From<PriceProvenance> 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<crate::PriceTimestamp>,
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<crate::PriceTimestamp>,
provenance: crate::PriceProvenance,
) -> ksp_core_lib::Result<Self> {
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<crate::PriceTimestamp> {
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;

View File

@@ -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<u32>,
kind: crate::PriceProviderRateLimitKind,
requests: std::option::Option<u32>,
scope: crate::PriceProviderRateLimitScope,
window_seconds: std::option::Option<u32>,
}
impl PriceProviderRateLimit {
/// Creates a validated fixed request limit.
pub fn fixed(requests: u32, window_seconds: u32, burst: std::option::Option<u32>, scope: crate::PriceProviderRateLimitScope) -> ksp_core_lib::Result<Self> {
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<u32> {
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<u32> {
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<u32> {
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<Self> {
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<std::string::String>) -> ksp_core_lib::Result<Self> {
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<std::string::String> for PriceProviderId {
type Error = ksp_core_lib::Error;
fn try_from(value: std::string::String) -> std::result::Result<Self, Self::Error> {
return crate::PriceProviderId::new(value);
}
}
impl std::convert::From<PriceProviderId> 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<crate::PriceProviderLongTermQuota>,
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<std::string::String>,
semantics: crate::PriceSemantics,
auth_mode: crate::PriceProviderAuthMode,
rate_limit: crate::PriceProviderRateLimit,
long_term_quota: std::option::Option<crate::PriceProviderLongTermQuota>,
supports_sol_usd: bool,
) -> ksp_core_lib::Result<Self> {
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<crate::PriceProviderLongTermQuota> {
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<crate::PriceTimestamp>,
},
}
/// 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;

View File

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

View File

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

View File

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

View File

@@ -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::<crate::PriceDecimal>("123.45").is_err());
return std::result::Result::Ok(());
}

View File

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

View File

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

View File

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

278
deltas/0.2.11/pre.002.md Normal file
View File

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

View File

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

View File

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