v0.2.11-pre.010
This commit is contained in:
138
crates/ksp-offchain-transport-lib/README.md
Normal file
138
crates/ksp-offchain-transport-lib/README.md
Normal file
@@ -0,0 +1,138 @@
|
||||
<!-- file: crates/ksp-offchain-transport-lib/README.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# ksp-offchain-transport-lib
|
||||
|
||||
`ksp-offchain-transport-lib` est le propriétaire des transports et adaptations de données **off-chain** utilisés par KSP. La release `0.2.11` matérialise sa première famille fonctionnelle, `market_price`, limitée à des observations SOL/USD multi-provider.
|
||||
|
||||
La crate n'est pas une crate « prix uniquement ». Les responsabilités durables sont séparées par famille :
|
||||
|
||||
```text
|
||||
http_* mécanique HTTP partagée réellement commune
|
||||
market_price_* prix de marché / spot normalisés
|
||||
swap_quote_* future famille de quotes montant/route
|
||||
<future_capability>_* ajoutée uniquement lorsqu'un scope réel l'exige
|
||||
```
|
||||
|
||||
## Contrat `market_price` V1
|
||||
|
||||
La façade publique fournit :
|
||||
|
||||
- `MarketPriceDecimal`, représentation décimale exacte positive sans vérité canonique `f64` ;
|
||||
- `MarketPriceObservation`, avec paire, prix, sémantique, timestamps KSP/provider et provenance sûre ;
|
||||
- `MarketPriceProviderDescriptor` et `MarketPriceProviderState`, pour décrire capacités et availability sans logique provider côté consumer ;
|
||||
- `MarketPriceProviderRegistry`, inventaire déterministe des providers configurés ;
|
||||
- `MarketPriceService`, façade provider-agnostic pour `refresh`, `refresh_many` et `refresh_all` ;
|
||||
- `MarketPriceProviderSetup`, frontière de composition initiale provider-specific qui ne doit pas devenir la surface runtime de la HID.
|
||||
|
||||
La paire publique V1 est exclusivement :
|
||||
|
||||
```text
|
||||
SOL/USD
|
||||
```
|
||||
|
||||
Les enums publiques susceptibles d'évoluer sont `#[non_exhaustive]`. Un consumer externe doit donc conserver une branche future-safe et ne pas supposer que les paires, sémantiques, états ou providers resteront définitivement fermés à ceux de `0.2.11`.
|
||||
|
||||
## Providers V1
|
||||
|
||||
L'inventaire fonctionnel comporte exactement huit adapters :
|
||||
|
||||
```text
|
||||
birdeye
|
||||
coinbase_exchange
|
||||
coingecko
|
||||
coinmarketcap
|
||||
coinpaprika
|
||||
dexscreener
|
||||
jupiter
|
||||
kraken
|
||||
```
|
||||
|
||||
Ils ne prétendent pas produire la même vérité de marché. `MarketPriceSemantics` conserve notamment la différence entre agrégateur, dernier trade d'exchange, heuristique Solana, spot Solana et paire DEX.
|
||||
|
||||
Les origines HTTPS, chemins, headers d'authentification, identités d'asset et limites provider restent possédés par les adapters. V1 n'expose aucune URL provider arbitraire ni aucun SDK fournisseur.
|
||||
|
||||
DexScreener reste un cas volontairement strict : une paire Solana explicite est fournie à la composition lorsqu'il est activé ; l'adapter appelle uniquement la paire configurée et ne découvre, ne classe ni n'agrège automatiquement des pools.
|
||||
|
||||
## HTTP et résilience
|
||||
|
||||
Les primitives `http_*` sont crate-private. Elles appliquent notamment :
|
||||
|
||||
```text
|
||||
reqwest uniquement
|
||||
HTTPS provider fixe
|
||||
redirects désactivés
|
||||
Referer automatique désactivé
|
||||
proxy système implicite désactivé
|
||||
retries reqwest implicites désactivés
|
||||
connect/request timeouts bornés
|
||||
body borné pendant la lecture
|
||||
JSON validé avant mapping typed
|
||||
URL retirée des erreurs reqwest
|
||||
aucun body distant brut dans KspError
|
||||
429 et Retry-After classés
|
||||
```
|
||||
|
||||
Le rate limiting est provider-owned et non bloquant. Un provider non éligible est projeté en availability/cooldown ; le service ne dort pas pour attendre sa prochaine fenêtre.
|
||||
|
||||
## Refresh individuel et multiple
|
||||
|
||||
`MarketPriceService` est la surface runtime générique.
|
||||
|
||||
La V1 garde un comportement multiple **séquentiel et déterministe** :
|
||||
|
||||
```text
|
||||
refresh(provider_id) un provider opaque
|
||||
refresh_many(provider_ids) ordre demandé conservé
|
||||
refresh_all() ordre stable du registry
|
||||
```
|
||||
|
||||
Un provider en cooldown ou en erreur n'empêche pas la projection des autres outcomes. Le service ne fait aucun fallback, aucun consensus et aucune agrégation de prix entre providers.
|
||||
|
||||
Le séquentiel de V1 est un contrat volontaire de simplicité et de déterminisme, pas une obligation architecturale éternelle. Une évolution vers une orchestration concurrente demanderait un contrat explicite sur l'ordre, les limites et les effets observables.
|
||||
|
||||
## Config et secrets
|
||||
|
||||
Cette crate **ne lit jamais** directement `KSP_*`, `KSPB_*`, `.env` ou les documents Config.
|
||||
|
||||
La direction autorisée est :
|
||||
|
||||
```text
|
||||
ksp-config-lib
|
||||
-> ksp-offchain-transport-lib
|
||||
```
|
||||
|
||||
`ksp-config-lib` résout les credentials, vérifie leur provenance et construit `MarketPriceProviderSetup` / `MarketPriceService`. La dépendance inverse est interdite.
|
||||
|
||||
Les API keys ne sont pas exposées par les projections publiques usuelles et leurs `Debug` sont redacted. Les erreurs/logs n'embarquent ni credential, ni URL sensible, ni payload distant brut.
|
||||
|
||||
## Numeric safety et provenance
|
||||
|
||||
`MarketPriceDecimal` accepte les formes décimales/scientifiques bornées nécessaires aux wire providers, puis normalise vers un coefficient `u128` et une scale limitée. Sont rejetés notamment : zéro pour une observation réussie, négatifs, valeurs non numériques, overflow, scale excessive et exposants pathologiques.
|
||||
|
||||
Les timestamps provider ne sont présents que lorsqu'un provider fournit réellement une information temporelle correspondant au prix. Un block id, une date de création d'asset ou une donnée de récence non temporelle n'est jamais convertie en faux timestamp.
|
||||
|
||||
La provenance textuelle est bornée et contrôlée afin de rester sûre pour les projections/logs.
|
||||
|
||||
## Hors scope de `0.2.11`
|
||||
|
||||
```text
|
||||
SOL/EUR
|
||||
fallback automatique
|
||||
consensus ou moyenne multi-provider
|
||||
découverte automatique de pool DexScreener
|
||||
scheduler périodique
|
||||
historique persistant
|
||||
swap routing / Jupiter quote
|
||||
soumission ou signature de transaction Solana
|
||||
SDK provider
|
||||
URL provider configurable
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — construction programmatique et utilisation de la façade générique ;
|
||||
- [`../../docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md`](../../docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md) — plan de release ;
|
||||
- [`../../docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md`](../../docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md) — matrice de validation.
|
||||
|
||||
Ce README est un **brouillon technique de `pre.010`**. La réconciliation documentaire finale reste la responsabilité de `pre.012`, après le gate technique/live de `pre.011`.
|
||||
152
crates/ksp-offchain-transport-lib/USAGE.md
Normal file
152
crates/ksp-offchain-transport-lib/USAGE.md
Normal file
@@ -0,0 +1,152 @@
|
||||
<!-- file: crates/ksp-offchain-transport-lib/USAGE.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Usage de ksp-offchain-transport-lib
|
||||
|
||||
Cette page montre la surface technique disponible à `0.2.11-pre.010`. Les credentials et documents runtime doivent normalement être résolus par `ksp-config-lib`; les constructions directes ci-dessous servent surtout aux tests, outils bas niveau et compositions programmatiques maîtrisées.
|
||||
|
||||
## 1. Construire un service programmatique
|
||||
|
||||
Un service reçoit une liste de `MarketPriceProviderSetup`. Le setup est provider-specific uniquement au moment de la composition :
|
||||
|
||||
```rust
|
||||
let coingecko = ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(true)?;
|
||||
let kraken = ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(true)?;
|
||||
let service = ksp_offchain_transport_lib::MarketPriceService::new(std::vec![
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
|
||||
])?;
|
||||
```
|
||||
|
||||
Une application normale n'a pas à reproduire le mapping des huit providers. `ksp-config-lib::ResolvedOffchainTransportConfig` construit cette composition depuis `cfg.std.offchain_transport`.
|
||||
|
||||
## 2. Découvrir le registry générique
|
||||
|
||||
Après construction, le consumer travaille sur le registry sans matcher les variants provider :
|
||||
|
||||
```rust
|
||||
for entry in service.registry().entries() {
|
||||
let descriptor = entry.descriptor();
|
||||
let state = entry.state();
|
||||
println!(
|
||||
"{} {:?} {:?}",
|
||||
descriptor.display_name(),
|
||||
descriptor.semantics(),
|
||||
state.availability(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Le `provider_id` est opaque. Il sert d'identité stable pour rappeler le service, pas de signal autorisant le consumer à reconstruire un endpoint ou une règle provider.
|
||||
|
||||
## 3. Rafraîchir un provider
|
||||
|
||||
```rust
|
||||
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("coingecko")?;
|
||||
let outcome = service.refresh(&provider_id).await?;
|
||||
```
|
||||
|
||||
L'outcome expose génériquement l'observation éventuelle et l'état provider. Une erreur provider normalisée n'oblige pas le consumer à parser CoinGecko, Kraken ou Jupiter.
|
||||
|
||||
Avant un refresh, l'état peut être consulté via le registry. `MarketPriceProviderAvailability::retry_at()` expose le prochain instant connu lorsqu'il existe réellement.
|
||||
|
||||
## 4. Rafraîchir plusieurs providers
|
||||
|
||||
```rust
|
||||
let ids = service
|
||||
.registry()
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| return entry.descriptor().id().clone())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
let outcomes = service.refresh_many(ids.as_slice()).await?;
|
||||
```
|
||||
|
||||
La V1 exécute ce batch **séquentiellement**, dans l'ordre demandé. Elle valide les IDs avant le premier dispatch, rejette les doublons, ne dort pas pour un cooldown et produit un outcome générique par provider lorsque l'opération est valide.
|
||||
|
||||
`refresh_all()` applique le même contrat dans l'ordre stable du registry :
|
||||
|
||||
```rust
|
||||
let outcomes = service.refresh_all().await?;
|
||||
```
|
||||
|
||||
Il n'existe pas de fallback, consensus ou moyenne implicite. Une application voulant comparer les observations doit conserver leurs sémantiques et provenances ; elle ne doit pas présenter leurs différences comme une erreur de KSP.
|
||||
|
||||
## 5. Construire depuis Config
|
||||
|
||||
La voie runtime normale est :
|
||||
|
||||
```rust
|
||||
let resolved = engine.load_resolved_offchain_transport_config(
|
||||
std::option::Option::None,
|
||||
&environment,
|
||||
)?;
|
||||
let service = resolved.service();
|
||||
let registry = service.registry();
|
||||
```
|
||||
|
||||
Le profil `public_keyless` du document standard peut être résolu sans credentials. Le profil `all_free` attend les secrets/public fields inventoriés dans `.env.example` et validés par `ksp-config-lib`.
|
||||
|
||||
Off-chain Transport ne lit pas l'environnement lui-même. Ne passez pas un credential via une URL, une query arbitraire ou une surface UI libre pour contourner Config.
|
||||
|
||||
## 6. DexScreener
|
||||
|
||||
DexScreener doit recevoir la paire SOL/USD Solana explicitement approuvée par la composition lorsqu'il est activé :
|
||||
|
||||
```rust
|
||||
let pair = ksp_core_lib::Pubkey::parse("<PAIR_ADDRESS_APPROUVEE>")?;
|
||||
let settings = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(
|
||||
true,
|
||||
std::option::Option::Some(pair),
|
||||
)?;
|
||||
```
|
||||
|
||||
La paire n'est pas un secret. Aucun helper V1 ne découvre automatiquement une autre pool, ne trie par liquidité ou ne remplace la paire configurée.
|
||||
|
||||
## 7. Exactitude numérique
|
||||
|
||||
Ne convertissez pas l'observation canonique en `f64` pour la stocker ou la comparer comme vérité KSP. `MarketPriceDecimal` conserve une forme décimale exacte et sérialise une représentation canonique.
|
||||
|
||||
Pour l'affichage, un consumer peut utiliser sa représentation textuelle publique. Toute conversion approximative éventuelle appartient à une couche de présentation qui accepte explicitement cette perte ; elle ne doit pas remplacer le type canonique dans le transport.
|
||||
|
||||
## 8. Forward compatibility
|
||||
|
||||
Les enums publiques susceptibles d'évoluer sont `#[non_exhaustive]`. Hors de la crate, les matches doivent donc prévoir un fallback :
|
||||
|
||||
```rust
|
||||
match entry.state().availability() {
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready => {},
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled => {},
|
||||
_ => {},
|
||||
}
|
||||
```
|
||||
|
||||
La branche `_` est intentionnelle : de nouveaux providers, états, sémantiques ou paires pourront être ajoutés sans imposer une rupture source aux consumers bien écrits.
|
||||
|
||||
## 9. Diagnostics sûrs
|
||||
|
||||
Les diagnostics applicatifs peuvent journaliser :
|
||||
|
||||
```text
|
||||
provider_id validé
|
||||
code d'erreur KSP
|
||||
classe d'availability
|
||||
retry_at borné lorsqu'il existe
|
||||
durée/opération générique
|
||||
```
|
||||
|
||||
Ils ne doivent pas journaliser :
|
||||
|
||||
```text
|
||||
API key
|
||||
URL complète sensible
|
||||
header provider secret
|
||||
body distant brut
|
||||
payload de Config secret
|
||||
```
|
||||
|
||||
`ksp-offchain-transport-lib` utilise `ksp-logging-lib` et son `TRACING_TARGET` propriétaire ; une application ne doit pas ajouter un bypass direct `tracing` pour obtenir les payloads rejetés.
|
||||
|
||||
## 10. Statut documentaire
|
||||
|
||||
Ce document est un **brouillon technique de `pre.010`**. Il ne vaut ni smoke live, ni garantie commerciale sur les plans gratuits des providers. `pre.011` porte les contrôles techniques/live finaux ; `pre.012` porte la réconciliation documentaire finale.
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/lib.rs
|
||||
// version: 10
|
||||
// version: 11
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -7,10 +7,11 @@
|
||||
|
||||
//! KSP-owned off-chain transport foundation.
|
||||
//!
|
||||
//! `0.2.11-pre.008` adds the generic SOL/USD refresh service over the complete eight-provider adapter inventory. The service owns provider dispatch,
|
||||
//! non-blocking rate-limit/cooldown handling, provider-neutral availability transitions and individual/multiple/all refresh operations. Provider-specific setup
|
||||
//! remains confined to composition while runtime consumers operate only on opaque provider identifiers, registry projections and normalized outcomes. Config
|
||||
//! integration remains reserved for the next tranche.
|
||||
//! `0.2.11-pre.010` hardens the complete SOL/USD V1 surface over eight providers. The crate owns exact decimal normalization, provider adapters, hardened
|
||||
//! HTTP, non-blocking rate-limit/cooldown handling, provider-neutral registry/availability and individual/multiple/all refresh operations. Provider-specific
|
||||
//! setup remains confined to composition while runtime consumers can operate on opaque provider identifiers, registry projections and normalized outcomes.
|
||||
//! Config integration is implemented externally by `ksp-config-lib`; this crate remains independent from Config and never reads KSP environment variables
|
||||
//! directly.
|
||||
|
||||
mod constants;
|
||||
mod error;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
//! Shared market-price adapter mechanics layered over crate-wide HTTP primitives.
|
||||
|
||||
@@ -153,3 +153,6 @@ fn retry_after_from_error(error: &ksp_core_lib::Error) -> std::option::Option<st
|
||||
}
|
||||
return std::option::Option::None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/market_price_adapter.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! CoinGecko SOL/USD market-price adapter using the official REST API directly through `reqwest`.
|
||||
|
||||
@@ -8,6 +8,7 @@ const COINGECKO_PROVIDER_ID: &str = "coingecko";
|
||||
const COINGECKO_SIMPLE_PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price";
|
||||
|
||||
/// CoinGecko V1 access mode supported by Off-chain Transport.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceCoinGeckoAccessMode {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! CoinMarketCap SOL/USD market-price adapter using the current Simple Price V2 REST surface.
|
||||
|
||||
@@ -10,6 +10,7 @@ const COINMARKETCAP_PROVIDER_ID: &str = "coinmarketcap";
|
||||
const COINMARKETCAP_SOL_ID: u64 = 5_426;
|
||||
|
||||
/// CoinMarketCap V1 access mode supported by Off-chain Transport.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceCoinMarketCapAccessMode {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
//! Jupiter Price V3 SOL/USD adapter using the current Developer Platform REST surface.
|
||||
|
||||
@@ -10,6 +10,7 @@ const JUPITER_SOL_DECIMALS: u8 = 9;
|
||||
const JUPITER_SOL_MINT: &str = "So11111111111111111111111111111111111111112";
|
||||
|
||||
/// Jupiter V1 access mode supported by Off-chain Transport.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceJupiterAccessMode {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/market_price_provider.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
/// Maximum UTF-8 byte length of one provider display name.
|
||||
pub const MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES: usize = 96;
|
||||
@@ -7,6 +7,7 @@ pub const MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES: usize = 96;
|
||||
pub const MARKET_PRICE_PROVIDER_ID_MAX_BYTES: usize = 64;
|
||||
|
||||
/// Only price pair exposed by the `0.2.11` V1 public contract.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPricePair {
|
||||
@@ -25,6 +26,7 @@ impl MarketPricePair {
|
||||
}
|
||||
|
||||
/// Market-price semantics retained so normalized observations do not imply cross-provider equivalence.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceSemantics {
|
||||
@@ -41,6 +43,7 @@ pub enum MarketPriceSemantics {
|
||||
}
|
||||
|
||||
/// Generic authentication capability exposed by one configured provider.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceProviderAuthMode {
|
||||
@@ -53,6 +56,7 @@ pub enum MarketPriceProviderAuthMode {
|
||||
}
|
||||
|
||||
/// Scope to which a provider documents a request limit.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceProviderRateLimitScope {
|
||||
@@ -67,6 +71,7 @@ pub enum MarketPriceProviderRateLimitScope {
|
||||
}
|
||||
|
||||
/// Shape of one generic provider request-limit capability.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceProviderRateLimitKind {
|
||||
@@ -150,6 +155,7 @@ impl MarketPriceProviderRateLimit {
|
||||
}
|
||||
|
||||
/// Period used by one documented long-term provider quota.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceProviderQuotaPeriod {
|
||||
@@ -160,6 +166,7 @@ pub enum MarketPriceProviderQuotaPeriod {
|
||||
}
|
||||
|
||||
/// Unit used by one documented long-term provider quota.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketPriceProviderQuotaUnit {
|
||||
@@ -386,6 +393,7 @@ impl MarketPriceProviderDescriptor {
|
||||
}
|
||||
|
||||
/// Generic runtime availability state exposed without provider-specific error parsing.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum MarketPriceProviderAvailability {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/market_price_service.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Generic market-price refresh service owning provider dispatch and availability transitions.
|
||||
|
||||
@@ -9,6 +9,7 @@ const MARKET_PRICE_REFRESH_MAX_PROVIDERS: usize = 64;
|
||||
/// Provider-specific runtime setup consumed once by [`crate::MarketPriceService`].
|
||||
///
|
||||
/// This enum is intended for composition layers such as Config. Runtime consumers use the generic service methods and never need to branch on provider kinds.
|
||||
#[non_exhaustive]
|
||||
pub enum MarketPriceProviderSetup {
|
||||
/// Birdeye Standard setup.
|
||||
Birdeye(crate::MarketPriceBirdeyeSettings),
|
||||
|
||||
161
crates/ksp-offchain-transport-lib/tests/release_completeness.rs
Normal file
161
crates/ksp-offchain-transport-lib/tests/release_completeness.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
// file: crates/ksp-offchain-transport-lib/tests/release_completeness.rs
|
||||
// version: 1
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Release-level completeness canaries for the eight-provider SOL/USD V1 contract.
|
||||
|
||||
#[test]
|
||||
fn pre_010_exact_eight_provider_inventory_and_semantics_are_stable() -> ksp_core_lib::Result<()> {
|
||||
let setups = match all_disabled_setups() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let service = match ksp_offchain_transport_lib::MarketPriceService::new(setups) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = service.registry();
|
||||
assert_eq!(registry.len(), 8);
|
||||
let expected = [
|
||||
(
|
||||
"birdeye",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot,
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::RequiredApiKey,
|
||||
),
|
||||
(
|
||||
"coinbase_exchange",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade,
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||
),
|
||||
(
|
||||
"coingecko",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||
),
|
||||
(
|
||||
"coinmarketcap",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||
),
|
||||
(
|
||||
"coinpaprika",
|
||||
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||
),
|
||||
("dexscreener", ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
|
||||
("jupiter", ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
|
||||
("kraken", ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
|
||||
];
|
||||
for (entry, (provider_id, semantics, auth_mode)) in registry.entries().iter().zip(expected) {
|
||||
assert_eq!(entry.descriptor().id().as_str(), provider_id);
|
||||
assert_eq!(entry.descriptor().semantics(), semantics);
|
||||
assert_eq!(entry.descriptor().auth_mode(), auth_mode);
|
||||
assert!(entry.descriptor().supports_sol_usd());
|
||||
assert_eq!(entry.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_010_evolutive_public_enums_are_non_exhaustive() -> std::io::Result<()> {
|
||||
let root = crate_root();
|
||||
let provider = match std::fs::read_to_string(root.join("src/market_price_provider.rs")) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
for enum_name in [
|
||||
"MarketPricePair",
|
||||
"MarketPriceSemantics",
|
||||
"MarketPriceProviderAuthMode",
|
||||
"MarketPriceProviderRateLimitScope",
|
||||
"MarketPriceProviderRateLimitKind",
|
||||
"MarketPriceProviderQuotaPeriod",
|
||||
"MarketPriceProviderQuotaUnit",
|
||||
"MarketPriceProviderAvailability",
|
||||
] {
|
||||
assert_non_exhaustive(provider.as_str(), enum_name);
|
||||
}
|
||||
let coingecko = match std::fs::read_to_string(root.join("src/market_price_coingecko.rs")) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_non_exhaustive(coingecko.as_str(), "MarketPriceCoinGeckoAccessMode");
|
||||
let coinmarketcap = match std::fs::read_to_string(root.join("src/market_price_coinmarketcap.rs")) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_non_exhaustive(coinmarketcap.as_str(), "MarketPriceCoinMarketCapAccessMode");
|
||||
let jupiter = match std::fs::read_to_string(root.join("src/market_price_jupiter.rs")) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_non_exhaustive(jupiter.as_str(), "MarketPriceJupiterAccessMode");
|
||||
let service = match std::fs::read_to_string(root.join("src/market_price_service.rs")) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert_non_exhaustive(service.as_str(), "MarketPriceProviderSetup");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn all_disabled_setups() -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
|
||||
let birdeye = 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 coinbase = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let dexscreener = match ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let kraken = match ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(std::vec![
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::DexScreener(dexscreener),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
|
||||
]);
|
||||
}
|
||||
|
||||
fn assert_non_exhaustive(source: &str, enum_name: &str) {
|
||||
let marker = std::format!("pub enum {enum_name}");
|
||||
let position = source.find(marker.as_str());
|
||||
assert!(position.is_some(), "public enum must remain present: {enum_name}");
|
||||
if let std::option::Option::Some(position) = position {
|
||||
let start = position.saturating_sub(256);
|
||||
assert!(source[start..position].contains("#[non_exhaustive]"), "evolutive public enum must be non_exhaustive: {enum_name}");
|
||||
}
|
||||
}
|
||||
|
||||
fn crate_root() -> std::path::PathBuf {
|
||||
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// file: crates/ksp-offchain-transport-lib/tests/security_hardening.rs
|
||||
// version: 1
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Adversarial security canaries for Off-chain Transport diagnostics, credentials and ownership boundaries.
|
||||
|
||||
#[test]
|
||||
fn pre_010_keyed_settings_and_service_debug_never_expose_credentials() -> ksp_core_lib::Result<()> {
|
||||
let secret = "pre010-api-key-secret-canary";
|
||||
let birdeye = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(true, std::option::Option::Some(secret.to_owned())) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::Some(secret.to_owned())) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::basic(true, std::option::Option::Some(secret.to_owned())) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::free(true, std::option::Option::Some(secret.to_owned())) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
for debug in [std::format!("{birdeye:?}"), std::format!("{coingecko:?}"), std::format!("{coinmarketcap:?}"), std::format!("{jupiter:?}")] {
|
||||
assert!(!debug.contains(secret));
|
||||
assert!(debug.contains("api_key_present"));
|
||||
}
|
||||
let service = match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
|
||||
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
|
||||
]) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(!std::format!("{service:?}").contains(secret));
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_010_market_price_sources_do_not_read_environment_or_use_float_price_truth() -> std::io::Result<()> {
|
||||
let src = crate_root().join("src");
|
||||
let entries = match std::fs::read_dir(src) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if !name.starts_with("market_price_") || !name.ends_with(".rs") {
|
||||
continue;
|
||||
}
|
||||
let source = match std::fs::read_to_string(entry.path()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(!source.contains("std::env"), "market-price source must not bypass Config environment ownership: {name}");
|
||||
assert!(!source.contains("as_f64("), "market-price source must not use serde_json f64 as canonical price truth: {name}");
|
||||
assert!(!source.contains("tracing::"), "market-price source must use ksp-logging-lib rather than tracing directly: {name}");
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_010_config_schema_exposes_no_provider_url_or_rate_limit_override() -> std::io::Result<()> {
|
||||
let root = crate_root().join("../..");
|
||||
let schema = match std::fs::read_to_string(root.join("config/schemas/std.offchain_transport.schema.json")) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(!schema.contains("base_url"));
|
||||
assert!(!schema.contains("endpoint_url"));
|
||||
assert!(!schema.contains("rate_limit"));
|
||||
assert!(!schema.contains("requests_per"));
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn crate_root() -> std::path::PathBuf {
|
||||
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_adapter.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn timestamp_helpers_preserve_valid_values_and_reject_malformed_pre_epoch_and_overflow() {
|
||||
let epoch_fraction = crate::market_price_timestamp_from_rfc3339("1970-01-01T00:00:00.123Z");
|
||||
assert_eq!(epoch_fraction.map(|value| return value.unix_millis()), std::option::Option::Some(123));
|
||||
assert!(crate::market_price_timestamp_from_rfc3339("1969-12-31T23:59:59Z").is_none());
|
||||
assert!(crate::market_price_timestamp_from_rfc3339("not-a-timestamp").is_none());
|
||||
assert_eq!(crate::market_price_timestamp_from_unix_seconds(1).map(|value| return value.unix_millis()), std::option::Option::Some(1_000));
|
||||
assert!(crate::market_price_timestamp_from_unix_seconds(u64::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_provider_response_error_contains_only_safe_provider_and_field_context() {
|
||||
let error = crate::invalid_provider_response("coingecko", "price");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID);
|
||||
let context = error.context();
|
||||
assert_eq!(context.len(), 2);
|
||||
assert_eq!(context[0].key(), "provider");
|
||||
assert_eq!(context[0].value(), "coingecko");
|
||||
assert_eq!(context[1].key(), "field");
|
||||
assert_eq!(context[1].value(), "price");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
#[test]
|
||||
fn decimal_normalizes_fractional_and_scientific_forms_without_f64() -> ksp_core_lib::Result<()> {
|
||||
@@ -84,3 +84,19 @@ fn decimal_parses_raw_json_number_and_string_without_f64_round_trip() -> ksp_cor
|
||||
assert_eq!(parsed.to_canonical_string(), "151.23");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_raw_json_rejects_non_numeric_and_pathological_values() {
|
||||
let invalid = ["null", "true", "false", "{}", "[]", r#""0""#, r#""-1""#, r#""1e-19""#, r#""1e129""#];
|
||||
for source in invalid {
|
||||
let raw = serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>(source);
|
||||
assert!(raw.is_ok(), "adversarial raw JSON fixture must itself be syntactically valid: {source}");
|
||||
if let std::result::Result::Ok(raw) = raw {
|
||||
let parsed = crate::MarketPriceDecimal::parse_json_raw(raw.as_ref());
|
||||
assert!(parsed.is_err(), "non-price raw JSON must not become a successful decimal: {source}");
|
||||
if let std::result::Result::Err(error) = parsed {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
#[test]
|
||||
fn observation_preserves_pair_exact_price_semantics_timestamps_and_safe_provenance() -> ksp_core_lib::Result<()> {
|
||||
@@ -67,3 +67,18 @@ fn observation_rejects_reversed_ksp_timestamps_and_unsafe_provenance() -> ksp_co
|
||||
assert!(result.is_err());
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provenance_accepts_exact_boundary_and_rejects_trim_control_and_oversize() -> ksp_core_lib::Result<()> {
|
||||
let exact = "a".repeat(crate::MARKET_PRICE_PROVENANCE_MAX_BYTES);
|
||||
let provenance = crate::MarketPriceProvenance::new(exact.clone());
|
||||
assert!(provenance.is_ok());
|
||||
if let std::result::Result::Ok(provenance) = provenance {
|
||||
assert_eq!(provenance.as_str(), exact);
|
||||
}
|
||||
assert!(crate::MarketPriceProvenance::new(format!("{exact}a")).is_err());
|
||||
assert!(crate::MarketPriceProvenance::new(" leading").is_err());
|
||||
assert!(crate::MarketPriceProvenance::new("trailing ").is_err());
|
||||
assert!(crate::MarketPriceProvenance::new("tab\tvalue").is_err());
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user