v0.2.11-pre.007
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 277
|
||||
# version: 278
|
||||
|
||||
[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-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.11-pre.6"
|
||||
version = "0.2.11-pre.7"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/error.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
/// Stable off-chain transport error for HTTP 401/403 access denial.
|
||||
pub const ERROR_CODE_HTTP_ACCESS_DENIED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_access_denied");
|
||||
@@ -47,3 +47,6 @@ pub const ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID: ksp_core_lib::Error
|
||||
/// Stable off-chain transport error for invalid common market-price provider settings.
|
||||
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_settings_invalid");
|
||||
/// Stable off-chain transport error for an invalid market-price provider registry.
|
||||
pub const ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_registry_invalid");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/lib.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
//! KSP-owned off-chain transport foundation.
|
||||
//!
|
||||
//! `0.2.11-pre.006` keeps the first capability family deliberately narrow (`market_price`, SOL/USD only) and adds Jupiter Price V3 plus DexScreener.
|
||||
//! Jupiter preserves its Solana heuristic semantics and block provenance without inventing a price timestamp. DexScreener is bound to one explicitly
|
||||
//! configured Solana pair address, validates that the returned pair is the configured pair with wrapped SOL as base identity, and consumes only `priceUsd`.
|
||||
//! Provider wire DTOs remain private, no provider SDK is used, and Config plus generic registry/refresh orchestration remain outside this tranche.
|
||||
//! `0.2.11-pre.007` completes the eight-provider SOL/USD adapter inventory with Birdeye and materializes the provider-neutral runtime registry.
|
||||
//! Birdeye uses its authenticated Solana Price Single endpoint, retains provider update time, and models both the Standard account cadence and informational
|
||||
//! compute-unit quota/cost metadata. Registry projections expose descriptors and generic availability without provider-specific branching. Refresh dispatch and
|
||||
//! Config integration remain outside this tranche.
|
||||
|
||||
mod constants;
|
||||
mod error;
|
||||
@@ -19,6 +19,7 @@ mod http_client;
|
||||
mod http_settings;
|
||||
mod market_price_adapter;
|
||||
mod market_price_api_key;
|
||||
mod market_price_birdeye;
|
||||
mod market_price_coinbase_exchange;
|
||||
mod market_price_coingecko;
|
||||
mod market_price_coinmarketcap;
|
||||
@@ -29,6 +30,7 @@ mod market_price_jupiter;
|
||||
mod market_price_kraken;
|
||||
mod market_price_observation;
|
||||
mod market_price_provider;
|
||||
mod market_price_registry;
|
||||
mod market_price_settings;
|
||||
|
||||
/// Stable error code for HTTP access denial.
|
||||
@@ -71,6 +73,12 @@ pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID;
|
||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID;
|
||||
/// Stable error code for invalid common provider settings.
|
||||
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID;
|
||||
/// Stable error code for an invalid provider registry.
|
||||
pub use self::error::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID;
|
||||
/// Birdeye Standard SOL/USD provider adapter.
|
||||
pub use self::market_price_birdeye::MarketPriceBirdeyeProvider;
|
||||
/// Birdeye Standard runtime settings.
|
||||
pub use self::market_price_birdeye::MarketPriceBirdeyeSettings;
|
||||
/// Coinbase Exchange SOL/USD provider adapter.
|
||||
pub use self::market_price_coinbase_exchange::MarketPriceCoinbaseExchangeProvider;
|
||||
/// Coinbase Exchange runtime settings.
|
||||
@@ -145,10 +153,16 @@ pub use self::market_price_provider::MarketPriceProviderRateLimit;
|
||||
pub use self::market_price_provider::MarketPriceProviderRateLimitKind;
|
||||
/// Scope to which a provider documents one request limit.
|
||||
pub use self::market_price_provider::MarketPriceProviderRateLimitScope;
|
||||
/// Informational cost of one normalized SOL/USD request in a provider-defined quota unit.
|
||||
pub use self::market_price_provider::MarketPriceProviderRequestCost;
|
||||
/// Current provider-neutral runtime state projection.
|
||||
pub use self::market_price_provider::MarketPriceProviderState;
|
||||
/// Market-price semantics retained so consumers never assume all providers report equivalent market values.
|
||||
pub use self::market_price_provider::MarketPriceSemantics;
|
||||
/// Deterministically ordered registry of configured market-price providers.
|
||||
pub use self::market_price_registry::MarketPriceProviderRegistry;
|
||||
/// Immutable descriptor-plus-state entry exposed by the configured provider registry.
|
||||
pub use self::market_price_registry::MarketPriceProviderRegistryEntry;
|
||||
/// Common provider settings shared by provider-specific runtime settings.
|
||||
pub use self::market_price_settings::MarketPriceProviderCommonSettings;
|
||||
|
||||
|
||||
238
crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
Normal file
238
crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
|
||||
// version: 1
|
||||
|
||||
//! Birdeye SOL/USD market-price adapter using the official Price Single REST endpoint.
|
||||
|
||||
const BIRDEYE_API_KEY_HEADER: &str = "x-api-key";
|
||||
const BIRDEYE_CHAIN_HEADER: &str = "x-chain";
|
||||
const BIRDEYE_PRICE_URL: &str = "https://public-api.birdeye.so/defi/price";
|
||||
const BIRDEYE_PROVIDER_ID: &str = "birdeye";
|
||||
const BIRDEYE_SOL_MINT: &str = "So11111111111111111111111111111111111111112";
|
||||
|
||||
/// Runtime settings for the Birdeye Standard market-price adapter.
|
||||
pub struct MarketPriceBirdeyeSettings {
|
||||
api_key: std::option::Option<crate::MarketPriceApiKey>,
|
||||
common: crate::MarketPriceProviderCommonSettings,
|
||||
}
|
||||
|
||||
impl crate::MarketPriceBirdeyeSettings {
|
||||
/// Creates Birdeye settings. An API key is mandatory while the provider is enabled.
|
||||
pub fn new(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
let provider_id = match crate::MarketPriceProviderId::new(BIRDEYE_PROVIDER_ID) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let api_key = match api_key {
|
||||
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(BIRDEYE_PROVIDER_ID, value) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
},
|
||||
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
|
||||
return std::result::Result::Ok(Self { api_key, common });
|
||||
}
|
||||
|
||||
/// Returns common provider identity and enablement settings.
|
||||
#[must_use]
|
||||
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
||||
return &self.common;
|
||||
}
|
||||
|
||||
fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> {
|
||||
return self.api_key.as_ref();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::MarketPriceBirdeyeSettings {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("MarketPriceBirdeyeSettings")
|
||||
.field("api_key_present", &self.api_key.is_some())
|
||||
.field("common", &self.common)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Birdeye SOL/USD provider adapter backed by the Standard Price Single endpoint.
|
||||
pub struct MarketPriceBirdeyeProvider {
|
||||
admission: crate::HttpAdmissionController,
|
||||
descriptor: crate::MarketPriceProviderDescriptor,
|
||||
http: crate::HttpRestClient,
|
||||
settings: crate::MarketPriceBirdeyeSettings,
|
||||
}
|
||||
|
||||
impl crate::MarketPriceBirdeyeProvider {
|
||||
/// Builds one Birdeye provider from validated runtime settings.
|
||||
pub fn new(settings: crate::MarketPriceBirdeyeSettings) -> ksp_core_lib::Result<Self> {
|
||||
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
|
||||
}
|
||||
|
||||
/// Returns the provider-neutral Birdeye capability descriptor.
|
||||
#[must_use]
|
||||
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||
return &self.descriptor;
|
||||
}
|
||||
|
||||
/// Returns the validated Birdeye settings without exposing credential material.
|
||||
#[must_use]
|
||||
pub const fn settings(&self) -> &crate::MarketPriceBirdeyeSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Fetches one normalized SOL/USD observation from Birdeye Price Single.
|
||||
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||
if !self.settings.common().enabled() {
|
||||
return std::result::Result::Err(crate::provider_disabled_error(BIRDEYE_PROVIDER_ID));
|
||||
}
|
||||
if let std::result::Result::Err(error) = crate::admit_request(BIRDEYE_PROVIDER_ID, &self.admission) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let request_started_at = match crate::current_timestamp() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let request = match build_request(&self.settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let document = match crate::get_json(&self.http, &self.admission, BIRDEYE_PROVIDER_ID, request).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let received_at = match crate::current_timestamp() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request(settings: &crate::MarketPriceBirdeyeSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
||||
let mut request = match crate::HttpGetRequest::new_https(BIRDEYE_PRICE_URL) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
request.append_query_pair("address", BIRDEYE_SOL_MINT);
|
||||
if let std::result::Result::Err(error) = request.insert_sensitive_header(BIRDEYE_CHAIN_HEADER, "solana") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let api_key = match settings.api_key() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(provider_settings_error("api_key")),
|
||||
};
|
||||
if let std::result::Result::Err(error) = request.insert_sensitive_header(BIRDEYE_API_KEY_HEADER, api_key.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(request);
|
||||
}
|
||||
|
||||
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let quota = match crate::MarketPriceProviderLongTermQuota::new(
|
||||
30_000,
|
||||
crate::MarketPriceProviderQuotaPeriod::Month,
|
||||
crate::MarketPriceProviderQuotaUnit::ComputeUnits,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let request_cost = match crate::MarketPriceProviderRequestCost::new(3, crate::MarketPriceProviderQuotaUnit::ComputeUnits) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let descriptor = match crate::MarketPriceProviderDescriptor::new(
|
||||
provider_id,
|
||||
"Birdeye",
|
||||
crate::MarketPriceSemantics::SolanaSpot,
|
||||
crate::MarketPriceProviderAuthMode::RequiredApiKey,
|
||||
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),
|
||||
};
|
||||
return descriptor.with_sol_usd_request_cost(request_cost);
|
||||
}
|
||||
|
||||
fn parse_response(
|
||||
bytes: &[u8],
|
||||
provider_id: crate::MarketPriceProviderId,
|
||||
request_started_at: crate::MarketPriceTimestamp,
|
||||
received_at: crate::MarketPriceTimestamp,
|
||||
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||
let wire = match serde_json::from_slice::<BirdeyeWireResponse>(bytes) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(crate::invalid_provider_response_with_source(BIRDEYE_PROVIDER_ID, "response", error));
|
||||
},
|
||||
};
|
||||
if !wire.success {
|
||||
return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "success"));
|
||||
}
|
||||
let data = match wire.data {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "data")),
|
||||
};
|
||||
let price_raw = match data.value.as_deref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "data.value")),
|
||||
};
|
||||
let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provider_timestamp = match data.update_unix_time.and_then(crate::market_price_timestamp_from_unix_seconds) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "data.updateUnixTime")),
|
||||
};
|
||||
let provenance = match crate::MarketPriceProvenance::new("birdeye:solana:wsol:value") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::MarketPriceObservation::new(
|
||||
provider_id,
|
||||
price,
|
||||
crate::MarketPriceSemantics::SolanaSpot,
|
||||
request_started_at,
|
||||
received_at,
|
||||
std::option::Option::Some(provider_timestamp),
|
||||
provenance,
|
||||
);
|
||||
}
|
||||
|
||||
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "Birdeye market-price settings are invalid")
|
||||
.with_context("provider", BIRDEYE_PROVIDER_ID)
|
||||
.with_context("field", field);
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BirdeyeWireData {
|
||||
#[serde(rename = "updateUnixTime")]
|
||||
update_unix_time: std::option::Option<u64>,
|
||||
value: std::option::Option<std::boxed::Box<serde_json::value::RawValue>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BirdeyeWireResponse {
|
||||
data: std::option::Option<BirdeyeWireData>,
|
||||
success: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/market_price_birdeye.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/market_price_provider.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Maximum UTF-8 byte length of one provider display name.
|
||||
pub const MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES: usize = 96;
|
||||
@@ -163,6 +163,8 @@ pub enum MarketPriceProviderQuotaPeriod {
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceProviderQuotaUnit {
|
||||
/// Provider-defined compute units consumed by API operations.
|
||||
ComputeUnits,
|
||||
/// Provider-specific credits, not assumed to equal HTTP requests.
|
||||
Credits,
|
||||
/// HTTP/API requests.
|
||||
@@ -205,6 +207,35 @@ impl MarketPriceProviderLongTermQuota {
|
||||
}
|
||||
}
|
||||
|
||||
/// Informational cost of one normalized SOL/USD request in a provider-defined quota unit.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
|
||||
pub struct MarketPriceProviderRequestCost {
|
||||
amount: u64,
|
||||
unit: crate::MarketPriceProviderQuotaUnit,
|
||||
}
|
||||
|
||||
impl crate::MarketPriceProviderRequestCost {
|
||||
/// Creates a non-zero informational request-cost descriptor.
|
||||
pub fn new(amount: u64, unit: crate::MarketPriceProviderQuotaUnit) -> ksp_core_lib::Result<Self> {
|
||||
if amount == 0 {
|
||||
return std::result::Result::Err(provider_descriptor_error());
|
||||
}
|
||||
return std::result::Result::Ok(Self { amount, unit });
|
||||
}
|
||||
|
||||
/// Returns the documented amount consumed by one SOL/USD request.
|
||||
#[must_use]
|
||||
pub const fn amount(&self) -> u64 {
|
||||
return self.amount;
|
||||
}
|
||||
|
||||
/// Returns the provider-defined quota unit used by this request cost.
|
||||
#[must_use]
|
||||
pub const fn unit(&self) -> crate::MarketPriceProviderQuotaUnit {
|
||||
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")]
|
||||
@@ -261,6 +292,7 @@ pub struct MarketPriceProviderDescriptor {
|
||||
long_term_quota: std::option::Option<crate::MarketPriceProviderLongTermQuota>,
|
||||
rate_limit: crate::MarketPriceProviderRateLimit,
|
||||
semantics: crate::MarketPriceSemantics,
|
||||
sol_usd_request_cost: std::option::Option<crate::MarketPriceProviderRequestCost>,
|
||||
supports_sol_usd: bool,
|
||||
}
|
||||
|
||||
@@ -281,7 +313,27 @@ impl MarketPriceProviderDescriptor {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "display_name", "rejected invalid market-price provider descriptor");
|
||||
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 });
|
||||
return std::result::Result::Ok(Self {
|
||||
auth_mode,
|
||||
display_name,
|
||||
id,
|
||||
long_term_quota,
|
||||
rate_limit,
|
||||
semantics,
|
||||
sol_usd_request_cost: std::option::Option::None,
|
||||
supports_sol_usd,
|
||||
});
|
||||
}
|
||||
|
||||
/// Attaches informational provider cost metadata for one normalized SOL/USD request.
|
||||
pub fn with_sol_usd_request_cost(mut self, request_cost: crate::MarketPriceProviderRequestCost) -> ksp_core_lib::Result<Self> {
|
||||
if let std::option::Option::Some(quota) = self.long_term_quota
|
||||
&& quota.unit() != request_cost.unit()
|
||||
{
|
||||
return std::result::Result::Err(provider_descriptor_error());
|
||||
}
|
||||
self.sol_usd_request_cost = std::option::Option::Some(request_cost);
|
||||
return std::result::Result::Ok(self);
|
||||
}
|
||||
|
||||
/// Returns the configured authentication capability.
|
||||
@@ -320,6 +372,12 @@ impl MarketPriceProviderDescriptor {
|
||||
return self.semantics;
|
||||
}
|
||||
|
||||
/// Returns optional informational provider cost for one normalized SOL/USD request.
|
||||
#[must_use]
|
||||
pub const fn sol_usd_request_cost(&self) -> std::option::Option<crate::MarketPriceProviderRequestCost> {
|
||||
return self.sol_usd_request_cost;
|
||||
}
|
||||
|
||||
/// Reports whether this descriptor can serve the V1 SOL/USD pair.
|
||||
#[must_use]
|
||||
pub const fn supports_sol_usd(&self) -> bool {
|
||||
@@ -353,6 +411,24 @@ pub enum MarketPriceProviderAvailability {
|
||||
},
|
||||
}
|
||||
|
||||
impl crate::MarketPriceProviderAvailability {
|
||||
/// Reports whether a generic refresh may be attempted immediately.
|
||||
#[must_use]
|
||||
pub const fn is_refresh_eligible(&self) -> bool {
|
||||
return matches!(self, Self::Ready);
|
||||
}
|
||||
|
||||
/// Returns the known next retry timestamp for cooling-down or temporary states.
|
||||
#[must_use]
|
||||
pub const fn retry_at(&self) -> std::option::Option<crate::MarketPriceTimestamp> {
|
||||
return match self {
|
||||
Self::CoolingDown { retry_at } => std::option::Option::Some(*retry_at),
|
||||
Self::TemporarilyUnavailable { retry_at } => *retry_at,
|
||||
Self::AuthenticationUnavailable | Self::Disabled | Self::Misconfigured | Self::QuotaUnavailable | Self::Ready => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Current provider-neutral runtime state projection.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
pub struct MarketPriceProviderState {
|
||||
|
||||
104
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
Normal file
104
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/market_price_registry.rs
|
||||
// version: 1
|
||||
|
||||
//! Provider-neutral registry projection for configured market-price providers.
|
||||
|
||||
const MARKET_PRICE_PROVIDER_REGISTRY_MAX_ENTRIES: usize = 64;
|
||||
|
||||
/// Immutable provider descriptor plus its current generic runtime state.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
pub struct MarketPriceProviderRegistryEntry {
|
||||
descriptor: crate::MarketPriceProviderDescriptor,
|
||||
state: crate::MarketPriceProviderState,
|
||||
}
|
||||
|
||||
impl crate::MarketPriceProviderRegistryEntry {
|
||||
/// Returns the provider capability descriptor.
|
||||
#[must_use]
|
||||
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||
return &self.descriptor;
|
||||
}
|
||||
|
||||
/// Returns the provider-neutral runtime state.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> &crate::MarketPriceProviderState {
|
||||
return &self.state;
|
||||
}
|
||||
|
||||
/// Creates one generic registry entry while keeping descriptor and state identity synchronized.
|
||||
#[must_use]
|
||||
pub fn new(descriptor: crate::MarketPriceProviderDescriptor, availability: crate::MarketPriceProviderAvailability) -> Self {
|
||||
let state = crate::MarketPriceProviderState::new(descriptor.id().clone(), availability);
|
||||
return Self { descriptor, state };
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministically ordered registry of configured market-price providers.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)]
|
||||
pub struct MarketPriceProviderRegistry {
|
||||
entries: std::vec::Vec<crate::MarketPriceProviderRegistryEntry>,
|
||||
}
|
||||
|
||||
impl crate::MarketPriceProviderRegistry {
|
||||
/// Returns a provider descriptor by opaque identifier.
|
||||
#[must_use]
|
||||
pub fn descriptor(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<&crate::MarketPriceProviderDescriptor> {
|
||||
return self.entry(provider_id).map(crate::MarketPriceProviderRegistryEntry::descriptor);
|
||||
}
|
||||
|
||||
/// Returns all configured provider entries in stable provider-id order.
|
||||
#[must_use]
|
||||
pub fn entries(&self) -> &[crate::MarketPriceProviderRegistryEntry] {
|
||||
return self.entries.as_slice();
|
||||
}
|
||||
|
||||
/// Returns one configured registry entry by opaque identifier.
|
||||
#[must_use]
|
||||
pub fn entry(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<&crate::MarketPriceProviderRegistryEntry> {
|
||||
let index = self.entries.binary_search_by(|entry| return entry.descriptor().id().cmp(provider_id));
|
||||
return match index {
|
||||
std::result::Result::Ok(value) => self.entries.get(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Reports whether no provider is currently registered.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
return self.entries.is_empty();
|
||||
}
|
||||
|
||||
/// Returns the configured provider count.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
return self.entries.len();
|
||||
}
|
||||
|
||||
/// Returns one provider-neutral runtime state by opaque identifier.
|
||||
#[must_use]
|
||||
pub fn state(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<&crate::MarketPriceProviderState> {
|
||||
return self.entry(provider_id).map(crate::MarketPriceProviderRegistryEntry::state);
|
||||
}
|
||||
|
||||
/// Builds one bounded deterministic registry and rejects duplicate provider identifiers.
|
||||
pub fn new(mut entries: std::vec::Vec<crate::MarketPriceProviderRegistryEntry>) -> ksp_core_lib::Result<Self> {
|
||||
if entries.len() > MARKET_PRICE_PROVIDER_REGISTRY_MAX_ENTRIES {
|
||||
return std::result::Result::Err(registry_error("entry_count"));
|
||||
}
|
||||
entries.sort_by(|left, right| return left.descriptor().id().cmp(right.descriptor().id()));
|
||||
for index in 1..entries.len() {
|
||||
if entries[index - 1].descriptor().id() == entries[index].descriptor().id() {
|
||||
return std::result::Result::Err(registry_error("provider_id"));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(Self { entries });
|
||||
}
|
||||
}
|
||||
|
||||
fn registry_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID, "Market-price provider registry is invalid").with_context("field", field);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/market_price_registry.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -87,3 +87,23 @@ fn pre_006_jupiter_and_dexscreener_stay_market_price_scoped_fixed_origin_and_dis
|
||||
assert!(!dexscreener.contains("std::env"));
|
||||
assert!(!jupiter.contains("std::env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_birdeye_and_registry_remain_provider_owned_generic_and_fixed_origin() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(crate_root.contains("mod market_price_birdeye;"));
|
||||
assert!(crate_root.contains("mod market_price_registry;"));
|
||||
let birdeye = include_str!("../src/market_price_birdeye.rs");
|
||||
let registry = include_str!("../src/market_price_registry.rs");
|
||||
assert!(birdeye.contains("https://public-api.birdeye.so/defi/price"));
|
||||
assert!(birdeye.contains("x-api-key"));
|
||||
assert!(birdeye.contains("x-chain"));
|
||||
assert!(birdeye.contains("So11111111111111111111111111111111111111112"));
|
||||
assert!(!birdeye.contains("std::env"));
|
||||
assert!(!registry.contains("CoinGecko"));
|
||||
assert!(!registry.contains("CoinMarketCap"));
|
||||
assert!(!registry.contains("CoinPaprika"));
|
||||
assert!(!registry.contains("DexScreener"));
|
||||
assert!(!registry.contains("Jupiter"));
|
||||
assert!(!registry.contains("Kraken"));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -147,6 +147,42 @@ fn public_pre_006_jupiter_and_dexscreener_settings_and_adapters_are_available_fr
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_007_birdeye_and_provider_registry_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
|
||||
let settings = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provider = match ksp_offchain_transport_lib::MarketPriceBirdeyeProvider::new(settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(provider.descriptor().id().as_str(), "birdeye");
|
||||
assert_eq!(provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot);
|
||||
let request_cost = match provider.descriptor().sol_usd_request_cost() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID,
|
||||
"Birdeye descriptor is missing request-cost metadata",
|
||||
));
|
||||
},
|
||||
};
|
||||
assert_eq!(request_cost.amount(), 3);
|
||||
assert_eq!(request_cost.unit(), ksp_offchain_transport_lib::MarketPriceProviderQuotaUnit::ComputeUnits);
|
||||
let ready = ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready;
|
||||
assert!(ready.is_refresh_eligible());
|
||||
assert_eq!(ready.retry_at(), std::option::Option::None);
|
||||
let entry = ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry::new(provider.descriptor().clone(), ready);
|
||||
let registry = match ksp_offchain_transport_lib::MarketPriceProviderRegistry::new(std::vec![entry]) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.entries()[0].descriptor().id().as_str(), "birdeye");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offchain_error_codes_use_owned_domain() {
|
||||
let codes = [
|
||||
@@ -170,6 +206,7 @@ fn offchain_error_codes_use_owned_domain() {
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID,
|
||||
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID,
|
||||
];
|
||||
for code in codes {
|
||||
assert_eq!(code.domain(), "offchain_transport");
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_birdeye.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn birdeye_fixture_maps_exact_spot_price_and_provider_update_time() -> ksp_core_lib::Result<()> {
|
||||
let provider_id = match crate::MarketPriceProviderId::new(super::BIRDEYE_PROVIDER_ID) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fixture = br#"{"data":{"value":151.987654321012345678,"updateUnixTime":1778248899,"updateHumanTime":"2026-05-08T19:01:39"},"success":true}"#;
|
||||
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
|
||||
let observation = match super::parse_response(fixture, provider_id, started, received) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
|
||||
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::SolanaSpot);
|
||||
assert_eq!(observation.provider_timestamp().map(|value| return value.unix_millis()), std::option::Option::Some(1_778_248_899_000));
|
||||
assert_eq!(observation.provenance().as_str(), "birdeye:solana:wsol:value");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn birdeye_standard_descriptor_models_account_rate_compute_quota_and_request_cost() -> ksp_core_lib::Result<()> {
|
||||
let settings = match crate::MarketPriceBirdeyeSettings::new(true, std::option::Option::Some("birdeye-standard-canary-secret".to_owned())) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(!std::format!("{settings:?}").contains("birdeye-standard-canary-secret"));
|
||||
let request = match super::build_request(&settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(request.has_header_for_test(super::BIRDEYE_API_KEY_HEADER));
|
||||
assert!(request.has_header_for_test(super::BIRDEYE_CHAIN_HEADER));
|
||||
assert_eq!(request.url_for_test().query(), std::option::Option::Some("address=So11111111111111111111111111111111111111112"));
|
||||
let provider = match crate::MarketPriceBirdeyeProvider::new(settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let descriptor = provider.descriptor();
|
||||
assert_eq!(descriptor.auth_mode(), crate::MarketPriceProviderAuthMode::RequiredApiKey);
|
||||
assert_eq!(descriptor.semantics(), crate::MarketPriceSemantics::SolanaSpot);
|
||||
assert_eq!(descriptor.rate_limit().requests(), std::option::Option::Some(1));
|
||||
assert_eq!(descriptor.rate_limit().window_seconds(), std::option::Option::Some(1));
|
||||
assert_eq!(descriptor.rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Account);
|
||||
let quota = match descriptor.long_term_quota() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(test_error("quota")),
|
||||
};
|
||||
assert_eq!(quota.amount(), 30_000);
|
||||
assert_eq!(quota.period(), crate::MarketPriceProviderQuotaPeriod::Month);
|
||||
assert_eq!(quota.unit(), crate::MarketPriceProviderQuotaUnit::ComputeUnits);
|
||||
let request_cost = match descriptor.sol_usd_request_cost() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(test_error("request_cost")),
|
||||
};
|
||||
assert_eq!(request_cost.amount(), 3);
|
||||
assert_eq!(request_cost.unit(), crate::MarketPriceProviderQuotaUnit::ComputeUnits);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn birdeye_rejects_missing_key_unsuccessful_missing_price_and_missing_update_time() -> ksp_core_lib::Result<()> {
|
||||
assert!(crate::MarketPriceBirdeyeSettings::new(true, std::option::Option::None).is_err());
|
||||
assert!(crate::MarketPriceBirdeyeSettings::new(false, std::option::Option::None).is_ok());
|
||||
let provider_id = match crate::MarketPriceProviderId::new(super::BIRDEYE_PROVIDER_ID) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||
let unsuccessful = br#"{"data":{"value":151.9,"updateUnixTime":1778248899},"success":false}"#;
|
||||
assert!(super::parse_response(unsuccessful, provider_id.clone(), timestamp, timestamp).is_err());
|
||||
let missing_price = br#"{"data":{"value":null,"updateUnixTime":1778248899},"success":true}"#;
|
||||
assert!(super::parse_response(missing_price, provider_id.clone(), timestamp, timestamp).is_err());
|
||||
let missing_update_time = br#"{"data":{"value":151.9},"success":true}"#;
|
||||
assert!(super::parse_response(missing_update_time, provider_id, timestamp, timestamp).is_err());
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn test_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID, "Birdeye test expectation failed")
|
||||
.with_context("field", field);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
#[test]
|
||||
fn provider_id_is_opaque_bounded_and_stable() -> ksp_core_lib::Result<()> {
|
||||
@@ -46,6 +46,7 @@ fn provider_descriptor_preserves_semantics_auth_limits_and_informational_quota()
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(descriptor.display_name(), "Provider A");
|
||||
assert_eq!(descriptor.sol_usd_request_cost(), std::option::Option::None);
|
||||
assert_eq!(descriptor.semantics(), crate::MarketPriceSemantics::AggregatedMarket);
|
||||
assert_eq!(descriptor.auth_mode(), crate::MarketPriceProviderAuthMode::OptionalApiKey);
|
||||
assert_eq!(descriptor.rate_limit(), rate_limit);
|
||||
@@ -67,6 +68,54 @@ fn provider_limit_descriptors_reject_zero_and_model_dynamic_scope() {
|
||||
assert_eq!(dynamic.burst(), std::option::Option::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_request_cost_matches_quota_unit_and_rejects_zero() -> ksp_core_lib::Result<()> {
|
||||
assert!(crate::MarketPriceProviderRequestCost::new(0, crate::MarketPriceProviderQuotaUnit::ComputeUnits).is_err());
|
||||
let id = match crate::MarketPriceProviderId::new("provider-cost") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let quota = match crate::MarketPriceProviderLongTermQuota::new(
|
||||
30_000,
|
||||
crate::MarketPriceProviderQuotaPeriod::Month,
|
||||
crate::MarketPriceProviderQuotaUnit::ComputeUnits,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let descriptor = match crate::MarketPriceProviderDescriptor::new(
|
||||
id,
|
||||
"Provider Cost",
|
||||
crate::MarketPriceSemantics::SolanaSpot,
|
||||
crate::MarketPriceProviderAuthMode::RequiredApiKey,
|
||||
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),
|
||||
};
|
||||
let request_cost = match crate::MarketPriceProviderRequestCost::new(3, crate::MarketPriceProviderQuotaUnit::ComputeUnits) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let descriptor = match descriptor.with_sol_usd_request_cost(request_cost) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(descriptor.sol_usd_request_cost(), std::option::Option::Some(request_cost));
|
||||
let wrong_unit = match crate::MarketPriceProviderRequestCost::new(3, crate::MarketPriceProviderQuotaUnit::Credits) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(descriptor.with_sol_usd_request_cost(wrong_unit).is_err());
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_availability_keeps_cooldown_and_outage_distinct() -> ksp_core_lib::Result<()> {
|
||||
let id = match crate::MarketPriceProviderId::new("jupiter") {
|
||||
@@ -76,6 +125,8 @@ fn provider_availability_keeps_cooldown_and_outage_distinct() -> ksp_core_lib::R
|
||||
let retry_at = crate::MarketPriceTimestamp::from_unix_millis(1_777_777_777_000);
|
||||
let state = crate::MarketPriceProviderState::new(id, crate::MarketPriceProviderAvailability::CoolingDown { retry_at });
|
||||
assert_eq!(state.availability(), crate::MarketPriceProviderAvailability::CoolingDown { retry_at });
|
||||
assert!(!state.availability().is_refresh_eligible());
|
||||
assert_eq!(state.availability().retry_at(), std::option::Option::Some(retry_at));
|
||||
assert_ne!(state.availability(), crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(retry_at) });
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn registry_orders_all_eight_v1_providers_and_exposes_only_generic_entries() -> ksp_core_lib::Result<()> {
|
||||
let entries = match all_v1_entries() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = match crate::MarketPriceProviderRegistry::new(entries) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_eq!(registry.len(), 8);
|
||||
let ids = registry.entries().iter().map(|entry| return entry.descriptor().id().as_str()).collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(ids, std::vec!["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken",]);
|
||||
assert!(registry.entries().iter().all(|entry| return entry.state().availability() == crate::MarketPriceProviderAvailability::Disabled));
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_rejects_duplicate_provider_ids_and_updates_generic_availability() -> ksp_core_lib::Result<()> {
|
||||
let descriptor = match simple_descriptor("provider-a") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let duplicate = std::vec![
|
||||
crate::MarketPriceProviderRegistryEntry::new(descriptor.clone(), crate::MarketPriceProviderAvailability::Disabled),
|
||||
crate::MarketPriceProviderRegistryEntry::new(descriptor, crate::MarketPriceProviderAvailability::Ready),
|
||||
];
|
||||
assert!(crate::MarketPriceProviderRegistry::new(duplicate).is_err());
|
||||
let descriptor = match simple_descriptor("provider-b") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provider_id = descriptor.id().clone();
|
||||
let retry_at = crate::MarketPriceTimestamp::from_unix_millis(42);
|
||||
let entry = crate::MarketPriceProviderRegistryEntry::new(descriptor, crate::MarketPriceProviderAvailability::CoolingDown { retry_at });
|
||||
let registry = match crate::MarketPriceProviderRegistry::new(std::vec![entry]) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let state = match registry.state(&provider_id) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(test_error("state")),
|
||||
};
|
||||
assert!(!state.availability().is_refresh_eligible());
|
||||
assert_eq!(state.availability().retry_at(), std::option::Option::Some(retry_at));
|
||||
assert_eq!(registry.descriptor(&provider_id).map(|value| return value.id()), std::option::Option::Some(&provider_id));
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn all_v1_entries() -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderRegistryEntry>> {
|
||||
let birdeye_settings = match crate::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let birdeye = match crate::MarketPriceBirdeyeProvider::new(birdeye_settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinbase_settings = match crate::MarketPriceCoinbaseExchangeSettings::new(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinbase = match crate::MarketPriceCoinbaseExchangeProvider::new(coinbase_settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coingecko_settings = match crate::MarketPriceCoinGeckoSettings::keyless(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coingecko = match crate::MarketPriceCoinGeckoProvider::new(coingecko_settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinmarketcap_settings = match crate::MarketPriceCoinMarketCapSettings::keyless(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinmarketcap = match crate::MarketPriceCoinMarketCapProvider::new(coinmarketcap_settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinpaprika_settings = match crate::MarketPriceCoinPaprikaSettings::new(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinpaprika = match crate::MarketPriceCoinPaprikaProvider::new(coinpaprika_settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let dexscreener_settings = match crate::MarketPriceDexScreenerSettings::new(false, "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let dexscreener = match crate::MarketPriceDexScreenerProvider::new(dexscreener_settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let jupiter_settings = match crate::MarketPriceJupiterSettings::keyless(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let jupiter = match crate::MarketPriceJupiterProvider::new(jupiter_settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let kraken_settings = match crate::MarketPriceKrakenSettings::new(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let kraken = match crate::MarketPriceKrakenProvider::new(kraken_settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(std::vec![
|
||||
disabled_entry(birdeye.descriptor()),
|
||||
disabled_entry(coinbase.descriptor()),
|
||||
disabled_entry(coingecko.descriptor()),
|
||||
disabled_entry(coinmarketcap.descriptor()),
|
||||
disabled_entry(coinpaprika.descriptor()),
|
||||
disabled_entry(dexscreener.descriptor()),
|
||||
disabled_entry(jupiter.descriptor()),
|
||||
disabled_entry(kraken.descriptor()),
|
||||
]);
|
||||
}
|
||||
|
||||
fn disabled_entry(descriptor: &crate::MarketPriceProviderDescriptor) -> crate::MarketPriceProviderRegistryEntry {
|
||||
return crate::MarketPriceProviderRegistryEntry::new(descriptor.clone(), crate::MarketPriceProviderAvailability::Disabled);
|
||||
}
|
||||
|
||||
fn simple_descriptor(provider_id: &'static str) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||
let provider_id = match crate::MarketPriceProviderId::new(provider_id) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::MarketPriceProviderDescriptor::new(
|
||||
provider_id,
|
||||
"Provider",
|
||||
crate::MarketPriceSemantics::AggregatedMarket,
|
||||
crate::MarketPriceProviderAuthMode::None,
|
||||
rate_limit,
|
||||
std::option::Option::None,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
fn test_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID, "Market-price registry test expectation failed")
|
||||
.with_context("field", field);
|
||||
}
|
||||
280
deltas/0.2.11/pre.007.md
Normal file
280
deltas/0.2.11/pre.007.md
Normal file
@@ -0,0 +1,280 @@
|
||||
<!-- file: deltas/0.2.11/pre.007.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.11-pre.007` — Birdeye + registry + availability
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Cette tranche s'applique exclusivement après `0.2.11-pre.006` accepté par l'opérateur.
|
||||
|
||||
Version Cargo attendue à l'entrée :
|
||||
|
||||
```text
|
||||
0.2.11-pre.6
|
||||
```
|
||||
|
||||
Version Cargo de sortie :
|
||||
|
||||
```text
|
||||
0.2.11-pre.7
|
||||
```
|
||||
|
||||
Le gate d'entrée ciblé du `2026-08-25` est PASS : `cargo fmt --all`, audit Rust clean avec 0 candidate export, audit Markdown clean à 120 tables / 111 fichiers, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test -p ksp-offchain-transport-lib` avec 43 unitaires + 4 boundary + 5 public API. `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.006` avant le passage explicite à cette tranche.
|
||||
|
||||
## 2. Objet strict de la tranche
|
||||
|
||||
`pre.007` ferme l'inventaire V1 des huit providers et matérialise le registry provider-neutral :
|
||||
|
||||
```text
|
||||
Birdeye Standard
|
||||
MarketPriceProviderRegistry
|
||||
MarketPriceProviderRegistryEntry
|
||||
availability générique consultable
|
||||
metadata de coût provider informatif
|
||||
```
|
||||
|
||||
La tranche n'ajoute pas encore :
|
||||
|
||||
```text
|
||||
refresh(provider_id)
|
||||
refresh multiple/all
|
||||
service de dispatch provider
|
||||
Config Off-chain Transport
|
||||
smoke live
|
||||
fallback/consensus
|
||||
```
|
||||
|
||||
## 3. Birdeye Standard
|
||||
|
||||
Ajout de :
|
||||
|
||||
```text
|
||||
market_price_birdeye.rs
|
||||
unit_tests/market_price_birdeye.rs
|
||||
MarketPriceBirdeyeSettings
|
||||
MarketPriceBirdeyeProvider
|
||||
```
|
||||
|
||||
Le réaudit officiel courant confirme :
|
||||
|
||||
```text
|
||||
base URL https://public-api.birdeye.so
|
||||
endpoint GET /defi/price
|
||||
mint SOL So11111111111111111111111111111111111111112
|
||||
chain header x-chain: solana
|
||||
auth header X-API-KEY
|
||||
Standard 0 USD, 30 000 compute units inclus
|
||||
limite compte 1 RPS
|
||||
Price Single 3 compute units par appel
|
||||
```
|
||||
|
||||
Une clé Birdeye est obligatoire lorsque le provider est activé. Un provider désactivé peut être construit sans secret afin que Config et le futur service puissent représenter un état non actif sans inventer de credential.
|
||||
|
||||
La clé reste dans `MarketPriceApiKey`, n'est jamais lue directement depuis l'environnement par Off-chain Transport et n'apparaît ni dans `Debug`, ni dans les logs, ni dans les erreurs.
|
||||
|
||||
Le request builder utilise uniquement l'origine officielle fixe, le mint WSOL fixe et `x-chain = solana`.
|
||||
|
||||
La réponse Price Single est normalisée depuis :
|
||||
|
||||
```text
|
||||
data.value
|
||||
data.updateUnixTime
|
||||
success
|
||||
```
|
||||
|
||||
`data.value` passe par `serde_json::RawValue` puis `MarketPriceDecimal` sans round-trip `f64`. `updateUnixTime` devient un vrai `provider_timestamp` en secondes Unix converties en millisecondes.
|
||||
|
||||
La sémantique retenue est :
|
||||
|
||||
```text
|
||||
MarketPriceSemantics::SolanaSpot
|
||||
```
|
||||
|
||||
La provenance sûre est :
|
||||
|
||||
```text
|
||||
birdeye:solana:wsol:value
|
||||
```
|
||||
|
||||
Une réponse `success = false`, un prix absent/null, un timestamp absent/pathologique ou un JSON incompatible sont rejetés sans copier le body distant dans les diagnostics.
|
||||
|
||||
## 4. Quota et coût provider informatifs
|
||||
|
||||
Birdeye utilise des compute units. Pour ne pas les confondre avec des crédits abstraits ou des requêtes HTTP, `MarketPriceProviderQuotaUnit` gagne :
|
||||
|
||||
```text
|
||||
ComputeUnits
|
||||
```
|
||||
|
||||
`MarketPriceProviderRequestCost` représente un coût unitaire documenté pour l'opération SOL/USD courante.
|
||||
|
||||
Le descriptor Birdeye expose :
|
||||
|
||||
```text
|
||||
long_term_quota = 30 000 ComputeUnits / Month
|
||||
sol_usd_request_cost = 3 ComputeUnits
|
||||
```
|
||||
|
||||
Ces valeurs sont informatives. KSP ne décrémente pas localement le quota et ne prétend pas connaître un solde authoritative : un autre processus ou une autre API du même compte peut consommer les mêmes compute units.
|
||||
|
||||
`MarketPriceProviderDescriptor::with_sol_usd_request_cost` vérifie que le coût et le quota utilisent la même unité lorsqu'un quota est présent.
|
||||
|
||||
## 5. Registry provider-neutral
|
||||
|
||||
Ajout de :
|
||||
|
||||
```text
|
||||
market_price_registry.rs
|
||||
unit_tests/market_price_registry.rs
|
||||
MarketPriceProviderRegistry
|
||||
MarketPriceProviderRegistryEntry
|
||||
ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID
|
||||
```
|
||||
|
||||
Une entrée contient seulement :
|
||||
|
||||
```text
|
||||
MarketPriceProviderDescriptor
|
||||
MarketPriceProviderState
|
||||
```
|
||||
|
||||
Le registry :
|
||||
|
||||
```text
|
||||
est borné à 64 entrées
|
||||
trie les entrées par provider_id
|
||||
rejette les provider_id dupliqués
|
||||
permet lookup descriptor/state/entry par identifiant opaque
|
||||
expose une slice déterministe d'entrées
|
||||
ne contient aucun nom ou dispatch provider spécifique
|
||||
```
|
||||
|
||||
Le constructeur reste public et provider-neutral, comme `MarketPriceProviderDescriptor::new` déjà existant. Cela ne donne aucun accès à un endpoint ou à un client HTTP. Le futur service `pre.008` possédera les transitions runtime et le dispatch des adapters ; la HID ne devra pas construire ou matcher des providers spécifiques.
|
||||
|
||||
Un canari de complétude construit les huit adapters V1 en mode désactivé et vérifie l'inventaire ordonné :
|
||||
|
||||
```text
|
||||
birdeye
|
||||
coinbase_exchange
|
||||
coingecko
|
||||
coinmarketcap
|
||||
coinpaprika
|
||||
dexscreener
|
||||
jupiter
|
||||
kraken
|
||||
```
|
||||
|
||||
## 6. Availability générique
|
||||
|
||||
`MarketPriceProviderAvailability` gagne deux helpers publics provider-neutral :
|
||||
|
||||
```text
|
||||
is_refresh_eligible()
|
||||
retry_at()
|
||||
```
|
||||
|
||||
Ils permettent à un consumer d'interpréter `Ready`, `CoolingDown` ou `TemporarilyUnavailable` sans parser des erreurs ni connaître le fournisseur.
|
||||
|
||||
Les catégories existantes restent distinctes :
|
||||
|
||||
```text
|
||||
AuthenticationUnavailable
|
||||
CoolingDown
|
||||
Disabled
|
||||
Misconfigured
|
||||
QuotaUnavailable
|
||||
Ready
|
||||
TemporarilyUnavailable
|
||||
```
|
||||
|
||||
`pre.007` ne relie pas encore automatiquement les erreurs d'un refresh à ces transitions. Cette mutation runtime appartient au service de `pre.008`.
|
||||
|
||||
## 7. Façade publique
|
||||
|
||||
La taxonomie reste capability-scoped :
|
||||
|
||||
```text
|
||||
MarketPriceBirdeye*
|
||||
MarketPriceProviderRegistry*
|
||||
MarketPriceProviderRequestCost
|
||||
```
|
||||
|
||||
Aucun DTO wire Birdeye, `HttpRestClient`, limiter interne ou primitive `reqwest` n'est exporté.
|
||||
|
||||
Le registry public reste générique ; aucun consumer n'a besoin d'un enum CoinGecko/Birdeye/Jupiter pour l'inspecter.
|
||||
|
||||
## 8. Tests déterministes ajoutés
|
||||
|
||||
Les nouveaux canaries couvrent :
|
||||
|
||||
```text
|
||||
Birdeye API key obligatoire si enabled
|
||||
redaction du secret Birdeye
|
||||
endpoint fixe et headers attendus
|
||||
mint WSOL fixe
|
||||
1 RPS par compte
|
||||
30 000 compute units informatifs
|
||||
3 compute units par Price Single
|
||||
prix exact via RawValue
|
||||
updateUnixTime -> provider_timestamp
|
||||
success false rejeté
|
||||
prix null rejeté
|
||||
timestamp absent rejeté
|
||||
request cost nul rejeté
|
||||
mismatch unité quota/coût rejeté
|
||||
registry des huit providers complet
|
||||
ordre déterministe du registry
|
||||
doublons provider_id rejetés
|
||||
lookup descriptor/state générique
|
||||
availability is_refresh_eligible/retry_at
|
||||
absence de noms provider dans le module registry
|
||||
```
|
||||
|
||||
## 9. Documentation réconciliée
|
||||
|
||||
Mise à jour de :
|
||||
|
||||
```text
|
||||
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||
```
|
||||
|
||||
Le plan enregistre le réaudit Birdeye courant et précise que quotas/cost metadata restent non-authoritative.
|
||||
|
||||
## 10. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
|
||||
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
|
||||
crates/ksp-offchain-transport-lib/unit_tests/market_price_birdeye.rs
|
||||
crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs
|
||||
deltas/0.2.11/pre.007.md
|
||||
```
|
||||
|
||||
## 11. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-offchain-transport-lib/src/error.rs
|
||||
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||
crates/ksp-offchain-transport-lib/src/market_price_provider.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/market_price_provider.rs
|
||||
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||
```
|
||||
|
||||
## 12. Validation attendue
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
`pre.008` ne doit commencer qu'après un gate propre de `pre.007` ou un delta fix explicite.
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- file: docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md -->
|
||||
<!-- version: 12 -->
|
||||
<!-- version: 13 -->
|
||||
|
||||
# Plan `0.2.11` — Off-chain price transport SOL/USD multi-provider
|
||||
|
||||
**Statut courant : `0.2.11-pre.006` ajoute Jupiter Price V3 et DexScreener à la famille `market_price`. Jupiter supporte keyless ou Free key sur `api.jup.ag`, conserve `SolanaHeuristic` et le `blockId` comme provenance sans inventer de timestamp de prix. DexScreener utilise exclusivement l'endpoint direct d'une paire Solana explicitement configurée, vérifie `chainId`, pair address et identité WSOL avant d'accepter `priceUsd`. Le gate opérateur ciblé de `pre.005` est PASS ; `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.005`.**
|
||||
**Statut courant : `0.2.11-pre.007` complète les huit adapters `market_price` avec Birdeye Standard et matérialise le registry provider-neutral. Birdeye exige `X-API-KEY`, utilise `/defi/price` sur Solana/WSOL, modélise 1 RPS par compte, 30 000 compute units de quota informatif et 3 compute units par requête SOL/USD. Le registry expose uniquement descriptor + état générique en ordre déterministe ; le dispatch/refresh reste réservé à `pre.008`. Le gate opérateur ciblé de `pre.006` est PASS ; `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.006`.**
|
||||
|
||||
## 1. Base et autorité
|
||||
|
||||
@@ -245,6 +245,8 @@ Réaudit `pre.004` : CoinMarketCap Simple Price est implémenté sur la surface
|
||||
|
||||
Réaudit `pre.006` : le Developer Platform Jupiter courant utilise `https://api.jup.ag` pour les accès keyless et API-key. Le keyless est documenté à 0,5 RPS et le plan Free à 1 RPS. Price V3 expose `usdPrice` comme prix USD unique et `blockId` comme information de récence ; `createdAt` décrit la création du token et ne doit pas être transformé en timestamp du prix. DexScreener documente l'endpoint direct `/latest/dex/pairs/{chainId}/{pairId}` à 300 requêtes par minute. KSP sélectionne uniquement `chainId = solana` et interdit dans V1 les endpoints de search ou de découverte de pools.
|
||||
|
||||
Réaudit `pre.007` : Birdeye Standard reste gratuit à 30 000 compute units inclus et 1 RPS au niveau du compte. L'endpoint Price Single `GET /defi/price` consomme actuellement 3 compute units, exige `X-API-KEY`, accepte `x-chain: solana` et le mint canonique WSOL, et fournit `value` plus `updateUnixTime`. Le quota et le coût par requête sont des metadata informatives ; KSP ne décrémente jamais localement le quota comme une vérité authoritative.
|
||||
|
||||
### 6.1 Gratuité technique et conditions d'usage
|
||||
|
||||
Le verdict `IN` signifie ici qu'un accès HTTP gratuit permet techniquement de développer et live-tester le provider dans `0.2.11`. Il ne constitue pas une affirmation de gratuité commerciale permanente.
|
||||
@@ -420,7 +422,7 @@ limitation générique
|
||||
support SOL/USD
|
||||
```
|
||||
|
||||
`pre.002` fixe `MarketPriceProviderId`, `MarketPriceProviderDescriptor`, `MarketPriceProviderAuthMode`, `MarketPriceProviderRateLimit`, `MarketPriceProviderLongTermQuota` 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.
|
||||
`pre.002` fixe `MarketPriceProviderId`, `MarketPriceProviderDescriptor`, `MarketPriceProviderAuthMode`, `MarketPriceProviderRateLimit`, `MarketPriceProviderLongTermQuota` et leurs enums de scope/période/unité. `pre.007` ajoute `MarketPriceProviderRequestCost` et l'unité `ComputeUnits` afin de représenter sans perte le modèle Birdeye. Les limites fixes exigent un budget et une fenêtre non nuls ; les limites dynamiques restent explicitement distinctes. Les quotas longs termes et coûts unitaires sont descriptifs et ne deviennent jamais un compteur local de quota restant.
|
||||
|
||||
L'état runtime peut représenter au minimum :
|
||||
|
||||
@@ -434,7 +436,9 @@ misconfigured
|
||||
disabled
|
||||
```
|
||||
|
||||
`MarketPriceProviderAvailability` et `MarketPriceProviderState` matérialisent cette projection générique dès `pre.002`; le registry et les transitions runtime effectives restent prévus en `pre.007`.
|
||||
`MarketPriceProviderAvailability` et `MarketPriceProviderState` matérialisent cette projection générique dès `pre.002`. `pre.007` ajoute `MarketPriceProviderRegistry` et `MarketPriceProviderRegistryEntry` : le registry est borné, trié de façon déterministe par `provider_id`, rejette les doublons et n'expose que `descriptor + state`. Son module ne contient aucun dispatch par nom de provider. Le registry reste une projection générique sans méthode de dispatch ni transition réseau ; le futur service `pre.008` possédera les mutations runtime et republiera les états mis à jour.
|
||||
|
||||
`MarketPriceProviderAvailability` expose aussi génériquement l'éligibilité immédiate au refresh et un `retry_at` lorsqu'il est réellement connu. `AuthenticationUnavailable`, `QuotaUnavailable`, `Misconfigured`, `Disabled`, `CoolingDown`, `TemporarilyUnavailable` et `Ready` restent distincts sans parsing provider dans les consumers.
|
||||
|
||||
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.
|
||||
|
||||
@@ -730,15 +734,15 @@ Implémentation des deux adapters exchange keyless avec DTOs wire privés et end
|
||||
|
||||
### `pre.006` — Jupiter et DexScreener
|
||||
|
||||
**Statut : réalisé, gate Cargo opérateur à exécuter.**
|
||||
**Statut : réalisé, gate opérateur ciblé PASS ; workspace complet non rejoué.**
|
||||
|
||||
Implémentation des adapters Jupiter Price V3 et DexScreener avec DTOs wire privés et endpoints HTTPS officiels fixes. Jupiter distingue keyless à 0,5 requête par seconde et Free key à 1 requête par seconde sur le même `api.jup.ag`, utilise uniquement le mint WSOL et conserve `blockId` comme provenance de récence sans le convertir en timestamp. DexScreener exige un pair address Solana explicite dans ses settings, appelle uniquement `/latest/dex/pairs/solana/{pairId}`, vérifie `chainId = solana`, le pair address retourné et le mint WSOL comme base avant de lire `priceUsd`. Aucune découverte de pool, aucun tri de liquidité, aucun consensus et aucun registry global ne sont avancés.
|
||||
|
||||
### `pre.007` — Birdeye, registry et availability
|
||||
|
||||
**Statut : planifié.**
|
||||
**Statut : réalisé, gate Cargo opérateur à exécuter.**
|
||||
|
||||
Implémentation de Birdeye avec son auth provider, finalisation du registry multi-provider et des états génériques d'indisponibilité.
|
||||
Implémentation de Birdeye Standard avec `X-API-KEY`, endpoint Price Single Solana/WSOL, prix exact et timestamp provider réel. Le descriptor modélise 1 RPS par compte, 30 000 compute units de quota informatif et 3 compute units par refresh SOL/USD. `MarketPriceProviderRegistry` finalise l'inventaire runtime provider-neutral des huit adapters sous forme d'entrées `descriptor + state`, avec ordre déterministe et doublons rejetés. Les transitions runtime et le dispatch restent réservés à `pre.008`. Aucun dispatch de refresh, Config ou smoke live n'est avancé.
|
||||
|
||||
### `pre.008` — Refresh individuel et multiple
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md -->
|
||||
<!-- version: 11 -->
|
||||
<!-- version: 12 -->
|
||||
|
||||
# Validation `0.2.11` — Off-chain price transport SOL/USD
|
||||
|
||||
@@ -209,27 +209,55 @@ Les tests déterministes de `pre.005` couvrent les descriptors exchange, la pré
|
||||
|
||||
## 3.9 Gate `0.2.11-pre.006`
|
||||
|
||||
| Critère | Statut | Preuve |
|
||||
|-----------------------------------------------------------------|---------|---------------------------------------------------------|
|
||||
| version workspace `0.2.11-pre.6` | PASS | `Cargo.toml` racine |
|
||||
| Jupiter Price V3 adapter public | PASS | `market_price_jupiter.rs` |
|
||||
| Jupiter keyless et Free key distingués | PASS | settings + descriptor + header sensible |
|
||||
| Jupiter keyless 0,5 RPS et Free 1 RPS représentés | PASS | descriptors + canaries unitaires |
|
||||
| Jupiter utilise uniquement le mint WSOL | PASS | query `ids` fixe |
|
||||
| `usdPrice` Jupiter parsé exactement sans `f64` canonique | PASS | `RawValue` -> `MarketPriceDecimal` |
|
||||
| `blockId` Jupiter conservé en provenance sans faux timestamp | PASS | provenance bornée + `provider_timestamp = None` |
|
||||
| DexScreener adapter public avec pair address explicite | PASS | `MarketPriceDexScreenerSettings` |
|
||||
| DexScreener endpoint direct pair uniquement | PASS | `/latest/dex/pairs/solana/{pairId}` |
|
||||
| DexScreener vérifie `chainId`, pair address et identité WSOL | PASS | parser provider |
|
||||
| DexScreener utilise uniquement `priceUsd` | PASS | DTO wire privé + parsing exact |
|
||||
| DexScreener 300 req/min représenté | PASS | descriptor provider |
|
||||
| aucune découverte/selection/ranking automatique de pool | PASS | dependency boundary + absence search/token-pairs/tokens |
|
||||
| aucun SDK provider, Config, registry ou refresh multiple avancé | PASS | manifest + scope des modules |
|
||||
| audit Rust sandbox | PASS | clean, 0 candidate export |
|
||||
| audit Markdown sandbox | PLANNED | à exécuter après réconciliation documentaire |
|
||||
| gate Cargo | PLANNED | à exécuter par l'opérateur |
|
||||
| Critère | Statut | Preuve |
|
||||
|-----------------------------------------------------------------|--------|---------------------------------------------------------|
|
||||
| version workspace `0.2.11-pre.6` | PASS | `Cargo.toml` racine |
|
||||
| Jupiter Price V3 adapter public | PASS | `market_price_jupiter.rs` |
|
||||
| Jupiter keyless et Free key distingués | PASS | settings + descriptor + header sensible |
|
||||
| Jupiter keyless 0,5 RPS et Free 1 RPS représentés | PASS | descriptors + canaries unitaires |
|
||||
| Jupiter utilise uniquement le mint WSOL | PASS | query `ids` fixe |
|
||||
| `usdPrice` Jupiter parsé exactement sans `f64` canonique | PASS | `RawValue` -> `MarketPriceDecimal` |
|
||||
| `blockId` Jupiter conservé en provenance sans faux timestamp | PASS | provenance bornée + `provider_timestamp = None` |
|
||||
| DexScreener adapter public avec pair address explicite | PASS | `MarketPriceDexScreenerSettings` |
|
||||
| DexScreener endpoint direct pair uniquement | PASS | `/latest/dex/pairs/solana/{pairId}` |
|
||||
| DexScreener vérifie `chainId`, pair address et identité WSOL | PASS | parser provider |
|
||||
| DexScreener utilise uniquement `priceUsd` | PASS | DTO wire privé + parsing exact |
|
||||
| DexScreener 300 req/min représenté | PASS | descriptor provider |
|
||||
| aucune découverte/selection/ranking automatique de pool | PASS | dependency boundary + absence search/token-pairs/tokens |
|
||||
| aucun SDK provider, Config, registry ou refresh multiple avancé | PASS | manifest + scope des modules |
|
||||
| audit Rust sandbox | PASS | clean, 0 candidate export |
|
||||
| audit Markdown sandbox | PASS | clean, 120 tables / 111 fichiers |
|
||||
| `cargo check` / Clippy / tests Off-chain | PASS | résultats opérateur du 2026-08-25 |
|
||||
| `cargo test --workspace` sur état exact `pre.006` | N/R | non rejoué avant passage explicite à `pre.007` |
|
||||
|
||||
Les fixtures de `pre.006` couvrent Jupiter keyless/Free, redaction de la clé, prix exact, identité WSOL, `blockId`, absence volontaire de faux timestamp, ainsi que DexScreener avec pair Solana explicite, `priceUsd`, rejet de chain/pair/base invalides, réponse multiple et prix absent. Aucun smoke live n'est revendiqué dans cette tranche.
|
||||
Les fixtures de `pre.006` couvrent Jupiter keyless/Free, redaction de la clé, prix exact, identité WSOL, `blockId`, absence volontaire de faux timestamp, ainsi que DexScreener avec pair Solana explicite, `priceUsd`, rejet de chain/pair/base invalides, réponse multiple et prix absent. Le gate opérateur ciblé du `2026-08-25` est PASS : audit Rust clean, audit Markdown clean à 120 tables / 111 fichiers, `cargo check --workspace` PASS, `cargo clippy --workspace --all-targets` PASS et `cargo test -p ksp-offchain-transport-lib` PASS avec 43 unitaires + 4 boundary + 5 public API. `cargo test --workspace` n'a pas été rejoué sur cet état exact.
|
||||
|
||||
## 3.10 Gate `0.2.11-pre.007`
|
||||
|
||||
| Critère | Statut | Preuve |
|
||||
|------------------------------------------------------------------|---------|---------------------------------------|
|
||||
| version workspace `0.2.11-pre.7` | PASS | `Cargo.toml` racine |
|
||||
| Birdeye Standard SOL/USD adapter public | PASS | `market_price_birdeye.rs` |
|
||||
| Birdeye exige `X-API-KEY` lorsqu'activé | PASS | settings + header sensible |
|
||||
| Birdeye fixe `x-chain = solana` et le mint WSOL | PASS | request builder |
|
||||
| Birdeye Price Single `value` parsé exactement | PASS | `RawValue` -> `MarketPriceDecimal` |
|
||||
| Birdeye `updateUnixTime` conservé comme timestamp provider | PASS | parser provider |
|
||||
| Birdeye Standard 1 RPS compte modélisé | PASS | descriptor provider |
|
||||
| quota Birdeye 30 000 compute units informatif | PASS | `MarketPriceProviderLongTermQuota` |
|
||||
| coût Price Single 3 compute units informatif | PASS | `MarketPriceProviderRequestCost` |
|
||||
| registry provider-neutral matérialisé | PASS | `MarketPriceProviderRegistry` |
|
||||
| huit providers V1 présents dans le canari de complétude registry | PASS | test `market_price_registry` |
|
||||
| registry trié par provider id et doublons rejetés | PASS | constructeur public générique + tests |
|
||||
| entry publique limitée à descriptor + état générique | PASS | `MarketPriceProviderRegistryEntry` |
|
||||
| availability expose éligibilité et `retry_at` génériques | PASS | `MarketPriceProviderAvailability` |
|
||||
| registry sans mutation ou dispatch réseau | PASS | projection générique publique |
|
||||
| registry ne contient aucun dispatch/name provider | PASS | dependency boundary |
|
||||
| refresh single/multiple, Config et smoke live non avancés | PASS | scope réservé à `pre.008+` |
|
||||
| audit Rust sandbox | PASS | clean, 0 candidate export |
|
||||
| audit Markdown sandbox | PASS | clean, 121 tables / 112 fichiers |
|
||||
| gate Cargo | PLANNED | à exécuter par l'opérateur |
|
||||
|
||||
Les tests déterministes de `pre.007` couvrent l'auth Birdeye et sa redaction, le descriptor 1 RPS / 30 000 CU / 3 CU, le parsing exact du prix et du timestamp, les réponses invalides, ainsi que le registry complet des huit providers, son ordre déterministe, le rejet de doublons et les projections d'availability. Aucun refresh réseau générique ni smoke live n'est revendiqué dans cette tranche.
|
||||
|
||||
## 4. Matrice provider prévue
|
||||
|
||||
@@ -241,7 +269,7 @@ Les fixtures de `pre.006` couvrent Jupiter keyless/Free, redaction de la clé, p
|
||||
| Kraken | Oui | Oui | aucune | PASS | PLANNED | IMPLEMENTED |
|
||||
| Coinbase Exchange | Oui | Oui | aucune | PASS | PLANNED | IMPLEMENTED |
|
||||
| Jupiter Price V3 | Oui | Oui | keyless ou Free key | PASS | PLANNED | IMPLEMENTED |
|
||||
| Birdeye | Oui | Oui | API key Standard | PLANNED | PLANNED | AUDITED |
|
||||
| Birdeye | Oui | Oui | API key Standard | PASS | PLANNED | IMPLEMENTED |
|
||||
| DexScreener | Oui | Oui | aucune | PASS | PLANNED | IMPLEMENTED |
|
||||
|
||||
`AUDITED` signifie uniquement que la documentation officielle actuelle satisfait le gate d'entrée. Ce statut ne vaut pas preuve d'un smoke réseau KSP.
|
||||
@@ -262,7 +290,7 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d
|
||||
| `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 |
|
||||
| registry provider et état runtime possédés par Off-chain Transport | PASS |
|
||||
| refresh provider par identifiant générique | PLANNED |
|
||||
| refresh multiple sans connaissance provider côté consumer | PLANNED |
|
||||
|
||||
@@ -275,13 +303,13 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d
|
||||
| limite dynamique keyless représentable | PASS |
|
||||
| `429` classé | PASS |
|
||||
| `Retry-After` honoré lorsqu'exploitable | PASS |
|
||||
| cooldown expose prochain instant admissible | PLANNED |
|
||||
| cooldown expose prochain instant admissible | PASS |
|
||||
| refresh all ne lance que les providers éligibles | PLANNED |
|
||||
| provider en cooldown ne bloque pas les autres | PLANNED |
|
||||
| provider down ne rend pas le service global inutilisable | PLANNED |
|
||||
| auth manquante distinguée d'un transport down | PLANNED |
|
||||
| quota indisponible distingué d'un transport down | PLANNED |
|
||||
| quota mensuel local non présenté comme compteur authoritative | PLANNED |
|
||||
| quota mensuel local non présenté comme compteur authoritative | PASS |
|
||||
|
||||
## 7. Numeric safety
|
||||
|
||||
|
||||
Reference in New Issue
Block a user