From 2db3c4be78c408c71d569f9328bb9c27f16e50aa Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 26 Aug 2026 12:06:51 +0200 Subject: [PATCH] v0.2.11-pre.010 --- Cargo.toml | 2 +- crates/ksp-config-lib/README.md | 16 +- crates/ksp-config-lib/USAGE.md | 34 +- .../ksp-config-lib/src/offchain_transport.rs | 23 +- .../unit_tests/offchain_transport.rs | 47 ++- crates/ksp-offchain-transport-lib/README.md | 138 ++++++++ crates/ksp-offchain-transport-lib/USAGE.md | 152 +++++++++ crates/ksp-offchain-transport-lib/src/lib.rs | 11 +- .../src/market_price_adapter.rs | 5 +- .../src/market_price_coingecko.rs | 3 +- .../src/market_price_coinmarketcap.rs | 3 +- .../src/market_price_jupiter.rs | 3 +- .../src/market_price_provider.rs | 10 +- .../src/market_price_service.rs | 3 +- .../tests/release_completeness.rs | 161 +++++++++ .../tests/security_hardening.rs | 90 +++++ .../unit_tests/market_price_adapter.rs | 24 ++ .../unit_tests/market_price_decimal.rs | 18 +- .../unit_tests/market_price_observation.rs | 17 +- deltas/0.2.11/pre.010.md | 309 ++++++++++++++++++ ...8-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md | 19 +- .../014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md | 72 ++-- 22 files changed, 1104 insertions(+), 56 deletions(-) create mode 100644 crates/ksp-offchain-transport-lib/README.md create mode 100644 crates/ksp-offchain-transport-lib/USAGE.md create mode 100644 crates/ksp-offchain-transport-lib/tests/release_completeness.rs create mode 100644 crates/ksp-offchain-transport-lib/tests/security_hardening.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/market_price_adapter.rs create mode 100644 deltas/0.2.11/pre.010.md diff --git a/Cargo.toml b/Cargo.toml index e3f2a84..b3991b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ 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.9" +version = "0.2.11-pre.10" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-config-lib/README.md b/crates/ksp-config-lib/README.md index 13cf9c1..d6d9a85 100644 --- a/crates/ksp-config-lib/README.md +++ b/crates/ksp-config-lib/README.md @@ -1,5 +1,5 @@ - + # 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 n’introduit 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 n’introduit 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. diff --git a/crates/ksp-config-lib/USAGE.md b/crates/ksp-config-lib/USAGE.md index 8e5fb13..6ed43b0 100644 --- a/crates/ksp-config-lib/USAGE.md +++ b/crates/ksp-config-lib/USAGE.md @@ -1,5 +1,5 @@ - + # 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é : diff --git a/crates/ksp-config-lib/src/offchain_transport.rs b/crates/ksp-config-lib/src/offchain_transport.rs index 608a5ba..d0d9828 100644 --- a/crates/ksp-config-lib/src/offchain_transport.rs +++ b/crates/ksp-config-lib/src/offchain_transport.rs @@ -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), diff --git a/crates/ksp-config-lib/unit_tests/offchain_transport.rs b/crates/ksp-config-lib/unit_tests/offchain_transport.rs index 24405a7..eb54d4d 100644 --- a/crates/ksp-config-lib/unit_tests/offchain_transport.rs +++ b/crates/ksp-config-lib/unit_tests/offchain_transport.rs @@ -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, diff --git a/crates/ksp-offchain-transport-lib/README.md b/crates/ksp-offchain-transport-lib/README.md new file mode 100644 index 0000000..281b660 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/README.md @@ -0,0 +1,138 @@ + + + +# 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 +_* 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`. diff --git a/crates/ksp-offchain-transport-lib/USAGE.md b/crates/ksp-offchain-transport-lib/USAGE.md new file mode 100644 index 0000000..620f980 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/USAGE.md @@ -0,0 +1,152 @@ + + + +# 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::>(); +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("")?; +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. diff --git a/crates/ksp-offchain-transport-lib/src/lib.rs b/crates/ksp-offchain-transport-lib/src/lib.rs index 30d0ce1..cf1b613 100644 --- a/crates/ksp-offchain-transport-lib/src/lib.rs +++ b/crates/ksp-offchain-transport-lib/src/lib.rs @@ -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; diff --git a/crates/ksp-offchain-transport-lib/src/market_price_adapter.rs b/crates/ksp-offchain-transport-lib/src/market_price_adapter.rs index 0fc4108..94e1719 100644 --- a/crates/ksp-offchain-transport-lib/src/market_price_adapter.rs +++ b/crates/ksp-offchain-transport-lib/src/market_price_adapter.rs @@ -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 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> { + 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")); +} diff --git a/crates/ksp-offchain-transport-lib/tests/security_hardening.rs b/crates/ksp-offchain-transport-lib/tests/security_hardening.rs new file mode 100644 index 0000000..eae46fd --- /dev/null +++ b/crates/ksp-offchain-transport-lib/tests/security_hardening.rs @@ -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")); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_adapter.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_adapter.rs new file mode 100644 index 0000000..5176568 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_adapter.rs @@ -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"); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs index e9357f8..4cb2196 100644 --- a/crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs @@ -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::>(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); + } + } + } +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs index 1c5eac0..2df56a9 100644 --- a/crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs @@ -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(()); +} diff --git a/deltas/0.2.11/pre.010.md b/deltas/0.2.11/pre.010.md new file mode 100644 index 0000000..325070c --- /dev/null +++ b/deltas/0.2.11/pre.010.md @@ -0,0 +1,309 @@ + + + +# Delta `0.2.11-pre.010` — Hardening et complétude technique Off-chain Transport + +## 1. Base requise + +Cette tranche s'applique exclusivement après l'état validé : + +```text +v0.2.10 ++ 0.2.11-pre.001 ++ 0.2.11-pre.001-fix.001 ++ 0.2.11-pre.002 ++ 0.2.11-pre.002-fix.001 ++ 0.2.11-pre.002-fix.002 ++ 0.2.11-pre.002-fix.003 ++ 0.2.11-pre.003 ++ 0.2.11-pre.003-fix.001 ++ 0.2.11-pre.004 ++ 0.2.11-pre.004-fix.001 ++ 0.2.11-pre.005 ++ 0.2.11-pre.006 ++ 0.2.11-pre.007 ++ 0.2.11-pre.008 ++ 0.2.11-pre.008-fix.001 ++ 0.2.11-pre.009 +``` + +Le gate opérateur de `pre.009` est intégralement PASS : audits Rust/Markdown, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests ciblés `ksp-offchain-transport-lib`, tests Config et `cargo test --workspace`. Seuls les smokes live et probes de timing explicitement `ignored` restent hors du gate déterministe. + +La version workspace devient : + +```text +0.2.11-pre.10 +``` + +## 2. Mission + +`pre.010` ne crée aucun provider ni endpoint supplémentaire. La tranche durcit l'état fonctionnel complet obtenu par `pre.009` avant le gate technique/live de `pre.011`. + +Le scope est limité à : + +```text +forward compatibility de la façade publique +complétude exacte des huit providers V1 +hardening numeric/timestamps/provenance +redaction et ownership de diagnostics/secrets +interdiction durable des URL/rate-limit provider libres dans Config +brouillons techniques README/USAGE +correction de la documentation technique du refresh multiple V1 +``` + +La réconciliation documentaire finale reste explicitement réservée à `pre.012`. + +## 3. Forward compatibility de la façade publique + +Les enums publiques dont l'espace de valeurs doit pouvoir évoluer sont désormais `#[non_exhaustive]` : + +```text +MarketPricePair +MarketPriceSemantics +MarketPriceProviderAuthMode +MarketPriceProviderRateLimitScope +MarketPriceProviderRateLimitKind +MarketPriceProviderQuotaPeriod +MarketPriceProviderQuotaUnit +MarketPriceProviderAvailability +MarketPriceCoinGeckoAccessMode +MarketPriceCoinMarketCapAccessMode +MarketPriceJupiterAccessMode +MarketPriceProviderSetup +``` + +Cette décision évite qu'une future paire, sémantique, classe de quota, disponibilité, access mode ou provider impose une rupture source aux consumers qui respectent la façade évolutive. + +Les structs à champs privés restent extensibles sans attribut supplémentaire. + +`ksp-config-lib` étant une crate downstream, ses matches CoinGecko/CoinMarketCap/Jupiter absorbent maintenant explicitement les futures variantes inconnues via une erreur Config effective sûre. La Config ne suppose donc plus que ces enums resteront exhaustifs pour toujours. + +## 4. Complétude release-level des huit providers + +Un nouveau canari `tests/release_completeness.rs` construit les huit setups désactivés et vérifie l'inventaire exact, ordonné et provider-neutral : + +```text +birdeye +coinbase_exchange +coingecko +coinmarketcap +coinpaprika +dexscreener +jupiter +kraken +``` + +Il verrouille pour chaque entrée : + +```text +provider_id +MarketPriceSemantics +MarketPriceProviderAuthMode du mode testé +support SOL/USD +availability Disabled +``` + +Le même canari vérifie statiquement que les enums évolutives restent `#[non_exhaustive]`. + +Aucun neuvième provider, autre paire ou abstraction wire publique n'est ajouté. + +## 5. Hardening adversarial numeric, timestamps et provenance + +Les tests déterministes sont complétés pour couvrir : + +### 5.1 `MarketPriceDecimal` + +Rejet explicite de formes JSON qui ne doivent jamais devenir un prix réussi : + +```text +null +booléens +objet +tableau +zéro string +négatif string +scale/exposant pathologique +overflow d'exposant +``` + +Le chemin canonique reste sans `serde_json::Value::as_f64` dans les modules `market_price_*`. + +### 5.2 Timestamps provider + +Les helpers communs couvrent désormais : + +```text +RFC3339 valide avec millisecondes fractionnelles +pré-epoch rejeté +RFC3339 invalide rejeté +Unix seconds valide +multiplication Unix seconds overflow rejetée +``` + +Aucun timestamp provider n'est inventé lorsqu'une source ne fournit pas une vraie information temporelle du prix. + +### 5.3 Provenance + +`MarketPriceProvenance` est canarisée à sa borne maximale exacte et rejette : + +```text +dépassement de borne +leading/trailing whitespace +contrôle tabulation +``` + +Les erreurs provider invalides conservent seulement le provider et le champ sûrs dans leur contexte. + +## 6. Hardening secrets, diagnostics et ownership + +Le nouveau canari `tests/security_hardening.rs` vérifie notamment : + +```text +Debug Birdeye/CoinGecko/CoinMarketCap/Jupiter ne contient pas la clé +Debug MarketPriceService ne contient pas la clé +aucun module market_price_* ne lit std::env +aucun module market_price_* n'utilise as_f64 comme vérité prix +aucun module market_price_* n'utilise tracing:: directement +schema Config sans base_url/endpoint_url/rate_limit/requests_per provider +``` + +Les origines HTTPS, headers et cadences restent donc possédés par Off-chain Transport. + +## 7. Hardening Config + +Un test Config adversarial injecte dans le document standard des champs non autorisés représentant : + +```text +base_url provider arbitraire +rate_limit provider arbitraire +``` + +Le schema strict doit les rejeter avant le mapping runtime. Cette protection complète les canaries statiques et empêche qu'une future modification du document transforme silencieusement Config en bypass SSRF/credential-redirection ou en moyen d'augmenter une cadence provider. + +Les access modes `#[non_exhaustive]` reçoivent aussi une branche downstream future-safe dans l'adapter Config ; aucune variante inconnue n'est interprétée arbitrairement. + +## 8. Refresh multiple V1 explicitement séquentiel + +Le plan d'origine conservait encore une phrase préparatoire affirmant que les providers éligibles seraient lancés concurremment. Cette phrase était devenue fausse depuis `pre.008`, qui a délibérément matérialisé V1 comme séquentielle et déterministe. + +`pre.010` réconcilie le **contrat technique courant**, sans attendre la documentation finale : + +```text +refresh_many valide tous les IDs avant dispatch +ordre demandé conservé +refresh_all suit l'ordre stable du registry +aucun sleep pour attendre un cooldown +provider non éligible projeté immédiatement +aucun fallback +aucun consensus +aucune agrégation +``` + +La concurrence n'est pas interdite pour toujours, mais elle nécessitera un contrat futur explicite sur ordre observable, admission et rate limiting. + +## 9. Brouillons techniques README/USAGE + +La nouvelle crate reçoit ses premiers documents techniques : + +```text +crates/ksp-offchain-transport-lib/README.md +crates/ksp-offchain-transport-lib/USAGE.md +``` + +Ils décrivent la taxonomie hétérogène, la façade `MarketPrice*`, les huit providers, les frontières Config/HTTP, numeric safety, refresh séquentiel V1 et les règles de diagnostic. + +`ksp-config-lib/README.md` et `USAGE.md` sont complétés pour documenter `cfg.std.offchain_transport`, les nouvelles ressources du registry et la direction : + +```text +ksp-config-lib -> ksp-offchain-transport-lib +``` + +Ces textes sont explicitement qualifiés de brouillons techniques. Ils ne remplacent pas la réconciliation documentaire finale de `pre.012` et ne revendiquent aucun résultat live de `pre.011`. + +## 10. Scope inchangé + +La tranche n'ajoute pas : + +```text +provider supplémentaire +SOL/EUR ou autre paire +endpoint nouveau +SDK provider +fallback +consensus/moyenne/médiane +scheduler périodique +concurrence de refresh multiple +cache/historique +smoke live +swap_quote +application Price Desk +``` + +## 11. Validations sandbox et gate opérateur demandé + +Les audits exécutables dans le sandbox sur l'état final donnent : + +```text +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +KSP workspace Rust rule audit: clean +Markdown table audit: clean (124 tables / 118 files) +Rust > 160 colonnes dans les crates touchées: 0 +``` + +Cargo n'est pas disponible dans le sandbox de production du delta ; aucune compilation locale n'est donc revendiquée. + +Après application : + +```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 -p ksp-config-lib +cargo test --workspace +``` + +Le gate attendu doit rester sans warning. Les smokes live demeurent réservés à `pre.011`. + +## 12. Fichiers ajoutés + +```text +crates/ksp-offchain-transport-lib/README.md +crates/ksp-offchain-transport-lib/USAGE.md +crates/ksp-offchain-transport-lib/tests/release_completeness.rs +crates/ksp-offchain-transport-lib/tests/security_hardening.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_adapter.rs +deltas/0.2.11/pre.010.md +``` + +## 13. Fichiers modifiés + +```text +Cargo.toml +crates/ksp-config-lib/README.md +crates/ksp-config-lib/USAGE.md +crates/ksp-config-lib/src/offchain_transport.rs +crates/ksp-config-lib/unit_tests/offchain_transport.rs +crates/ksp-offchain-transport-lib/src/lib.rs +crates/ksp-offchain-transport-lib/src/market_price_adapter.rs +crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs +crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs +crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs +crates/ksp-offchain-transport-lib/src/market_price_provider.rs +crates/ksp-offchain-transport-lib/src/market_price_service.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs +docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +``` + +## 14. Fichiers supprimés + +Aucun. + +## 15. Autorité du delta + +Le présent delta décrit uniquement `0.2.11-pre.010`. Les résultats Cargo du gate opérateur demandé après application devront être reportés dans la validation courante ou dans un éventuel fix si un défaut réel est détecté. `pre.011` reste propriétaire du gate technique/live final ; `pre.012` de la réconciliation documentaire finale ; `pre.013` de la préparation minimale de publication. diff --git a/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md b/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md index 2352021..6e7ccb9 100644 --- a/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +++ b/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md @@ -1,9 +1,9 @@ - + # Plan `0.2.11` — Off-chain price transport SOL/USD multi-provider -**Statut courant : `0.2.11-pre.009` matérialise la frontière Config -> Off-chain Transport. `cfg.std.offchain_transport` et `schema.std.offchain_transport` possèdent désormais un domaine `market_price` avec les huit providers V1, des profils `public_keyless` et `all_free`, les credentials résolus exclusivement depuis une provenance Config classée Secret, et une adresse de paire DexScreener publique lorsqu'elle provient de l'environnement. `ResolvedOffchainTransportConfig` construit `MarketPriceService` sans créer de dépendance inverse depuis Off-chain Transport. Les limites provider restent possédées par la crate runtime et ne deviennent pas des cadences libres de Config.** +**Statut courant : `0.2.11-pre.010` durcit la façade et les invariants avant le gate live. Les enums publiques évolutives de `market_price` sont désormais `#[non_exhaustive]`, des canaries release-level verrouillent l'inventaire exact des huit providers, la redaction et les frontières Config/HTTP, et des tests adversariaux complètent numeric safety, timestamps et provenance. `README.md` / `USAGE.md` de la nouvelle crate sont introduits comme brouillons techniques uniquement ; la réconciliation documentaire finale reste réservée à `pre.012`. Le gate opérateur de `pre.009` est intégralement PASS.** ## 1. Base et autorité @@ -473,18 +473,19 @@ ne contourne jamais le cooldown ### Refresh multiple/all -Un refresh multiple : +Un refresh multiple V1 : ```text +valide la sélection complète avant le premier dispatch évalue chaque provider indépendamment -lance concurremment les providers éligibles +exécute séquentiellement dans un ordre déterministe ne bloque pas Coinbase parce que Jupiter est en cooldown -ne dort pas pour attendre tous les providers non éligibles +ne dort pas pour attendre les providers non éligibles retourne un résultat générique par provider expose le prochain instant admissible pour les providers différés ``` -La future UI peut donc désactiver/annoter un bouton à partir de l'état retourné sans connaître la règle provider. +Le choix séquentiel de V1 privilégie la simplicité, la reproductibilité des effets et l'absence de scheduler implicite. Il n'interdit pas une évolution concurrente future, mais une telle évolution devra définir explicitement ordre observable, admission et interactions de rate limiting au lieu d'être supposée par la HID. La future UI peut donc désactiver/annoter un bouton à partir de l'état retourné sans connaître la règle provider. ## 14. HTTP commun et résilience @@ -769,15 +770,15 @@ Implémentation de Birdeye Standard avec `X-API-KEY`, endpoint Price Single Sola ### `pre.009` — Config Off-chain Transport -**Statut : réalisé ; gate Cargo opérateur à rejouer.** +**Statut : réalisé ; gate opérateur complet PASS.** Ajout de `cfg.std.offchain_transport` / `schema.std.offchain_transport`, du document standard V1, de son schema strict, d'un exemple et de la fixture Config. Le domaine `market_price` configure les huit providers sans rendre leurs cadences modifiables. `public_keyless` fonctionne sans secret ; `all_free` référence quatre credentials `KSP_SECRET_*` et une paire DexScreener `KSP_PUBLIC_*`. `ResolvedOffchainTransportConfig` résout l'environnement, contrôle la provenance des credentials/public fields et construit `MarketPriceService` via `MarketPriceProviderSetup`, sans dépendance inverse. Les deux applications desktop embarquent les nouvelles ressources Config. La tranche réconcilie aussi DexScreener pour qu'une paire soit requise uniquement quand le provider est activé. ### `pre.010` — Hardening et complétude technique -**Statut : planifié.** +**Statut : réalisé côté delta ; gate Cargo opérateur à rejouer.** -Hardening de l'API publique, tests adversariaux/completeness et brouillons techniques README/USAGE sans réconciliation documentaire finale. +Durcissement de la façade publique avec `#[non_exhaustive]` sur les enums évolutives, absorption future-safe de ces enums par l'adapter Config, canaries de complétude exacte des huit providers, tests adversariaux numeric/timestamps/provenance/redaction et vérification de l'absence d'URL/rate-limit provider configurables. Les premiers `README.md` / `USAGE.md` d'Off-chain Transport et les ajouts Config associés sont des brouillons techniques ; `pre.012` reste seule propriétaire de la réconciliation documentaire finale. Le contrat `refresh_many`/`refresh_all` V1 est explicitement réaffirmé séquentiel et déterministe. ### `pre.011` — Gate technique et live final diff --git a/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md b/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md index 552d4e4..e8c3f0f 100644 --- a/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +++ b/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md @@ -1,5 +1,5 @@ - + # Validation `0.2.11` — Off-chain price transport SOL/USD @@ -288,30 +288,54 @@ Les tests déterministes de `pre.008` vérifient le registry de service sur les ## 3.12 Gate `0.2.11-pre.009` -| Critère | Statut | Preuve | -|----------------------------------------------------------------------|---------|----------------------------------------------------------------| -| version workspace `0.2.11-pre.9` | PASS | `Cargo.toml` racine | -| document `cfg.std.offchain_transport` enregistré | PASS | registry Config + `std.offchain_transport.json` | -| schema `schema.std.offchain_transport` enregistré | PASS | registry Config + schema Draft 2020-12 | -| domaine actuel imbriqué sous `market_price` | PASS | document/schema V1 | -| profils `public_keyless` et `all_free` versionnés | PASS | document standard + exemple | -| huit branches provider Config obligatoires | PASS | schema + fixture | -| credentials conditionnés par mode/provider | PASS | `if/then` du schema | -| credentials effectifs exigent une provenance Config Secret | PASS | adapter `offchain_transport.rs` + tests | -| littéral credential rejeté par l'adapter effectif | PASS | test `literal_or_nonsecret_provider_credentials...` | -| DexScreener pair requis seulement lorsque le provider est activé | PASS | schema + `MarketPriceDexScreenerSettings` | -| Dex pair via environnement exige une provenance Public | PASS | adapter Config + test | -| `.env.example` inventorie quatre secrets et la paire publique | PASS | `.env.example` | -| Config construit `MarketPriceService` via `MarketPriceProviderSetup` | PASS | `ResolvedOffchainTransportConfig` | -| Off-chain Transport ne dépend pas de Config | PASS | dependency ownership + manifest | -| aucune cadence provider libre ajoutée à Config | PASS | schema/document sans rate-limit override | -| Config Debug/safe view ne divulgue pas les API keys | PASS | tests adapter | -| applications desktop embarquent document + schema | PASS | deux `tauri.conf.json` + canaries desktop | -| audit Rust sandbox | PASS | clean, 0 candidate export | -| audit Markdown sandbox | PASS | clean, 123 tables / 115 fichiers | -| `cargo check` / Clippy / tests Config et workspace | PLANNED | gate opérateur requis après application de l'archive `pre.009` | +| Critère | Statut | Preuve | +|----------------------------------------------------------------------|--------|-----------------------------------------------------| +| version workspace `0.2.11-pre.9` | PASS | `Cargo.toml` racine | +| document `cfg.std.offchain_transport` enregistré | PASS | registry Config + `std.offchain_transport.json` | +| schema `schema.std.offchain_transport` enregistré | PASS | registry Config + schema Draft 2020-12 | +| domaine actuel imbriqué sous `market_price` | PASS | document/schema V1 | +| profils `public_keyless` et `all_free` versionnés | PASS | document standard + exemple | +| huit branches provider Config obligatoires | PASS | schema + fixture | +| credentials conditionnés par mode/provider | PASS | `if/then` du schema | +| credentials effectifs exigent une provenance Config Secret | PASS | adapter `offchain_transport.rs` + tests | +| littéral credential rejeté par l'adapter effectif | PASS | test `literal_or_nonsecret_provider_credentials...` | +| DexScreener pair requis seulement lorsque le provider est activé | PASS | schema + `MarketPriceDexScreenerSettings` | +| Dex pair via environnement exige une provenance Public | PASS | adapter Config + test | +| `.env.example` inventorie quatre secrets et la paire publique | PASS | `.env.example` | +| Config construit `MarketPriceService` via `MarketPriceProviderSetup` | PASS | `ResolvedOffchainTransportConfig` | +| Off-chain Transport ne dépend pas de Config | PASS | dependency ownership + manifest | +| aucune cadence provider libre ajoutée à Config | PASS | schema/document sans rate-limit override | +| Config Debug/safe view ne divulgue pas les API keys | PASS | tests adapter | +| applications desktop embarquent document + schema | PASS | deux `tauri.conf.json` + canaries desktop | +| audit Rust sandbox | PASS | clean, 0 candidate export | +| audit Markdown sandbox | PASS | clean, 123 tables / 115 fichiers | +| `cargo check` / Clippy / tests Config et workspace | PASS | gate opérateur complet du 2026-08-26 | -La tranche n'ajoute aucune URL provider configurable, aucun SDK provider et aucune lecture directe de l'environnement dans `ksp-offchain-transport-lib`. Les secrets restent résolus par `ksp-config-lib`; le service runtime reçoit uniquement les settings typés nécessaires. La modification DexScreener est capability-aware : une paire absente est valide uniquement lorsque l'adapter est désactivé, ce qui permet de conserver son entrée dans l'inventaire générique sans fabriquer de fausse configuration. +La tranche n'ajoute aucune URL provider configurable, aucun SDK provider et aucune lecture directe de l'environnement dans `ksp-offchain-transport-lib`. Les secrets restent résolus par `ksp-config-lib`; le service runtime reçoit uniquement les settings typés nécessaires. La modification DexScreener est capability-aware : une paire absente est valide uniquement lorsque l'adapter est désactivé, ce qui permet de conserver son entrée dans l'inventaire générique sans fabriquer de fausse configuration. Le gate opérateur de `pre.009` est intégralement PASS : audits Rust/Markdown, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests ciblés Off-chain, 119 tests unitaires Config et `cargo test --workspace`. Seuls les smokes live et probes de timing explicitement `ignored` restent non exécutés par le gate déterministe. + +## 3.13 Gate `0.2.11-pre.010` + +| Critère | Statut | Preuve | +|------------------------------------------------------------------------|---------|------------------------------------------------------| +| version workspace `0.2.11-pre.10` | PASS | `Cargo.toml` racine | +| enums publiques évolutives marquées `#[non_exhaustive]` | PASS | provider/access modes/setup + canari completeness | +| adapter Config future-safe face aux access modes non exhaustifs | PASS | branches wildcard vers erreur effective sûre | +| inventaire exact des huit providers et sémantiques verrouillé | PASS | `tests/release_completeness.rs` | +| aucun environnement lu par les modules `market_price_*` | PASS | `tests/security_hardening.rs` | +| aucun `as_f64` utilisé comme vérité de prix `market_price` | PASS | `tests/security_hardening.rs` | +| aucun `tracing::` direct dans `market_price_*` | PASS | `tests/security_hardening.rs` | +| Debug keyed settings/service ne divulgue pas les API keys | PASS | canari secret déterministe | +| Config rejette URL provider et override de rate limit | PASS | schema strict + test Config adversarial | +| timestamps RFC3339/pre-epoch/overflow durcis | PASS | unit test `market_price_adapter` | +| JSON numeric pathologique/null/non-numérique rejeté | PASS | unit test `market_price_decimal` | +| provenance accepte la borne et rejette trim/control/oversize | PASS | unit test `market_price_observation` | +| `refresh_many` / `refresh_all` V1 documentés séquentiels/déterministes | PASS | plan + `README.md` / `USAGE.md` Off-chain | +| README/USAGE techniques créés sans usurper la réconciliation finale | PASS | crate Off-chain + compléments Config | +| audit Rust sandbox | PLANNED | à exécuter sur l'état final de l'archive | +| audit Markdown sandbox | PLANNED | à exécuter sur l'état final de l'archive | +| `cargo check` / Clippy / tests ciblés et workspace | PLANNED | gate opérateur requis après application de `pre.010` | + +`pre.010` ne fait aucun smoke live, ne change aucun endpoint provider et n'introduit ni fallback, ni consensus, ni scheduler. Les documents crate créés ici sont des brouillons techniques courants ; `pre.012` reste seule propriétaire de la réconciliation documentaire finale après le gate technique/live de `pre.011`. ## 4. Matrice provider prévue