From 69294a153f73869223b81d637c5c4afef705d4be Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 26 Aug 2026 11:23:01 +0200 Subject: [PATCH] v0.2.11-pre.009 --- .env.example | 22 +- Cargo.toml | 4 +- .../std.offchain_transport.example.json | 43 ++ .../std.offchain_transport.schema.json | 366 +++++++++++++ config/std.offchain_transport.json | 75 +++ crates/ksp-app-config-desk/tauri.conf.json | 2 + .../tests/desktop_contract.rs | 12 +- crates/ksp-app-wallet-desk/tauri.conf.json | 2 + .../tests/desktop_contract.rs | 12 +- .../tests/release_compliance.rs | 4 +- crates/ksp-config-lib/Cargo.toml | 3 +- crates/ksp-config-lib/src/lib.rs | 13 +- .../ksp-config-lib/src/offchain_transport.rs | 499 ++++++++++++++++++ crates/ksp-config-lib/src/registry.rs | 43 +- crates/ksp-config-lib/tests/ownership.rs | 6 +- crates/ksp-config-lib/tests/public_api.rs | 32 +- .../fixtures/std.offchain_transport.json | 75 +++ .../unit_tests/offchain_transport.rs | 239 +++++++++ crates/ksp-config-lib/unit_tests/registry.rs | 51 +- .../src/market_price_dexscreener.rs | 41 +- .../tests/public_api.rs | 4 +- .../unit_tests/market_price_dexscreener.rs | 23 +- .../unit_tests/market_price_registry.rs | 4 +- .../unit_tests/market_price_service.rs | 4 +- deltas/0.2.11/pre.009.md | 240 +++++++++ ...8-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md | 57 +- .../014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md | 121 +++-- 27 files changed, 1864 insertions(+), 133 deletions(-) create mode 100644 config/examples/std.offchain_transport.example.json create mode 100644 config/schemas/std.offchain_transport.schema.json create mode 100644 config/std.offchain_transport.json create mode 100644 crates/ksp-config-lib/src/offchain_transport.rs create mode 100644 crates/ksp-config-lib/unit_tests/fixtures/std.offchain_transport.json create mode 100644 crates/ksp-config-lib/unit_tests/offchain_transport.rs create mode 100644 deltas/0.2.11/pre.009.md diff --git a/.env.example b/.env.example index b7d4b70..1c7f241 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # file: .env.example -# version: 9 +# version: 10 # KSP Logging root directory. Used by config/std.logging.json for relative log output paths. # The current Config document fallback is "logs" when neither the process environment nor .env defines this variable. @@ -50,6 +50,26 @@ KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com # Keep the real credential only in the process environment or local .env; never commit it. # KSP_SECRET_HELIUS_API_KEY=replace-me +# Birdeye Standard API key used by the optional `all_free` profile in config/std.offchain_transport.json. +# Keep the real credential only in the process environment or local .env; never commit it. +# KSP_SECRET_BIRDEYE_API_KEY=replace-me + +# CoinGecko Demo API key used by the optional `all_free` Off-chain Transport profile. +# Keep the real credential only in the process environment or local .env; never commit it. +# KSP_SECRET_COINGECKO_DEMO_API_KEY=replace-me + +# CoinMarketCap Basic API key used by the optional `all_free` Off-chain Transport profile. +# Keep the real credential only in the process environment or local .env; never commit it. +# KSP_SECRET_COINMARKETCAP_API_KEY=replace-me + +# Public Solana pair address used by DexScreener for explicit SOL/USD pair lookup in the optional `all_free` profile. +# This value is intentionally public and must identify the exact pair chosen by the operator. +# KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS=replace-with-solana-pair-pubkey + +# Jupiter Free API key used by the optional `all_free` Off-chain Transport profile. +# Keep the real credential only in the process environment or local .env; never commit it. +# KSP_SECRET_JUPITER_API_KEY=replace-me + # Fade-in duration in milliseconds used by the common KSP desk splash lifecycle. KSP_DESK_SPLASH_FADE_IN_MS=300 diff --git a/Cargo.toml b/Cargo.toml index afc98e5..e3f2a84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 280 +# version: 281 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.2.11-pre.8.fix.1" +version = "0.2.11-pre.9" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/config/examples/std.offchain_transport.example.json b/config/examples/std.offchain_transport.example.json new file mode 100644 index 0000000..9f14bb3 --- /dev/null +++ b/config/examples/std.offchain_transport.example.json @@ -0,0 +1,43 @@ +{ + "format_version": 1, + "default_profile": "custom_free", + "profiles": [ + { + "profile_id": "custom_free", + "market_price": { + "birdeye": { + "enabled": true, + "api_key": "${KSP_SECRET_BIRDEYE_API_KEY}" + }, + "coinbase_exchange": { + "enabled": true + }, + "coingecko": { + "enabled": true, + "access_mode": "demo", + "api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}" + }, + "coinmarketcap": { + "enabled": true, + "access_mode": "basic", + "api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}" + }, + "coinpaprika": { + "enabled": true + }, + "dexscreener": { + "enabled": true, + "sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}" + }, + "jupiter": { + "enabled": true, + "access_mode": "free", + "api_key": "${KSP_SECRET_JUPITER_API_KEY}" + }, + "kraken": { + "enabled": true + } + } + } + ] +} diff --git a/config/schemas/std.offchain_transport.schema.json b/config/schemas/std.offchain_transport.schema.json new file mode 100644 index 0000000..205f0f7 --- /dev/null +++ b/config/schemas/std.offchain_transport.schema.json @@ -0,0 +1,366 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:ksp:schema:std.offchain_transport:v1", + "title": "KSP standard Off-chain Transport configuration", + "type": "object", + "additionalProperties": false, + "required": [ + "format_version", + "default_profile", + "profiles" + ], + "properties": { + "format_version": { + "const": 1 + }, + "default_profile": { + "$ref": "#/$defs/profileId" + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/profile" + } + } + }, + "$defs": { + "profileId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "enabledOnly": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "coingecko": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "access_mode" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "access_mode": { + "enum": [ + "demo", + "keyless" + ] + }, + "api_key": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "if": { + "properties": { + "access_mode": { + "const": "keyless" + } + }, + "required": [ + "access_mode" + ] + }, + "then": { + "not": { + "required": [ + "api_key" + ] + } + } + }, + { + "if": { + "properties": { + "enabled": { + "const": true + }, + "access_mode": { + "const": "demo" + } + }, + "required": [ + "enabled", + "access_mode" + ] + }, + "then": { + "required": [ + "api_key" + ] + } + } + ] + }, + "coinmarketcap": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "access_mode" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "access_mode": { + "enum": [ + "basic", + "keyless" + ] + }, + "api_key": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "if": { + "properties": { + "access_mode": { + "const": "keyless" + } + }, + "required": [ + "access_mode" + ] + }, + "then": { + "not": { + "required": [ + "api_key" + ] + } + } + }, + { + "if": { + "properties": { + "enabled": { + "const": true + }, + "access_mode": { + "const": "basic" + } + }, + "required": [ + "enabled", + "access_mode" + ] + }, + "then": { + "required": [ + "api_key" + ] + } + } + ] + }, + "jupiter": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "access_mode" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "access_mode": { + "enum": [ + "free", + "keyless" + ] + }, + "api_key": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "if": { + "properties": { + "access_mode": { + "const": "keyless" + } + }, + "required": [ + "access_mode" + ] + }, + "then": { + "not": { + "required": [ + "api_key" + ] + } + } + }, + { + "if": { + "properties": { + "enabled": { + "const": true + }, + "access_mode": { + "const": "free" + } + }, + "required": [ + "enabled", + "access_mode" + ] + }, + "then": { + "required": [ + "api_key" + ] + } + } + ] + }, + "birdeye": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "api_key": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "required": [ + "api_key" + ] + } + } + ] + }, + "dexscreener": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "sol_usd_pair_address": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "required": [ + "sol_usd_pair_address" + ] + } + } + ] + }, + "marketPrice": { + "type": "object", + "additionalProperties": false, + "required": [ + "birdeye", + "coinbase_exchange", + "coingecko", + "coinmarketcap", + "coinpaprika", + "dexscreener", + "jupiter", + "kraken" + ], + "properties": { + "birdeye": { + "$ref": "#/$defs/birdeye" + }, + "coinbase_exchange": { + "$ref": "#/$defs/enabledOnly" + }, + "coingecko": { + "$ref": "#/$defs/coingecko" + }, + "coinmarketcap": { + "$ref": "#/$defs/coinmarketcap" + }, + "coinpaprika": { + "$ref": "#/$defs/enabledOnly" + }, + "dexscreener": { + "$ref": "#/$defs/dexscreener" + }, + "jupiter": { + "$ref": "#/$defs/jupiter" + }, + "kraken": { + "$ref": "#/$defs/enabledOnly" + } + } + }, + "profile": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile_id", + "market_price" + ], + "properties": { + "profile_id": { + "$ref": "#/$defs/profileId" + }, + "market_price": { + "$ref": "#/$defs/marketPrice" + } + } + } + } +} diff --git a/config/std.offchain_transport.json b/config/std.offchain_transport.json new file mode 100644 index 0000000..989ea05 --- /dev/null +++ b/config/std.offchain_transport.json @@ -0,0 +1,75 @@ +{ + "format_version": 1, + "default_profile": "public_keyless", + "profiles": [ + { + "profile_id": "public_keyless", + "market_price": { + "birdeye": { + "enabled": false + }, + "coinbase_exchange": { + "enabled": true + }, + "coingecko": { + "enabled": true, + "access_mode": "keyless" + }, + "coinmarketcap": { + "enabled": true, + "access_mode": "keyless" + }, + "coinpaprika": { + "enabled": true + }, + "dexscreener": { + "enabled": false + }, + "jupiter": { + "enabled": true, + "access_mode": "keyless" + }, + "kraken": { + "enabled": true + } + } + }, + { + "profile_id": "all_free", + "market_price": { + "birdeye": { + "enabled": true, + "api_key": "${KSP_SECRET_BIRDEYE_API_KEY}" + }, + "coinbase_exchange": { + "enabled": true + }, + "coingecko": { + "enabled": true, + "access_mode": "demo", + "api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}" + }, + "coinmarketcap": { + "enabled": true, + "access_mode": "basic", + "api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}" + }, + "coinpaprika": { + "enabled": true + }, + "dexscreener": { + "enabled": true, + "sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}" + }, + "jupiter": { + "enabled": true, + "access_mode": "free", + "api_key": "${KSP_SECRET_JUPITER_API_KEY}" + }, + "kraken": { + "enabled": true + } + } + } + ] +} diff --git a/crates/ksp-app-config-desk/tauri.conf.json b/crates/ksp-app-config-desk/tauri.conf.json index 2852369..0619a83 100644 --- a/crates/ksp-app-config-desk/tauri.conf.json +++ b/crates/ksp-app-config-desk/tauri.conf.json @@ -55,10 +55,12 @@ "resources": { "../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json", "../../config/std.logging.json": "config/std.logging.json", + "../../config/std.offchain_transport.json": "config/std.offchain_transport.json", "../../config/std.transport.json": "config/std.transport.json", "../../config/std.wallet.json": "config/std.wallet.json", "../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json", "../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json", + "../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json", "../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json", "../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json" } diff --git a/crates/ksp-app-config-desk/tests/desktop_contract.rs b/crates/ksp-app-config-desk/tests/desktop_contract.rs index df3141a..5768af7 100644 --- a/crates/ksp-app-config-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-config-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-config-desk/tests/desktop_contract.rs -// version: 7 +// version: 8 //! Desktop build/shell contract audits for Config Desk. @@ -150,8 +150,16 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writab let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object); assert!(resources.is_some(), "packaged Config resources map must exist"); if let std::option::Option::Some(resources) = resources { - assert_eq!(resources.len(), 8); + assert_eq!(resources.len(), 10); assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json")); + assert_eq!( + resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str), + std::option::Option::Some("config/std.offchain_transport.json"), + ); + assert_eq!( + resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str), + std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"), + ); assert_eq!( resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/schemas/std.wallet.schema.json"), diff --git a/crates/ksp-app-wallet-desk/tauri.conf.json b/crates/ksp-app-wallet-desk/tauri.conf.json index a308c12..22c32eb 100644 --- a/crates/ksp-app-wallet-desk/tauri.conf.json +++ b/crates/ksp-app-wallet-desk/tauri.conf.json @@ -55,10 +55,12 @@ "resources": { "../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json", "../../config/std.logging.json": "config/std.logging.json", + "../../config/std.offchain_transport.json": "config/std.offchain_transport.json", "../../config/std.transport.json": "config/std.transport.json", "../../config/std.wallet.json": "config/std.wallet.json", "../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json", "../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json", + "../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json", "../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json", "../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json" } diff --git a/crates/ksp-app-wallet-desk/tests/desktop_contract.rs b/crates/ksp-app-wallet-desk/tests/desktop_contract.rs index c2b6a54..2692353 100644 --- a/crates/ksp-app-wallet-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-wallet-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs -// version: 22 +// version: 23 //! Desktop build, shell and Config-status contract audits for Wallet Desk. @@ -472,7 +472,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object); assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist"); if let std::option::Option::Some(resources) = resources { - assert_eq!(resources.len(), 8); + assert_eq!(resources.len(), 10); assert_eq!( resources.get("../../config/composite.ksp-app-wallet-desk.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/composite.ksp-app-wallet-desk.json"), @@ -481,6 +481,14 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi resources.get("../../config/schemas/composite.schema.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/schemas/composite.schema.json"), ); + assert_eq!( + resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str), + std::option::Option::Some("config/std.offchain_transport.json"), + ); + assert_eq!( + resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str), + std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"), + ); } let main = read_text(root.join("frontend/main.html").as_path()); assert!(main.contains(packaged_version)); diff --git a/crates/ksp-app-wallet-desk/tests/release_compliance.rs b/crates/ksp-app-wallet-desk/tests/release_compliance.rs index 1de72ec..6091c4e 100644 --- a/crates/ksp-app-wallet-desk/tests/release_compliance.rs +++ b/crates/ksp-app-wallet-desk/tests/release_compliance.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/tests/release_compliance.rs -// version: 5 +// version: 6 //! Release-wide deterministic compliance canaries for Wallet Desk. @@ -198,7 +198,7 @@ fn packaged_resources_include_only_registered_config_sources_and_schemas() { std::option::Option::Some(value) => value, std::option::Option::None => return, }; - assert_eq!(resources.len(), 8); + assert_eq!(resources.len(), 10); for (source, destination) in resources { let destination = destination.as_str(); assert!(destination.is_some(), "resource destination must be textual"); diff --git a/crates/ksp-config-lib/Cargo.toml b/crates/ksp-config-lib/Cargo.toml index 40ca1f6..991db1f 100644 --- a/crates/ksp-config-lib/Cargo.toml +++ b/crates/ksp-config-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-config-lib/Cargo.toml -# version: 8 +# version: 9 [package] name = "ksp-config-lib" @@ -12,6 +12,7 @@ directories.workspace = true jsonschema.workspace = true ksp-core-lib = { path = "../ksp-core-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" } +ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" } ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true diff --git a/crates/ksp-config-lib/src/lib.rs b/crates/ksp-config-lib/src/lib.rs index 93465aa..c245bbc 100644 --- a/crates/ksp-config-lib/src/lib.rs +++ b/crates/ksp-config-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/lib.rs -// version: 18 +// version: 19 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -21,6 +21,7 @@ mod environment; mod error; mod logging; mod management; +mod offchain_transport; mod packaging; mod persistence; mod profile; @@ -127,6 +128,8 @@ pub use self::management::LoggingOutputFilterConfig; pub use self::management::LoggingProfileConfig; /// Typed source contract for one global Logging target override. pub use self::management::LoggingTargetFilterConfig; +/// Effective standard Off-chain Transport configuration resolved from Config. +pub use self::offchain_transport::ResolvedOffchainTransportConfig; /// Writable KSP runtime roots prepared from packaged Config resources. pub use self::packaging::PackagedRuntimeLayout; /// Prepares the shared writable KSP desktop runtime from immutable packaged resources. @@ -155,6 +158,10 @@ pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME; pub use self::registry::DEFAULT_STD_LOGGING_FILENAME; /// Default physical filename for the standard Logging JSON Schema document. pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME; +/// Default physical filename for the standard Off-chain Transport configuration document. +pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME; +/// Default physical filename for the standard Off-chain Transport JSON Schema document. +pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME; /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document. pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME; /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. @@ -169,12 +176,16 @@ pub use self::registry::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK; pub use self::registry::FILE_ID_SCHEMA_COMPOSITE; /// Logical file identifier for the standard Logging JSON Schema document. pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING; +/// Logical file identifier for the standard Off-chain Transport JSON Schema document. +pub use self::registry::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT; /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT; /// Logical file identifier for the standard Wallet JSON Schema document. pub use self::registry::FILE_ID_SCHEMA_STD_WALLET; /// Logical file identifier for the standard Logging configuration document. pub use self::registry::FILE_ID_STD_LOGGING; +/// Logical file identifier for the standard Off-chain Transport configuration document. +pub use self::registry::FILE_ID_STD_OFFCHAIN_TRANSPORT; /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document. pub use self::registry::FILE_ID_STD_TRANSPORT; /// Logical file identifier for the standard Wallet configuration document. diff --git a/crates/ksp-config-lib/src/offchain_transport.rs b/crates/ksp-config-lib/src/offchain_transport.rs new file mode 100644 index 0000000..608a5ba --- /dev/null +++ b/crates/ksp-config-lib/src/offchain_transport.rs @@ -0,0 +1,499 @@ +// file: crates/ksp-config-lib/src/offchain_transport.rs +// version: 1 + +//! Adapter from Config-owned Off-chain Transport documents to the provider-agnostic market-price runtime service. + +/// Effective standard Off-chain Transport configuration resolved from Config. +pub struct ResolvedOffchainTransportConfig { + effective: crate::ResolvedConfigJson, + file_id: crate::ConfigFileId, + profile_id: String, + selection_source: crate::ConfigProfileSelectionSource, + service: ksp_offchain_transport_lib::MarketPriceService, + source_path: std::path::PathBuf, +} + +impl crate::ResolvedOffchainTransportConfig { + /// Returns the detailed environment-resolved effective Config view with secret-safe diagnostics. + #[must_use] + pub const fn effective(&self) -> &crate::ResolvedConfigJson { + return &self.effective; + } + + /// Returns the logical Config document identifier used by this runtime configuration. + #[must_use] + pub const fn file_id(&self) -> &crate::ConfigFileId { + return &self.file_id; + } + + /// Returns the selected standard Off-chain Transport profile identifier. + #[must_use] + pub fn profile_id(&self) -> &str { + return self.profile_id.as_str(); + } + + /// Returns the source that selected the standard Off-chain Transport profile. + #[must_use] + pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource { + return self.selection_source; + } + + /// Returns the provider-agnostic market-price runtime service. + #[must_use] + pub const fn service(&self) -> &ksp_offchain_transport_lib::MarketPriceService { + return &self.service; + } + + /// Returns the physical source Config document path. + #[must_use] + pub fn source_path(&self) -> &std::path::Path { + return self.source_path.as_path(); + } +} + +impl std::fmt::Debug for crate::ResolvedOffchainTransportConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let registry = self.service.registry(); + return formatter + .debug_struct("ResolvedOffchainTransportConfig") + .field("effective", &self.effective) + .field("file_id", &self.file_id) + .field("profile_id", &self.profile_id) + .field("provider_registry", ®istry) + .field("selection_source", &self.selection_source) + .field("source_path", &self.source_path) + .finish(); + } +} + +impl crate::ConfigDocumentEngine { + /// Loads the standard Off-chain Transport document and maps one profile to the generic market-price runtime service. + pub fn load_resolved_offchain_transport_config( + &self, + requested_profile: std::option::Option<&str>, + environment: &crate::ConfigEnvironment, + ) -> ksp_core_lib::Result { + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_OFFCHAIN_TRANSPORT); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let profile = self.load_resolved_profile(&file_id, requested_profile); + let profile = match profile { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return resolve_offchain_transport_profile(&profile, environment); + } + + /// Maps an already resolved standard Off-chain Transport profile to the runtime service while preserving composite selection provenance. + pub fn resolve_offchain_transport_config_profile( + &self, + profile: &crate::ResolvedConfigProfile, + environment: &crate::ConfigEnvironment, + ) -> ksp_core_lib::Result { + if profile.file_id().as_str() != crate::FILE_ID_STD_OFFCHAIN_TRANSPORT { + return std::result::Result::Err(effective_error(profile, "resolved Config profile does not reference the standard Off-chain Transport document")); + } + let descriptor = self.registry().descriptor(profile.file_id()); + if let std::result::Result::Err(error) = descriptor { + return std::result::Result::Err(error); + } + return resolve_offchain_transport_profile(profile, environment); + } +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveOffchainTransportSource { + format_version: u32, + market_price: EffectiveMarketPriceSource, + profile_id: String, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveMarketPriceSource { + birdeye: EffectiveBirdeyeSource, + coinbase_exchange: EffectiveEnabledSource, + coingecko: EffectiveCoinGeckoSource, + coinmarketcap: EffectiveCoinMarketCapSource, + coinpaprika: EffectiveEnabledSource, + dexscreener: EffectiveDexScreenerSource, + jupiter: EffectiveJupiterSource, + kraken: EffectiveEnabledSource, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveEnabledSource { + enabled: bool, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveBirdeyeSource { + api_key: std::option::Option, + enabled: bool, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveCoinGeckoSource { + access_mode: ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode, + api_key: std::option::Option, + enabled: bool, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveCoinMarketCapSource { + access_mode: ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode, + api_key: std::option::Option, + enabled: bool, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveDexScreenerSource { + enabled: bool, + sol_usd_pair_address: std::option::Option, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveJupiterSource { + access_mode: ksp_offchain_transport_lib::MarketPriceJupiterAccessMode, + api_key: std::option::Option, + enabled: bool, +} + +fn resolve_offchain_transport_profile( + profile: &crate::ResolvedConfigProfile, + environment: &crate::ConfigEnvironment, +) -> ksp_core_lib::Result { + ksp_logging_lib::trace!( + target: crate::TRACING_TARGET, + profile_id = profile.profile_id(), + "mapping standard Off-chain Transport Config profile" + ); + let effective = profile.resolve_effective_environment_detailed(environment); + let effective = match effective { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let source = serde_json::from_value::(effective.value().clone()); + let source = match source { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + effective_error(profile, "effective Off-chain Transport Config cannot be decoded into the runtime adapter contract").with_source(error), + ); + }, + }; + if source.format_version != 1 { + return std::result::Result::Err(effective_error(profile, "effective Off-chain Transport format_version is unsupported")); + } + if source.profile_id != profile.profile_id() { + return std::result::Result::Err(effective_error(profile, "effective Off-chain Transport profile_id does not match the selected profile")); + } + let setups = map_market_price_setups(source.market_price, &effective, profile); + let setups = match setups { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let service = ksp_offchain_transport_lib::MarketPriceService::new(setups); + let service = match service { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(offchain_contract_error( + profile, + "effective market-price providers fail the Off-chain Transport runtime contract", + &error, + )); + }, + }; + let provider_count = service.registry().entries().len(); + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + profile_id = profile.profile_id(), + format_version = source.format_version, + provider_count, + "mapped standard Off-chain Transport Config to market-price service" + ); + return std::result::Result::Ok(crate::ResolvedOffchainTransportConfig { + effective, + file_id: profile.file_id().clone(), + profile_id: profile.profile_id().to_owned(), + selection_source: profile.selection_source(), + service, + source_path: profile.path().to_path_buf(), + }); +} + +fn map_market_price_setups( + source: EffectiveMarketPriceSource, + effective: &crate::ResolvedConfigJson, + profile: &crate::ResolvedConfigProfile, +) -> ksp_core_lib::Result> { + let birdeye = map_birdeye(source.birdeye, effective, profile); + let birdeye = match birdeye { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let coinbase_exchange = ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(source.coinbase_exchange.enabled); + let coinbase_exchange = match coinbase_exchange { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "coinbase_exchange", &error)), + }; + let coingecko = map_coingecko(source.coingecko, effective, profile); + let coingecko = match coingecko { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let coinmarketcap = map_coinmarketcap(source.coinmarketcap, effective, profile); + let coinmarketcap = match coinmarketcap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let coinpaprika = ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(source.coinpaprika.enabled); + let coinpaprika = match coinpaprika { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "coinpaprika", &error)), + }; + let dexscreener = map_dexscreener(source.dexscreener, effective, profile); + let dexscreener = match dexscreener { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let jupiter = map_jupiter(source.jupiter, effective, profile); + let jupiter = match jupiter { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let kraken = ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(source.kraken.enabled); + let kraken = match kraken { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "kraken", &error)), + }; + return std::result::Result::Ok(std::vec![ + ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye), + ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase_exchange), + 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 map_birdeye( + source: EffectiveBirdeyeSource, + effective: &crate::ResolvedConfigJson, + profile: &crate::ResolvedConfigProfile, +) -> ksp_core_lib::Result { + if source.api_key.is_some() + && let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/birdeye/api_key", profile, "birdeye") + { + return std::result::Result::Err(error); + } + let settings = ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(source.enabled, source.api_key); + return match settings { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "birdeye", &error)), + }; +} + +fn map_coingecko( + source: EffectiveCoinGeckoSource, + effective: &crate::ResolvedConfigJson, + profile: &crate::ResolvedConfigProfile, +) -> ksp_core_lib::Result { + if source.api_key.is_some() + && let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/coingecko/api_key", profile, "coingecko") + { + return std::result::Result::Err(error); + } + let settings = match source.access_mode { + ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode::Demo => { + ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::demo(source.enabled, source.api_key) + }, + ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode::Keyless => { + ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(source.enabled) + }, + }; + return match settings { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "coingecko", &error)), + }; +} + +fn map_coinmarketcap( + source: EffectiveCoinMarketCapSource, + effective: &crate::ResolvedConfigJson, + profile: &crate::ResolvedConfigProfile, +) -> ksp_core_lib::Result { + if source.api_key.is_some() + && let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/coinmarketcap/api_key", profile, "coinmarketcap") + { + return std::result::Result::Err(error); + } + let settings = match source.access_mode { + ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode::Basic => { + ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::basic(source.enabled, source.api_key) + }, + ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode::Keyless => { + ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(source.enabled) + }, + }; + return match settings { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "coinmarketcap", &error)), + }; +} + +fn map_dexscreener( + source: EffectiveDexScreenerSource, + effective: &crate::ResolvedConfigJson, + profile: &crate::ResolvedConfigProfile, +) -> ksp_core_lib::Result { + if source.sol_usd_pair_address.is_some() + && let std::result::Result::Err(error) = + validate_public_field_provenance(effective, "/market_price/dexscreener/sol_usd_pair_address", profile, "dexscreener") + { + return std::result::Result::Err(error); + } + let settings = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(source.enabled, source.sol_usd_pair_address); + return match settings { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "dexscreener", &error)), + }; +} + +fn map_jupiter( + source: EffectiveJupiterSource, + effective: &crate::ResolvedConfigJson, + profile: &crate::ResolvedConfigProfile, +) -> ksp_core_lib::Result { + if source.api_key.is_some() + && let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/jupiter/api_key", profile, "jupiter") + { + return std::result::Result::Err(error); + } + let settings = match source.access_mode { + ksp_offchain_transport_lib::MarketPriceJupiterAccessMode::Free => { + 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 match settings { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "jupiter", &error)), + }; +} + +fn validate_secret_field_provenance( + effective: &crate::ResolvedConfigJson, + pointer: &str, + profile: &crate::ResolvedConfigProfile, + provider: &'static str, +) -> ksp_core_lib::Result<()> { + let provenance = match effective.provenance_at(pointer) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + effective_error(profile, "Off-chain Transport secret provenance is unavailable") + .with_context("provider", provider) + .with_context("field", pointer), + ); + }, + }; + let mut has_secret_environment = false; + for item in provenance { + let variable_name = match item.variable_name() { + std::option::Option::Some(value) => value, + std::option::Option::None => continue, + }; + let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name); + let sensitivity = match sensitivity { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if !sensitivity.is_secret() { + return std::result::Result::Err( + effective_error(profile, "Off-chain Transport credentials may reference only secret environment variables") + .with_context("provider", provider) + .with_context("field", pointer), + ); + } + has_secret_environment = true; + } + if !has_secret_environment { + return std::result::Result::Err( + effective_error(profile, "Off-chain Transport credentials require secret environment provenance") + .with_context("provider", provider) + .with_context("field", pointer), + ); + } + return std::result::Result::Ok(()); +} + +fn validate_public_field_provenance( + effective: &crate::ResolvedConfigJson, + pointer: &str, + profile: &crate::ResolvedConfigProfile, + provider: &'static str, +) -> ksp_core_lib::Result<()> { + let provenance = match effective.provenance_at(pointer) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + effective_error(profile, "Off-chain Transport public provenance is unavailable") + .with_context("provider", provider) + .with_context("field", pointer), + ); + }, + }; + for item in provenance { + let variable_name = match item.variable_name() { + std::option::Option::Some(value) => value, + std::option::Option::None => continue, + }; + let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name); + let sensitivity = match sensitivity { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if sensitivity != crate::ConfigSensitivity::Public { + return std::result::Result::Err( + effective_error(profile, "public Off-chain Transport fields may reference only public environment variables") + .with_context("provider", provider) + .with_context("field", pointer), + ); + } + } + return std::result::Result::Ok(()); +} + +fn provider_contract_error(profile: &crate::ResolvedConfigProfile, provider: &'static str, error: &ksp_core_lib::Error) -> ksp_core_lib::Error { + return offchain_contract_error(profile, "effective market-price provider settings fail the Off-chain Transport runtime contract", error) + .with_context("provider", provider); +} + +fn offchain_contract_error(profile: &crate::ResolvedConfigProfile, reason: &'static str, error: &ksp_core_lib::Error) -> ksp_core_lib::Error { + return effective_error(profile, reason) + .with_context("offchain_transport_error_domain", error.code().domain()) + .with_context("offchain_transport_error_code", error.code().code()); +} + +fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract") + .with_context("file_id", profile.file_id().as_str()) + .with_context("profile_id", profile.profile_id()) + .with_context("reason", reason); +} + +#[cfg(test)] +#[path = "../unit_tests/offchain_transport.rs"] +mod tests; diff --git a/crates/ksp-config-lib/src/registry.rs b/crates/ksp-config-lib/src/registry.rs index c3ba72e..d6efdc3 100644 --- a/crates/ksp-config-lib/src/registry.rs +++ b/crates/ksp-config-lib/src/registry.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/registry.rs -// version: 10 +// version: 11 /// Bootstrap argument used to replace a known Config filename mapping. pub const ARG_FILE_MAP: &str = "--filemap"; @@ -11,6 +11,10 @@ pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json"; pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json"; /// Default physical filename for the standard Logging JSON Schema document. pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json"; +/// Default physical filename for the standard Off-chain Transport configuration document. +pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME: &str = "std.offchain_transport.json"; +/// Default physical filename for the standard Off-chain Transport JSON Schema document. +pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME: &str = "std.offchain_transport.schema.json"; /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document. pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json"; /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. @@ -25,12 +29,16 @@ pub const FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK: &str = "cfg.composite.ksp-app-w pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite"; /// Logical file identifier for the standard Logging JSON Schema document. pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging"; +/// Logical file identifier for the standard Off-chain Transport JSON Schema document. +pub const FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT: &str = "schema.std.offchain_transport"; /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport"; /// Logical file identifier for the standard Wallet JSON Schema document. pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet"; /// Logical file identifier for the standard Logging configuration document. pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging"; +/// Logical file identifier for the standard Off-chain Transport configuration document. +pub const FILE_ID_STD_OFFCHAIN_TRANSPORT: &str = "cfg.std.offchain_transport"; /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document. pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport"; /// Logical file identifier for the standard Wallet configuration document. @@ -172,6 +180,26 @@ impl ConfigFileRegistry { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; + let offchain_transport = ConfigFileDescriptor::new( + FILE_ID_STD_OFFCHAIN_TRANSPORT, + ConfigFileKind::Config, + DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME, + std::option::Option::Some(FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT), + ); + let offchain_transport = match offchain_transport { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let offchain_transport_schema = ConfigFileDescriptor::new( + FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT, + ConfigFileKind::Schema, + DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME, + std::option::Option::None, + ); + let offchain_transport_schema = match offchain_transport_schema { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; let transport = ConfigFileDescriptor::new( FILE_ID_STD_TRANSPORT, ConfigFileKind::Config, @@ -204,7 +232,18 @@ impl ConfigFileRegistry { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - return crate::build_registry([composite, composite_schema, logging, logging_schema, transport, transport_schema, wallet, wallet_schema]); + return crate::build_registry([ + composite, + composite_schema, + logging, + logging_schema, + offchain_transport, + offchain_transport_schema, + transport, + transport_schema, + wallet, + wallet_schema, + ]); } /// Creates the default registry and applies repeatable `--filemap==` overrides from raw process arguments. diff --git a/crates/ksp-config-lib/tests/ownership.rs b/crates/ksp-config-lib/tests/ownership.rs index 5edd6d4..932cdaa 100644 --- a/crates/ksp-config-lib/tests/ownership.rs +++ b/crates/ksp-config-lib/tests/ownership.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/tests/ownership.rs -// version: 6 +// version: 7 //! Workspace ownership audits for KSP application configuration boundaries. @@ -177,7 +177,7 @@ fn has_preceding_explanatory_comment(source: &str, assignment_line_index: usize) #[test] fn foundational_dependency_direction_does_not_point_back_to_config() { let root = workspace_root(); - for crate_name in ["ksp-core-lib", "ksp-logging-lib"] { + for crate_name in ["ksp-core-lib", "ksp-logging-lib", "ksp-offchain-transport-lib"] { let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml"); let manifest = std::fs::read_to_string(manifest_path.as_path()); assert!(manifest.is_ok(), "unable to read {}", manifest_path.display()); @@ -266,6 +266,8 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() { "\"composite.schema.json\"", "\"std.logging.json\"", "\"std.logging.schema.json\"", + "\"std.offchain_transport.json\"", + "\"std.offchain_transport.schema.json\"", "\"std.transport.json\"", "\"std.transport.schema.json\"", "\"std.wallet.json\"", diff --git a/crates/ksp-config-lib/tests/public_api.rs b/crates/ksp-config-lib/tests/public_api.rs index 32ac03c..f9c0535 100644 --- a/crates/ksp-config-lib/tests/public_api.rs +++ b/crates/ksp-config-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/tests/public_api.rs -// version: 23 +// version: 24 //! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, //! Logging/Transport adapters and management contracts. @@ -80,16 +80,18 @@ fn registry_descriptor_inventory_is_available_from_crate_root() { assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}"); if let std::result::Result::Ok(registry) = registry { let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect(); - assert_eq!(descriptors.len(), 8); + assert_eq!(descriptors.len(), 10); assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK); assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING); - assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT); - assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET); - assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE); - assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING); - assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT); - assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET); - let schema_file_id = descriptors[3].schema_file_id(); + assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT); + assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT); + assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET); + assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE); + assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING); + assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT); + assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT); + assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET); + let schema_file_id = descriptors[4].schema_file_id(); assert!(schema_file_id.is_some(), "public Wallet descriptor should preserve schema association"); if let std::option::Option::Some(schema_file_id) = schema_file_id { assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET); @@ -276,6 +278,18 @@ fn wallet_adapter_contract_is_available_from_crate_root() { assert_eq!(ksp_config_lib::DEFAULT_STD_WALLET_SCHEMA_FILENAME, "std.wallet.schema.json"); } +#[test] +fn offchain_transport_adapter_contract_is_available_from_crate_root() { + let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_offchain_transport_config; + let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_offchain_transport_config_profile; + assert!(std::mem::size_of::() > 0); + let _service = ksp_config_lib::ResolvedOffchainTransportConfig::service; + assert_eq!(ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT, "cfg.std.offchain_transport"); + assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT, "schema.std.offchain_transport"); + assert_eq!(ksp_config_lib::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME, "std.offchain_transport.json"); + assert_eq!(ksp_config_lib::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME, "std.offchain_transport.schema.json"); +} + #[test] fn packaged_runtime_layout_contract_is_available_from_crate_root() { let prepare: fn(&std::path::Path) -> ksp_core_lib::Result = ksp_config_lib::prepare_packaged_runtime; diff --git a/crates/ksp-config-lib/unit_tests/fixtures/std.offchain_transport.json b/crates/ksp-config-lib/unit_tests/fixtures/std.offchain_transport.json new file mode 100644 index 0000000..989ea05 --- /dev/null +++ b/crates/ksp-config-lib/unit_tests/fixtures/std.offchain_transport.json @@ -0,0 +1,75 @@ +{ + "format_version": 1, + "default_profile": "public_keyless", + "profiles": [ + { + "profile_id": "public_keyless", + "market_price": { + "birdeye": { + "enabled": false + }, + "coinbase_exchange": { + "enabled": true + }, + "coingecko": { + "enabled": true, + "access_mode": "keyless" + }, + "coinmarketcap": { + "enabled": true, + "access_mode": "keyless" + }, + "coinpaprika": { + "enabled": true + }, + "dexscreener": { + "enabled": false + }, + "jupiter": { + "enabled": true, + "access_mode": "keyless" + }, + "kraken": { + "enabled": true + } + } + }, + { + "profile_id": "all_free", + "market_price": { + "birdeye": { + "enabled": true, + "api_key": "${KSP_SECRET_BIRDEYE_API_KEY}" + }, + "coinbase_exchange": { + "enabled": true + }, + "coingecko": { + "enabled": true, + "access_mode": "demo", + "api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}" + }, + "coinmarketcap": { + "enabled": true, + "access_mode": "basic", + "api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}" + }, + "coinpaprika": { + "enabled": true + }, + "dexscreener": { + "enabled": true, + "sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}" + }, + "jupiter": { + "enabled": true, + "access_mode": "free", + "api_key": "${KSP_SECRET_JUPITER_API_KEY}" + }, + "kraken": { + "enabled": true + } + } + } + ] +} diff --git a/crates/ksp-config-lib/unit_tests/offchain_transport.rs b/crates/ksp-config-lib/unit_tests/offchain_transport.rs new file mode 100644 index 0000000..24405a7 --- /dev/null +++ b/crates/ksp-config-lib/unit_tests/offchain_transport.rs @@ -0,0 +1,239 @@ +// file: crates/ksp-config-lib/unit_tests/offchain_transport.rs +// version: 1 + +const TEST_PAIR: &str = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE"; + +#[test] +fn committed_public_keyless_profile_maps_all_eight_providers_without_secret_environment() { + let engine = committed_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::None, &environment); + assert!(resolved.is_ok(), "committed public_keyless Off-chain Transport profile should map: {resolved:?}"); + if let std::result::Result::Ok(resolved) = resolved { + assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT); + assert_eq!(resolved.profile_id(), "public_keyless"); + assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile); + let registry = resolved.service().registry(); + assert_eq!(registry.len(), 8); + let ids: std::vec::Vec<&str> = registry.entries().iter().map(|entry| return entry.descriptor().id().as_str()).collect(); + assert_eq!(ids, ["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken"]); + assert_provider_availability(®istry, "birdeye", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled); + assert_provider_availability(®istry, "dexscreener", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled); + for provider_id in ["coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "jupiter", "kraken"] { + assert_provider_availability(®istry, provider_id, ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready); + } + let debug = format!("{resolved:?}"); + assert!(!debug.contains("api_key"), "safe Debug should not expose credential field contents from the selected keyless profile"); + } +} + +#[test] +fn committed_all_free_profile_requires_config_owned_secrets_and_public_pair_provenance() { + let engine = committed_engine(); + let engine = match engine { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let canaries = [ + ("KSP_SECRET_BIRDEYE_API_KEY", "birdeye-secret-canary"), + ("KSP_SECRET_COINGECKO_DEMO_API_KEY", "coingecko-secret-canary"), + ("KSP_SECRET_COINMARKETCAP_API_KEY", "coinmarketcap-secret-canary"), + ("KSP_SECRET_JUPITER_API_KEY", "jupiter-secret-canary"), + ("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS", TEST_PAIR), + ]; + let mut process = std::collections::BTreeMap::::new(); + for (name, value) in canaries { + process.insert(name.to_owned(), value.to_owned()); + } + let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new()); + let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("all_free"), &environment); + assert!(resolved.is_ok(), "committed all_free Off-chain Transport profile should map from Config-owned environment: {resolved:?}"); + if let std::result::Result::Ok(resolved) = resolved { + assert_eq!(resolved.profile_id(), "all_free"); + assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit); + let registry = resolved.service().registry(); + assert_eq!(registry.len(), 8); + for entry in registry.entries() { + assert_eq!(entry.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready); + } + assert!(resolved.effective().sensitivity().is_secret()); + let safe = resolved.effective().safe_value().to_string(); + for secret in ["birdeye-secret-canary", "coingecko-secret-canary", "coinmarketcap-secret-canary", "jupiter-secret-canary"] { + assert!(!safe.contains(secret), "safe effective Config must redact provider credential canary"); + } + assert!(safe.contains(TEST_PAIR), "public DexScreener pair should remain visible in the safe effective Config"); + let debug = format!("{resolved:?}"); + for secret in ["birdeye-secret-canary", "coingecko-secret-canary", "coinmarketcap-secret-canary", "jupiter-secret-canary"] { + assert!(!debug.contains(secret), "ResolvedOffchainTransportConfig Debug must redact provider credential canary"); + } + } +} + +#[test] +fn literal_or_nonsecret_provider_credentials_are_rejected_by_effective_adapter() { + 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 all_free = profiles + .iter_mut() + .find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("all_free")); + assert!(all_free.is_some(), "fixture should contain all_free profile"); + if let std::option::Option::Some(all_free) = all_free { + all_free["market_price"]["birdeye"]["api_key"] = serde_json::Value::String("literal-secret".to_owned()); + } + } + let engine = fixture_engine_with_document(fixture.path(), &source); + assert!(engine.is_ok(), "literal-secret fixture engine should be constructible: {engine:?}"); + let engine = match engine { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let mut process = all_free_environment(); + process.insert("KSP_SECRET_BIRDEYE_API_KEY".to_owned(), "unused-secret".to_owned()); + let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new()); + let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("all_free"), &environment); + assert!(resolved.is_err(), "literal provider credential must be rejected even though the JSON Schema accepts a non-empty string"); + if let std::result::Result::Err(error) = resolved { + assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID); + assert!(!format!("{error:?}").contains("literal-secret")); + } +} + +#[test] +fn dexscreener_pair_environment_must_use_public_namespace_and_disabled_pair_may_be_absent() { + 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); + if let std::option::Option::Some(profiles) = profiles { + let all_free = profiles + .iter_mut() + .find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("all_free")); + if let std::option::Option::Some(all_free) = all_free { + all_free["market_price"]["dexscreener"]["sol_usd_pair_address"] = serde_json::Value::String("${KSP_SECRET_DEXSCREENER_PAIR}".to_owned()); + } + } + let engine = fixture_engine_with_document(fixture.path(), &source); + assert!(engine.is_ok(), "secret-pair fixture engine should be constructible: {engine:?}"); + let engine = match engine { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let mut process = all_free_environment(); + process.remove("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS"); + process.insert("KSP_SECRET_DEXSCREENER_PAIR".to_owned(), TEST_PAIR.to_owned()); + let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new()); + let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("all_free"), &environment); + assert!(resolved.is_err(), "DexScreener pair environment must not use secret provenance"); + if let std::result::Result::Err(error) = resolved { + assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID); + } + let disabled = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, std::option::Option::None); + assert!(disabled.is_ok(), "disabled DexScreener runtime settings should accept an absent pair after pre.009 capability reconciliation: {disabled:?}"); +} + +fn assert_provider_availability( + registry: &ksp_offchain_transport_lib::MarketPriceProviderRegistry, + provider_id: &str, + expected: ksp_offchain_transport_lib::MarketPriceProviderAvailability, +) { + let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id); + assert!(provider_id.is_ok(), "provider id fixture should be valid: {provider_id:?}"); + if let std::result::Result::Ok(provider_id) = provider_id { + let state = registry.state(&provider_id); + assert!(state.is_some(), "provider should be present in Config-produced service registry"); + if let std::option::Option::Some(state) = state { + assert_eq!(state.availability(), expected); + } + } +} + +fn committed_engine() -> ksp_core_lib::Result { + let workspace = workspace_root(); + let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let registry = crate::ConfigFileRegistry::defaults(); + let registry = match registry { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry)); +} + +fn fixture_engine_with_document(root: &std::path::Path, document: &serde_json::Value) -> ksp_core_lib::Result { + let config_root = root.join("config"); + let create = std::fs::create_dir_all(config_root.as_path()); + if let std::result::Result::Err(error) = create { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Config root cannot be created").with_source(error), + ); + } + let bytes = serde_json::to_vec_pretty(document); + let bytes = match bytes { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "test Config cannot be encoded").with_source(error), + ); + }, + }; + let path = config_root.join(crate::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME); + if let std::result::Result::Err(error) = std::fs::write(path.as_path(), bytes) { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Config cannot be written").with_source(error)); + } + let bootstrap = crate::ConfigBootstrapOptions::from_paths(config_root, workspace_root().join("config/schemas")); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let registry = crate::ConfigFileRegistry::defaults(); + let registry = match registry { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry)); +} + +fn committed_document_value() -> std::result::Result { + return serde_json::from_str(include_str!("../../../config/std.offchain_transport.json")); +} + +fn all_free_environment() -> std::collections::BTreeMap { + let mut process = std::collections::BTreeMap::::new(); + process.insert("KSP_SECRET_BIRDEYE_API_KEY".to_owned(), "birdeye-test".to_owned()); + process.insert("KSP_SECRET_COINGECKO_DEMO_API_KEY".to_owned(), "coingecko-test".to_owned()); + process.insert("KSP_SECRET_COINMARKETCAP_API_KEY".to_owned(), "coinmarketcap-test".to_owned()); + process.insert("KSP_SECRET_JUPITER_API_KEY".to_owned(), "jupiter-test".to_owned()); + process.insert("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS".to_owned(), TEST_PAIR.to_owned()); + return process; +} + +fn workspace_root() -> std::path::PathBuf { + return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); +} diff --git a/crates/ksp-config-lib/unit_tests/registry.rs b/crates/ksp-config-lib/unit_tests/registry.rs index 12c042c..287897c 100644 --- a/crates/ksp-config-lib/unit_tests/registry.rs +++ b/crates/ksp-config-lib/unit_tests/registry.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/unit_tests/registry.rs -// version: 8 +// version: 9 #[test] fn descriptors_expose_complete_registry_in_deterministic_file_id_order() { @@ -7,21 +7,23 @@ fn descriptors_expose_complete_registry_in_deterministic_file_id_order() { assert!(registry.is_ok(), "default registry should be valid: {registry:?}"); if let std::result::Result::Ok(registry) = registry { let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect(); - assert_eq!(descriptors.len(), 8); + assert_eq!(descriptors.len(), 10); assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK); assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME)); assert_eq!(descriptors[0].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE)); assert_eq!(descriptors[1].file_id().as_str(), crate::FILE_ID_STD_LOGGING); - assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT); - assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_WALLET); - assert_eq!(descriptors[3].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME)); - assert_eq!(descriptors[3].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET)); - assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE); - assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING); - assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT); - assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET); - assert!(descriptors[0..4].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config)); - assert!(descriptors[4..8].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema)); + assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT); + assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT); + assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_WALLET); + assert_eq!(descriptors[4].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME)); + assert_eq!(descriptors[4].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET)); + assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE); + assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING); + assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT); + assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT); + assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET); + assert!(descriptors[0..5].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config)); + assert!(descriptors[5..10].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema)); } } @@ -85,6 +87,31 @@ fn defaults_register_logging_document_and_schema_with_distinct_roots() { } } +#[test] +fn defaults_register_offchain_transport_document_and_schema_with_distinct_roots() { + let registry = crate::ConfigFileRegistry::defaults(); + assert!(registry.is_ok(), "default registry should be valid: {registry:?}"); + if let std::result::Result::Ok(registry) = registry { + let config_id = crate::ConfigFileId::new(crate::FILE_ID_STD_OFFCHAIN_TRANSPORT); + let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT); + assert!(config_id.is_ok(), "Off-chain Transport file_id should be valid: {config_id:?}"); + assert!(schema_id.is_ok(), "Off-chain Transport schema file_id should be valid: {schema_id:?}"); + if let (std::result::Result::Ok(config_id), std::result::Result::Ok(schema_id)) = (config_id, schema_id) { + let config = registry.descriptor(&config_id); + let schema = registry.descriptor(&schema_id); + assert!(config.is_ok(), "Off-chain Transport descriptor should exist: {config:?}"); + assert!(schema.is_ok(), "Off-chain Transport schema descriptor should exist: {schema:?}"); + if let (std::result::Result::Ok(config), std::result::Result::Ok(schema)) = (config, schema) { + assert_eq!(config.kind(), crate::ConfigFileKind::Config); + assert_eq!(config.filename(), std::path::Path::new(crate::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME)); + assert_eq!(config.schema_file_id(), std::option::Option::Some(&schema_id)); + assert_eq!(schema.kind(), crate::ConfigFileKind::Schema); + assert_eq!(schema.filename(), std::path::Path::new(crate::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME)); + } + } + } +} + #[test] fn defaults_register_transport_document_and_schema_with_distinct_roots() { let registry = crate::ConfigFileRegistry::defaults(); diff --git a/crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs b/crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs index dc12341..e259698 100644 --- a/crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs +++ b/crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs @@ -1,5 +1,5 @@ // file: crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs -// version: 1 +// version: 2 //! DexScreener SOL/USD adapter bound to one explicitly configured Solana pair address. @@ -11,19 +11,26 @@ const DEXSCREENER_SOL_MINT: &str = "So11111111111111111111111111111111111111112" #[derive(Clone, Debug, Eq, PartialEq)] pub struct MarketPriceDexScreenerSettings { common: crate::MarketPriceProviderCommonSettings, - pair_address: ksp_core_lib::Pubkey, + pair_address: std::option::Option, } impl crate::MarketPriceDexScreenerSettings { - /// Creates DexScreener settings for one explicit Solana pair address. - pub fn new(enabled: bool, pair_address: impl std::convert::Into) -> ksp_core_lib::Result { + /// Creates DexScreener settings for one optional explicit Solana pair address. + /// + /// An enabled provider requires a valid pair address. A disabled provider may omit it so Config does not need to fabricate provider data merely to keep the + /// provider visible in the generic registry. + pub fn new(enabled: bool, pair_address: std::option::Option) -> ksp_core_lib::Result { let provider_id = match crate::MarketPriceProviderId::new(DEXSCREENER_PROVIDER_ID) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let pair_address = match pair_address.into().parse::() { - std::result::Result::Ok(value) => value, - std::result::Result::Err(_) => return std::result::Result::Err(provider_settings_error("pair_address")), + let pair_address = match pair_address { + std::option::Option::Some(value) => match value.parse::() { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(_) => return std::result::Result::Err(provider_settings_error("pair_address")), + }, + std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("pair_address")), + std::option::Option::None => std::option::Option::None, }; let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled); return std::result::Result::Ok(Self { common, pair_address }); @@ -35,10 +42,10 @@ impl crate::MarketPriceDexScreenerSettings { return &self.common; } - /// Returns the explicit Solana pair address used by this adapter. + /// Returns the explicit Solana pair address when one is configured. #[must_use] - pub const fn pair_address(&self) -> &ksp_core_lib::Pubkey { - return &self.pair_address; + pub const fn pair_address(&self) -> std::option::Option<&ksp_core_lib::Pubkey> { + return self.pair_address.as_ref(); } } @@ -84,11 +91,15 @@ impl crate::MarketPriceDexScreenerProvider { if let std::result::Result::Err(error) = crate::admit_request(DEXSCREENER_PROVIDER_ID, &self.admission) { return std::result::Result::Err(error); } + let pair_address = match self.settings.pair_address() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(provider_settings_error("pair_address")), + }; let request_started_at = match crate::current_timestamp() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let request = match build_request(self.settings.pair_address()) { + let request = match build_request(pair_address) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; @@ -100,13 +111,7 @@ impl crate::MarketPriceDexScreenerProvider { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - return parse_response( - document.as_bytes(), - self.settings.common().provider_id().clone(), - self.settings.pair_address(), - request_started_at, - received_at, - ); + return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), pair_address, request_started_at, received_at); } } diff --git a/crates/ksp-offchain-transport-lib/tests/public_api.rs b/crates/ksp-offchain-transport-lib/tests/public_api.rs index 445f13f..65458d2 100644 --- a/crates/ksp-offchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-offchain-transport-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-offchain-transport-lib/tests/public_api.rs -// version: 9 +// version: 10 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -134,7 +134,7 @@ fn public_pre_006_jupiter_and_dexscreener_settings_and_adapters_are_available_fr }; assert_eq!(jupiter_provider.descriptor().id().as_str(), "jupiter"); assert_eq!(jupiter_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic); - let dexscreener = match ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE") { + 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), }; diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs index e13391b..9498b76 100644 --- a/crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs @@ -1,5 +1,5 @@ // file: crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs -// version: 1 +// version: 2 const TEST_PAIR: &str = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE"; @@ -35,13 +35,26 @@ fn dexscreener_fixture_validates_chain_pair_and_sol_identity_and_maps_exact_pair #[test] fn dexscreener_settings_and_descriptor_keep_pair_explicit_and_pair_endpoint_rate_limited() -> ksp_core_lib::Result<()> { - assert!(crate::MarketPriceDexScreenerSettings::new(true, "not-a-pubkey").is_err()); - let settings = match crate::MarketPriceDexScreenerSettings::new(true, TEST_PAIR) { + let disabled = crate::MarketPriceDexScreenerSettings::new(false, std::option::Option::None); + assert!(disabled.is_ok(), "disabled DexScreener settings must not require a fake pair address: {disabled:?}"); + assert!(crate::MarketPriceDexScreenerSettings::new(true, std::option::Option::Some("not-a-pubkey".to_owned())).is_err()); + let settings = match crate::MarketPriceDexScreenerSettings::new(true, std::option::Option::Some(TEST_PAIR.to_owned())) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - assert_eq!(settings.pair_address().to_string(), TEST_PAIR); - let request = match super::build_request(settings.pair_address()) { + let pair_address = settings.pair_address(); + assert!(pair_address.is_some(), "enabled DexScreener settings should retain the configured pair"); + let pair_address = match pair_address { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, + "enabled DexScreener settings lost their pair address", + )); + }, + }; + assert_eq!(pair_address.to_string(), TEST_PAIR); + let request = match super::build_request(pair_address) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs index bfd1704..6b5b646 100644 --- a/crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs @@ -1,5 +1,5 @@ // file: crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs -// version: 1 +// version: 2 #[test] fn registry_orders_all_eight_v1_providers_and_exposes_only_generic_entries() -> ksp_core_lib::Result<()> { @@ -91,7 +91,7 @@ fn all_v1_entries() -> ksp_core_lib::Result value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let dexscreener_settings = match crate::MarketPriceDexScreenerSettings::new(false, "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE") { + let dexscreener_settings = match crate::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; diff --git a/crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs b/crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs index 5551c06..1da01b0 100644 --- a/crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs +++ b/crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs @@ -1,5 +1,5 @@ // file: crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs -// version: 1 +// version: 2 #[test] fn service_registry_orders_all_eight_v1_setups_and_keeps_runtime_projection_generic() -> ksp_core_lib::Result<()> { @@ -118,7 +118,7 @@ fn all_disabled_setups() -> ksp_core_lib::Result value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let dexscreener = match crate::MarketPriceDexScreenerSettings::new(false, "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE") { + let dexscreener = match crate::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; diff --git a/deltas/0.2.11/pre.009.md b/deltas/0.2.11/pre.009.md new file mode 100644 index 0000000..23a295d --- /dev/null +++ b/deltas/0.2.11/pre.009.md @@ -0,0 +1,240 @@ + + + +# Delta `0.2.11-pre.009` — Config Off-chain Transport + +## 1. Base requise + +Cette tranche s'applique exclusivement après `0.2.11-pre.008-fix.001` validé. + +Version Cargo attendue à l'entrée : + +```text +0.2.11-pre.8.fix.1 +``` + +Version Cargo de sortie : + +```text +0.2.11-pre.9 +``` + +Le gate opérateur de la base est intégralement PASS : audits Rust/Markdown, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests ciblés Off-chain puis `cargo test --workspace`. + +## 2. Scope réalisé + +`pre.009` matérialise la frontière Config -> Off-chain Transport sans modifier les contrats réseau des huit providers V1. + +La tranche ajoute : + +```text +cfg.std.offchain_transport +schema.std.offchain_transport +std.offchain_transport.json format_version 1 +std.offchain_transport.schema.json +exemple et fixture Config +ResolvedOffchainTransportConfig +adapter Config -> MarketPriceService +variables d'environnement documentées pour les credentials/pair DexScreener +ressources desktop nécessaires au packaged runtime +``` + +Elle n'ajoute pas : + +```text +nouveau provider +nouvel endpoint provider +SDK provider +URL provider configurable +rate limit librement configurable +scheduler périodique +fallback / consensus / agrégation +smoke live +``` + +## 3. Structure durable du document + +Le document Off-chain Transport reste générique et place la capacité actuelle sous : + +```text +market_price +``` + +Deux profils versionnés sont fournis : + +```text +public_keyless +all_free +``` + +`public_keyless` active uniquement les modes réellement utilisables sans secret. Birdeye et DexScreener y restent désactivés. + +`all_free` active les modes gratuits nécessitant une clé et DexScreener avec une paire SOL/USD explicite. + +Les huit branches `market_price` sont obligatoires dans le schema afin que l'inventaire V1 reste explicite et déterministe. + +## 4. Capabilities provider et secrets + +Le schema conditionne les credentials selon les capacités : + +```text +CoinGecko keyless -> api_key interdite +CoinGecko demo activé -> api_key requise +CoinMarketCap keyless -> api_key interdite +CoinMarketCap basic activé -> api_key requise +Jupiter keyless -> api_key interdite +Jupiter free activé -> api_key requise +Birdeye activé -> api_key requise +DexScreener activé -> sol_usd_pair_address requise +``` + +L'adapter effectif ajoute une contrainte de provenance que le JSON Schema ne peut pas exprimer : toute API key provider doit provenir d'une variable classée Secret par Config. Un littéral credential ou une variable d'environnement non secret sont rejetés sans recopier la valeur dans l'erreur. + +Variables versionnées dans `.env.example` : + +```text +KSP_SECRET_BIRDEYE_API_KEY +KSP_SECRET_COINGECKO_DEMO_API_KEY +KSP_SECRET_COINMARKETCAP_API_KEY +KSP_SECRET_JUPITER_API_KEY +KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS +``` + +La paire DexScreener est publique. Lorsqu'elle provient de l'environnement, l'adapter exige une provenance `Public` ; un namespace Secret/Internal est rejeté. + +## 5. Adapter Config -> runtime + +`ResolvedOffchainTransportConfig` conserve : + +```text +file_id logique +profile_id +selection_source +source_path +ResolvedConfigJson effectif et safe +MarketPriceService construit +``` + +`ConfigDocumentEngine` expose : + +```text +load_resolved_offchain_transport_config +resolve_offchain_transport_config_profile +``` + +Le mapping utilise les settings publics des huit adapters et construit `MarketPriceProviderSetup`, puis `MarketPriceService`. Après cette composition, le consumer runtime reste provider-agnostic. + +`ksp-offchain-transport-lib` ne dépend jamais de `ksp-config-lib` et ne lit pas directement l'environnement. + +## 6. Réconciliation DexScreener capability-aware + +Le contrat de plan exigeait `sol_usd_pair_address` uniquement si DexScreener est activé. La construction runtime demandait encore une paire même lorsque le provider était désactivé. + +`MarketPriceDexScreenerSettings` est donc réconcilié : + +```text +enabled = true + pair absente -> erreur +pair présente mais invalide -> erreur +enabled = false + pair absente -> valide +``` + +Cette évolution évite de fabriquer une fausse adresse de paire uniquement pour conserver DexScreener dans le registry générique. Elle ne change ni l'endpoint, ni les validations chain/pair/WSOL, ni le parsing `priceUsd` lorsque le provider est actif. + +## 7. Registry Config et packaged runtime + +Le registry Config enregistre désormais dix descriptors et ajoute le document/schema Off-chain Transport avec leurs file ids durables. + +Les deux applications desktop existantes embarquent également : + +```text +config/std.offchain_transport.json +config/schemas/std.offchain_transport.schema.json +``` + +Cette inclusion est nécessaire parce que `prepare_packaged_runtime` synchronise l'intégralité du registry Config, même si Wallet Desk et Config Desk ne consomment pas directement le service de prix dans cette release. + +## 8. Tests et canaries ajoutés + +Les nouveaux tests couvrent notamment : + +```text +registry document/schema Off-chain Transport +profil public_keyless -> huit providers, Birdeye/Dex disabled +profil all_free -> huit providers Ready avec environnement complet +redaction des quatre credential canaries +rejet d'un credential provider littéral +rejet d'une paire DexScreener provenant d'un namespace Secret +DexScreener disabled sans paire +façade Config publique depuis crate root +ownership Off-chain -> Config interdit +inventaire .env.example +ressources packaged desktop mises à jour +``` + +## 9. Fichiers ajoutés + +```text +config/examples/std.offchain_transport.example.json +config/schemas/std.offchain_transport.schema.json +config/std.offchain_transport.json +crates/ksp-config-lib/src/offchain_transport.rs +crates/ksp-config-lib/unit_tests/fixtures/std.offchain_transport.json +crates/ksp-config-lib/unit_tests/offchain_transport.rs +deltas/0.2.11/pre.009.md +``` + +## 10. Fichiers modifiés + +```text +.env.example +Cargo.toml +crates/ksp-app-config-desk/tauri.conf.json +crates/ksp-app-config-desk/tests/desktop_contract.rs +crates/ksp-app-wallet-desk/tauri.conf.json +crates/ksp-app-wallet-desk/tests/desktop_contract.rs +crates/ksp-app-wallet-desk/tests/release_compliance.rs +crates/ksp-config-lib/Cargo.toml +crates/ksp-config-lib/src/lib.rs +crates/ksp-config-lib/src/registry.rs +crates/ksp-config-lib/tests/ownership.rs +crates/ksp-config-lib/tests/public_api.rs +crates/ksp-config-lib/unit_tests/registry.rs +crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs +crates/ksp-offchain-transport-lib/tests/public_api.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs +crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs +docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +``` + +## 11. Audits sandbox + +Contrôles disponibles à la construction de la tranche : + +```text +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +KSP workspace Rust rule audit: clean +Markdown table audit: clean (123 tables / 115 files) +JSON syntax: document, exemple, schema, fixture et Tauri configs PASS +JSON Schema Draft 2020-12: document, exemple et fixture PASS +schema conditionals: keyless credential interdit, keyed credential requis si activé, Birdeye key requis si activé, Dex pair requis si activé PASS +``` + +Cargo n'est pas disponible dans cet environnement ; les validations compilation/Clippy/tests restent donc opérateur. + +## 12. Gate opérateur requis + +```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-config-lib +cargo test -p ksp-offchain-transport-lib +cargo test --workspace +``` + +`pre.010` ne doit commencer qu'après ce gate propre ou un delta fix explicite. 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 66ac43d..2352021 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.008-fix.001` corrige le gate de `pre.008` sans modifier son contrat fonctionnel. `MarketPriceService` possède toujours le dispatch provider, le registry runtime et les transitions d'availability ; les consumers utilisent uniquement les provider ids opaques, `registry()`, `refresh`, `refresh_many` et `refresh_all`. Le fix supprime un warning Clippy `collapsible_if` et remplace un canari lexical trop large sur le mot `consensus` par des vérifications de surfaces exécutables réellement interdites. `MarketPriceProviderSetup` reste la frontière provider-specific destinée à la composition/Config de `pre.009`.** +**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.** ## 1. Base et autorité @@ -520,28 +520,43 @@ Le limiter `pre.003` est non bloquant. Une cadence fixe est matérialisée par u ## 15. Config provider-capability-aware -Config reste l'unique propriétaire des documents/env/secrets. - -Off-chain Transport expose ses settings publics ; Config adapte un futur document standard dédié, pressenti : +Config reste l'unique propriétaire des documents, de l'environnement et des secrets. `pre.009` matérialise les identifiants durables : ```text -file_id = std.offchain_transport +file_id document = cfg.std.offchain_transport +file_id schema = schema.std.offchain_transport +fichier = std.offchain_transport.json +schema = std.offchain_transport.schema.json +format_version = 1 ``` -Les branches de configuration correspondent aux capacités réelles de chaque provider. +Le document reste générique Off-chain Transport et place la capacité actuelle sous `market_price`. Cette structure évite de transformer le document durable en Config dédiée uniquement au prix lorsque d'autres familles telles que `swap_quote` apparaîtront. -| Provider | Champs spécifiques V1 attendus | -|-------------------|------------------------------------------------------------| -| CoinGecko | enabled, access_mode keyless ou demo, api_key optionnelle | -| CoinMarketCap | enabled, access_mode keyless ou basic, api_key optionnelle | -| CoinPaprika | enabled | -| Kraken | enabled | -| Coinbase Exchange | enabled | -| Jupiter | enabled, access_mode keyless ou free, api_key optionnelle | -| Birdeye | enabled, api_key requise si activé | -| DexScreener | enabled, sol_usd_pair_address requise si activé | +Deux profils versionnés servent de canaries : -Le schema final doit conditionner la présence des credentials au mode choisi. Aucun secret n'est versionné. Toute variable d'environnement ajoutée respecte `KSP_SECRET_*` et est inventoriée dans `.env.example`. +```text +public_keyless = providers réellement utilisables sans secret ; Birdeye et DexScreener désactivés +all_free = modes gratuits à clé activés + paire DexScreener explicite +``` + +Les branches correspondent aux capacités réelles de chaque provider. + +| Provider | Champs spécifiques V1 | +|-------------------|---------------------------------------------------------------| +| CoinGecko | enabled, access_mode keyless ou demo, api_key conditionnelle | +| CoinMarketCap | enabled, access_mode keyless ou basic, api_key conditionnelle | +| CoinPaprika | enabled | +| Kraken | enabled | +| Coinbase Exchange | enabled | +| Jupiter | enabled, access_mode keyless ou free, api_key conditionnelle | +| Birdeye | enabled, api_key requise si activé | +| DexScreener | enabled, sol_usd_pair_address requise si activé | + +Le schema conditionne les credentials au mode choisi et interdit un faux champ `api_key` sur les modes keyless. L'adapter effectif applique en plus la provenance : une API key provider doit provenir d'une variable classée Secret (`KSP_SECRET_*` ou namespace KSPB secret admis par Config), même si un littéral non vide est syntaxiquement valide JSON. Les erreurs et `Debug` ne recopient jamais le credential. + +La paire DexScreener n'est pas un secret. Un littéral validé comme Pubkey Solana est admis ; si la valeur vient de l'environnement, sa provenance doit rester Public, avec le canari versionné `KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS`. Pour respecter « requise seulement si activé », `MarketPriceDexScreenerSettings` accepte désormais l'absence de paire lorsque le provider est désactivé et la refuse toujours lorsqu'il est activé. + +`.env.example` inventorie les variables nécessaires sans valeur réelle. Les applications desktop empaquetent aussi le nouveau document et son schema parce que `prepare_packaged_runtime` synchronise l'intégralité du registry Config. Les limites provider ne deviennent pas des valeurs libres permettant à Config/UI d'augmenter la cadence. Une future policy pourra permettre un throttle **plus strict** si un besoin réel apparaît. @@ -736,7 +751,7 @@ Implémentation des deux adapters exchange keyless avec DTOs wire privés et end **Statut : réalisé, gate opérateur ciblé PASS ; workspace complet non rejoué.** -Implémentation des adapters Jupiter Price V3 et DexScreener avec DTOs wire privés et endpoints HTTPS officiels fixes. Jupiter distingue keyless à 0,5 requête par seconde et Free key à 1 requête par seconde sur le même `api.jup.ag`, utilise uniquement le mint WSOL et conserve `blockId` comme provenance de récence sans le convertir en timestamp. DexScreener exige un pair address Solana explicite dans ses settings, appelle uniquement `/latest/dex/pairs/solana/{pairId}`, vérifie `chainId = solana`, le pair address retourné et le mint WSOL comme base avant de lire `priceUsd`. Aucune découverte de pool, aucun tri de liquidité, aucun consensus et aucun registry global ne sont avancés. +Implémentation des adapters Jupiter Price V3 et DexScreener avec DTOs wire privés et endpoints HTTPS officiels fixes. Jupiter distingue keyless à 0,5 requête par seconde et Free key à 1 requête par seconde sur le même `api.jup.ag`, utilise uniquement le mint WSOL et conserve `blockId` comme provenance de récence sans le convertir en timestamp. DexScreener exige un pair address Solana explicite lorsqu'il est activé, appelle uniquement `/latest/dex/pairs/solana/{pairId}`, vérifie `chainId = solana`, le pair address retourné et le mint WSOL comme base avant de lire `priceUsd`. Aucune découverte de pool, aucun tri de liquidité, aucun consensus et aucun registry global ne sont avancés. ### `pre.007` — Birdeye, registry et availability @@ -754,9 +769,9 @@ Implémentation de Birdeye Standard avec `X-API-KEY`, endpoint Price Single Sola ### `pre.009` — Config Off-chain Transport -**Statut : planifié.** +**Statut : réalisé ; gate Cargo opérateur à rejouer.** -Document standard `std.offchain_transport`, schema, exemples, secrets d'environnement nécessaires et adapter Config -> Off-chain Transport conforme aux capacités de chaque provider. +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 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 972cef7..552d4e4 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 @@ -261,30 +261,57 @@ Les tests déterministes de `pre.007` couvrent l'auth Birdeye et sa redaction, l ## 3.11 Gate `0.2.11-pre.008` -| Critère | Statut | Preuve | -|-------------------------------------------------------------------------|---------|--------------------------------------------| -| version workspace `0.2.11-pre.8` | PASS | `Cargo.toml` racine | -| `MarketPriceService` provider-agnostic public | PASS | `market_price_service.rs` | -| setup provider-specific limité à la composition | PASS | `MarketPriceProviderSetup` | -| registry détachable sans dispatch provider | PASS | `MarketPriceService::registry` | -| refresh individuel par `MarketPriceProviderId` | PASS | `MarketPriceService::refresh` | -| refresh multiple par liste d'identifiants opaques | PASS | `MarketPriceService::refresh_many` | -| refresh all en ordre provider déterministe | PASS | `MarketPriceService::refresh_all` | -| doublons / provider inconnu rejetés avant dispatch | PASS | tests `market_price_service` | -| provider non éligible projeté sans requête réseau | PASS | eligibility + test huit providers disabled | -| `429` / defer local projetés en `CoolingDown` | PASS | classification générique | -| `401/403` keyed projetés en `AuthenticationUnavailable` | PASS | classification auth-aware | -| transport temporaire projeté sans rendre le service global inutilisable | PASS | outcome provider-neutral | -| aucune attente/sleep de rate limit dans le service | PASS | dependency boundary | -| aucun fallback, consensus ou agrégation multi-provider | PASS | scope + dependency boundary | -| Config, scheduling périodique et smoke live non avancés | PASS | scope réservé à `pre.009+` | -| audit Rust sandbox | PASS | clean, 0 candidate export | -| audit Markdown sandbox | PASS | clean, 122 tables / 113 fichiers | -| gate `pre.008` brut | FAIL | 1 warning Clippy + 1 canari boundary FAIL | -| fix `pre.008-fix.001` statique | PASS | audits Rust/Markdown sandbox | -| gate Cargo du fix | PLANNED | à exécuter par l'opérateur | +| Critère | Statut | Preuve | +|-------------------------------------------------------------------------|--------|--------------------------------------------| +| version workspace `0.2.11-pre.8` | PASS | `Cargo.toml` racine | +| `MarketPriceService` provider-agnostic public | PASS | `market_price_service.rs` | +| setup provider-specific limité à la composition | PASS | `MarketPriceProviderSetup` | +| registry détachable sans dispatch provider | PASS | `MarketPriceService::registry` | +| refresh individuel par `MarketPriceProviderId` | PASS | `MarketPriceService::refresh` | +| refresh multiple par liste d'identifiants opaques | PASS | `MarketPriceService::refresh_many` | +| refresh all en ordre provider déterministe | PASS | `MarketPriceService::refresh_all` | +| doublons / provider inconnu rejetés avant dispatch | PASS | tests `market_price_service` | +| provider non éligible projeté sans requête réseau | PASS | eligibility + test huit providers disabled | +| `429` / defer local projetés en `CoolingDown` | PASS | classification générique | +| `401/403` keyed projetés en `AuthenticationUnavailable` | PASS | classification auth-aware | +| transport temporaire projeté sans rendre le service global inutilisable | PASS | outcome provider-neutral | +| aucune attente/sleep de rate limit dans le service | PASS | dependency boundary | +| aucun fallback, consensus ou agrégation multi-provider | PASS | scope + dependency boundary | +| Config, scheduling périodique et smoke live non avancés | PASS | scope réservé à `pre.009+` | +| audit Rust sandbox | PASS | clean, 0 candidate export | +| audit Markdown sandbox | PASS | clean, 122 tables / 113 fichiers | +| gate `pre.008` brut | FAIL | 1 warning Clippy + 1 canari boundary FAIL | +| fix `pre.008-fix.001` statique | PASS | audits Rust/Markdown sandbox | +| gate Cargo du fix | PASS | gate opérateur complet du 2026-08-26 | -Les tests déterministes de `pre.008` vérifient le registry de service sur les huit setups V1, `refresh_all` sans réseau lorsque tous les providers sont désactivés, le rejet pré-dispatch des doublons/providers inconnus, les classes auth/rate-limit/temporaire/local misconfiguration et l'éligibilité temporelle des cooldowns. Les méthodes multiples restent séquentielles et déterministes dans V1 ; elles ne dorment jamais pour attendre un provider et ne font aucun fallback/consensus. Le gate opérateur initial a toutefois trouvé un warning `clippy::collapsible_if` et un canari boundary trop large (`!service.contains("consensus")`) qui échouait sur la rustdoc décrivant justement l'absence de consensus. `pre.008-fix.001` corrige ces deux points sans modifier le comportement runtime. +Les tests déterministes de `pre.008` vérifient le registry de service sur les huit setups V1, `refresh_all` sans réseau lorsque tous les providers sont désactivés, le rejet pré-dispatch des doublons/providers inconnus, les classes auth/rate-limit/temporaire/local misconfiguration et l'éligibilité temporelle des cooldowns. Les méthodes multiples restent séquentielles et déterministes dans V1 ; elles ne dorment jamais pour attendre un provider et ne font aucun fallback/consensus. Le gate opérateur initial a toutefois trouvé un warning `clippy::collapsible_if` et un canari boundary trop large (`!service.contains("consensus")`) qui échouait sur la rustdoc décrivant justement l'absence de consensus. `pre.008-fix.001` corrige ces deux points sans modifier le comportement runtime. Le gate rejoué sur `0.2.11-pre.8.fix.1` est intégralement PASS : audits Rust/Markdown, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, 54 tests unitaires + 6 boundary + 7 public API Off-chain, puis `cargo test --workspace` avec uniquement les smokes/timing explicitement ignorés. + +## 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` | + +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. ## 4. Matrice provider prévue @@ -374,32 +401,32 @@ Avant la stable, les conditions d'usage et la persistance des offres gratuites d ## 9. DexScreener V1 -| Invariant | Statut | -|----------------------------------------------|--------| -| pair address SOL/USD explicite dans settings | PASS | -| `chainId = solana` vérifié | PASS | -| identité SOL de la paire vérifiée | PASS | -| `priceUsd` utilisé comme prix commun | PASS | -| aucune recherche automatique de pool | PASS | -| aucun tri automatique par liquidité | PASS | -| aucune moyenne/consensus entre pools | PASS | +| Invariant | Statut | +|--------------------------------------------------|--------| +| pair address SOL/USD explicite si provider actif | PASS | +| `chainId = solana` vérifié | PASS | +| identité SOL de la paire vérifiée | PASS | +| `priceUsd` utilisé comme prix commun | PASS | +| aucune recherche automatique de pool | PASS | +| aucun tri automatique par liquidité | PASS | +| aucune moyenne/consensus entre pools | PASS | ## 10. Config -> Off-chain Transport -| Invariant | Statut | -|------------------------------------------------------------------|---------| -| Off-chain Transport ne dépend pas de Config | PLANNED | -| document standard offchain dédié | PLANNED | -| branches provider conditionnelles dans schema | PLANNED | -| CoinGecko keyless/Demo représentés | PLANNED | -| CoinMarketCap keyless/Basic représentés | PLANNED | -| Jupiter keyless/Free représentés | PLANNED | -| Birdeye exige credential lorsqu'activé | PLANNED | -| DexScreener exige pair address lorsqu'activé | PLANNED | -| providers sans auth n'exposent pas de faux champ API key | PLANNED | -| secrets via Config uniquement | PLANNED | -| `.env.example` synchronisé pour les secrets réellement ajoutés | PLANNED | -| aucune cadence provider configurable au-dessus de la limite sûre | PLANNED | +| Invariant | Statut | +|------------------------------------------------------------------|--------| +| Off-chain Transport ne dépend pas de Config | PASS | +| document standard offchain dédié | PASS | +| branches provider conditionnelles dans schema | PASS | +| CoinGecko keyless/Demo représentés | PASS | +| CoinMarketCap keyless/Basic représentés | PASS | +| Jupiter keyless/Free représentés | PASS | +| Birdeye exige credential lorsqu'activé | PASS | +| DexScreener exige pair address lorsqu'activé | PASS | +| providers sans auth n'exposent pas de faux champ API key | PASS | +| secrets via Config uniquement | PASS | +| `.env.example` synchronisé pour les secrets réellement ajoutés | PASS | +| aucune cadence provider configurable au-dessus de la limite sûre | PASS | ## 11. Frontière future `ksp-app-solprices-desk`