v0.2.11-pre.002
This commit is contained in:
@@ -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"
|
||||
|
||||
18
crates/ksp-offchain-transport-lib/Cargo.toml
Normal file
18
crates/ksp-offchain-transport-lib/Cargo.toml
Normal 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
|
||||
225
crates/ksp-offchain-transport-lib/src/decimal.rs
Normal file
225
crates/ksp-offchain-transport-lib/src/decimal.rs
Normal 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;
|
||||
13
crates/ksp-offchain-transport-lib/src/error.rs
Normal file
13
crates/ksp-offchain-transport-lib/src/error.rs
Normal 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");
|
||||
75
crates/ksp-offchain-transport-lib/src/lib.rs
Normal file
75
crates/ksp-offchain-transport-lib/src/lib.rs
Normal 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;
|
||||
169
crates/ksp-offchain-transport-lib/src/observation.rs
Normal file
169
crates/ksp-offchain-transport-lib/src/observation.rs
Normal 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;
|
||||
401
crates/ksp-offchain-transport-lib/src/provider.rs
Normal file
401
crates/ksp-offchain-transport-lib/src/provider.rs
Normal 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;
|
||||
36
crates/ksp-offchain-transport-lib/src/settings.rs
Normal file
36
crates/ksp-offchain-transport-lib/src/settings.rs
Normal 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;
|
||||
@@ -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"));
|
||||
}
|
||||
78
crates/ksp-offchain-transport-lib/tests/public_api.rs
Normal file
78
crates/ksp-offchain-transport-lib/tests/public_api.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
59
crates/ksp-offchain-transport-lib/unit_tests/decimal.rs
Normal file
59
crates/ksp-offchain-transport-lib/unit_tests/decimal.rs
Normal 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(());
|
||||
}
|
||||
69
crates/ksp-offchain-transport-lib/unit_tests/observation.rs
Normal file
69
crates/ksp-offchain-transport-lib/unit_tests/observation.rs
Normal 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(());
|
||||
}
|
||||
77
crates/ksp-offchain-transport-lib/unit_tests/provider.rs
Normal file
77
crates/ksp-offchain-transport-lib/unit_tests/provider.rs
Normal 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(());
|
||||
}
|
||||
23
crates/ksp-offchain-transport-lib/unit_tests/settings.rs
Normal file
23
crates/ksp-offchain-transport-lib/unit_tests/settings.rs
Normal 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
278
deltas/0.2.11/pre.002.md
Normal 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+`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user