v0.2.11-pre.010

This commit is contained in:
2026-08-26 12:06:51 +02:00
parent 69294a153f
commit 2db3c4be78
22 changed files with 1104 additions and 56 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/README.md -->
<!-- version: 9 -->
<!-- version: 10 -->
# ksp-config-lib
@@ -23,7 +23,8 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
- la classification `Public`, `Internal`, `Secret` ;
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
- l'adapter du document Transport V1/V2 vers `HttpTransportSettings` et, en V2, `WsTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
- l'adapter du document Transport V1/V2/V3 vers `HttpTransportSettings`, `WsTransportSettings` et, en V3, `YellowstoneGrpcTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
- l'adapter de `cfg.std.offchain_transport` vers `ksp_offchain_transport_lib::MarketPriceService`, avec contrôle de provenance des credentials/public fields et sans rendre les limites provider configurables ;
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
@@ -35,10 +36,12 @@ Le registre par défaut connaît :
```text
cfg.composite.ksp-app-wallet-desk -> config/composite.ksp-app-wallet-desk.json
cfg.std.logging -> config/std.logging.json
cfg.std.offchain_transport -> config/std.offchain_transport.json
cfg.std.transport -> config/std.transport.json
cfg.std.wallet -> config/std.wallet.json
schema.composite -> config/schemas/composite.schema.json
schema.std.logging -> config/schemas/std.logging.schema.json
schema.std.offchain_transport -> config/schemas/std.offchain_transport.schema.json
schema.std.transport -> config/schemas/std.transport.schema.json
schema.std.wallet -> config/schemas/std.wallet.schema.json
```
@@ -65,11 +68,11 @@ Les autres crates et applications KSP ne doivent pas :
- parser ou écrire directement `.env` ;
- ouvrir directement les documents Config connus par leur filename physique ;
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
- reconstruire elles-mêmes la configuration Logging, Transport ou Wallet depuis le JSON.
- reconstruire elles-mêmes la configuration Logging, On-chain Transport, Off-chain Transport ou Wallet depuis le JSON.
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, et de `ksp-onchain-transport-lib` pour construire le contrat runtime Transport dans la direction Config -> Transport. Le document Wallet reste un contrat de chemins/profils Config et nintroduit aucune dépendance Config -> `ksp-wallet-lib`.
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, de `ksp-onchain-transport-lib` pour construire le contrat runtime On-chain Transport et de `ksp-offchain-transport-lib` pour construire le service market-price dans la direction Config -> Transport. Le document Wallet reste un contrat de chemins/profils Config et nintroduit aucune dépendance Config -> `ksp-wallet-lib`.
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib` et `ksp-onchain-transport-lib` ne dépendent pas de Config.
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib`, `ksp-onchain-transport-lib` et `ksp-offchain-transport-lib` ne dépendent pas de Config.
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
@@ -81,7 +84,7 @@ Un secret reste accessible au runtime ou au management lorsqu'un consumer autori
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport accepte les valeurs secrètes pour les URLs HTTP/WebSocket et, en V3, pour `grpc_endpoints[].secret_metadata[]` : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. Les metadata gRPC publiques et secrètes sont séparées et leur provenance Config est contrôlée avant mapping. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport accepte les valeurs secrètes pour les URLs HTTP/WebSocket et, en V3, pour `grpc_endpoints[].secret_metadata[]` : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. Les metadata gRPC publiques et secrètes sont séparées et leur provenance Config est contrôlée avant mapping. `std.offchain_transport` exige une provenance `Secret` pour les API keys effectives et une provenance `Public` pour la paire DexScreener lorsqu'elle vient de l'environnement ; il ne permet ni URL provider arbitraire ni override de rate limit. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
## Documentation
@@ -90,6 +93,7 @@ Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configur
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport V3 HTTP + WebSocket + Yellowstone gRPC, avec lecture backward des V1/V2 ;
- [`../../config/std.offchain_transport.json`](../../config/std.offchain_transport.json) — document standard Off-chain Transport V1, actuellement limité au domaine `market_price` SOL/USD ;
- [`../../config/std.wallet.json`](../../config/std.wallet.json) — racine Wallet globale et sous-répertoire optionnel par profil ;
- [`../../config/composite.ksp-app-wallet-desk.json`](../../config/composite.ksp-app-wallet-desk.json) — composition Logging/Transport/Wallet de Wallet Desk ;
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/USAGE.md -->
<!-- version: 12 -->
<!-- version: 13 -->
# Utilisation de ksp-config-lib
@@ -182,6 +182,38 @@ let _ = (root, profile_subdirectory, effective);
Les chemins Wallet refusent toute valeur `KSP_SECRET_*`. Les futurs `KSP_SECRET_WALLET_PASS_*` constituent un flux de secrets distinct et ne sont pas des champs de `std.wallet.json`.
### 4.3 Construire le service Off-chain Transport depuis Config
Le document `cfg.std.offchain_transport` possède actuellement le domaine `market_price`. Config résout les placeholders, vérifie la provenance des API keys et de la paire DexScreener, puis construit directement `MarketPriceService` :
```rust
let offchain = match engine.load_resolved_offchain_transport_config(
std::option::Option::None,
&environment,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
for entry in offchain.service().registry().entries() {
println!(
"{} {:?}",
entry.descriptor().display_name(),
entry.state().availability(),
);
}
```
Le profil versionné `public_keyless` ne requiert aucun secret. Le profil `all_free` utilise les credentials `KSP_SECRET_*` et la paire `KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS` inventoriés dans `.env.example`. Une API key littérale ou issue d'une provenance non secrète est refusée par l'adapter effectif ; une paire DexScreener issue d'une provenance Secret est également refusée.
Config ne permet pas de fournir `base_url`, `endpoint_url`, `rate_limit` ou `requests_per` aux branches provider. Les origines et cadences sûres restent possédées par `ksp-offchain-transport-lib`. La direction de dépendance reste donc :
```text
ksp-config-lib -> ksp-offchain-transport-lib
```
Off-chain Transport ne lit ni `.env`, ni `KSP_*`, ni les documents Config. Une application telle que la future `ksp-app-solprices-desk` peut recevoir le service déjà composé puis utiliser uniquement `registry()`, `refresh`, `refresh_many` et `refresh_all`.
## 5. Profils et composites
Pour un document standard profilé :

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/offchain_transport.rs
// version: 1
// version: 2
//! Adapter from Config-owned Off-chain Transport documents to the provider-agnostic market-price runtime service.
@@ -322,6 +322,13 @@ fn map_coingecko(
ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode::Keyless => {
ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(source.enabled)
},
_ => {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport CoinGecko access mode is not supported by this Config adapter")
.with_context("provider", "coingecko")
.with_context("field", "access_mode"),
);
},
};
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
@@ -346,6 +353,13 @@ fn map_coinmarketcap(
ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode::Keyless => {
ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(source.enabled)
},
_ => {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport CoinMarketCap access mode is not supported by this Config adapter")
.with_context("provider", "coinmarketcap")
.with_context("field", "access_mode"),
);
},
};
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
@@ -386,6 +400,13 @@ fn map_jupiter(
ksp_offchain_transport_lib::MarketPriceJupiterSettings::free(source.enabled, source.api_key)
},
ksp_offchain_transport_lib::MarketPriceJupiterAccessMode::Keyless => ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(source.enabled),
_ => {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport Jupiter access mode is not supported by this Config adapter")
.with_context("provider", "jupiter")
.with_context("field", "access_mode"),
);
},
};
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/offchain_transport.rs
// version: 1
// version: 2
const TEST_PAIR: &str = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE";
@@ -155,6 +155,51 @@ fn dexscreener_pair_environment_must_use_public_namespace_and_disabled_pair_may_
assert!(disabled.is_ok(), "disabled DexScreener runtime settings should accept an absent pair after pre.009 capability reconciliation: {disabled:?}");
}
#[test]
fn provider_url_and_rate_limit_overrides_are_rejected_by_schema_before_runtime_mapping() {
for (provider, field, value) in [
("coingecko", "base_url", serde_json::Value::String("https://example.invalid".to_owned())),
("coinmarketcap", "rate_limit", serde_json::json!({"requests": 999999, "window_seconds": 1})),
] {
let fixture = tempfile::tempdir();
assert!(fixture.is_ok(), "temporary Config root should be creatable: {fixture:?}");
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let source = committed_document_value();
let mut source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
assert!(profiles.is_some(), "fixture should expose profiles");
if let std::option::Option::Some(profiles) = profiles {
let public_keyless = profiles
.iter_mut()
.find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("public_keyless"));
assert!(public_keyless.is_some(), "fixture should contain public_keyless profile");
if let std::option::Option::Some(public_keyless) = public_keyless {
public_keyless["market_price"][provider][field] = value;
}
}
let engine = fixture_engine_with_document(fixture.path(), &source);
assert!(engine.is_ok(), "override fixture engine should be constructible: {engine:?}");
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("public_keyless"), &environment);
assert!(resolved.is_err(), "provider URL/rate-limit override must be rejected before runtime mapping: provider={provider} field={field}");
if let std::result::Result::Err(error) = resolved {
assert_eq!(error.code(), crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED);
let debug = format!("{error:?}");
assert!(!debug.contains("https://example.invalid"));
}
}
}
fn assert_provider_availability(
registry: &ksp_offchain_transport_lib::MarketPriceProviderRegistry,
provider_id: &str,

View 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`.

View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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