30 Commits

Author SHA1 Message Date
db1f8d4a53 0.2.11-rel.001 2026-08-26 17:49:35 +02:00
9852613426 v0.2.11-pre.013 2026-08-26 17:41:00 +02:00
81eb295a09 v0.2.11-pre.012 2026-08-26 15:45:02 +02:00
b5a1293953 v0.2.11-pre.011-fix.001 2026-08-26 14:56:44 +02:00
251f9f9ad8 v0.2.11-pre.011 2026-08-26 14:37:59 +02:00
98708df6e0 v0.2.11-pre.010-fix.001 2026-08-26 12:34:48 +02:00
2db3c4be78 v0.2.11-pre.010 2026-08-26 12:06:51 +02:00
69294a153f v0.2.11-pre.009 2026-08-26 11:23:01 +02:00
2fdff17651 v0.2.11-pre.008-fix.001 2026-08-26 10:30:17 +02:00
87d7314bf4 v0.2.11-pre.008 2026-08-26 10:23:42 +02:00
5aeff5ca14 v0.2.11-pre.007 2026-08-26 10:06:21 +02:00
98093d859b v0.2.11-pre.006 2026-08-25 23:06:26 +02:00
4021d08541 v0.2.11-pre.005 2026-08-25 22:32:07 +02:00
5c97a772be v0.2.11-pre.004-fix.001 2026-08-25 21:58:05 +02:00
60afb51451 v0.2.11-pre.004 2026-08-25 21:47:47 +02:00
f4413ebbb0 v0.2.11-pre.003-fix.001 2026-08-25 21:24:30 +02:00
be2a06bc80 v0.2.11-pre.003 2026-08-25 21:00:19 +02:00
2dbddfe367 v0.2.11-pre.002-fix.003 2026-08-25 20:37:09 +02:00
126e8abfcd v0.2.11-pre.002-fix.002 2026-08-25 20:24:21 +02:00
3fd540a9db v0.2.11-pre.002-fix.001 2026-08-25 20:17:31 +02:00
4f92fb03f1 v0.2.11-pre.002 2026-08-25 19:45:06 +02:00
f42fa8f4f1 v0.2.11-pre.001-fix.001 2026-08-25 18:37:51 +02:00
a8e980b225 v0.2.11-pre.001 2026-08-25 17:50:08 +02:00
afaa9c0239 v0.2.10-rel.001 2026-08-25 16:40:46 +02:00
7d456b41dc v0.2.10-pre.005 2026-08-25 15:43:15 +02:00
0edeed1c48 v0.2.10-pre.004 2026-08-25 15:26:04 +02:00
be5e3464ee v0.2.10-pre.003 2026-08-25 15:18:28 +02:00
1967e845b0 v0.2.10-pre.002-fix.001 2026-08-25 14:17:40 +02:00
af807afff5 v0.2.10-pre.002 2026-08-25 10:29:16 +02:00
7f567bc1bb v0.2.10-pre.001 2026-08-25 10:14:37 +02:00
125 changed files with 20362 additions and 120 deletions

View File

@@ -1,5 +1,5 @@
# file: .env.example # file: .env.example
# version: 8 # version: 10
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths. # 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. # The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
@@ -42,10 +42,34 @@ KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
# Keep the real value only in the process environment or local .env; do not assume that a Mainnet token is authorized on Testnet. # Keep the real value only in the process environment or local .env; do not assume that a Mainnet token is authorized on Testnet.
# KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN=replace-me # KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN=replace-me
# OrbitFlare Solana Free License Key used as secret x-token metadata by the committed Devnet Yellowstone gRPC profile.
# Keep the real ORBIT-* value only in the process environment or local .env; do not substitute the Customer API X-ORBIT-KEY.
# KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN=replace-me
# Helius API key used by the LaserStream WebSocket endpoint in config/examples/std.transport.example.json. # Helius API key used by the LaserStream WebSocket endpoint in config/examples/std.transport.example.json.
# Keep the real credential only in the process environment or local .env; never commit it. # Keep the real credential only in the process environment or local .env; never commit it.
# KSP_SECRET_HELIUS_API_KEY=replace-me # 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. # Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
KSP_DESK_SPLASH_FADE_IN_MS=300 KSP_DESK_SPLASH_FADE_IN_MS=300

View File

@@ -1,8 +1,26 @@
<!-- file: CHANGELOG.md --> <!-- file: CHANGELOG.md -->
<!-- version: 13 --> <!-- version: 15 -->
# Changelog KSP # Changelog KSP
## 0.2.11 — Off-chain price transport — 2026-08-26
`0.2.11` crée `ksp-offchain-transport-lib` avec une première surface volontairement bornée à **SOL/USD** et huit adapters REST V1 : CoinGecko, CoinMarketCap, CoinPaprika, Kraken, Coinbase Exchange, Jupiter Price V3, Birdeye et DexScreener. Les adapters utilisent `reqwest` directement, sans SDK provider, avec origines HTTPS possédées par la crate, DTOs wire privés et prix normalisés par `MarketPriceDecimal` sans passage canonique par `f64`. `MarketPriceObservation` conserve paire, sémantique, provenance et timestamps réellement disponibles afin de ne pas confondre prix agrégé, dernier trade d'exchange, heuristique Solana, spot provider et prix DEX pair. DexScreener exige une paire Solana explicite et ne réalise aucune discovery ou sélection automatique de pool.
La crate possède désormais le registry provider-neutral, les descriptors/capabilities, les limites et quotas informatifs, les états `MarketPriceProviderAvailability`, le cooldown et le service générique `MarketPriceService`. Les consumers disposent de `registry`, `refresh`, `refresh_many` et `refresh_all`; le batch V1 reste séquentiel et déterministe, ne dort jamais pour attendre un rate limit et n'introduit ni fallback, ni consensus, ni moyenne KSP. Les erreurs provider sont normalisées en états génériques et les credentials/endpoints/raw bodies restent absents des projections et diagnostics publics.
Config ajoute `std.offchain_transport` V1, son schema, les profils `public_keyless` et `all_free`, ainsi que `ResolvedOffchainTransportConfig` qui construit le service sans dépendance inverse. Les credentials CoinGecko/CoinMarketCap/Jupiter/Birdeye restent sous `KSP_SECRET_*`; la paire DexScreener utilise une provenance `KSP_PUBLIC_*`. Les deux applications desktop existantes embarquent les nouvelles resources Config sans encore consommer fonctionnellement le prix. Les canaris de sécurité verrouillent notamment l'absence d'URL/rate-limit provider configurable au-dessus de Config, de lecture directe d'environnement dans Off-chain Transport et de fuite de clés par `Debug`.
Le gate technique final a passé les audits Rust/Markdown, `cargo check`, Clippy, les tests ciblés Config/Off-chain, le workspace complet et les graphes Cargo. Le premier smoke live keyless a isolé une erreur de query CoinMarketCap (`ids=5426` au lieu de `id=5426`); `pre.011-fix.001` la corrige pour les modes Keyless et Basic, puis le re-smoke passe **7/7** providers keyless. Le smoke keyed Birdeye/CoinGecko Demo/CoinMarketCap Basic/Jupiter Free reste explicitement `SKIP opérateur`. `prompts/017-V0_2_12_START_PROMPT.md` ouvre ensuite `0.2.12 — SOL Prices Desk + intégration prix Wallet Desk` uniquement depuis le tag stable `v0.2.11`, avec audit/sizing desktop strict avant toute implémentation UI lourde.
## 0.2.10 — OrbitFlare Yellowstone gRPC — 2026-08-25
`0.2.10` ajoute OrbitFlare comme provider Yellowstone gRPC sur le moteur N1 et le standard N2 acquis dans `0.2.9`, sans modifier le moteur gRPC ni créer de façade provider inutile. L'audit et le smoke live ont établi le contrat réel du service Solana Free Devnet : endpoint `http://devnet.rpc.orbitflare.com:10000`, authentification par metadata gRPC secrète `x-token` portant la License Key OrbitFlare, et émission du Ping Yellowstone standard. Config Transport V3 représente ce provider avec `provider = orbitflare`, `cluster = devnet`, `protocol = solana_yellowstone` et `secret_metadata`, tandis que `.env.example` inventorie `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` sans valeur réelle.
La première tentative live sans metadata a volontairement échoué avec `Unauthenticated`, ce qui a conduit à `pre.002-fix.001` après audit des sources OrbitFlare actuelles. Le smoke corrigé reçoit la License Key uniquement par stdin opérateur, ouvre `Subscribe`, observe un Slot non nul et un `SubscribeUpdate::Ping`, puis ferme la session proprement. Le même smoke est repassé au gate technique final `pre.003`. La clôture conserve donc N1/N2 inchangés, n'ajoute ni heartbeat OrbitFlare, ni SDK provider, ni Config V4, et confirme que la réponse automatique au Ping standard existante suffit.
Le gate final conserve l'ensemble du workspace vert : audits Rust/Markdown, `cargo check`, Clippy, tests Config et Transport, `cargo test --workspace`, canaris de dépendances et graphes Cargo. Helius LaserStream gRPC, audité comme largement wire-compatible Yellowstone mais non raisonnablement live-testable sans abonnement payant, est reporté dans les TODO provider sans numéro de release. La séquence active passe donc à `0.2.11 — off-chain price transport`; `prompts/016-V0_2_11_START_PROMPT.md` l'ouvre exclusivement depuis le tag stable `v0.2.10` avec audit actuel des sources de prix, de leurs sémantiques et de leur testabilité avant toute implémentation lourde.
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`. Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
## 0.2.9 — Yellowstone gRPC standard/provider-neutral — 2026-08-24 ## 0.2.9 — Yellowstone gRPC standard/provider-neutral — 2026-08-24

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml # file: Cargo.toml
# version: 261 # version: 285
[workspace] [workspace]
resolver = "3" 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-onchain-transport-lib", "crates/ksp-wallet-lib"] 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] [workspace.package]
version = "0.2.9" version = "0.2.11"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md --> <!-- file: ROADMAP.md -->
<!-- version: 86 --> <!-- version: 89 -->
# Roadmap KSP # Roadmap KSP
@@ -54,15 +54,15 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
- [X] `0.2.7` — WebSocket Solana standard stable : 9 familles subscribe/unsubscribe typées (18/18 opérations), sessions physiques multiples explicites, subscriptions logiques typées, lifecycle/reconnect/resubscribe/backpressure/shutdown bornés, Config V2, non-régression HTTP 52+14, compliance finale, smoke WebSocket Devnet et audit de dépendances validés ; publication `rel.001` et prompt `0.2.8` prêts. - [X] `0.2.7` — WebSocket Solana standard stable : 9 familles subscribe/unsubscribe typées (18/18 opérations), sessions physiques multiples explicites, subscriptions logiques typées, lifecycle/reconnect/resubscribe/backpressure/shutdown bornés, Config V2, non-régression HTTP 52+14, compliance finale, smoke WebSocket Devnet et audit de dépendances validés ; publication `rel.001` et prompt `0.2.8` prêts.
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur lactor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt. - [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur lactor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes dupdate, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope. - [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes dupdate, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
- [ ] `0.2.10`Ajouter lintégration Yellowstone gRPC OrbitFlare sur le moteur/standard acquis, avec audit explicite des auth/capabilities/restrictions/extensions et Devnet comme cible gratuite prioritaire. - [X] `0.2.10`OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
- [ ] `0.2.11`Ajouter lintégration Helius LaserStream gRPC sur le moteur Yellowstone partagé, avec audit de compatibilité/replay/auth/extensions, sans confondre cette surface avec LaserStream WebSocket. - [X] `0.2.11`Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique nest introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
- [ ] `0.2.12` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR. - [ ] `0.2.12` — Introduire `ksp-app-solprices-desk` comme HID provider-agnostic consommant uniquement `ksp-offchain-transport-lib`, avec tableau prix/provider et refresh individuel/multiple ; puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation.
- [ ] `0.2.13` — Introduire une petite application desk de visualisation/validation des prix offchain, puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation possédée par le composant spécialisé. - [ ] `0.2.13` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
- [ ] `0.2.14` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes. - [ ] `0.2.14` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
- [ ] `0.2.15` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
### TODO/IDEAS — providers Yellowstone non planifiés ### TODO/IDEAS — providers Yellowstone non planifiés
- [ ] **TODO** — Helius LaserStream gRPC : réauditer lorsque l'accès live gRPC est raisonnablement disponible ; conserver N1/N2 Yellowstone inchangés, vérifier auth/endpoints/Subscribe/Ping/replay/from_slot/erreurs provider et traiter les preprocessed transactions comme extension Helius séparée.
- [ ] **TODO** — eRPC : réauditer accès, auth/IP policy, capabilities et produits complémentaires avant toute décision dimplémentation. - [ ] **TODO** — eRPC : réauditer accès, auth/IP policy, capabilities et produits complémentaires avant toute décision dimplémentation.
- [ ] **TODO** — Triton : réauditer la frontière Yellowstone upstream / extensions Triton, notamment Deshred et futures extensions. - [ ] **TODO** — Triton : réauditer la frontière Yellowstone upstream / extensions Triton, notamment Deshred et futures extensions.
- [ ] **TODO** — Alchemy : réauditer auth, replay, limites et capabilities Yellowstone avant toute intégration. - [ ] **TODO** — Alchemy : réauditer auth, replay, limites et capabilities Yellowstone avant toute intégration.

View File

@@ -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
}
}
}
]
}

View File

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

View File

@@ -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
}
}
}
]
}

View File

@@ -79,6 +79,64 @@
} }
] ]
}, },
{
"profile_id": "orbitflare_devnet",
"endpoints": [
{
"name": "solana_devnet_public",
"enabled": true,
"provider": "solana-public",
"cluster": "devnet",
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
"connect_timeout_ms": 5000,
"request_timeout_ms": 15000,
"max_idle_connections_per_host": 8,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": [
"*"
],
"priority": 100,
"limits": {
"requests_per_second": 5,
"burst_capacity": 10,
"max_concurrent_requests": 8,
"pause_after_rate_limit_ms": 1000
}
}
]
}
],
"ws_endpoints": [
{
"name": "solana_devnet_public_ws",
"enabled": true,
"provider": "solana-public",
"cluster": "devnet",
"kind": "solana_standard",
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
}
],
"grpc_endpoints": [
{
"name": "orbitflare_solana_devnet_yellowstone",
"enabled": true,
"provider": "orbitflare",
"cluster": "devnet",
"protocol": "solana_yellowstone",
"url": "http://devnet.rpc.orbitflare.com:10000",
"metadata": [],
"secret_metadata": [
{
"key": "x-token",
"value": "${KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN}"
}
]
}
]
},
{ {
"profile_id": "mainnet_public", "profile_id": "mainnet_public",
"endpoints": [ "endpoints": [

View File

@@ -55,10 +55,12 @@
"resources": { "resources": {
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json", "../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
"../../config/std.logging.json": "config/std.logging.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.transport.json": "config/std.transport.json",
"../../config/std.wallet.json": "config/std.wallet.json", "../../config/std.wallet.json": "config/std.wallet.json",
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.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.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.transport.schema.json": "config/schemas/std.transport.schema.json",
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json" "../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
} }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs // file: crates/ksp-app-config-desk/tests/desktop_contract.rs
// version: 7 // version: 8
//! Desktop build/shell contract audits for Config Desk. //! 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); let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
assert!(resources.is_some(), "packaged Config resources map must exist"); assert!(resources.is_some(), "packaged Config resources map must exist");
if let std::option::Option::Some(resources) = resources { 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.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!( assert_eq!(
resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str), 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"), std::option::Option::Some("config/schemas/std.wallet.schema.json"),

View File

@@ -55,10 +55,12 @@
"resources": { "resources": {
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json", "../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
"../../config/std.logging.json": "config/std.logging.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.transport.json": "config/std.transport.json",
"../../config/std.wallet.json": "config/std.wallet.json", "../../config/std.wallet.json": "config/std.wallet.json",
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.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.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.transport.schema.json": "config/schemas/std.transport.schema.json",
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json" "../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
} }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs // 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. //! 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); 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"); assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
if let std::option::Option::Some(resources) = resources { if let std::option::Option::Some(resources) = resources {
assert_eq!(resources.len(), 8); assert_eq!(resources.len(), 10);
assert_eq!( assert_eq!(
resources.get("../../config/composite.ksp-app-wallet-desk.json").and_then(serde_json::Value::as_str), 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"), 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), resources.get("../../config/schemas/composite.schema.json").and_then(serde_json::Value::as_str),
std::option::Option::Some("config/schemas/composite.schema.json"), 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()); let main = read_text(root.join("frontend/main.html").as_path());
assert!(main.contains(packaged_version)); assert!(main.contains(packaged_version));

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/release_compliance.rs // file: crates/ksp-app-wallet-desk/tests/release_compliance.rs
// version: 5 // version: 6
//! Release-wide deterministic compliance canaries for Wallet Desk. //! 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::Some(value) => value,
std::option::Option::None => return, std::option::Option::None => return,
}; };
assert_eq!(resources.len(), 8); assert_eq!(resources.len(), 10);
for (source, destination) in resources { for (source, destination) in resources {
let destination = destination.as_str(); let destination = destination.as_str();
assert!(destination.is_some(), "resource destination must be textual"); assert!(destination.is_some(), "resource destination must be textual");

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-config-lib/Cargo.toml # file: crates/ksp-config-lib/Cargo.toml
# version: 8 # version: 9
[package] [package]
name = "ksp-config-lib" name = "ksp-config-lib"
@@ -12,6 +12,7 @@ directories.workspace = true
jsonschema.workspace = true jsonschema.workspace = true
ksp-core-lib = { path = "../ksp-core-lib" } ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-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" } ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/lib.rs // file: crates/ksp-config-lib/src/lib.rs
// version: 18 // version: 19
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -21,6 +21,7 @@ mod environment;
mod error; mod error;
mod logging; mod logging;
mod management; mod management;
mod offchain_transport;
mod packaging; mod packaging;
mod persistence; mod persistence;
mod profile; mod profile;
@@ -127,6 +128,8 @@ pub use self::management::LoggingOutputFilterConfig;
pub use self::management::LoggingProfileConfig; pub use self::management::LoggingProfileConfig;
/// Typed source contract for one global Logging target override. /// Typed source contract for one global Logging target override.
pub use self::management::LoggingTargetFilterConfig; 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. /// Writable KSP runtime roots prepared from packaged Config resources.
pub use self::packaging::PackagedRuntimeLayout; pub use self::packaging::PackagedRuntimeLayout;
/// Prepares the shared writable KSP desktop runtime from immutable packaged resources. /// 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; pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
/// Default physical filename for the standard Logging JSON Schema document. /// Default physical filename for the standard Logging JSON Schema document.
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME; 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. /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME; pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. /// 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; pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
/// Logical file identifier for the standard Logging JSON Schema document. /// Logical file identifier for the standard Logging JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING; 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. /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT; pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
/// Logical file identifier for the standard Wallet JSON Schema document. /// Logical file identifier for the standard Wallet JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_WALLET; pub use self::registry::FILE_ID_SCHEMA_STD_WALLET;
/// Logical file identifier for the standard Logging configuration document. /// Logical file identifier for the standard Logging configuration document.
pub use self::registry::FILE_ID_STD_LOGGING; 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. /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
pub use self::registry::FILE_ID_STD_TRANSPORT; pub use self::registry::FILE_ID_STD_TRANSPORT;
/// Logical file identifier for the standard Wallet configuration document. /// Logical file identifier for the standard Wallet configuration document.

View File

@@ -0,0 +1,520 @@
// file: crates/ksp-config-lib/src/offchain_transport.rs
// version: 2
//! 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", &registry)
.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<crate::ResolvedOffchainTransportConfig> {
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<crate::ResolvedOffchainTransportConfig> {
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<String>,
enabled: bool,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveCoinGeckoSource {
access_mode: ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode,
api_key: std::option::Option<String>,
enabled: bool,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveCoinMarketCapSource {
access_mode: ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode,
api_key: std::option::Option<String>,
enabled: bool,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveDexScreenerSource {
enabled: bool,
sol_usd_pair_address: std::option::Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveJupiterSource {
access_mode: ksp_offchain_transport_lib::MarketPriceJupiterAccessMode,
api_key: std::option::Option<String>,
enabled: bool,
}
fn resolve_offchain_transport_profile(
profile: &crate::ResolvedConfigProfile,
environment: &crate::ConfigEnvironment,
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
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::<EffectiveOffchainTransportSource>(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<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
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<ksp_offchain_transport_lib::MarketPriceBirdeyeSettings> {
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<ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings> {
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 std::result::Result::Err(
effective_error(profile, "Off-chain Transport CoinGecko access mode is not supported by this Config adapter")
.with_context("provider", "coingecko")
.with_context("field", "access_mode"),
);
},
};
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
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<ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings> {
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 std::result::Result::Err(
effective_error(profile, "Off-chain Transport CoinMarketCap access mode is not supported by this Config adapter")
.with_context("provider", "coinmarketcap")
.with_context("field", "access_mode"),
);
},
};
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
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<ksp_offchain_transport_lib::MarketPriceDexScreenerSettings> {
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<ksp_offchain_transport_lib::MarketPriceJupiterSettings> {
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 std::result::Result::Err(
effective_error(profile, "Off-chain Transport Jupiter access mode is not supported by this Config adapter")
.with_context("provider", "jupiter")
.with_context("field", "access_mode"),
);
},
};
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
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;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/registry.rs // file: crates/ksp-config-lib/src/registry.rs
// version: 10 // version: 11
/// Bootstrap argument used to replace a known Config filename mapping. /// Bootstrap argument used to replace a known Config filename mapping.
pub const ARG_FILE_MAP: &str = "--filemap"; 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"; pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
/// Default physical filename for the standard Logging JSON Schema document. /// Default physical filename for the standard Logging JSON Schema document.
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json"; 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. /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json"; pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. /// 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"; pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
/// Logical file identifier for the standard Logging JSON Schema document. /// Logical file identifier for the standard Logging JSON Schema document.
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging"; 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. /// 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"; pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
/// Logical file identifier for the standard Wallet JSON Schema document. /// Logical file identifier for the standard Wallet JSON Schema document.
pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet"; pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet";
/// Logical file identifier for the standard Logging configuration document. /// Logical file identifier for the standard Logging configuration document.
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging"; 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. /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport"; pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
/// Logical file identifier for the standard Wallet configuration document. /// Logical file identifier for the standard Wallet configuration document.
@@ -172,6 +180,26 @@ impl ConfigFileRegistry {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error), 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( let transport = ConfigFileDescriptor::new(
FILE_ID_STD_TRANSPORT, FILE_ID_STD_TRANSPORT,
ConfigFileKind::Config, ConfigFileKind::Config,
@@ -204,7 +232,18 @@ impl ConfigFileRegistry {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error), 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=<file_id>=<filename>` overrides from raw process arguments. /// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/ownership.rs // file: crates/ksp-config-lib/tests/ownership.rs
// version: 6 // version: 7
//! Workspace ownership audits for KSP application configuration boundaries. //! Workspace ownership audits for KSP application configuration boundaries.
@@ -177,7 +177,7 @@ fn has_preceding_explanatory_comment(source: &str, assignment_line_index: usize)
#[test] #[test]
fn foundational_dependency_direction_does_not_point_back_to_config() { fn foundational_dependency_direction_does_not_point_back_to_config() {
let root = workspace_root(); 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_path = root.join("crates").join(crate_name).join("Cargo.toml");
let manifest = std::fs::read_to_string(manifest_path.as_path()); let manifest = std::fs::read_to_string(manifest_path.as_path());
assert!(manifest.is_ok(), "unable to read {}", manifest_path.display()); 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\"", "\"composite.schema.json\"",
"\"std.logging.json\"", "\"std.logging.json\"",
"\"std.logging.schema.json\"", "\"std.logging.schema.json\"",
"\"std.offchain_transport.json\"",
"\"std.offchain_transport.schema.json\"",
"\"std.transport.json\"", "\"std.transport.json\"",
"\"std.transport.schema.json\"", "\"std.transport.schema.json\"",
"\"std.wallet.json\"", "\"std.wallet.json\"",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/public_api.rs // 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, //! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
//! Logging/Transport adapters and management contracts. //! 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:?}"); assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
if let std::result::Result::Ok(registry) = registry { if let std::result::Result::Ok(registry) = registry {
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect(); 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[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[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[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_WALLET); 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_SCHEMA_COMPOSITE); 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_STD_LOGGING); 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_TRANSPORT); 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_WALLET); assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
let schema_file_id = descriptors[3].schema_file_id(); 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"); 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 { 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); 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"); 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::<ksp_config_lib::ResolvedOffchainTransportConfig>() > 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] #[test]
fn packaged_runtime_layout_contract_is_available_from_crate_root() { fn packaged_runtime_layout_contract_is_available_from_crate_root() {
let prepare: fn(&std::path::Path) -> ksp_core_lib::Result<ksp_config_lib::PackagedRuntimeLayout> = ksp_config_lib::prepare_packaged_runtime; let prepare: fn(&std::path::Path) -> ksp_core_lib::Result<ksp_config_lib::PackagedRuntimeLayout> = ksp_config_lib::prepare_packaged_runtime;

View File

@@ -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
}
}
}
]
}

View File

@@ -0,0 +1,284 @@
// file: crates/ksp-config-lib/unit_tests/offchain_transport.rs
// version: 2
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(&registry, "birdeye", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
assert_provider_availability(&registry, "dexscreener", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
for provider_id in ["coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "jupiter", "kraken"] {
assert_provider_availability(&registry, 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::<String, String>::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:?}");
}
#[test]
fn provider_url_and_rate_limit_overrides_are_rejected_by_schema_before_runtime_mapping() {
for (provider, field, value) in [
("coingecko", "base_url", serde_json::Value::String("https://example.invalid".to_owned())),
("coinmarketcap", "rate_limit", serde_json::json!({"requests": 999999, "window_seconds": 1})),
] {
let fixture = tempfile::tempdir();
assert!(fixture.is_ok(), "temporary Config root should be creatable: {fixture:?}");
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let source = committed_document_value();
let mut source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
assert!(profiles.is_some(), "fixture should expose profiles");
if let std::option::Option::Some(profiles) = profiles {
let public_keyless = profiles
.iter_mut()
.find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("public_keyless"));
assert!(public_keyless.is_some(), "fixture should contain public_keyless profile");
if let std::option::Option::Some(public_keyless) = public_keyless {
public_keyless["market_price"][provider][field] = value;
}
}
let engine = fixture_engine_with_document(fixture.path(), &source);
assert!(engine.is_ok(), "override fixture engine should be constructible: {engine:?}");
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("public_keyless"), &environment);
assert!(resolved.is_err(), "provider URL/rate-limit override must be rejected before runtime mapping: provider={provider} field={field}");
if let std::result::Result::Err(error) = resolved {
assert_eq!(error.code(), crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED);
let debug = format!("{error:?}");
assert!(!debug.contains("https://example.invalid"));
}
}
}
fn assert_provider_availability(
registry: &ksp_offchain_transport_lib::MarketPriceProviderRegistry,
provider_id: &str,
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<crate::ConfigDocumentEngine> {
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<crate::ConfigDocumentEngine> {
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<serde_json::Value, serde_json::Error> {
return serde_json::from_str(include_str!("../../../config/std.offchain_transport.json"));
}
fn all_free_environment() -> std::collections::BTreeMap<String, String> {
let mut process = std::collections::BTreeMap::<String, String>::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("../..");
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/registry.rs // file: crates/ksp-config-lib/unit_tests/registry.rs
// version: 8 // version: 9
#[test] #[test]
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() { 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:?}"); assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
if let std::result::Result::Ok(registry) = registry { if let std::result::Result::Ok(registry) = registry {
let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect(); 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].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].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[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[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[2].file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_WALLET); assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[3].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME)); assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_WALLET);
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].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE); 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_STD_LOGGING); 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_TRANSPORT); 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_WALLET); assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
assert!(descriptors[0..4].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config)); assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
assert!(descriptors[4..8].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema)); 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] #[test]
fn defaults_register_transport_document_and_schema_with_distinct_roots() { fn defaults_register_transport_document_and_schema_with_distinct_roots() {
let registry = crate::ConfigFileRegistry::defaults(); let registry = crate::ConfigFileRegistry::defaults();

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/transport.rs // file: crates/ksp-config-lib/unit_tests/transport.rs
// version: 10 // version: 12
#[test] #[test]
fn fixture_transport_profile_maps_complete_runtime_contract() { fn fixture_transport_profile_maps_complete_runtime_contract() {
@@ -133,6 +133,52 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
} }
} }
#[test]
fn committed_v3_orbitflare_devnet_profile_maps_provider_neutral_yellowstone_grpc_with_license_x_token() {
let engine = committed_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let canary = "ORBITFLARE-DEVNET-LICENSE-X-TOKEN-CANARY";
let mut process = std::collections::BTreeMap::<String, String>::new();
process.insert("KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN".to_owned(), canary.to_owned());
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let resolved = engine.load_resolved_transport_config(std::option::Option::Some("orbitflare_devnet"), &environment);
assert!(resolved.is_ok(), "committed OrbitFlare Devnet Transport profile should map from its License Key x-token: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "orbitflare_devnet");
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
assert_eq!(resolved.settings().endpoints()[0].cluster().as_str(), "devnet");
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), "https://api.devnet.solana.com");
let ws = resolved.ws_settings();
assert!(ws.is_some(), "OrbitFlare Devnet profile should retain the standard Solana Devnet WebSocket companion endpoint");
if let std::option::Option::Some(ws) = ws {
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.devnet.solana.com");
assert_eq!(ws.endpoints()[0].protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
}
let grpc = resolved.grpc_settings();
assert!(grpc.is_some(), "OrbitFlare Devnet profile should expose one provider-neutral Yellowstone gRPC endpoint");
if let std::option::Option::Some(grpc) = grpc {
assert_eq!(grpc.endpoints().len(), 1);
let endpoint = &grpc.endpoints()[0];
assert_eq!(endpoint.name(), "orbitflare_solana_devnet_yellowstone");
assert_eq!(endpoint.provider().as_str(), "orbitflare");
assert_eq!(endpoint.cluster().as_str(), "devnet");
assert_eq!(endpoint.url().as_str(), "http://devnet.rpc.orbitflare.com:10000");
assert!(!endpoint.url().uses_tls(), "OrbitFlare shared Devnet URL must remain explicitly HTTP/2 plaintext");
assert_eq!(endpoint.metadata().len(), 1);
assert_eq!(endpoint.metadata()[0].key(), "x-token");
assert!(endpoint.metadata()[0].is_secret());
assert!(grpc.validate().is_ok(), "Config-produced OrbitFlare Yellowstone gRPC settings should satisfy Transport validation");
let debug = format!("{grpc:?}");
assert!(!debug.contains("orbitflare.com"), "Transport settings Debug must not expose OrbitFlare gRPC endpoint URLs");
assert!(!debug.contains(canary), "OrbitFlare License Key x-token must stay redacted from Transport settings Debug");
assert!(!debug.contains("X-ORBIT-KEY"), "OrbitFlare Customer API credentials must never enter Yellowstone Transport settings");
}
}
}
#[test] #[test]
fn committed_v3_publicnode_profiles_map_provider_neutral_yellowstone_grpc_with_network_scoped_secret_x_tokens() { fn committed_v3_publicnode_profiles_map_provider_neutral_yellowstone_grpc_with_network_scoped_secret_x_tokens() {
let engine = committed_engine(); let engine = committed_engine();

View File

@@ -0,0 +1,22 @@
# file: crates/ksp-offchain-transport-lib/Cargo.toml
# version: 4
[package]
name = "ksp-offchain-transport-lib"
version.workspace = true
edition.workspace = true
repository.workspace = true
[dependencies]
chrono = { workspace = true, features = ["std"] }
ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" }
reqwest = { workspace = true, features = ["rustls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
[dev-dependencies]
tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "time"] }
[lints]
workspace = true

View File

@@ -0,0 +1,146 @@
<!-- file: crates/ksp-offchain-transport-lib/README.md -->
<!-- version: 2 -->
# ksp-offchain-transport-lib
`ksp-offchain-transport-lib` est le propriétaire des transports et adaptations de données **off-chain** utilisés par KSP. La release `0.2.11` matérialise sa première famille fonctionnelle, `market_price`, limitée à des observations SOL/USD multi-provider.
La crate n'est pas une crate « prix uniquement ». Les responsabilités durables sont séparées par famille :
```text
http_* mécanique HTTP partagée réellement commune
market_price_* prix de marché / spot normalisés
swap_quote_* future famille de quotes montant/route
<future_capability>_* ajoutée uniquement lorsqu'un scope réel l'exige
```
## Contrat `market_price` V1
La façade publique fournit :
- `MarketPriceDecimal`, représentation décimale exacte positive sans vérité canonique `f64` ;
- `MarketPriceObservation`, avec paire, prix, sémantique, timestamps KSP/provider et provenance sûre ;
- `MarketPriceProviderDescriptor` et `MarketPriceProviderState`, pour décrire capacités et availability sans logique provider côté consumer ;
- `MarketPriceProviderRegistry`, inventaire déterministe des providers configurés ;
- `MarketPriceService`, façade provider-agnostic pour `refresh`, `refresh_many` et `refresh_all` ;
- `MarketPriceProviderSetup`, frontière de composition initiale provider-specific qui ne doit pas devenir la surface runtime de la HID.
La paire publique V1 est exclusivement :
```text
SOL/USD
```
Les enums publiques susceptibles d'évoluer sont `#[non_exhaustive]`. Un consumer externe doit donc conserver une branche future-safe et ne pas supposer que les paires, sémantiques, états ou providers resteront définitivement fermés à ceux de `0.2.11`.
## Providers V1
L'inventaire fonctionnel comporte exactement huit adapters :
```text
birdeye
coinbase_exchange
coingecko
coinmarketcap
coinpaprika
dexscreener
jupiter
kraken
```
Ils ne prétendent pas produire la même vérité de marché. `MarketPriceSemantics` conserve notamment la différence entre agrégateur, dernier trade d'exchange, heuristique Solana, spot Solana et paire DEX.
Les origines HTTPS, chemins, headers d'authentification, identités d'asset et limites provider restent possédés par les adapters. V1 n'expose aucune URL provider arbitraire ni aucun SDK fournisseur.
DexScreener reste un cas volontairement strict : une paire Solana explicite est fournie à la composition lorsqu'il est activé ; l'adapter appelle uniquement la paire configurée et ne découvre, ne classe ni n'agrège automatiquement des pools.
## HTTP et résilience
Les primitives `http_*` sont crate-private. Elles appliquent notamment :
```text
reqwest uniquement
HTTPS provider fixe
redirects désactivés
Referer automatique désactivé
proxy système implicite désactivé
retries reqwest implicites désactivés
connect/request timeouts bornés
body borné pendant la lecture
JSON validé avant mapping typed
URL retirée des erreurs reqwest
aucun body distant brut dans KspError
429 et Retry-After classés
```
Le rate limiting est provider-owned et non bloquant. Un provider non éligible est projeté en availability/cooldown ; le service ne dort pas pour attendre sa prochaine fenêtre.
## Refresh individuel et multiple
`MarketPriceService` est la surface runtime générique.
La V1 garde un comportement multiple **séquentiel et déterministe** :
```text
refresh(provider_id) un provider opaque
refresh_many(provider_ids) ordre demandé conservé
refresh_all() ordre stable du registry
```
Un provider en cooldown ou en erreur n'empêche pas la projection des autres outcomes. Le service ne fait aucun fallback, aucun consensus et aucune agrégation de prix entre providers.
Le séquentiel de V1 est un contrat volontaire de simplicité et de déterminisme, pas une obligation architecturale éternelle. Une évolution vers une orchestration concurrente demanderait un contrat explicite sur l'ordre, les limites et les effets observables.
## Config et secrets
Cette crate **ne lit jamais** directement `KSP_*`, `KSPB_*`, `.env` ou les documents Config.
La direction autorisée est :
```text
ksp-config-lib
-> ksp-offchain-transport-lib
```
`ksp-config-lib` résout les credentials, vérifie leur provenance et construit `MarketPriceProviderSetup` / `MarketPriceService`. La dépendance inverse est interdite.
Les API keys ne sont pas exposées par les projections publiques usuelles et leurs `Debug` sont redacted. Les erreurs/logs n'embarquent ni credential, ni URL sensible, ni payload distant brut.
## Numeric safety et provenance
`MarketPriceDecimal` accepte les formes décimales/scientifiques bornées nécessaires aux wire providers, puis normalise vers un coefficient `u128` et une scale limitée. Sont rejetés notamment : zéro pour une observation réussie, négatifs, valeurs non numériques, overflow, scale excessive et exposants pathologiques.
Les timestamps provider ne sont présents que lorsqu'un provider fournit réellement une information temporelle correspondant au prix. Un block id, une date de création d'asset ou une donnée de récence non temporelle n'est jamais convertie en faux timestamp.
La provenance textuelle est bornée et contrôlée afin de rester sûre pour les projections/logs.
## Hors scope de `0.2.11`
```text
SOL/EUR
fallback automatique
consensus ou moyenne multi-provider
découverte automatique de pool DexScreener
scheduler périodique
historique persistant
swap routing / Jupiter quote
soumission ou signature de transaction Solana
SDK provider
URL provider configurable
```
## Documentation
- [`USAGE.md`](USAGE.md) — construction programmatique et utilisation de la façade générique ;
- [`../../docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md`](../../docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md) — plan de release ;
- [`../../docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md`](../../docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md) — matrice de validation.
## Validation de release `0.2.11`
La candidate finale a été validée sur les huit adapters par tests déterministes. Le smoke live keyless de clôture a réellement rafraîchi les sept modes ne nécessitant aucun credential : Coinbase Exchange, CoinGecko, CoinMarketCap, CoinPaprika, DexScreener, Jupiter et Kraken. Un premier passage a révélé le paramètre CoinMarketCap V2 erroné `ids`; `pre.011-fix.001` l'a corrigé en `id=5426`, puis le re-smoke a passé les sept providers.
Birdeye n'expose pas de mode keyless V1. Le smoke keyed commun Birdeye/CoinGecko Demo/CoinMarketCap Basic/Jupiter Free reste opt-in et peut être `SKIP opérateur` lorsque les quatre credentials gratuits ne sont pas disponibles. KSP ne transforme jamais cette absence de credentials en preuve live fictive.
Les offres gratuites, quotas et conditions d'usage appartiennent aux providers et peuvent évoluer indépendamment du contrat KSP. Les descriptors représentent le snapshot audité pour `0.2.11`; un changement commercial ou de quota futur peut rendre un provider indisponible sans modifier la façade provider-neutral.
Ce README est la documentation durable de la surface `0.2.11`. Les détails de construction et les commandes de smoke sont conservés dans [`USAGE.md`](USAGE.md), tandis que le plan et la matrice de validation enregistrent les décisions et preuves de release.

View File

@@ -0,0 +1,181 @@
<!-- file: crates/ksp-offchain-transport-lib/USAGE.md -->
<!-- version: 2 -->
# Usage de ksp-offchain-transport-lib
Cette page documente la surface stable préparée pour `0.2.11`. Les credentials et documents runtime doivent normalement être résolus par `ksp-config-lib`; les constructions directes ci-dessous servent surtout aux tests, outils bas niveau et compositions programmatiques maîtrisées.
## 1. Construire un service programmatique
Un service reçoit une liste de `MarketPriceProviderSetup`. Le setup est provider-specific uniquement au moment de la composition :
```rust
let coingecko = ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(true)?;
let kraken = ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(true)?;
let service = ksp_offchain_transport_lib::MarketPriceService::new(std::vec![
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
])?;
```
Une application normale n'a pas à reproduire le mapping des huit providers. `ksp-config-lib::ResolvedOffchainTransportConfig` construit cette composition depuis `cfg.std.offchain_transport`.
## 2. Découvrir le registry générique
Après construction, le consumer travaille sur le registry sans matcher les variants provider :
```rust
for entry in service.registry().entries() {
let descriptor = entry.descriptor();
let state = entry.state();
println!(
"{} {:?} {:?}",
descriptor.display_name(),
descriptor.semantics(),
state.availability(),
);
}
```
Le `provider_id` est opaque. Il sert d'identité stable pour rappeler le service, pas de signal autorisant le consumer à reconstruire un endpoint ou une règle provider.
## 3. Rafraîchir un provider
```rust
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("coingecko")?;
let outcome = service.refresh(&provider_id).await?;
```
L'outcome expose génériquement l'observation éventuelle et l'état provider. Une erreur provider normalisée n'oblige pas le consumer à parser CoinGecko, Kraken ou Jupiter.
Avant un refresh, l'état peut être consulté via le registry. `MarketPriceProviderAvailability::retry_at()` expose le prochain instant connu lorsqu'il existe réellement.
## 4. Rafraîchir plusieurs providers
```rust
let ids = service
.registry()
.entries()
.iter()
.map(|entry| return entry.descriptor().id().clone())
.collect::<std::vec::Vec<_>>();
let outcomes = service.refresh_many(ids.as_slice()).await?;
```
La V1 exécute ce batch **séquentiellement**, dans l'ordre demandé. Elle valide les IDs avant le premier dispatch, rejette les doublons, ne dort pas pour un cooldown et produit un outcome générique par provider lorsque l'opération est valide.
`refresh_all()` applique le même contrat dans l'ordre stable du registry :
```rust
let outcomes = service.refresh_all().await?;
```
Il n'existe pas de fallback, consensus ou moyenne implicite. Une application voulant comparer les observations doit conserver leurs sémantiques et provenances ; elle ne doit pas présenter leurs différences comme une erreur de KSP.
## 5. Construire depuis Config
La voie runtime normale est :
```rust
let resolved = engine.load_resolved_offchain_transport_config(
std::option::Option::None,
&environment,
)?;
let service = resolved.service();
let registry = service.registry();
```
Le profil `public_keyless` du document standard peut être résolu sans credentials. Le profil `all_free` attend les secrets/public fields inventoriés dans `.env.example` et validés par `ksp-config-lib`.
Off-chain Transport ne lit pas l'environnement lui-même. Ne passez pas un credential via une URL, une query arbitraire ou une surface UI libre pour contourner Config.
## 6. DexScreener
DexScreener doit recevoir la paire SOL/USD Solana explicitement approuvée par la composition lorsqu'il est activé :
```rust
let pair = ksp_core_lib::Pubkey::parse("<PAIR_ADDRESS_APPROUVEE>")?;
let settings = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(
true,
std::option::Option::Some(pair),
)?;
```
La paire n'est pas un secret. Aucun helper V1 ne découvre automatiquement une autre pool, ne trie par liquidité ou ne remplace la paire configurée.
## 7. Exactitude numérique
Ne convertissez pas l'observation canonique en `f64` pour la stocker ou la comparer comme vérité KSP. `MarketPriceDecimal` conserve une forme décimale exacte et sérialise une représentation canonique.
Pour l'affichage, un consumer peut utiliser sa représentation textuelle publique. Toute conversion approximative éventuelle appartient à une couche de présentation qui accepte explicitement cette perte ; elle ne doit pas remplacer le type canonique dans le transport.
## 8. Forward compatibility
Les enums publiques susceptibles d'évoluer sont `#[non_exhaustive]`. Hors de la crate, les matches doivent donc prévoir un fallback :
```rust
match entry.state().availability() {
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready => {},
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled => {},
_ => {},
}
```
La branche `_` est intentionnelle : de nouveaux providers, états, sémantiques ou paires pourront être ajoutés sans imposer une rupture source aux consumers bien écrits.
## 9. Diagnostics sûrs
Les diagnostics applicatifs peuvent journaliser :
```text
provider_id validé
code d'erreur KSP
classe d'availability
retry_at borné lorsqu'il existe
durée/opération générique
```
Ils ne doivent pas journaliser :
```text
API key
URL complète sensible
header provider secret
body distant brut
payload de Config secret
```
`ksp-offchain-transport-lib` utilise `ksp-logging-lib` et son `TRACING_TARGET` propriétaire ; une application ne doit pas ajouter un bypass direct `tracing` pour obtenir les payloads rejetés.
## 10. Smokes live de release
Le smoke keyless final ne requiert aucun secret et couvre les sept modes V1 concernés :
```bash
cargo test -p ksp-offchain-transport-lib \
--test market_price_live_smoke \
keyless_market_price_providers_live_smoke \
-- --ignored --exact --nocapture --test-threads=1
```
Le gate `0.2.11-pre.011-fix.001` a passé ce smoke en `7/7`. Le test ne compare jamais les providers à un prix exact commun : il valide l'identité, la paire SOL/USD, la sémantique, un prix canonique positif et la cohérence des timestamps disponibles.
Le smoke keyed reste volontairement distinct. Il lit quatre clés sur `stdin`, dans l'ordre Birdeye, CoinGecko Demo, CoinMarketCap Basic et Jupiter Free :
```bash
printf '%s\n%s\n%s\n%s\n' \
"$KSP_SECRET_BIRDEYE_API_KEY" \
"$KSP_SECRET_COINGECKO_DEMO_API_KEY" \
"$KSP_SECRET_COINMARKETCAP_API_KEY" \
"$KSP_SECRET_JUPITER_API_KEY" \
| cargo test -p ksp-offchain-transport-lib \
--test market_price_live_smoke \
keyed_market_price_providers_live_smoke \
-- --ignored --exact --nocapture --test-threads=1
```
Ce second smoke peut être omis si l'opérateur ne possède pas les quatre credentials ; l'absence de credentials doit alors rester explicitement `SKIP opérateur`.
## 11. Statut documentaire
Ce document est la référence d'usage durable de la surface `market_price` livrée par `0.2.11`. Il ne promet ni prix identique entre providers, ni disponibilité permanente de leurs plans gratuits, ni compatibilité avec une surface provider qui changerait après la release.

View File

@@ -0,0 +1,7 @@
// file: crates/ksp-offchain-transport-lib/src/constants.rs
// version: 1
//! Off-chain Transport-owned tracing constants.
/// Owning tracing target for events emitted by the off-chain transport crate.
pub(crate) const TRACING_TARGET: &str = "ksp-offchain-transport-lib";

View File

@@ -0,0 +1,57 @@
// file: crates/ksp-offchain-transport-lib/src/error.rs
// version: 9
/// Stable off-chain transport error for HTTP 401/403 access denial.
pub const ERROR_CODE_HTTP_ACCESS_DENIED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_access_denied");
/// Stable off-chain transport error when local provider admission defers a request.
pub const ERROR_CODE_HTTP_ADMISSION_DEFERRED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_admission_deferred");
/// Stable off-chain transport error when the hardened reqwest client cannot be initialized.
pub const ERROR_CODE_HTTP_CLIENT_BUILD_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_client_build_failed");
/// Stable off-chain transport error for a connection failure without exposing the provider URL.
pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_connection_failed");
/// Stable off-chain transport error when a successful HTTP response is not syntactically valid JSON.
pub const ERROR_CODE_HTTP_INVALID_JSON: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_invalid_json");
/// Stable off-chain transport error for HTTP 429 rate limiting.
pub const ERROR_CODE_HTTP_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_rate_limited");
/// Stable off-chain transport error for an invalid local request-admission/rate-limit policy.
pub const ERROR_CODE_HTTP_RATE_LIMIT_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_rate_limit_invalid");
/// Stable off-chain transport error for a generic unsuccessful or transport-level HTTP request.
pub const ERROR_CODE_HTTP_REQUEST_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_request_failed");
/// Stable off-chain transport error for an invalid crate-owned HTTP request definition.
pub const ERROR_CODE_HTTP_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_request_invalid");
/// Stable off-chain transport error when a response exceeds the defensive body limit.
pub const ERROR_CODE_HTTP_RESPONSE_TOO_LARGE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_response_too_large");
/// Stable off-chain transport error for an invalid crate-wide HTTP runtime configuration.
pub const ERROR_CODE_HTTP_SETTINGS_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_settings_invalid");
/// Stable off-chain transport error for transient HTTP status failures such as 408 or 5xx.
pub const ERROR_CODE_HTTP_TEMPORARY_FAILURE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_temporary_failure");
/// Stable off-chain transport error for an end-to-end HTTP timeout.
pub const ERROR_CODE_HTTP_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_timeout");
/// Stable off-chain transport error for an invalid exact market-price decimal.
pub const ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_decimal_invalid");
/// Stable off-chain transport error for an invalid normalized market-price observation.
pub const ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_observation_invalid");
/// Stable off-chain transport error for an invalid market-price provider descriptor.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_descriptor_invalid");
/// Stable off-chain transport error when a disabled market-price provider is invoked directly.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_disabled");
/// Stable off-chain transport error for an invalid market-price provider identifier.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_id_invalid");
/// Stable off-chain transport error when a requested provider is absent from the configured service.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_not_found");
/// Stable off-chain transport error when a provider response violates its adapter contract.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_response_invalid");
/// Stable off-chain transport error for invalid common market-price provider settings.
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_settings_invalid");
/// Stable off-chain transport error for an invalid generic market-price refresh request.
pub const ERROR_CODE_MARKET_PRICE_REFRESH_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_refresh_invalid");
/// Stable off-chain transport error for an invalid market-price provider registry.
pub const ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_registry_invalid");

View File

@@ -0,0 +1,209 @@
// file: crates/ksp-offchain-transport-lib/src/http_admission.rs
// version: 3
//! Provider-neutral local request admission and rate-limit cooldown primitives.
/// Maximum provider-directed cooldown accepted from `Retry-After`.
pub(crate) const HTTP_MAX_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(3_600);
const DEFAULT_RATE_LIMIT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(1);
const MAX_RATE_LIMIT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3_600);
/// Crate-internal local request-admission policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum HttpAdmissionPolicy {
/// No stable local cadence is known; only provider-driven cooldown is enforced.
Dynamic,
/// Enforce a smooth token bucket for a documented request budget and window.
Fixed { requests: u32, window: std::time::Duration, burst: u32 },
}
impl crate::HttpAdmissionPolicy {
/// Creates a validated fixed-window admission policy.
pub(crate) fn fixed(requests: u32, window: std::time::Duration, burst: std::option::Option<u32>) -> ksp_core_lib::Result<Self> {
let burst = match burst {
std::option::Option::Some(value) => value,
std::option::Option::None => 1,
};
if requests == 0 || window.is_zero() || window > MAX_RATE_LIMIT_WINDOW || burst == 0 {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "HTTP request-admission policy is invalid")
.with_context("field", "rate_limit"),
);
}
return std::result::Result::Ok(Self::Fixed { requests, window, burst });
}
}
/// Result of one non-blocking local admission attempt.
pub(crate) enum HttpAdmissionDecision {
/// The request may dispatch now and one local token has been consumed when applicable.
Ready,
/// The request must be deferred for at least this duration.
Deferred(std::time::Duration),
}
/// Shared non-blocking limiter used by provider adapters and later refresh orchestration.
pub(crate) struct HttpAdmissionController {
cooldown_until: std::sync::Mutex<std::option::Option<std::time::Instant>>,
fallback_cooldown: std::time::Duration,
token_bucket: std::sync::Mutex<std::option::Option<HttpTokenBucketState>>,
}
impl crate::HttpAdmissionController {
/// Creates one limiter from a provider-owned local admission policy.
pub(crate) fn new(policy: crate::HttpAdmissionPolicy, fallback_cooldown: std::option::Option<std::time::Duration>) -> ksp_core_lib::Result<Self> {
let fallback_cooldown = match fallback_cooldown {
std::option::Option::Some(value) => value,
std::option::Option::None => DEFAULT_RATE_LIMIT_COOLDOWN,
};
if fallback_cooldown.is_zero() || fallback_cooldown > crate::HTTP_MAX_RETRY_AFTER {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "HTTP fallback cooldown is outside the supported bounds")
.with_context("field", "fallback_cooldown"),
);
}
let token_bucket = match policy {
crate::HttpAdmissionPolicy::Fixed { requests, window, burst } => {
std::option::Option::Some(HttpTokenBucketState::new(requests, window, burst, std::time::Instant::now()))
},
crate::HttpAdmissionPolicy::Dynamic => std::option::Option::None,
};
return std::result::Result::Ok(Self {
cooldown_until: std::sync::Mutex::new(std::option::Option::None),
fallback_cooldown,
token_bucket: std::sync::Mutex::new(token_bucket),
});
}
/// Tries to admit one request immediately without sleeping.
pub(crate) fn try_admit(&self) -> crate::HttpAdmissionDecision {
return self.try_admit_at(std::time::Instant::now());
}
/// Records a provider 429 and extends cooldown using a bounded `Retry-After` value when present.
pub(crate) fn record_rate_limited(&self, provider_retry_after: std::option::Option<std::time::Duration>) -> std::time::Duration {
let provider_delay = match provider_retry_after {
std::option::Option::Some(value) => std::cmp::min(value, crate::HTTP_MAX_RETRY_AFTER),
std::option::Option::None => std::time::Duration::ZERO,
};
let effective = std::cmp::max(self.fallback_cooldown, provider_delay);
self.record_cooldown_until(std::time::Instant::now(), effective);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
cooldown_ms = duration_millis_u64(effective),
provider_retry_after_present = provider_retry_after.is_some(),
"recorded off-chain HTTP provider cooldown"
);
return effective;
}
/// Returns the remaining provider cooldown for deterministic limiter tests.
#[cfg(test)]
pub(crate) fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
return self.cooldown_remaining_at(std::time::Instant::now());
}
fn try_admit_at(&self, now: std::time::Instant) -> crate::HttpAdmissionDecision {
if let std::option::Option::Some(remaining) = self.cooldown_remaining_at(now) {
return crate::HttpAdmissionDecision::Deferred(remaining);
}
let lock_result = self.token_bucket.lock();
let mut token_bucket = match lock_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(poisoned) => poisoned.into_inner(),
};
let state = match token_bucket.as_mut() {
std::option::Option::Some(value) => value,
std::option::Option::None => return crate::HttpAdmissionDecision::Ready,
};
return match state.try_consume_at(now) {
std::option::Option::Some(delay) => crate::HttpAdmissionDecision::Deferred(delay),
std::option::Option::None => crate::HttpAdmissionDecision::Ready,
};
}
fn cooldown_remaining_at(&self, now: std::time::Instant) -> std::option::Option<std::time::Duration> {
let lock_result = self.cooldown_until.lock();
let mut cooldown_until = match lock_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(poisoned) => poisoned.into_inner(),
};
let deadline = match *cooldown_until {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
if deadline <= now {
*cooldown_until = std::option::Option::None;
return std::option::Option::None;
}
return std::option::Option::Some(deadline.duration_since(now));
}
fn record_cooldown_until(&self, now: std::time::Instant, delay: std::time::Duration) {
let candidate = match now.checked_add(delay) {
std::option::Option::Some(value) => value,
std::option::Option::None => now,
};
let lock_result = self.cooldown_until.lock();
let mut cooldown_until = match lock_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(poisoned) => poisoned.into_inner(),
};
let replace = match *cooldown_until {
std::option::Option::Some(current) => candidate > current,
std::option::Option::None => true,
};
if replace {
*cooldown_until = std::option::Option::Some(candidate);
}
return;
}
}
#[derive(Debug)]
struct HttpTokenBucketState {
available_tokens: f64,
burst: u32,
last_refill: std::time::Instant,
refill_per_second: f64,
}
impl HttpTokenBucketState {
fn new(requests: u32, window: std::time::Duration, burst: u32, now: std::time::Instant) -> Self {
let refill_per_second = f64::from(requests) / window.as_secs_f64();
return Self { available_tokens: f64::from(burst), burst, last_refill: now, refill_per_second };
}
fn try_consume_at(&mut self, now: std::time::Instant) -> std::option::Option<std::time::Duration> {
self.refill_at(now);
if self.available_tokens >= 1.0 {
self.available_tokens -= 1.0;
return std::option::Option::None;
}
let missing = 1.0 - self.available_tokens;
let wait_seconds = missing / self.refill_per_second;
return std::option::Option::Some(std::time::Duration::from_secs_f64(wait_seconds));
}
fn refill_at(&mut self, now: std::time::Instant) {
if now <= self.last_refill {
return;
}
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
self.available_tokens = (self.available_tokens + elapsed * self.refill_per_second).min(f64::from(self.burst));
self.last_refill = now;
return;
}
}
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
return match u64::try_from(duration.as_millis()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => u64::MAX,
};
}
#[cfg(test)]
#[path = "../unit_tests/http_admission.rs"]
mod tests;

View File

@@ -0,0 +1,364 @@
// file: crates/ksp-offchain-transport-lib/src/http_client.rs
// version: 4
//! Crate-wide hardened REST client used internally by off-chain capability adapters.
/// Bounded successful JSON document returned by the crate-internal REST client.
pub(crate) struct HttpJsonDocument {
bytes: std::vec::Vec<u8>,
}
impl crate::HttpJsonDocument {
/// Returns the validated raw JSON bytes for provider-specific typed deserialization.
#[must_use]
pub(crate) fn as_bytes(&self) -> &[u8] {
return self.bytes.as_slice();
}
}
impl std::fmt::Debug for crate::HttpJsonDocument {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("HttpJsonDocument").field("byte_len", &self.bytes.len()).finish();
}
}
/// Crate-internal fixed-origin GET request.
///
/// URLs and headers are deliberately absent from [`std::fmt::Debug`] because future provider adapters can attach credentials to headers.
pub(crate) struct HttpGetRequest {
headers: reqwest::header::HeaderMap,
url: reqwest::Url,
}
impl crate::HttpGetRequest {
/// Creates one HTTPS GET request from a crate-owned official provider URL.
pub(crate) fn new_https(url: &'static str) -> ksp_core_lib::Result<Self> {
return Self::parse(url, false);
}
/// Appends one validated non-secret path segment to a crate-owned official provider base URL.
pub(crate) fn append_path_segment(&mut self, value: &str) -> ksp_core_lib::Result<()> {
let segments_result = self.url.path_segments_mut();
let mut segments = match segments_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(()) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot accept a path segment")
.with_context("field", "provider_url_path"),
);
},
};
segments.push(value);
return std::result::Result::Ok(());
}
/// Appends one non-secret query pair using URL encoding.
pub(crate) fn append_query_pair(&mut self, name: &'static str, value: &str) {
self.url.query_pairs_mut().append_pair(name, value);
return;
}
/// Adds one sensitive header without exposing its value through this type's debug representation.
pub(crate) fn insert_sensitive_header(&mut self, name: &'static str, value: &str) -> ksp_core_lib::Result<()> {
let name_result = reqwest::header::HeaderName::from_bytes(name.as_bytes());
let name = match name_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header name is invalid")
.with_context("field", "header_name")
.with_source(error),
);
},
};
let value_result = reqwest::header::HeaderValue::from_bytes(value.as_bytes());
let mut value = match value_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header value is invalid")
.with_context("field", "header_value")
.with_source(error),
);
},
};
value.set_sensitive(true);
self.headers.insert(name, value);
return std::result::Result::Ok(());
}
/// Reports whether a named header is present without exposing its value in tests.
#[cfg(test)]
pub(crate) fn has_header_for_test(&self, name: &'static str) -> bool {
return self.headers.contains_key(name);
}
/// Creates a plain-HTTP request for loopback-only deterministic unit tests.
#[cfg(test)]
pub(crate) fn new_test_http(url: &str) -> ksp_core_lib::Result<Self> {
return Self::parse(url, true);
}
/// Returns the constructed URL only to deterministic in-crate tests; production diagnostics remain redacted.
#[cfg(test)]
pub(crate) fn url_for_test(&self) -> &reqwest::Url {
return &self.url;
}
fn parse(url: &str, allow_http_for_tests: bool) -> ksp_core_lib::Result<Self> {
let parsed_result = reqwest::Url::parse(url);
let parsed = match parsed_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL is invalid")
.with_context("field", "provider_url")
.with_source(error),
);
},
};
let scheme_allowed = parsed.scheme() == "https" || (allow_http_for_tests && parsed.scheme() == "http");
if !scheme_allowed || parsed.host_str().is_none() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL must use an allowed scheme and host")
.with_context("field", "provider_url"),
);
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot embed credentials")
.with_context("field", "provider_url"),
);
}
return std::result::Result::Ok(Self { headers: reqwest::header::HeaderMap::new(), url: parsed });
}
}
impl std::fmt::Debug for crate::HttpGetRequest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("HttpGetRequest(<redacted>)");
}
}
/// Shareable hardened REST client owned by Off-chain Transport.
#[derive(Clone)]
pub(crate) struct HttpRestClient {
client: reqwest::Client,
settings: crate::HttpClientSettings,
}
impl crate::HttpRestClient {
/// Builds one hardened client with redirects, system proxies and reqwest automatic retries disabled.
pub(crate) fn new(settings: crate::HttpClientSettings) -> ksp_core_lib::Result<Self> {
let client_result = reqwest::Client::builder()
.connect_timeout(settings.connect_timeout())
.timeout(settings.request_timeout())
.redirect(reqwest::redirect::Policy::none())
.referer(false)
.retry(reqwest::retry::never())
.no_proxy()
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
.build();
let client = match client_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED, "Off-chain HTTP client could not be initialized")
.with_source(error.without_url()),
);
},
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
connect_timeout_ms = duration_millis_u64(settings.connect_timeout()),
request_timeout_ms = duration_millis_u64(settings.request_timeout()),
max_response_body_bytes = settings.max_response_body_bytes(),
"created hardened off-chain HTTP REST client"
);
return std::result::Result::Ok(Self { client, settings });
}
/// Executes one GET request and returns only a bounded syntactically valid JSON document.
pub(crate) async fn get_json(
&self,
provider: &'static str,
operation: &'static str,
request: crate::HttpGetRequest,
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
let send_result = self.client.get(request.url).headers(request.headers).send().await;
let mut response = match send_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)),
};
let status = response.status().as_u16();
let retry_after = parse_retry_after(response.headers());
if !(200..300).contains(&status) {
return classify_http_status(provider, operation, status, retry_after);
}
if let std::option::Option::Some(content_length) = response.content_length()
&& content_length > usize_to_u64(self.settings.max_response_body_bytes())
{
return response_too_large(provider, operation, self.settings.max_response_body_bytes());
}
let mut body = std::vec::Vec::new();
loop {
let chunk_result = response.chunk().await;
let chunk = match chunk_result {
std::result::Result::Ok(std::option::Option::Some(value)) => value,
std::result::Result::Ok(std::option::Option::None) => break,
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)),
};
let next_len = body.len().saturating_add(chunk.len());
if next_len > self.settings.max_response_body_bytes() {
return response_too_large(provider, operation, self.settings.max_response_body_bytes());
}
body.extend_from_slice(chunk.as_ref());
}
let json_validation = serde_json::from_slice::<serde::de::IgnoredAny>(body.as_slice());
if let std::result::Result::Err(error) = json_validation {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_INVALID_JSON, "Off-chain provider returned invalid JSON")
.with_context("provider", provider)
.with_context("operation", operation)
.with_source(error),
);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
provider = provider,
operation = operation,
http_status = status,
response_body_bytes = body.len(),
"completed off-chain HTTP REST request"
);
return std::result::Result::Ok(crate::HttpJsonDocument { bytes: body });
}
}
fn classify_http_status(
provider: &'static str,
operation: &'static str,
status: u16,
retry_after: std::option::Option<std::time::Duration>,
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
if status == 401 || status == 403 {
return std::result::Result::Err(http_status_error(
crate::ERROR_CODE_HTTP_ACCESS_DENIED,
"Off-chain provider denied HTTP access",
provider,
operation,
status,
std::option::Option::None,
));
}
if status == 429 {
return std::result::Result::Err(http_status_error(
crate::ERROR_CODE_HTTP_RATE_LIMITED,
"Off-chain provider rate-limited the request",
provider,
operation,
status,
retry_after,
));
}
if status == 408 || (500..600).contains(&status) {
return std::result::Result::Err(http_status_error(
crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE,
"Off-chain provider returned a temporary HTTP failure",
provider,
operation,
status,
retry_after,
));
}
return std::result::Result::Err(http_status_error(
crate::ERROR_CODE_HTTP_REQUEST_FAILED,
"Off-chain provider returned an unsuccessful HTTP status",
provider,
operation,
status,
std::option::Option::None,
));
}
fn http_status_error(
code: ksp_core_lib::ErrorCode,
message: &'static str,
provider: &'static str,
operation: &'static str,
status: u16,
retry_after: std::option::Option<std::time::Duration>,
) -> ksp_core_lib::Error {
let mut error = ksp_core_lib::Error::new(code, message)
.with_context("provider", provider)
.with_context("operation", operation)
.with_context("http_status", status.to_string());
if let std::option::Option::Some(value) = retry_after {
error = error.with_context("retry_after_seconds", value.as_secs().to_string());
}
return error;
}
fn map_reqwest_error(provider: &'static str, operation: &'static str, error: reqwest::Error) -> ksp_core_lib::Error {
let code = if error.is_timeout() {
crate::ERROR_CODE_HTTP_TIMEOUT
} else if error.is_connect() {
crate::ERROR_CODE_HTTP_CONNECTION_FAILED
} else {
crate::ERROR_CODE_HTTP_REQUEST_FAILED
};
let message = if code == crate::ERROR_CODE_HTTP_TIMEOUT {
"Off-chain HTTP request timed out"
} else if code == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
"Off-chain HTTP connection failed"
} else {
"Off-chain HTTP request failed"
};
return ksp_core_lib::Error::new(code, message)
.with_context("provider", provider)
.with_context("operation", operation)
.with_source(error.without_url());
}
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> std::option::Option<std::time::Duration> {
let value = match headers.get(reqwest::header::RETRY_AFTER) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let text = match value.to_str() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
let seconds = match text.parse::<u64>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
return std::option::Option::Some(std::cmp::min(std::time::Duration::from_secs(seconds), crate::HTTP_MAX_RETRY_AFTER));
}
fn response_too_large(provider: &'static str, operation: &'static str, limit: usize) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, "Off-chain provider response exceeded the configured body limit")
.with_context("provider", provider)
.with_context("operation", operation)
.with_context("max_response_body_bytes", limit.to_string()),
);
}
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
return match u64::try_from(duration.as_millis()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => u64::MAX,
};
}
fn usize_to_u64(value: usize) -> u64 {
return match u64::try_from(value) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => u64::MAX,
};
}
#[cfg(test)]
#[path = "../unit_tests/http_client.rs"]
mod tests;

View File

@@ -0,0 +1,84 @@
// file: crates/ksp-offchain-transport-lib/src/http_settings.rs
// version: 3
//! Crate-wide bounded HTTP runtime settings shared by off-chain capability families.
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 1_048_576;
const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[cfg(test)]
const MAX_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[cfg(test)]
const MAX_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
#[cfg(test)]
const MAX_RESPONSE_BODY_BYTES: usize = 4_194_304;
/// Crate-internal HTTP client settings with defensive hard bounds.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct HttpClientSettings {
connect_timeout: std::time::Duration,
max_response_body_bytes: usize,
request_timeout: std::time::Duration,
}
impl crate::HttpClientSettings {
/// Creates one validated HTTP settings value for deterministic settings tests until runtime configuration consumes this constructor.
#[cfg(test)]
pub(crate) fn new(
connect_timeout: std::time::Duration,
request_timeout: std::time::Duration,
max_response_body_bytes: usize,
) -> ksp_core_lib::Result<Self> {
if connect_timeout.is_zero() || connect_timeout > MAX_CONNECT_TIMEOUT {
return invalid_http_settings("HTTP connect timeout is outside the supported bounds", "connect_timeout");
}
if request_timeout.is_zero() || request_timeout > MAX_REQUEST_TIMEOUT {
return invalid_http_settings("HTTP request timeout is outside the supported bounds", "request_timeout");
}
if connect_timeout > request_timeout {
return invalid_http_settings("HTTP connect timeout cannot exceed the total request timeout", "connect_timeout");
}
if max_response_body_bytes == 0 || max_response_body_bytes > MAX_RESPONSE_BODY_BYTES {
return invalid_http_settings("HTTP response-body limit is outside the supported bounds", "max_response_body_bytes");
}
return std::result::Result::Ok(Self { connect_timeout, max_response_body_bytes, request_timeout });
}
/// Returns the bounded connect timeout.
#[must_use]
pub(crate) const fn connect_timeout(&self) -> std::time::Duration {
return self.connect_timeout;
}
/// Returns the maximum decoded response body accepted before JSON parsing.
#[must_use]
pub(crate) const fn max_response_body_bytes(&self) -> usize {
return self.max_response_body_bytes;
}
/// Returns the end-to-end request timeout.
#[must_use]
pub(crate) const fn request_timeout(&self) -> std::time::Duration {
return self.request_timeout;
}
}
impl std::default::Default for crate::HttpClientSettings {
fn default() -> Self {
return Self {
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
max_response_body_bytes: DEFAULT_MAX_RESPONSE_BODY_BYTES,
request_timeout: DEFAULT_REQUEST_TIMEOUT,
};
}
}
#[cfg(test)]
fn invalid_http_settings(message: &str, field: &'static str) -> ksp_core_lib::Result<crate::HttpClientSettings> {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, "rejected invalid off-chain HTTP client settings");
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_SETTINGS_INVALID, message).with_context("field", field));
}
#[cfg(test)]
#[path = "../unit_tests/http_settings.rs"]
mod tests;

View File

@@ -0,0 +1,218 @@
// file: crates/ksp-offchain-transport-lib/src/lib.rs
// version: 12
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! KSP-owned off-chain transport foundation.
//!
//! `0.2.11` delivers the complete SOL/USD V1 surface over eight providers. The crate owns exact decimal normalization, provider adapters, hardened
//! HTTP, non-blocking rate-limit/cooldown handling, provider-neutral registry/availability and individual/multiple/all refresh operations. Provider-specific
//! setup remains confined to composition while runtime consumers can operate on opaque provider identifiers, registry projections and normalized outcomes.
//! Config integration is implemented externally by `ksp-config-lib`; this crate remains independent from Config and never reads KSP environment variables
//! directly.
mod constants;
mod error;
mod http_admission;
mod http_client;
mod http_settings;
mod market_price_adapter;
mod market_price_api_key;
mod market_price_birdeye;
mod market_price_coinbase_exchange;
mod market_price_coingecko;
mod market_price_coinmarketcap;
mod market_price_coinpaprika;
mod market_price_decimal;
mod market_price_dexscreener;
mod market_price_jupiter;
mod market_price_kraken;
mod market_price_observation;
mod market_price_provider;
mod market_price_registry;
mod market_price_service;
mod market_price_settings;
/// Stable error code for HTTP access denial.
pub use self::error::ERROR_CODE_HTTP_ACCESS_DENIED;
/// Stable error code for local HTTP request deferral.
pub use self::error::ERROR_CODE_HTTP_ADMISSION_DEFERRED;
/// Stable error code for hardened HTTP client initialization failure.
pub use self::error::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED;
/// Stable error code for an off-chain HTTP connection failure.
pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
/// Stable error code for a syntactically invalid JSON response.
pub use self::error::ERROR_CODE_HTTP_INVALID_JSON;
/// Stable error code for an invalid local HTTP rate-limit policy.
pub use self::error::ERROR_CODE_HTTP_RATE_LIMIT_INVALID;
/// Stable error code for HTTP 429 rate limiting.
pub use self::error::ERROR_CODE_HTTP_RATE_LIMITED;
/// Stable error code for a generic unsuccessful HTTP request.
pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED;
/// Stable error code for an invalid crate-owned HTTP request definition.
pub use self::error::ERROR_CODE_HTTP_REQUEST_INVALID;
/// Stable error code for an oversized HTTP response body.
pub use self::error::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE;
/// Stable error code for invalid crate-wide HTTP runtime settings.
pub use self::error::ERROR_CODE_HTTP_SETTINGS_INVALID;
/// Stable error code for temporary HTTP provider failures.
pub use self::error::ERROR_CODE_HTTP_TEMPORARY_FAILURE;
/// Stable error code for end-to-end HTTP timeout.
pub use self::error::ERROR_CODE_HTTP_TIMEOUT;
/// Stable error code for an invalid exact decimal price.
pub use self::error::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID;
/// Stable error code for an invalid provider-neutral observation.
pub use self::error::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID;
/// Stable error code for an invalid provider descriptor.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID;
/// Stable error code returned when a disabled provider is invoked directly.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED;
/// Stable error code for an invalid provider identifier.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID;
/// Stable error code when a generic refresh targets an unconfigured provider.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND;
/// Stable error code for a provider response that violates its adapter contract.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID;
/// Stable error code for invalid common provider settings.
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID;
/// Stable error code for an invalid generic refresh request.
pub use self::error::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID;
/// Stable error code for an invalid provider registry.
pub use self::error::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID;
/// Birdeye Standard SOL/USD provider adapter.
pub use self::market_price_birdeye::MarketPriceBirdeyeProvider;
/// Birdeye Standard runtime settings.
pub use self::market_price_birdeye::MarketPriceBirdeyeSettings;
/// Coinbase Exchange SOL/USD provider adapter.
pub use self::market_price_coinbase_exchange::MarketPriceCoinbaseExchangeProvider;
/// Coinbase Exchange runtime settings.
pub use self::market_price_coinbase_exchange::MarketPriceCoinbaseExchangeSettings;
/// CoinGecko V1 access mode.
pub use self::market_price_coingecko::MarketPriceCoinGeckoAccessMode;
/// CoinGecko SOL/USD provider adapter.
pub use self::market_price_coingecko::MarketPriceCoinGeckoProvider;
/// CoinGecko runtime settings.
pub use self::market_price_coingecko::MarketPriceCoinGeckoSettings;
/// CoinMarketCap V1 access mode.
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapAccessMode;
/// CoinMarketCap SOL/USD provider adapter.
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapProvider;
/// CoinMarketCap runtime settings.
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapSettings;
/// CoinPaprika SOL/USD provider adapter.
pub use self::market_price_coinpaprika::MarketPriceCoinPaprikaProvider;
/// CoinPaprika runtime settings.
pub use self::market_price_coinpaprika::MarketPriceCoinPaprikaSettings;
/// Maximum accepted byte length for one textual decimal input.
pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES;
/// Maximum decimal scale retained by the canonical SOL/USD price representation.
pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_SCALE;
/// Exact positive decimal value used by the public market-price contract.
pub use self::market_price_decimal::MarketPriceDecimal;
/// DexScreener SOL/USD provider adapter bound to one configured Solana pair.
pub use self::market_price_dexscreener::MarketPriceDexScreenerProvider;
/// DexScreener runtime settings carrying the explicit Solana pair address.
pub use self::market_price_dexscreener::MarketPriceDexScreenerSettings;
/// Jupiter Developer Platform access mode.
pub use self::market_price_jupiter::MarketPriceJupiterAccessMode;
/// Jupiter Price V3 SOL/USD provider adapter.
pub use self::market_price_jupiter::MarketPriceJupiterProvider;
/// Jupiter Price V3 runtime settings.
pub use self::market_price_jupiter::MarketPriceJupiterSettings;
/// Kraken Spot SOL/USD provider adapter.
pub use self::market_price_kraken::MarketPriceKrakenProvider;
/// Kraken Spot runtime settings.
pub use self::market_price_kraken::MarketPriceKrakenSettings;
/// Maximum safe provenance length attached to one normalized observation.
pub use self::market_price_observation::MARKET_PRICE_PROVENANCE_MAX_BYTES;
/// Public V1 SOL/USD observation normalized by Off-chain Transport.
pub use self::market_price_observation::MarketPriceObservation;
/// Safe bounded provenance supplied by one provider adapter.
pub use self::market_price_observation::MarketPriceProvenance;
/// Millisecond UTC timestamp used for request, receipt, provider and cooldown projections.
pub use self::market_price_observation::MarketPriceTimestamp;
/// Maximum provider display-name length accepted by descriptors.
pub use self::market_price_provider::MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES;
/// Maximum opaque provider identifier length accepted by the public contract.
pub use self::market_price_provider::MARKET_PRICE_PROVIDER_ID_MAX_BYTES;
/// Only price pair exposed by the `0.2.11` V1 public contract.
pub use self::market_price_provider::MarketPricePair;
/// Generic authentication capability exposed by a configured provider descriptor.
pub use self::market_price_provider::MarketPriceProviderAuthMode;
/// Generic runtime availability state exposed without provider-specific error parsing.
pub use self::market_price_provider::MarketPriceProviderAvailability;
/// Provider capability and presentation descriptor consumed by provider-agnostic callers.
pub use self::market_price_provider::MarketPriceProviderDescriptor;
/// Opaque validated provider identifier owned by Off-chain Transport.
pub use self::market_price_provider::MarketPriceProviderId;
/// Long-term provider quota descriptor that is informational rather than an authoritative local counter.
pub use self::market_price_provider::MarketPriceProviderLongTermQuota;
/// Period used by a documented long-term provider quota.
pub use self::market_price_provider::MarketPriceProviderQuotaPeriod;
/// Unit used by a documented long-term provider quota.
pub use self::market_price_provider::MarketPriceProviderQuotaUnit;
/// Generic provider request-limit capability.
pub use self::market_price_provider::MarketPriceProviderRateLimit;
/// Shape of one generic provider request-limit capability.
pub use self::market_price_provider::MarketPriceProviderRateLimitKind;
/// Scope to which a provider documents one request limit.
pub use self::market_price_provider::MarketPriceProviderRateLimitScope;
/// Informational cost of one normalized SOL/USD request in a provider-defined quota unit.
pub use self::market_price_provider::MarketPriceProviderRequestCost;
/// Current provider-neutral runtime state projection.
pub use self::market_price_provider::MarketPriceProviderState;
/// Market-price semantics retained so consumers never assume all providers report equivalent market values.
pub use self::market_price_provider::MarketPriceSemantics;
/// Deterministically ordered registry of configured market-price providers.
pub use self::market_price_registry::MarketPriceProviderRegistry;
/// Immutable descriptor-plus-state entry exposed by the configured provider registry.
pub use self::market_price_registry::MarketPriceProviderRegistryEntry;
/// Provider-specific setup accepted once by the generic market-price service.
pub use self::market_price_service::MarketPriceProviderSetup;
/// Generic result of one market-price refresh.
pub use self::market_price_service::MarketPriceRefreshOutcome;
/// Provider-agnostic market-price refresh service.
pub use self::market_price_service::MarketPriceService;
/// Common provider settings shared by provider-specific runtime settings.
pub use self::market_price_settings::MarketPriceProviderCommonSettings;
/// Owning tracing target for events emitted by Off-chain Transport.
pub(crate) use self::constants::TRACING_TARGET;
/// Maximum provider-directed cooldown accepted from a server `Retry-After` value.
pub(crate) use self::http_admission::HTTP_MAX_RETRY_AFTER;
/// Crate-internal non-blocking request-admission controller.
pub(crate) use self::http_admission::HttpAdmissionController;
/// Crate-internal result of one immediate request-admission attempt.
pub(crate) use self::http_admission::HttpAdmissionDecision;
/// Crate-internal provider-neutral local request-admission policy.
pub(crate) use self::http_admission::HttpAdmissionPolicy;
/// Crate-internal fixed-origin GET request with redacted diagnostics.
pub(crate) use self::http_client::HttpGetRequest;
/// Crate-internal bounded syntactically valid JSON response document.
pub(crate) use self::http_client::HttpJsonDocument;
/// Crate-internal hardened REST client shared by capability adapters.
pub(crate) use self::http_client::HttpRestClient;
/// Crate-internal bounded HTTP runtime settings.
pub(crate) use self::http_settings::HttpClientSettings;
/// Applies one market-price request admission decision.
pub(crate) use self::market_price_adapter::admit_request;
/// Captures the current market-price wall-clock timestamp.
pub(crate) use self::market_price_adapter::current_timestamp;
/// Executes one market-price HTTP GET with rate-limit feedback.
pub(crate) use self::market_price_adapter::get_json;
/// Builds one safe invalid-provider-response error.
pub(crate) use self::market_price_adapter::invalid_provider_response;
/// Builds one safe invalid-provider-response error with parser source.
pub(crate) use self::market_price_adapter::invalid_provider_response_with_source;
/// Parses one RFC 3339 provider market-price timestamp.
pub(crate) use self::market_price_adapter::market_price_timestamp_from_rfc3339;
/// Converts whole Unix seconds into a market-price timestamp.
pub(crate) use self::market_price_adapter::market_price_timestamp_from_unix_seconds;
/// Builds the disabled-provider error used by direct adapters.
pub(crate) use self::market_price_adapter::provider_disabled_error;
/// Builds common HTTP runtime primitives for one market-price provider.
pub(crate) use self::market_price_adapter::provider_http_runtime;
/// Crate-internal redacted holder for provider API keys.
pub(crate) use self::market_price_api_key::MarketPriceApiKey;

View File

@@ -0,0 +1,158 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
// version: 2
//! Shared market-price adapter mechanics layered over crate-wide HTTP primitives.
/// Applies one non-blocking market-price admission decision and maps deferral to a stable KSP error.
pub(crate) fn admit_request(provider: &'static str, admission: &crate::HttpAdmissionController) -> ksp_core_lib::Result<()> {
return match admission.try_admit() {
crate::HttpAdmissionDecision::Ready => std::result::Result::Ok(()),
crate::HttpAdmissionDecision::Deferred(delay) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_ADMISSION_DEFERRED, "Off-chain provider request is locally deferred")
.with_context("provider", provider)
.with_context("retry_after_millis", duration_millis_u64(delay).to_string()),
),
};
}
/// Captures the current UTC wall clock as a bounded market-price timestamp.
pub(crate) fn current_timestamp() -> ksp_core_lib::Result<crate::MarketPriceTimestamp> {
let duration = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID, "System clock cannot produce a market-price timestamp")
.with_source(error),
);
},
};
let millis = match u64::try_from(duration.as_millis()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID, "System clock exceeds market-price timestamp bounds")
.with_source(error),
);
},
};
return std::result::Result::Ok(crate::MarketPriceTimestamp::from_unix_millis(millis));
}
/// Executes one provider GET and feeds any HTTP 429 cooldown back into the provider admission controller.
pub(crate) async fn get_json(
http: &crate::HttpRestClient,
admission: &crate::HttpAdmissionController,
provider: &'static str,
request: crate::HttpGetRequest,
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
let result = http.get_json(provider, "sol_usd", request).await;
if let std::result::Result::Err(error) = &result
&& error.code() == crate::ERROR_CODE_HTTP_RATE_LIMITED
{
let retry_after = retry_after_from_error(error);
admission.record_rate_limited(retry_after);
}
return result;
}
/// Builds one safe provider-response contract error without copying remote payload data.
pub(crate) fn invalid_provider_response(provider: &'static str, field: &'static str) -> ksp_core_lib::Error {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, provider = provider, field = field, "rejected invalid market-price provider response");
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID, "Off-chain provider returned an invalid market-price response")
.with_context("provider", provider)
.with_context("field", field);
}
/// Builds one safe provider-response contract error and attaches a parser source that contains no remote payload copy.
pub(crate) fn invalid_provider_response_with_source<E>(provider: &'static str, field: &'static str, source: E) -> ksp_core_lib::Error
where
E: std::error::Error + std::marker::Send + std::marker::Sync + 'static,
{
return invalid_provider_response(provider, field).with_source(source);
}
/// Converts one provider RFC 3339 timestamp into the public millisecond timestamp contract.
pub(crate) fn market_price_timestamp_from_rfc3339(source: &str) -> std::option::Option<crate::MarketPriceTimestamp> {
let parsed = match chrono::DateTime::parse_from_rfc3339(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
let millis = parsed.timestamp_millis();
if millis < 0 {
return std::option::Option::None;
}
return match u64::try_from(millis) {
std::result::Result::Ok(value) => std::option::Option::Some(crate::MarketPriceTimestamp::from_unix_millis(value)),
std::result::Result::Err(_) => std::option::Option::None,
};
}
/// Converts whole Unix seconds into the public millisecond timestamp contract with overflow checking.
pub(crate) fn market_price_timestamp_from_unix_seconds(seconds: u64) -> std::option::Option<crate::MarketPriceTimestamp> {
return seconds.checked_mul(1_000).map(crate::MarketPriceTimestamp::from_unix_millis);
}
/// Builds the stable error returned when a disabled provider is invoked directly.
pub(crate) fn provider_disabled_error(provider: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED, "Off-chain market-price provider is disabled")
.with_context("provider", provider);
}
/// Builds hardened HTTP and admission runtime primitives from one provider-neutral rate-limit descriptor.
pub(crate) fn provider_http_runtime(
rate_limit: crate::MarketPriceProviderRateLimit,
) -> ksp_core_lib::Result<(crate::HttpRestClient, crate::HttpAdmissionController)> {
let policy = match rate_limit.kind() {
crate::MarketPriceProviderRateLimitKind::Dynamic => crate::HttpAdmissionPolicy::Dynamic,
crate::MarketPriceProviderRateLimitKind::Fixed => {
let requests = match rate_limit.requests() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(invalid_rate_limit_bridge()),
};
let window_seconds = match rate_limit.window_seconds() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(invalid_rate_limit_bridge()),
};
match crate::HttpAdmissionPolicy::fixed(requests, std::time::Duration::from_secs(u64::from(window_seconds)), rate_limit.burst()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
};
let admission = match crate::HttpAdmissionController::new(policy, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let http = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok((http, admission));
}
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
return match u64::try_from(duration.as_millis()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => u64::MAX,
};
}
fn invalid_rate_limit_bridge() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "Market-price rate-limit descriptor cannot map to HTTP admission policy");
}
fn retry_after_from_error(error: &ksp_core_lib::Error) -> std::option::Option<std::time::Duration> {
for context in error.context() {
if context.key() == "retry_after_seconds" {
let seconds = match context.value().parse::<u64>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
return std::option::Option::Some(std::time::Duration::from_secs(seconds));
}
}
return std::option::Option::None;
}
#[cfg(test)]
#[path = "../unit_tests/market_price_adapter.rs"]
mod tests;

View File

@@ -0,0 +1,35 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_api_key.rs
// version: 1
//! Secret API-key holder shared by keyed market-price adapters.
const MARKET_PRICE_API_KEY_MAX_BYTES: usize = 512;
/// Redacted bounded API-key holder used by keyed market-price adapters.
pub(crate) struct MarketPriceApiKey(std::boxed::Box<str>);
impl crate::MarketPriceApiKey {
/// Creates one validated API-key holder without logging or exposing the credential.
pub(crate) fn new(provider: &'static str, value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
let value = value.into();
if value.is_empty() || value.len() > MARKET_PRICE_API_KEY_MAX_BYTES || value.trim() != value || value.chars().any(char::is_control) {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "Market-price provider API key is invalid")
.with_context("provider", provider)
.with_context("field", "api_key"),
);
}
return std::result::Result::Ok(Self(value.into_boxed_str()));
}
/// Returns the credential only to the provider request builder that owns the corresponding secret header.
pub(crate) fn as_str(&self) -> &str {
return self.0.as_ref();
}
}
impl std::fmt::Debug for crate::MarketPriceApiKey {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("MarketPriceApiKey(<redacted>)");
}
}

View File

@@ -0,0 +1,238 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
// version: 1
//! Birdeye SOL/USD market-price adapter using the official Price Single REST endpoint.
const BIRDEYE_API_KEY_HEADER: &str = "x-api-key";
const BIRDEYE_CHAIN_HEADER: &str = "x-chain";
const BIRDEYE_PRICE_URL: &str = "https://public-api.birdeye.so/defi/price";
const BIRDEYE_PROVIDER_ID: &str = "birdeye";
const BIRDEYE_SOL_MINT: &str = "So11111111111111111111111111111111111111112";
/// Runtime settings for the Birdeye Standard market-price adapter.
pub struct MarketPriceBirdeyeSettings {
api_key: std::option::Option<crate::MarketPriceApiKey>,
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceBirdeyeSettings {
/// Creates Birdeye settings. An API key is mandatory while the provider is enabled.
pub fn new(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(BIRDEYE_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let api_key = match api_key {
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(BIRDEYE_PROVIDER_ID, value) {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(error) => return std::result::Result::Err(error),
},
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")),
std::option::Option::None => std::option::Option::None,
};
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
return std::result::Result::Ok(Self { api_key, common });
}
/// Returns common provider identity and enablement settings.
#[must_use]
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
return &self.common;
}
fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> {
return self.api_key.as_ref();
}
}
impl std::fmt::Debug for crate::MarketPriceBirdeyeSettings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("MarketPriceBirdeyeSettings")
.field("api_key_present", &self.api_key.is_some())
.field("common", &self.common)
.finish();
}
}
/// Birdeye SOL/USD provider adapter backed by the Standard Price Single endpoint.
pub struct MarketPriceBirdeyeProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceBirdeyeSettings,
}
impl crate::MarketPriceBirdeyeProvider {
/// Builds one Birdeye provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceBirdeyeSettings) -> ksp_core_lib::Result<Self> {
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral Birdeye capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated Birdeye settings without exposing credential material.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceBirdeyeSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD observation from Birdeye Price Single.
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
if !self.settings.common().enabled() {
return std::result::Result::Err(crate::provider_disabled_error(BIRDEYE_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(BIRDEYE_PROVIDER_ID, &self.admission) {
return std::result::Result::Err(error);
}
let request_started_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match build_request(&self.settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = match crate::get_json(&self.http, &self.admission, BIRDEYE_PROVIDER_ID, request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let received_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
}
}
fn build_request(settings: &crate::MarketPriceBirdeyeSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
let mut request = match crate::HttpGetRequest::new_https(BIRDEYE_PRICE_URL) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("address", BIRDEYE_SOL_MINT);
if let std::result::Result::Err(error) = request.insert_sensitive_header(BIRDEYE_CHAIN_HEADER, "solana") {
return std::result::Result::Err(error);
}
let api_key = match settings.api_key() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(provider_settings_error("api_key")),
};
if let std::result::Result::Err(error) = request.insert_sensitive_header(BIRDEYE_API_KEY_HEADER, api_key.as_str()) {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(request);
}
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let quota = match crate::MarketPriceProviderLongTermQuota::new(
30_000,
crate::MarketPriceProviderQuotaPeriod::Month,
crate::MarketPriceProviderQuotaUnit::ComputeUnits,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request_cost = match crate::MarketPriceProviderRequestCost::new(3, crate::MarketPriceProviderQuotaUnit::ComputeUnits) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let descriptor = match crate::MarketPriceProviderDescriptor::new(
provider_id,
"Birdeye",
crate::MarketPriceSemantics::SolanaSpot,
crate::MarketPriceProviderAuthMode::RequiredApiKey,
rate_limit,
std::option::Option::Some(quota),
true,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return descriptor.with_sol_usd_request_cost(request_cost);
}
fn parse_response(
bytes: &[u8],
provider_id: crate::MarketPriceProviderId,
request_started_at: crate::MarketPriceTimestamp,
received_at: crate::MarketPriceTimestamp,
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<BirdeyeWireResponse>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(BIRDEYE_PROVIDER_ID, "response", error));
},
};
if !wire.success {
return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "success"));
}
let data = match wire.data {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "data")),
};
let price_raw = match data.value.as_deref() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "data.value")),
};
let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_timestamp = match data.update_unix_time.and_then(crate::market_price_timestamp_from_unix_seconds) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "data.updateUnixTime")),
};
let provenance = match crate::MarketPriceProvenance::new("birdeye:solana:wsol:value") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::SolanaSpot,
request_started_at,
received_at,
std::option::Option::Some(provider_timestamp),
provenance,
);
}
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "Birdeye market-price settings are invalid")
.with_context("provider", BIRDEYE_PROVIDER_ID)
.with_context("field", field);
}
#[derive(serde::Deserialize)]
struct BirdeyeWireData {
#[serde(rename = "updateUnixTime")]
update_unix_time: std::option::Option<u64>,
value: std::option::Option<std::boxed::Box<serde_json::value::RawValue>>,
}
#[derive(serde::Deserialize)]
struct BirdeyeWireResponse {
data: std::option::Option<BirdeyeWireData>,
success: bool,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_birdeye.rs"]
mod tests;

View File

@@ -0,0 +1,159 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs
// version: 1
//! Coinbase Exchange SOL/USD market-price adapter using the public product ticker directly through `reqwest`.
const COINBASE_EXCHANGE_PROVIDER_ID: &str = "coinbase_exchange";
const COINBASE_EXCHANGE_SOL_USD_TICKER_URL: &str = "https://api.exchange.coinbase.com/products/SOL-USD/ticker";
/// Runtime settings for the keyless Coinbase Exchange market-price adapter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MarketPriceCoinbaseExchangeSettings {
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceCoinbaseExchangeSettings {
/// Creates Coinbase Exchange settings for the public keyless REST surface.
pub fn new(enabled: bool) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(COINBASE_EXCHANGE_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { common: crate::MarketPriceProviderCommonSettings::new(provider_id, enabled) });
}
/// Returns common provider identity and enablement settings.
#[must_use]
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
return &self.common;
}
}
/// Coinbase Exchange SOL/USD provider adapter.
pub struct MarketPriceCoinbaseExchangeProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceCoinbaseExchangeSettings,
}
impl crate::MarketPriceCoinbaseExchangeProvider {
/// Builds one Coinbase Exchange provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceCoinbaseExchangeSettings) -> ksp_core_lib::Result<Self> {
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral Coinbase Exchange capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated Coinbase Exchange runtime settings.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceCoinbaseExchangeSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD last-trade observation from Coinbase Exchange.
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
if !self.settings.common().enabled() {
return std::result::Result::Err(crate::provider_disabled_error(COINBASE_EXCHANGE_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(COINBASE_EXCHANGE_PROVIDER_ID, &self.admission) {
return std::result::Result::Err(error);
}
let request_started_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match build_request() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = match crate::get_json(&self.http, &self.admission, COINBASE_EXCHANGE_PROVIDER_ID, request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let received_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
}
}
fn build_request() -> ksp_core_lib::Result<crate::HttpGetRequest> {
return crate::HttpGetRequest::new_https(COINBASE_EXCHANGE_SOL_USD_TICKER_URL);
}
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(10, 1, std::option::Option::Some(15), crate::MarketPriceProviderRateLimitScope::Ip) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"Coinbase Exchange",
crate::MarketPriceSemantics::ExchangeLastTrade,
crate::MarketPriceProviderAuthMode::None,
rate_limit,
std::option::Option::None,
true,
);
}
fn parse_response(
bytes: &[u8],
provider_id: crate::MarketPriceProviderId,
request_started_at: crate::MarketPriceTimestamp,
received_at: crate::MarketPriceTimestamp,
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<CoinbaseExchangeWireTicker>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINBASE_EXCHANGE_PROVIDER_ID, "response", error));
},
};
let price = match crate::MarketPriceDecimal::parse_json_raw(wire.price.as_ref()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_timestamp = match crate::market_price_timestamp_from_rfc3339(wire.time.as_str()) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::invalid_provider_response(COINBASE_EXCHANGE_PROVIDER_ID, "time"));
},
};
let provenance = match crate::MarketPriceProvenance::new("coinbase_exchange:SOL-USD:last_trade") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::ExchangeLastTrade,
request_started_at,
received_at,
std::option::Option::Some(provider_timestamp),
provenance,
);
}
#[derive(serde::Deserialize)]
struct CoinbaseExchangeWireTicker {
price: std::boxed::Box<serde_json::value::RawValue>,
time: std::string::String,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_coinbase_exchange.rs"]
mod tests;

View File

@@ -0,0 +1,264 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
// version: 3
//! CoinGecko SOL/USD market-price adapter using the official REST API directly through `reqwest`.
const COINGECKO_DEMO_API_KEY_HEADER: &str = "x-cg-demo-api-key";
const COINGECKO_PROVIDER_ID: &str = "coingecko";
const COINGECKO_SIMPLE_PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price";
/// CoinGecko V1 access mode supported by Off-chain Transport.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPriceCoinGeckoAccessMode {
/// Free Demo plan with a provider-issued API key and published allowance.
Demo,
/// Shared keyless public API with dynamic IP-based throttling.
Keyless,
}
/// Runtime settings for the CoinGecko market-price adapter.
pub struct MarketPriceCoinGeckoSettings {
access_mode: crate::MarketPriceCoinGeckoAccessMode,
api_key: std::option::Option<crate::MarketPriceApiKey>,
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceCoinGeckoSettings {
/// Creates keyless CoinGecko settings without accepting a credential.
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceCoinGeckoAccessMode::Keyless, std::option::Option::None);
}
/// Creates Demo CoinGecko settings. An API key is mandatory while the provider is enabled.
pub fn demo(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceCoinGeckoAccessMode::Demo, api_key);
}
/// Returns the configured CoinGecko access mode.
#[must_use]
pub const fn access_mode(&self) -> crate::MarketPriceCoinGeckoAccessMode {
return self.access_mode;
}
/// Returns common provider identity and enablement settings.
#[must_use]
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
return &self.common;
}
fn new(enabled: bool, access_mode: crate::MarketPriceCoinGeckoAccessMode, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(COINGECKO_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let api_key = match access_mode {
crate::MarketPriceCoinGeckoAccessMode::Keyless => {
if api_key.is_some() {
return std::result::Result::Err(provider_settings_error("api_key"));
}
std::option::Option::None
},
crate::MarketPriceCoinGeckoAccessMode::Demo => match api_key {
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(COINGECKO_PROVIDER_ID, value) {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(error) => return std::result::Result::Err(error),
},
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")),
std::option::Option::None => std::option::Option::None,
},
};
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
return std::result::Result::Ok(Self { access_mode, api_key, common });
}
fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> {
return self.api_key.as_ref();
}
}
impl std::fmt::Debug for crate::MarketPriceCoinGeckoSettings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("MarketPriceCoinGeckoSettings")
.field("access_mode", &self.access_mode)
.field("api_key_present", &self.api_key.is_some())
.field("common", &self.common)
.finish();
}
}
/// CoinGecko SOL/USD provider adapter.
pub struct MarketPriceCoinGeckoProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceCoinGeckoSettings,
}
impl crate::MarketPriceCoinGeckoProvider {
/// Builds one CoinGecko provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceCoinGeckoSettings) -> ksp_core_lib::Result<Self> {
let descriptor = match descriptor_for(settings.access_mode(), settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral CoinGecko capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated CoinGecko runtime settings without exposing credential material.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceCoinGeckoSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD observation from CoinGecko.
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
if !self.settings.common().enabled() {
return std::result::Result::Err(crate::provider_disabled_error(COINGECKO_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(COINGECKO_PROVIDER_ID, &self.admission) {
return std::result::Result::Err(error);
}
let request_started_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match build_request(&self.settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = match crate::get_json(&self.http, &self.admission, COINGECKO_PROVIDER_ID, request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let received_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
}
}
fn build_request(settings: &crate::MarketPriceCoinGeckoSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
let mut request = match crate::HttpGetRequest::new_https(COINGECKO_SIMPLE_PRICE_URL) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("ids", "solana");
request.append_query_pair("vs_currencies", "usd");
request.append_query_pair("include_last_updated_at", "true");
if let std::option::Option::Some(api_key) = settings.api_key()
&& let std::result::Result::Err(error) = request.insert_sensitive_header(COINGECKO_DEMO_API_KEY_HEADER, api_key.as_str())
{
return std::result::Result::Err(error);
}
return std::result::Result::Ok(request);
}
fn descriptor_for(
access_mode: crate::MarketPriceCoinGeckoAccessMode,
provider_id: crate::MarketPriceProviderId,
) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let (auth_mode, rate_limit, long_term_quota) = match access_mode {
crate::MarketPriceCoinGeckoAccessMode::Keyless => (
crate::MarketPriceProviderAuthMode::None,
crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip),
std::option::Option::None,
),
crate::MarketPriceCoinGeckoAccessMode::Demo => {
let rate_limit =
match crate::MarketPriceProviderRateLimit::fixed(100, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let quota = match crate::MarketPriceProviderLongTermQuota::new(
10_000,
crate::MarketPriceProviderQuotaPeriod::Month,
crate::MarketPriceProviderQuotaUnit::Credits,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
(crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit, std::option::Option::Some(quota))
},
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"CoinGecko",
crate::MarketPriceSemantics::AggregatedMarket,
auth_mode,
rate_limit,
long_term_quota,
true,
);
}
fn parse_response(
bytes: &[u8],
provider_id: crate::MarketPriceProviderId,
request_started_at: crate::MarketPriceTimestamp,
received_at: crate::MarketPriceTimestamp,
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<CoinGeckoWireResponse>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINGECKO_PROVIDER_ID, "response", error));
},
};
let price = match crate::MarketPriceDecimal::parse_json_raw(wire.solana.usd.as_ref()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_timestamp = match crate::market_price_timestamp_from_unix_seconds(wire.solana.last_updated_at) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::invalid_provider_response(COINGECKO_PROVIDER_ID, "last_updated_at"));
},
};
let provenance = match crate::MarketPriceProvenance::new("coingecko:solana:usd") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::AggregatedMarket,
request_started_at,
received_at,
std::option::Option::Some(provider_timestamp),
provenance,
);
}
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "CoinGecko market-price settings are invalid")
.with_context("provider", COINGECKO_PROVIDER_ID)
.with_context("field", field);
}
#[derive(serde::Deserialize)]
struct CoinGeckoWireResponse {
solana: CoinGeckoWireSolana,
}
#[derive(serde::Deserialize)]
struct CoinGeckoWireSolana {
last_updated_at: u64,
usd: std::boxed::Box<serde_json::value::RawValue>,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_coingecko.rs"]
mod tests;

View File

@@ -0,0 +1,317 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs
// version: 4
//! CoinMarketCap SOL/USD market-price adapter using the current Simple Price V2 REST surface.
const COINMARKETCAP_API_KEY_HEADER: &str = "x-cmc_pro_api_key";
const COINMARKETCAP_BASIC_URL: &str = "https://pro-api.coinmarketcap.com/v2/simple/price";
const COINMARKETCAP_KEYLESS_URL: &str = "https://pro-api.coinmarketcap.com/public-api/v2/simple/price";
const COINMARKETCAP_PROVIDER_ID: &str = "coinmarketcap";
const COINMARKETCAP_SOL_ID: u64 = 5_426;
/// CoinMarketCap V1 access mode supported by Off-chain Transport.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPriceCoinMarketCapAccessMode {
/// Free authenticated Basic plan with a provider-issued API key.
Basic,
/// Keyless public API intended for evaluation and low-volume use.
Keyless,
}
/// Runtime settings for the CoinMarketCap market-price adapter.
pub struct MarketPriceCoinMarketCapSettings {
access_mode: crate::MarketPriceCoinMarketCapAccessMode,
api_key: std::option::Option<crate::MarketPriceApiKey>,
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceCoinMarketCapSettings {
/// Creates keyless CoinMarketCap settings without accepting a credential.
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceCoinMarketCapAccessMode::Keyless, std::option::Option::None);
}
/// Creates Basic CoinMarketCap settings. An API key is mandatory while the provider is enabled.
pub fn basic(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceCoinMarketCapAccessMode::Basic, api_key);
}
/// Returns the configured CoinMarketCap access mode.
#[must_use]
pub const fn access_mode(&self) -> crate::MarketPriceCoinMarketCapAccessMode {
return self.access_mode;
}
/// Returns common provider identity and enablement settings.
#[must_use]
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
return &self.common;
}
fn new(
enabled: bool,
access_mode: crate::MarketPriceCoinMarketCapAccessMode,
api_key: std::option::Option<std::string::String>,
) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(COINMARKETCAP_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let api_key = match access_mode {
crate::MarketPriceCoinMarketCapAccessMode::Keyless => {
if api_key.is_some() {
return std::result::Result::Err(provider_settings_error("api_key"));
}
std::option::Option::None
},
crate::MarketPriceCoinMarketCapAccessMode::Basic => match api_key {
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(COINMARKETCAP_PROVIDER_ID, value) {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(error) => return std::result::Result::Err(error),
},
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")),
std::option::Option::None => std::option::Option::None,
},
};
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
return std::result::Result::Ok(Self { access_mode, api_key, common });
}
fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> {
return self.api_key.as_ref();
}
}
impl std::fmt::Debug for crate::MarketPriceCoinMarketCapSettings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("MarketPriceCoinMarketCapSettings")
.field("access_mode", &self.access_mode)
.field("api_key_present", &self.api_key.is_some())
.field("common", &self.common)
.finish();
}
}
/// CoinMarketCap SOL/USD provider adapter.
pub struct MarketPriceCoinMarketCapProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceCoinMarketCapSettings,
}
impl crate::MarketPriceCoinMarketCapProvider {
/// Builds one CoinMarketCap provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceCoinMarketCapSettings) -> ksp_core_lib::Result<Self> {
let descriptor = match descriptor_for(settings.access_mode(), settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral CoinMarketCap capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated CoinMarketCap runtime settings without exposing credential material.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceCoinMarketCapSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD observation from CoinMarketCap.
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
if !self.settings.common().enabled() {
return std::result::Result::Err(crate::provider_disabled_error(COINMARKETCAP_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(COINMARKETCAP_PROVIDER_ID, &self.admission) {
return std::result::Result::Err(error);
}
let request_started_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match build_request(&self.settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = match crate::get_json(&self.http, &self.admission, COINMARKETCAP_PROVIDER_ID, request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let received_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
}
}
fn build_request(settings: &crate::MarketPriceCoinMarketCapSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
let url = match settings.access_mode() {
crate::MarketPriceCoinMarketCapAccessMode::Basic => COINMARKETCAP_BASIC_URL,
crate::MarketPriceCoinMarketCapAccessMode::Keyless => COINMARKETCAP_KEYLESS_URL,
};
let mut request = match crate::HttpGetRequest::new_https(url) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("id", "5426");
request.append_query_pair("convert", "USD");
request.append_query_pair("include_last_updated", "true");
if let std::option::Option::Some(api_key) = settings.api_key()
&& let std::result::Result::Err(error) = request.insert_sensitive_header(COINMARKETCAP_API_KEY_HEADER, api_key.as_str())
{
return std::result::Result::Err(error);
}
return std::result::Result::Ok(request);
}
fn descriptor_for(
access_mode: crate::MarketPriceCoinMarketCapAccessMode,
provider_id: crate::MarketPriceProviderId,
) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let (auth_mode, rate_limit, long_term_quota) = match access_mode {
crate::MarketPriceCoinMarketCapAccessMode::Keyless => (
crate::MarketPriceProviderAuthMode::None,
crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip),
std::option::Option::None,
),
crate::MarketPriceCoinMarketCapAccessMode::Basic => {
let rate_limit =
match crate::MarketPriceProviderRateLimit::fixed(50, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let quota = match crate::MarketPriceProviderLongTermQuota::new(
15_000,
crate::MarketPriceProviderQuotaPeriod::Month,
crate::MarketPriceProviderQuotaUnit::Credits,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
(crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit, std::option::Option::Some(quota))
},
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"CoinMarketCap",
crate::MarketPriceSemantics::AggregatedMarket,
auth_mode,
rate_limit,
long_term_quota,
true,
);
}
fn parse_response(
bytes: &[u8],
provider_id: crate::MarketPriceProviderId,
request_started_at: crate::MarketPriceTimestamp,
received_at: crate::MarketPriceTimestamp,
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<CoinMarketCapWireResponse>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINMARKETCAP_PROVIDER_ID, "response", error));
},
};
if !raw_status_is_zero(wire.status.error_code.as_ref()) {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "status.error_code"));
}
if wire.data.len() != 1 {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data"));
}
let item = &wire.data[0];
if item.id != COINMARKETCAP_SOL_ID || item.symbol != "SOL" {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.identity"));
}
let mut usd_quote = std::option::Option::None;
for quote in &item.quotes {
if quote.symbol == "USD" {
if usd_quote.is_some() {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes"));
}
usd_quote = std::option::Option::Some(quote);
}
}
let quote = match usd_quote {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes.USD"));
},
};
let price = match crate::MarketPriceDecimal::parse_json_raw(quote.price.as_ref()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_timestamp = match crate::market_price_timestamp_from_rfc3339(quote.last_updated.as_str()) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes.last_updated"));
},
};
let provenance = match crate::MarketPriceProvenance::new("coinmarketcap:5426:usd:v2") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::AggregatedMarket,
request_started_at,
received_at,
std::option::Option::Some(provider_timestamp),
provenance,
);
}
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "CoinMarketCap market-price settings are invalid")
.with_context("provider", COINMARKETCAP_PROVIDER_ID)
.with_context("field", field);
}
fn raw_status_is_zero(raw: &serde_json::value::RawValue) -> bool {
return raw.get() == "0" || raw.get() == "\"0\"";
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireQuote {
last_updated: std::string::String,
price: std::boxed::Box<serde_json::value::RawValue>,
symbol: std::string::String,
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireItem {
id: u64,
quotes: std::vec::Vec<CoinMarketCapWireQuote>,
symbol: std::string::String,
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireResponse {
data: std::vec::Vec<CoinMarketCapWireItem>,
status: CoinMarketCapWireStatus,
}
#[derive(serde::Deserialize)]
struct CoinMarketCapWireStatus {
error_code: std::boxed::Box<serde_json::value::RawValue>,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_coinmarketcap.rs"]
mod tests;

View File

@@ -0,0 +1,187 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_coinpaprika.rs
// version: 1
//! CoinPaprika SOL/USD market-price adapter using the free official REST API directly through `reqwest`.
const COINPAPRIKA_PROVIDER_ID: &str = "coinpaprika";
const COINPAPRIKA_SOL_ID: &str = "sol-solana";
const COINPAPRIKA_SOL_TICKER_URL: &str = "https://api.coinpaprika.com/v1/tickers/sol-solana";
/// Runtime settings for the keyless CoinPaprika market-price adapter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MarketPriceCoinPaprikaSettings {
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceCoinPaprikaSettings {
/// Creates CoinPaprika settings for the free keyless REST surface.
pub fn new(enabled: bool) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(COINPAPRIKA_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { common: crate::MarketPriceProviderCommonSettings::new(provider_id, enabled) });
}
/// Returns common provider identity and enablement settings.
#[must_use]
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
return &self.common;
}
}
/// CoinPaprika SOL/USD provider adapter.
pub struct MarketPriceCoinPaprikaProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceCoinPaprikaSettings,
}
impl crate::MarketPriceCoinPaprikaProvider {
/// Builds one CoinPaprika provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceCoinPaprikaSettings) -> ksp_core_lib::Result<Self> {
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral CoinPaprika capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated CoinPaprika runtime settings.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceCoinPaprikaSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD observation from CoinPaprika.
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
if !self.settings.common().enabled() {
return std::result::Result::Err(crate::provider_disabled_error(COINPAPRIKA_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(COINPAPRIKA_PROVIDER_ID, &self.admission) {
return std::result::Result::Err(error);
}
let request_started_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match build_request() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = match crate::get_json(&self.http, &self.admission, COINPAPRIKA_PROVIDER_ID, request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let received_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
}
}
fn build_request() -> ksp_core_lib::Result<crate::HttpGetRequest> {
let mut request = match crate::HttpGetRequest::new_https(COINPAPRIKA_SOL_TICKER_URL) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("quotes", "USD");
return std::result::Result::Ok(request);
}
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(10, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let quota =
match crate::MarketPriceProviderLongTermQuota::new(20_000, crate::MarketPriceProviderQuotaPeriod::Month, crate::MarketPriceProviderQuotaUnit::Requests)
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"CoinPaprika",
crate::MarketPriceSemantics::AggregatedMarket,
crate::MarketPriceProviderAuthMode::None,
rate_limit,
std::option::Option::Some(quota),
true,
);
}
fn parse_response(
bytes: &[u8],
provider_id: crate::MarketPriceProviderId,
request_started_at: crate::MarketPriceTimestamp,
received_at: crate::MarketPriceTimestamp,
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<CoinPaprikaWireResponse>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINPAPRIKA_PROVIDER_ID, "response", error));
},
};
if wire.id != COINPAPRIKA_SOL_ID || wire.symbol != "SOL" {
return std::result::Result::Err(crate::invalid_provider_response(COINPAPRIKA_PROVIDER_ID, "identity"));
}
let price = match crate::MarketPriceDecimal::parse_json_raw(wire.quotes.usd.price.as_ref()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_timestamp = match crate::market_price_timestamp_from_rfc3339(wire.last_updated.as_str()) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::invalid_provider_response(COINPAPRIKA_PROVIDER_ID, "last_updated"));
},
};
let provenance = match crate::MarketPriceProvenance::new("coinpaprika:sol-solana:usd") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::AggregatedMarket,
request_started_at,
received_at,
std::option::Option::Some(provider_timestamp),
provenance,
);
}
#[derive(serde::Deserialize)]
struct CoinPaprikaWireQuote {
price: std::boxed::Box<serde_json::value::RawValue>,
}
#[derive(serde::Deserialize)]
struct CoinPaprikaWireQuotes {
#[serde(rename = "USD")]
usd: CoinPaprikaWireQuote,
}
#[derive(serde::Deserialize)]
struct CoinPaprikaWireResponse {
id: std::string::String,
last_updated: std::string::String,
quotes: CoinPaprikaWireQuotes,
symbol: std::string::String,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_coinpaprika.rs"]
mod tests;

View File

@@ -0,0 +1,238 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
// version: 4
/// Maximum accepted UTF-8 byte length for one textual decimal input.
pub const MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES: usize = 96;
/// Maximum scale retained by the canonical exact decimal representation.
pub const MARKET_PRICE_DECIMAL_MAX_SCALE: u8 = 18;
/// Exact positive decimal value used for one successful V1 SOL/USD observation.
///
/// The value is represented as a positive `u128` coefficient plus a bounded decimal scale. Trailing fractional zeroes are removed during construction, and
/// Serde serialization always emits the canonical decimal string instead of an IEEE-754 number.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MarketPriceDecimal {
coefficient: u128,
scale: u8,
}
impl MarketPriceDecimal {
/// Parses a positive decimal or bounded scientific-notation value without converting through `f64`.
pub fn parse(source: &str) -> ksp_core_lib::Result<Self> {
if source.is_empty() || source.len() > crate::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES || source.trim() != source {
return std::result::Result::Err(invalid_decimal_error());
}
let (significand, exponent) = match split_exponent(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(()) => return std::result::Result::Err(invalid_decimal_error()),
};
let (digits, fractional_digits) = match significand_digits(significand) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(()) => return std::result::Result::Err(invalid_decimal_error()),
};
let mut coefficient = match digits.parse::<u128>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
};
if coefficient == 0 {
return std::result::Result::Err(invalid_decimal_error());
}
let mut effective_scale = i32::from(fractional_digits) - exponent;
if effective_scale < 0 {
let multiplication_power = match u32::try_from(-effective_scale) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
};
coefficient = match checked_multiply_power_of_ten(coefficient, multiplication_power) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(invalid_decimal_error()),
};
effective_scale = 0;
}
if effective_scale > i32::from(crate::MARKET_PRICE_DECIMAL_MAX_SCALE) {
return std::result::Result::Err(invalid_decimal_error());
}
let mut scale = match u8::try_from(effective_scale) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
};
while scale > 0 && coefficient % 10 == 0 {
coefficient /= 10;
scale -= 1;
}
return std::result::Result::Ok(Self { coefficient, scale });
}
/// Parses one provider JSON number or string while preserving the original numeric lexeme.
pub(crate) fn parse_json_raw(raw: &serde_json::value::RawValue) -> ksp_core_lib::Result<Self> {
let source = raw.get();
if source.starts_with('"') {
let decoded = match serde_json::from_str::<std::string::String>(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
};
return crate::MarketPriceDecimal::parse(decoded.as_str());
}
return crate::MarketPriceDecimal::parse(source);
}
/// Returns the normalized integer coefficient.
#[must_use]
pub const fn coefficient(&self) -> u128 {
return self.coefficient;
}
/// Returns the normalized decimal scale.
#[must_use]
pub const fn scale(&self) -> u8 {
return self.scale;
}
/// Returns the canonical non-scientific decimal representation.
#[must_use]
pub fn to_canonical_string(&self) -> std::string::String {
let digits = self.coefficient.to_string();
if self.scale == 0 {
return digits;
}
let scale = usize::from(self.scale);
if digits.len() > scale {
let split = digits.len() - scale;
return std::format!("{}.{}", &digits[..split], &digits[split..]);
}
let zero_count = scale - digits.len();
return std::format!("0.{}{}", "0".repeat(zero_count), digits);
}
}
impl std::fmt::Display for MarketPriceDecimal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str(self.to_canonical_string().as_str());
}
}
impl std::str::FromStr for MarketPriceDecimal {
type Err = ksp_core_lib::Error;
fn from_str(source: &str) -> std::result::Result<Self, Self::Err> {
return crate::MarketPriceDecimal::parse(source);
}
}
impl serde::Serialize for MarketPriceDecimal {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
return serializer.serialize_str(self.to_canonical_string().as_str());
}
}
impl<'de> serde::Deserialize<'de> for MarketPriceDecimal {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
return deserializer.deserialize_str(MarketPriceDecimalVisitor);
}
}
struct MarketPriceDecimalVisitor;
impl<'de> serde::de::Visitor<'de> for MarketPriceDecimalVisitor {
type Value = crate::MarketPriceDecimal;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("a canonicalizable positive decimal string");
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
return match crate::MarketPriceDecimal::parse(value) {
std::result::Result::Ok(decimal) => std::result::Result::Ok(decimal),
std::result::Result::Err(_) => std::result::Result::Err(E::custom("invalid KSP market-price decimal")),
};
}
}
fn checked_multiply_power_of_ten(mut value: u128, exponent: u32) -> std::option::Option<u128> {
let mut remaining = exponent;
while remaining > 0 {
value = match value.checked_mul(10) {
std::option::Option::Some(next) => next,
std::option::Option::None => return std::option::Option::None,
};
remaining -= 1;
}
return std::option::Option::Some(value);
}
fn invalid_decimal_error() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "invalid exact market-price decimal");
}
fn significand_digits(significand: &str) -> std::result::Result<(std::string::String, u8), ()> {
if significand.is_empty() || significand.starts_with('-') || significand.starts_with('+') {
return std::result::Result::Err(());
}
let mut digits = std::string::String::with_capacity(significand.len());
let mut fractional_digits: usize = 0;
let mut decimal_seen = false;
let mut digit_seen = false;
for character in significand.chars() {
if character.is_ascii_digit() {
digits.push(character);
digit_seen = true;
if decimal_seen {
fractional_digits += 1;
}
} else if character == '.' && !decimal_seen {
decimal_seen = true;
} else {
return std::result::Result::Err(());
}
}
if !digit_seen || significand.ends_with('.') || significand.starts_with('.') {
return std::result::Result::Err(());
}
let fractional_digits = match u8::try_from(fractional_digits) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(()),
};
return std::result::Result::Ok((digits, fractional_digits));
}
fn split_exponent(source: &str) -> std::result::Result<(&str, i32), ()> {
let mut separator_index = std::option::Option::None;
for (index, character) in source.char_indices() {
if character == 'e' || character == 'E' {
if separator_index.is_some() {
return std::result::Result::Err(());
}
separator_index = std::option::Option::Some(index);
}
}
let index = match separator_index {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok((source, 0)),
};
let significand = &source[..index];
let exponent_source = &source[index + 1..];
if exponent_source.is_empty() || exponent_source.len() > 4 {
return std::result::Result::Err(());
}
let exponent = match exponent_source.parse::<i32>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(()),
};
if !(-128..=128).contains(&exponent) {
return std::result::Result::Err(());
}
return std::result::Result::Ok((significand, exponent));
}
#[cfg(test)]
#[path = "../unit_tests/market_price_decimal.rs"]
mod tests;

View File

@@ -0,0 +1,238 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs
// version: 2
//! DexScreener SOL/USD adapter bound to one explicitly configured Solana pair address.
const DEXSCREENER_PAIR_BASE_URL: &str = "https://api.dexscreener.com/latest/dex/pairs/solana";
const DEXSCREENER_PROVIDER_ID: &str = "dexscreener";
const DEXSCREENER_SOL_MINT: &str = "So11111111111111111111111111111111111111112";
/// Runtime settings for the keyless DexScreener market-price adapter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MarketPriceDexScreenerSettings {
common: crate::MarketPriceProviderCommonSettings,
pair_address: std::option::Option<ksp_core_lib::Pubkey>,
}
impl crate::MarketPriceDexScreenerSettings {
/// 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<std::string::String>) -> ksp_core_lib::Result<Self> {
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 {
std::option::Option::Some(value) => match value.parse::<ksp_core_lib::Pubkey>() {
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 });
}
/// Returns common provider identity and enablement settings.
#[must_use]
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
return &self.common;
}
/// Returns the explicit Solana pair address when one is configured.
#[must_use]
pub const fn pair_address(&self) -> std::option::Option<&ksp_core_lib::Pubkey> {
return self.pair_address.as_ref();
}
}
/// DexScreener SOL/USD provider adapter for one configured pair.
pub struct MarketPriceDexScreenerProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceDexScreenerSettings,
}
impl crate::MarketPriceDexScreenerProvider {
/// Builds one DexScreener provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceDexScreenerSettings) -> ksp_core_lib::Result<Self> {
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral DexScreener capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated DexScreener settings including the safe configured pair address.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceDexScreenerSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD observation from the configured DexScreener pair.
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
if !self.settings.common().enabled() {
return std::result::Result::Err(crate::provider_disabled_error(DEXSCREENER_PROVIDER_ID));
}
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(pair_address) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = match crate::get_json(&self.http, &self.admission, DEXSCREENER_PROVIDER_ID, request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let received_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), pair_address, request_started_at, received_at);
}
}
fn build_request(pair_address: &ksp_core_lib::Pubkey) -> ksp_core_lib::Result<crate::HttpGetRequest> {
let mut request = match crate::HttpGetRequest::new_https(DEXSCREENER_PAIR_BASE_URL) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pair_address = pair_address.to_string();
if let std::result::Result::Err(error) = request.append_path_segment(pair_address.as_str()) {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(request);
}
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(300, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified)
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"DexScreener",
crate::MarketPriceSemantics::DexPairUsd,
crate::MarketPriceProviderAuthMode::None,
rate_limit,
std::option::Option::None,
true,
);
}
fn parse_response(
bytes: &[u8],
provider_id: crate::MarketPriceProviderId,
configured_pair_address: &ksp_core_lib::Pubkey,
request_started_at: crate::MarketPriceTimestamp,
received_at: crate::MarketPriceTimestamp,
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<DexScreenerWireResponse>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(DEXSCREENER_PROVIDER_ID, "response", error));
},
};
let pairs = match wire.pairs {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs")),
};
if pairs.len() != 1 {
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs"));
}
let pair = &pairs[0];
if pair.chain_id != "solana" {
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.chainId"));
}
let response_pair_address = match pair.pair_address.parse::<ksp_core_lib::Pubkey>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.pairAddress")),
};
if response_pair_address != *configured_pair_address {
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.pairAddress"));
}
let base_token = match &pair.base_token {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.baseToken")),
};
if base_token.address != DEXSCREENER_SOL_MINT {
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.baseToken.address"));
}
let price_raw = match pair.price_usd.as_deref() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.priceUsd")),
};
let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provenance = match crate::MarketPriceProvenance::new(std::format!("dexscreener:solana:{configured_pair_address}:priceUsd")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::DexPairUsd,
request_started_at,
received_at,
std::option::Option::None,
provenance,
);
}
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "DexScreener market-price settings are invalid")
.with_context("provider", DEXSCREENER_PROVIDER_ID)
.with_context("field", field);
}
#[derive(serde::Deserialize)]
struct DexScreenerWirePair {
#[serde(rename = "baseToken")]
base_token: std::option::Option<DexScreenerWireToken>,
#[serde(rename = "chainId")]
chain_id: std::string::String,
#[serde(rename = "pairAddress")]
pair_address: std::string::String,
#[serde(rename = "priceUsd")]
price_usd: std::option::Option<std::boxed::Box<serde_json::value::RawValue>>,
}
#[derive(serde::Deserialize)]
struct DexScreenerWireResponse {
pairs: std::option::Option<std::vec::Vec<DexScreenerWirePair>>,
}
#[derive(serde::Deserialize)]
struct DexScreenerWireToken {
address: std::string::String,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_dexscreener.rs"]
mod tests;

View File

@@ -0,0 +1,269 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs
// version: 2
//! Jupiter Price V3 SOL/USD adapter using the current Developer Platform REST surface.
const JUPITER_API_KEY_HEADER: &str = "x-api-key";
const JUPITER_PRICE_V3_URL: &str = "https://api.jup.ag/price/v3";
const JUPITER_PROVIDER_ID: &str = "jupiter";
const JUPITER_SOL_DECIMALS: u8 = 9;
const JUPITER_SOL_MINT: &str = "So11111111111111111111111111111111111111112";
/// Jupiter V1 access mode supported by Off-chain Transport.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPriceJupiterAccessMode {
/// Free Developer Platform plan using a provider-issued API key.
Free,
/// Keyless Developer Platform access with the lower documented request cadence.
Keyless,
}
/// Runtime settings for the Jupiter Price V3 market-price adapter.
pub struct MarketPriceJupiterSettings {
access_mode: crate::MarketPriceJupiterAccessMode,
api_key: std::option::Option<crate::MarketPriceApiKey>,
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceJupiterSettings {
/// Creates keyless Jupiter settings without accepting a credential.
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceJupiterAccessMode::Keyless, std::option::Option::None);
}
/// Creates Free-plan Jupiter settings. An API key is mandatory while the provider is enabled.
pub fn free(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
return Self::new(enabled, crate::MarketPriceJupiterAccessMode::Free, api_key);
}
/// Returns the configured Jupiter access mode.
#[must_use]
pub const fn access_mode(&self) -> crate::MarketPriceJupiterAccessMode {
return self.access_mode;
}
/// Returns common provider identity and enablement settings.
#[must_use]
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
return &self.common;
}
fn new(enabled: bool, access_mode: crate::MarketPriceJupiterAccessMode, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(JUPITER_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let api_key = match access_mode {
crate::MarketPriceJupiterAccessMode::Keyless => {
if api_key.is_some() {
return std::result::Result::Err(provider_settings_error("api_key"));
}
std::option::Option::None
},
crate::MarketPriceJupiterAccessMode::Free => match api_key {
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(JUPITER_PROVIDER_ID, value) {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(error) => return std::result::Result::Err(error),
},
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")),
std::option::Option::None => std::option::Option::None,
},
};
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
return std::result::Result::Ok(Self { access_mode, api_key, common });
}
fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> {
return self.api_key.as_ref();
}
}
impl std::fmt::Debug for crate::MarketPriceJupiterSettings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("MarketPriceJupiterSettings")
.field("access_mode", &self.access_mode)
.field("api_key_present", &self.api_key.is_some())
.field("common", &self.common)
.finish();
}
}
/// Jupiter Price V3 SOL/USD provider adapter.
pub struct MarketPriceJupiterProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceJupiterSettings,
}
impl crate::MarketPriceJupiterProvider {
/// Builds one Jupiter Price V3 provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceJupiterSettings) -> ksp_core_lib::Result<Self> {
let descriptor = match descriptor_for(settings.access_mode(), settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral Jupiter capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated Jupiter runtime settings without exposing credential material.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceJupiterSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD heuristic observation from Jupiter Price V3.
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
if !self.settings.common().enabled() {
return std::result::Result::Err(crate::provider_disabled_error(JUPITER_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(JUPITER_PROVIDER_ID, &self.admission) {
return std::result::Result::Err(error);
}
let request_started_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match build_request(&self.settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = match crate::get_json(&self.http, &self.admission, JUPITER_PROVIDER_ID, request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let received_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
}
}
fn build_request(settings: &crate::MarketPriceJupiterSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
let mut request = match crate::HttpGetRequest::new_https(JUPITER_PRICE_V3_URL) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("ids", JUPITER_SOL_MINT);
if let std::option::Option::Some(api_key) = settings.api_key()
&& let std::result::Result::Err(error) = request.insert_sensitive_header(JUPITER_API_KEY_HEADER, api_key.as_str())
{
return std::result::Result::Err(error);
}
return std::result::Result::Ok(request);
}
fn descriptor_for(
access_mode: crate::MarketPriceJupiterAccessMode,
provider_id: crate::MarketPriceProviderId,
) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let (auth_mode, rate_limit) = match access_mode {
crate::MarketPriceJupiterAccessMode::Keyless => {
let rate_limit =
match crate::MarketPriceProviderRateLimit::fixed(1, 2, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
(crate::MarketPriceProviderAuthMode::None, rate_limit)
},
crate::MarketPriceJupiterAccessMode::Free => {
let rate_limit =
match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
(crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit)
},
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"Jupiter Price V3",
crate::MarketPriceSemantics::SolanaHeuristic,
auth_mode,
rate_limit,
std::option::Option::None,
true,
);
}
fn parse_response(
bytes: &[u8],
provider_id: crate::MarketPriceProviderId,
request_started_at: crate::MarketPriceTimestamp,
received_at: crate::MarketPriceTimestamp,
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<std::collections::BTreeMap<std::string::String, JupiterWirePrice>>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(JUPITER_PROVIDER_ID, "response", error));
},
};
if wire.len() != 1 {
return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "response.identity"));
}
let item = match wire.get(JUPITER_SOL_MINT) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "response.identity")),
};
if item.decimals != std::option::Option::Some(JUPITER_SOL_DECIMALS) {
return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "decimals"));
}
let price_raw = match item.usd_price.as_deref() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "usdPrice")),
};
let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let block_id = match item.block_id {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "blockId")),
};
let provenance = match crate::MarketPriceProvenance::new(std::format!("jupiter:price_v3:{JUPITER_SOL_MINT}:block:{block_id}")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::SolanaHeuristic,
request_started_at,
received_at,
std::option::Option::None,
provenance,
);
}
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "Jupiter market-price settings are invalid")
.with_context("provider", JUPITER_PROVIDER_ID)
.with_context("field", field);
}
#[derive(serde::Deserialize)]
struct JupiterWirePrice {
#[serde(rename = "blockId")]
block_id: std::option::Option<u64>,
decimals: std::option::Option<u8>,
#[serde(rename = "usdPrice")]
usd_price: std::option::Option<std::boxed::Box<serde_json::value::RawValue>>,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_jupiter.rs"]
mod tests;

View File

@@ -0,0 +1,179 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_kraken.rs
// version: 1
//! Kraken Spot SOL/USD market-price adapter using the public REST ticker directly through `reqwest`.
const KRAKEN_PROVIDER_ID: &str = "kraken";
const KRAKEN_SOL_USD_PAIR: &str = "SOLUSD";
const KRAKEN_TICKER_URL: &str = "https://api.kraken.com/0/public/Ticker";
/// Runtime settings for the keyless Kraken Spot market-price adapter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MarketPriceKrakenSettings {
common: crate::MarketPriceProviderCommonSettings,
}
impl crate::MarketPriceKrakenSettings {
/// Creates Kraken Spot settings for the public keyless REST surface.
pub fn new(enabled: bool) -> ksp_core_lib::Result<Self> {
let provider_id = match crate::MarketPriceProviderId::new(KRAKEN_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { common: crate::MarketPriceProviderCommonSettings::new(provider_id, enabled) });
}
/// Returns common provider identity and enablement settings.
#[must_use]
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
return &self.common;
}
}
/// Kraken Spot SOL/USD provider adapter.
pub struct MarketPriceKrakenProvider {
admission: crate::HttpAdmissionController,
descriptor: crate::MarketPriceProviderDescriptor,
http: crate::HttpRestClient,
settings: crate::MarketPriceKrakenSettings,
}
impl crate::MarketPriceKrakenProvider {
/// Builds one Kraken Spot provider from validated runtime settings.
pub fn new(settings: crate::MarketPriceKrakenSettings) -> ksp_core_lib::Result<Self> {
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
}
/// Returns the provider-neutral Kraken Spot capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the validated Kraken Spot runtime settings.
#[must_use]
pub const fn settings(&self) -> &crate::MarketPriceKrakenSettings {
return &self.settings;
}
/// Fetches one normalized SOL/USD last-trade observation from Kraken Spot.
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
if !self.settings.common().enabled() {
return std::result::Result::Err(crate::provider_disabled_error(KRAKEN_PROVIDER_ID));
}
if let std::result::Result::Err(error) = crate::admit_request(KRAKEN_PROVIDER_ID, &self.admission) {
return std::result::Result::Err(error);
}
let request_started_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match build_request() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = match crate::get_json(&self.http, &self.admission, KRAKEN_PROVIDER_ID, request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let received_at = match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
}
}
fn build_request() -> ksp_core_lib::Result<crate::HttpGetRequest> {
let mut request = match crate::HttpGetRequest::new_https(KRAKEN_TICKER_URL) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("pair", KRAKEN_SOL_USD_PAIR);
return std::result::Result::Ok(request);
}
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"Kraken",
crate::MarketPriceSemantics::ExchangeLastTrade,
crate::MarketPriceProviderAuthMode::None,
rate_limit,
std::option::Option::None,
true,
);
}
fn parse_response(
bytes: &[u8],
provider_id: crate::MarketPriceProviderId,
request_started_at: crate::MarketPriceTimestamp,
received_at: crate::MarketPriceTimestamp,
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
let wire = match serde_json::from_slice::<KrakenWireResponse>(bytes) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(crate::invalid_provider_response_with_source(KRAKEN_PROVIDER_ID, "response", error));
},
};
if !wire.error.is_empty() {
return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "error"));
}
if wire.result.len() != 1 {
return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "result"));
}
let ticker = match wire.result.get(KRAKEN_SOL_USD_PAIR) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "identity")),
};
let last_trade = match ticker.last_trade.first() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "last_trade")),
};
let price = match crate::MarketPriceDecimal::parse_json_raw(last_trade.as_ref()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provenance = match crate::MarketPriceProvenance::new("kraken:SOLUSD:last_trade") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::ExchangeLastTrade,
request_started_at,
received_at,
std::option::Option::None,
provenance,
);
}
#[derive(serde::Deserialize)]
struct KrakenWireResponse {
error: std::vec::Vec<std::string::String>,
result: std::collections::BTreeMap<std::string::String, KrakenWireTicker>,
}
#[derive(serde::Deserialize)]
struct KrakenWireTicker {
#[serde(rename = "c")]
last_trade: std::vec::Vec<std::boxed::Box<serde_json::value::RawValue>>,
}
#[cfg(test)]
#[path = "../unit_tests/market_price_kraken.rs"]
mod tests;

View File

@@ -0,0 +1,169 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_observation.rs
// version: 3
/// Maximum UTF-8 byte length accepted for safe provider provenance.
pub const MARKET_PRICE_PROVENANCE_MAX_BYTES: usize = 256;
/// Millisecond UTC timestamp used by provider-neutral public projections.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)]
pub struct MarketPriceTimestamp {
unix_millis: u64,
}
impl MarketPriceTimestamp {
/// Creates a UTC timestamp from whole milliseconds since Unix epoch.
#[must_use]
pub const fn from_unix_millis(unix_millis: u64) -> Self {
return Self { unix_millis };
}
/// Returns whole milliseconds since Unix epoch.
#[must_use]
pub const fn unix_millis(&self) -> u64 {
return self.unix_millis;
}
}
/// Safe bounded provenance supplied by one provider adapter.
#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(try_from = "std::string::String", into = "std::string::String")]
pub struct MarketPriceProvenance(std::string::String);
impl MarketPriceProvenance {
/// Creates bounded non-empty provenance without accepting control characters.
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
let value = value.into();
if !valid_provenance(value.as_str()) {
return std::result::Result::Err(observation_error());
}
return std::result::Result::Ok(Self(value));
}
/// Returns the safe provider provenance.
#[must_use]
pub fn as_str(&self) -> &str {
return self.0.as_str();
}
}
impl std::convert::TryFrom<std::string::String> for MarketPriceProvenance {
type Error = ksp_core_lib::Error;
fn try_from(value: std::string::String) -> std::result::Result<Self, Self::Error> {
return crate::MarketPriceProvenance::new(value);
}
}
impl std::convert::From<MarketPriceProvenance> for std::string::String {
fn from(value: MarketPriceProvenance) -> Self {
return value.0;
}
}
/// Public V1 SOL/USD observation normalized by Off-chain Transport.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub struct MarketPriceObservation {
pair: crate::MarketPricePair,
price: crate::MarketPriceDecimal,
provider_id: crate::MarketPriceProviderId,
provider_timestamp: std::option::Option<crate::MarketPriceTimestamp>,
provenance: crate::MarketPriceProvenance,
received_at: crate::MarketPriceTimestamp,
request_started_at: crate::MarketPriceTimestamp,
semantics: crate::MarketPriceSemantics,
}
impl MarketPriceObservation {
/// Creates one successful normalized SOL/USD observation.
pub fn new(
provider_id: crate::MarketPriceProviderId,
price: crate::MarketPriceDecimal,
semantics: crate::MarketPriceSemantics,
request_started_at: crate::MarketPriceTimestamp,
received_at: crate::MarketPriceTimestamp,
provider_timestamp: std::option::Option<crate::MarketPriceTimestamp>,
provenance: crate::MarketPriceProvenance,
) -> ksp_core_lib::Result<Self> {
if received_at < request_started_at {
return std::result::Result::Err(observation_error());
}
return std::result::Result::Ok(Self {
pair: crate::MarketPricePair::SolUsd,
price,
provider_id,
provider_timestamp,
provenance,
received_at,
request_started_at,
semantics,
});
}
/// Returns the only V1 pair represented by this observation.
#[must_use]
pub const fn pair(&self) -> crate::MarketPricePair {
return self.pair;
}
/// Returns the exact positive SOL/USD price.
#[must_use]
pub const fn price(&self) -> crate::MarketPriceDecimal {
return self.price;
}
/// Returns the opaque provider identifier.
#[must_use]
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
return &self.provider_id;
}
/// Returns a provider timestamp only when the provider adapter has a real price-time field.
#[must_use]
pub const fn provider_timestamp(&self) -> std::option::Option<crate::MarketPriceTimestamp> {
return self.provider_timestamp;
}
/// Returns safe provider provenance.
#[must_use]
pub const fn provenance(&self) -> &crate::MarketPriceProvenance {
return &self.provenance;
}
/// Returns the KSP wall-clock receipt timestamp.
#[must_use]
pub const fn received_at(&self) -> crate::MarketPriceTimestamp {
return self.received_at;
}
/// Returns the KSP wall-clock request-start timestamp.
#[must_use]
pub const fn request_started_at(&self) -> crate::MarketPriceTimestamp {
return self.request_started_at;
}
/// Returns the provider-specific semantic class retained by the normalized observation.
#[must_use]
pub const fn semantics(&self) -> crate::MarketPriceSemantics {
return self.semantics;
}
}
fn observation_error() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID, "invalid off-chain market-price observation");
}
fn valid_provenance(value: &str) -> bool {
if value.is_empty() || value.len() > crate::MARKET_PRICE_PROVENANCE_MAX_BYTES || value.trim() != value {
return false;
}
for character in value.chars() {
if character.is_control() {
return false;
}
}
return true;
}
#[cfg(test)]
#[path = "../unit_tests/market_price_observation.rs"]
mod tests;

View File

@@ -0,0 +1,514 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_provider.rs
// version: 6
/// Maximum UTF-8 byte length of one provider display name.
pub const MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES: usize = 96;
/// Maximum byte length of one opaque provider identifier.
pub const MARKET_PRICE_PROVIDER_ID_MAX_BYTES: usize = 64;
/// Only price pair exposed by the `0.2.11` V1 public contract.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPricePair {
/// Native SOL quoted directly in US dollars according to one provider's documented semantics.
SolUsd,
}
impl MarketPricePair {
/// Returns the stable human-readable pair code.
#[must_use]
pub const fn code(&self) -> &'static str {
return match self {
Self::SolUsd => "SOL/USD",
};
}
}
/// Market-price semantics retained so normalized observations do not imply cross-provider equivalence.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPriceSemantics {
/// Aggregated market price produced by a multi-market data provider.
AggregatedMarket,
/// USD price associated with one explicitly configured DEX pair.
DexPairUsd,
/// Last-trade price reported by one centralized exchange market.
ExchangeLastTrade,
/// Heuristic USD price derived from Solana swap/liquidity activity.
SolanaHeuristic,
/// Direct Solana-oriented spot price supplied by an on-chain market data provider.
SolanaSpot,
}
/// Generic authentication capability exposed by one configured provider.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPriceProviderAuthMode {
/// No credential is required for the configured access mode.
None,
/// Provider accepts an API key but also supports an unauthenticated mode selected by configuration.
OptionalApiKey,
/// An API key is required for the configured access mode.
RequiredApiKey,
}
/// Scope to which a provider documents a request limit.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPriceProviderRateLimitScope {
/// Limit is associated with the configured account or API key.
Account,
/// Limit is associated with the source IP address.
Ip,
/// Limit is associated with an organization or project wider than one key.
Organization,
/// Provider documentation does not expose a stronger stable scope.
Unspecified,
}
/// Shape of one generic provider request-limit capability.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPriceProviderRateLimitKind {
/// Dynamic or server-driven limit that cannot be represented as one safe fixed local cadence.
Dynamic,
/// Locally enforceable fixed request budget over a documented window.
Fixed,
}
/// Generic provider request-limit capability with validated fixed-limit values.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
pub struct MarketPriceProviderRateLimit {
burst: std::option::Option<u32>,
kind: crate::MarketPriceProviderRateLimitKind,
requests: std::option::Option<u32>,
scope: crate::MarketPriceProviderRateLimitScope,
window_seconds: std::option::Option<u32>,
}
impl MarketPriceProviderRateLimit {
/// Creates a validated fixed request limit.
pub fn fixed(
requests: u32,
window_seconds: u32,
burst: std::option::Option<u32>,
scope: crate::MarketPriceProviderRateLimitScope,
) -> ksp_core_lib::Result<Self> {
if requests == 0 || window_seconds == 0 || burst == std::option::Option::Some(0) {
return std::result::Result::Err(provider_descriptor_error());
}
return std::result::Result::Ok(Self {
burst,
kind: crate::MarketPriceProviderRateLimitKind::Fixed,
requests: std::option::Option::Some(requests),
scope,
window_seconds: std::option::Option::Some(window_seconds),
});
}
/// Creates a dynamic/server-driven request-limit descriptor.
#[must_use]
pub const fn dynamic(scope: crate::MarketPriceProviderRateLimitScope) -> Self {
return Self {
burst: std::option::Option::None,
kind: crate::MarketPriceProviderRateLimitKind::Dynamic,
requests: std::option::Option::None,
scope,
window_seconds: std::option::Option::None,
};
}
/// Returns optional documented burst capacity.
#[must_use]
pub const fn burst(&self) -> std::option::Option<u32> {
return self.burst;
}
/// Returns whether the limit is fixed or dynamic/server-driven.
#[must_use]
pub const fn kind(&self) -> crate::MarketPriceProviderRateLimitKind {
return self.kind;
}
/// Returns the request budget for a fixed limit, or `None` for a dynamic limit.
#[must_use]
pub const fn requests(&self) -> std::option::Option<u32> {
return self.requests;
}
/// Returns the documented limit scope.
#[must_use]
pub const fn scope(&self) -> crate::MarketPriceProviderRateLimitScope {
return self.scope;
}
/// Returns the fixed window duration in seconds, or `None` for a dynamic limit.
#[must_use]
pub const fn window_seconds(&self) -> std::option::Option<u32> {
return self.window_seconds;
}
}
/// Period used by one documented long-term provider quota.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPriceProviderQuotaPeriod {
/// Quota resets on a provider-defined daily period.
Day,
/// Quota resets on a provider-defined monthly period.
Month,
}
/// Unit used by one documented long-term provider quota.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketPriceProviderQuotaUnit {
/// Provider-defined compute units consumed by API operations.
ComputeUnits,
/// Provider-specific credits, not assumed to equal HTTP requests.
Credits,
/// HTTP/API requests.
Requests,
}
/// Long-term provider quota descriptor exposed as non-authoritative capability metadata.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
pub struct MarketPriceProviderLongTermQuota {
amount: u64,
period: crate::MarketPriceProviderQuotaPeriod,
unit: crate::MarketPriceProviderQuotaUnit,
}
impl MarketPriceProviderLongTermQuota {
/// Creates a non-zero documented quota descriptor.
pub fn new(amount: u64, period: crate::MarketPriceProviderQuotaPeriod, unit: crate::MarketPriceProviderQuotaUnit) -> ksp_core_lib::Result<Self> {
if amount == 0 {
return std::result::Result::Err(provider_descriptor_error());
}
return std::result::Result::Ok(Self { amount, period, unit });
}
/// Returns the documented amount without treating it as a local remaining counter.
#[must_use]
pub const fn amount(&self) -> u64 {
return self.amount;
}
/// Returns the provider-defined quota period.
#[must_use]
pub const fn period(&self) -> crate::MarketPriceProviderQuotaPeriod {
return self.period;
}
/// Returns the documented quota unit.
#[must_use]
pub const fn unit(&self) -> crate::MarketPriceProviderQuotaUnit {
return self.unit;
}
}
/// Informational cost of one normalized SOL/USD request in a provider-defined quota unit.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
pub struct MarketPriceProviderRequestCost {
amount: u64,
unit: crate::MarketPriceProviderQuotaUnit,
}
impl crate::MarketPriceProviderRequestCost {
/// Creates a non-zero informational request-cost descriptor.
pub fn new(amount: u64, unit: crate::MarketPriceProviderQuotaUnit) -> ksp_core_lib::Result<Self> {
if amount == 0 {
return std::result::Result::Err(provider_descriptor_error());
}
return std::result::Result::Ok(Self { amount, unit });
}
/// Returns the documented amount consumed by one SOL/USD request.
#[must_use]
pub const fn amount(&self) -> u64 {
return self.amount;
}
/// Returns the provider-defined quota unit used by this request cost.
#[must_use]
pub const fn unit(&self) -> crate::MarketPriceProviderQuotaUnit {
return self.unit;
}
}
/// Opaque validated provider identifier owned by Off-chain Transport.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)]
#[serde(try_from = "std::string::String", into = "std::string::String")]
pub struct MarketPriceProviderId(std::string::String);
impl MarketPriceProviderId {
/// Creates one bounded stable provider identifier.
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating market-price provider identifier");
let value = value.into();
if !valid_provider_id(value.as_str()) {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "provider_id", "rejected invalid market-price provider identifier");
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID,
"invalid off-chain market-price provider identifier",
));
}
return std::result::Result::Ok(Self(value));
}
/// Returns the opaque identifier as a stable string.
#[must_use]
pub fn as_str(&self) -> &str {
return self.0.as_str();
}
}
impl std::fmt::Display for MarketPriceProviderId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str(self.0.as_str());
}
}
impl std::convert::TryFrom<std::string::String> for MarketPriceProviderId {
type Error = ksp_core_lib::Error;
fn try_from(value: std::string::String) -> std::result::Result<Self, Self::Error> {
return crate::MarketPriceProviderId::new(value);
}
}
impl std::convert::From<MarketPriceProviderId> for std::string::String {
fn from(value: MarketPriceProviderId) -> Self {
return value.0;
}
}
/// Provider capability and presentation descriptor consumed by provider-agnostic callers.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub struct MarketPriceProviderDescriptor {
auth_mode: crate::MarketPriceProviderAuthMode,
display_name: std::string::String,
id: crate::MarketPriceProviderId,
long_term_quota: std::option::Option<crate::MarketPriceProviderLongTermQuota>,
rate_limit: crate::MarketPriceProviderRateLimit,
semantics: crate::MarketPriceSemantics,
sol_usd_request_cost: std::option::Option<crate::MarketPriceProviderRequestCost>,
supports_sol_usd: bool,
}
impl MarketPriceProviderDescriptor {
/// Creates a validated provider-neutral descriptor.
pub fn new(
id: crate::MarketPriceProviderId,
display_name: impl std::convert::Into<std::string::String>,
semantics: crate::MarketPriceSemantics,
auth_mode: crate::MarketPriceProviderAuthMode,
rate_limit: crate::MarketPriceProviderRateLimit,
long_term_quota: std::option::Option<crate::MarketPriceProviderLongTermQuota>,
supports_sol_usd: bool,
) -> ksp_core_lib::Result<Self> {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating market-price provider descriptor");
let display_name = display_name.into();
if !valid_display_name(display_name.as_str()) {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "display_name", "rejected invalid market-price provider descriptor");
return std::result::Result::Err(provider_descriptor_error());
}
return std::result::Result::Ok(Self {
auth_mode,
display_name,
id,
long_term_quota,
rate_limit,
semantics,
sol_usd_request_cost: std::option::Option::None,
supports_sol_usd,
});
}
/// Attaches informational provider cost metadata for one normalized SOL/USD request.
pub fn with_sol_usd_request_cost(mut self, request_cost: crate::MarketPriceProviderRequestCost) -> ksp_core_lib::Result<Self> {
if let std::option::Option::Some(quota) = self.long_term_quota
&& quota.unit() != request_cost.unit()
{
return std::result::Result::Err(provider_descriptor_error());
}
self.sol_usd_request_cost = std::option::Option::Some(request_cost);
return std::result::Result::Ok(self);
}
/// Returns the configured authentication capability.
#[must_use]
pub const fn auth_mode(&self) -> crate::MarketPriceProviderAuthMode {
return self.auth_mode;
}
/// Returns the safe display name.
#[must_use]
pub fn display_name(&self) -> &str {
return self.display_name.as_str();
}
/// Returns the opaque provider identifier.
#[must_use]
pub const fn id(&self) -> &crate::MarketPriceProviderId {
return &self.id;
}
/// Returns optional long-term quota metadata without exposing a local remaining counter.
#[must_use]
pub const fn long_term_quota(&self) -> std::option::Option<crate::MarketPriceProviderLongTermQuota> {
return self.long_term_quota;
}
/// Returns the configured request-limit capability.
#[must_use]
pub const fn rate_limit(&self) -> crate::MarketPriceProviderRateLimit {
return self.rate_limit;
}
/// Returns the documented price semantics.
#[must_use]
pub const fn semantics(&self) -> crate::MarketPriceSemantics {
return self.semantics;
}
/// Returns optional informational provider cost for one normalized SOL/USD request.
#[must_use]
pub const fn sol_usd_request_cost(&self) -> std::option::Option<crate::MarketPriceProviderRequestCost> {
return self.sol_usd_request_cost;
}
/// Reports whether this descriptor can serve the V1 SOL/USD pair.
#[must_use]
pub const fn supports_sol_usd(&self) -> bool {
return self.supports_sol_usd;
}
}
/// Generic runtime availability state exposed without provider-specific error parsing.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum MarketPriceProviderAvailability {
/// Required authentication material is unavailable or rejected.
AuthenticationUnavailable,
/// Provider is locally cooling down until the supplied timestamp.
CoolingDown {
/// Earliest known wall-clock timestamp at which a new attempt may be admitted.
retry_at: crate::MarketPriceTimestamp,
},
/// Provider is disabled by runtime configuration.
Disabled,
/// Runtime settings do not satisfy the provider adapter contract.
Misconfigured,
/// Provider-reported quota prevents current use.
QuotaUnavailable,
/// Provider is eligible for a new request.
Ready,
/// Transport/provider failure is transient; retry time is present only when actually known.
TemporarilyUnavailable {
/// Optional next retry timestamp derived from safe runtime/provider information.
retry_at: std::option::Option<crate::MarketPriceTimestamp>,
},
}
impl crate::MarketPriceProviderAvailability {
/// Reports whether a generic refresh may be attempted immediately.
#[must_use]
pub const fn is_refresh_eligible(&self) -> bool {
return matches!(self, Self::Ready);
}
/// Reports whether a refresh may be attempted at the supplied wall-clock timestamp.
#[must_use]
pub fn is_refresh_eligible_at(&self, now: crate::MarketPriceTimestamp) -> bool {
return match self {
Self::Ready => true,
Self::CoolingDown { retry_at } => *retry_at <= now,
Self::TemporarilyUnavailable { retry_at: std::option::Option::Some(retry_at) } => *retry_at <= now,
Self::TemporarilyUnavailable { retry_at: std::option::Option::None } => true,
Self::AuthenticationUnavailable | Self::Disabled | Self::Misconfigured | Self::QuotaUnavailable => false,
};
}
/// Returns the known next retry timestamp for cooling-down or temporary states.
#[must_use]
pub const fn retry_at(&self) -> std::option::Option<crate::MarketPriceTimestamp> {
return match self {
Self::CoolingDown { retry_at } => std::option::Option::Some(*retry_at),
Self::TemporarilyUnavailable { retry_at } => *retry_at,
Self::AuthenticationUnavailable | Self::Disabled | Self::Misconfigured | Self::QuotaUnavailable | Self::Ready => std::option::Option::None,
};
}
}
/// Current provider-neutral runtime state projection.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub struct MarketPriceProviderState {
availability: crate::MarketPriceProviderAvailability,
provider_id: crate::MarketPriceProviderId,
}
impl MarketPriceProviderState {
/// Creates one generic state projection for a configured provider.
#[must_use]
pub fn new(provider_id: crate::MarketPriceProviderId, availability: crate::MarketPriceProviderAvailability) -> Self {
return Self { availability, provider_id };
}
/// Returns the generic availability classification.
#[must_use]
pub const fn availability(&self) -> crate::MarketPriceProviderAvailability {
return self.availability;
}
/// Returns the opaque provider identifier.
#[must_use]
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
return &self.provider_id;
}
/// Replaces the internal availability projection while preserving provider identity.
pub(crate) fn set_availability(&mut self, availability: crate::MarketPriceProviderAvailability) {
self.availability = availability;
}
}
fn provider_descriptor_error() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID, "invalid off-chain market-price provider descriptor");
}
fn valid_display_name(value: &str) -> bool {
if value.is_empty() || value.len() > crate::MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES || value.trim() != value {
return false;
}
for character in value.chars() {
if character.is_control() {
return false;
}
}
return true;
}
fn valid_provider_id(value: &str) -> bool {
if value.is_empty() || value.len() > crate::MARKET_PRICE_PROVIDER_ID_MAX_BYTES {
return false;
}
for byte in value.bytes() {
if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_') {
return false;
}
}
return true;
}
#[cfg(test)]
#[path = "../unit_tests/market_price_provider.rs"]
mod tests;

View File

@@ -0,0 +1,123 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_registry.rs
// version: 2
//! Provider-neutral registry projection for configured market-price providers.
const MARKET_PRICE_PROVIDER_REGISTRY_MAX_ENTRIES: usize = 64;
/// Immutable provider descriptor plus its current generic runtime state.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub struct MarketPriceProviderRegistryEntry {
descriptor: crate::MarketPriceProviderDescriptor,
state: crate::MarketPriceProviderState,
}
impl crate::MarketPriceProviderRegistryEntry {
/// Returns the provider capability descriptor.
#[must_use]
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return &self.descriptor;
}
/// Returns the provider-neutral runtime state.
#[must_use]
pub const fn state(&self) -> &crate::MarketPriceProviderState {
return &self.state;
}
/// Replaces this entry's generic availability projection for the owning runtime service.
pub(crate) fn set_availability(&mut self, availability: crate::MarketPriceProviderAvailability) {
self.state.set_availability(availability);
}
/// Creates one generic registry entry while keeping descriptor and state identity synchronized.
#[must_use]
pub fn new(descriptor: crate::MarketPriceProviderDescriptor, availability: crate::MarketPriceProviderAvailability) -> Self {
let state = crate::MarketPriceProviderState::new(descriptor.id().clone(), availability);
return Self { descriptor, state };
}
}
/// Deterministically ordered registry of configured market-price providers.
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)]
pub struct MarketPriceProviderRegistry {
entries: std::vec::Vec<crate::MarketPriceProviderRegistryEntry>,
}
impl crate::MarketPriceProviderRegistry {
/// Returns a provider descriptor by opaque identifier.
#[must_use]
pub fn descriptor(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<&crate::MarketPriceProviderDescriptor> {
return self.entry(provider_id).map(crate::MarketPriceProviderRegistryEntry::descriptor);
}
/// Returns all configured provider entries in stable provider-id order.
#[must_use]
pub fn entries(&self) -> &[crate::MarketPriceProviderRegistryEntry] {
return self.entries.as_slice();
}
/// Returns one configured registry entry by opaque identifier.
#[must_use]
pub fn entry(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<&crate::MarketPriceProviderRegistryEntry> {
let index = self.entries.binary_search_by(|entry| return entry.descriptor().id().cmp(provider_id));
return match index {
std::result::Result::Ok(value) => self.entries.get(value),
std::result::Result::Err(_) => std::option::Option::None,
};
}
/// Reports whether no provider is currently registered.
#[must_use]
pub fn is_empty(&self) -> bool {
return self.entries.is_empty();
}
/// Returns the configured provider count.
#[must_use]
pub fn len(&self) -> usize {
return self.entries.len();
}
/// Returns one provider-neutral runtime state by opaque identifier.
#[must_use]
pub fn state(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<&crate::MarketPriceProviderState> {
return self.entry(provider_id).map(crate::MarketPriceProviderRegistryEntry::state);
}
/// Updates one existing provider availability and reports whether the provider was present.
pub(crate) fn set_availability(&mut self, provider_id: &crate::MarketPriceProviderId, availability: crate::MarketPriceProviderAvailability) -> bool {
let index = self.entries.binary_search_by(|entry| return entry.descriptor().id().cmp(provider_id));
let entry = match index {
std::result::Result::Ok(value) => self.entries.get_mut(value),
std::result::Result::Err(_) => std::option::Option::None,
};
if let std::option::Option::Some(entry) = entry {
entry.set_availability(availability);
return true;
}
return false;
}
/// Builds one bounded deterministic registry and rejects duplicate provider identifiers.
pub fn new(mut entries: std::vec::Vec<crate::MarketPriceProviderRegistryEntry>) -> ksp_core_lib::Result<Self> {
if entries.len() > MARKET_PRICE_PROVIDER_REGISTRY_MAX_ENTRIES {
return std::result::Result::Err(registry_error("entry_count"));
}
entries.sort_by(|left, right| return left.descriptor().id().cmp(right.descriptor().id()));
for index in 1..entries.len() {
if entries[index - 1].descriptor().id() == entries[index].descriptor().id() {
return std::result::Result::Err(registry_error("provider_id"));
}
}
return std::result::Result::Ok(Self { entries });
}
}
fn registry_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID, "Market-price provider registry is invalid").with_context("field", field);
}
#[cfg(test)]
#[path = "../unit_tests/market_price_registry.rs"]
mod tests;

View File

@@ -0,0 +1,387 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_service.rs
// version: 3
//! Generic market-price refresh service owning provider dispatch and availability transitions.
const MARKET_PRICE_RATE_LIMIT_FALLBACK_MILLIS: u64 = 1_000;
const MARKET_PRICE_REFRESH_MAX_PROVIDERS: usize = 64;
/// Provider-specific runtime setup consumed once by [`crate::MarketPriceService`].
///
/// This enum is intended for composition layers such as Config. Runtime consumers use the generic service methods and never need to branch on provider kinds.
#[non_exhaustive]
pub enum MarketPriceProviderSetup {
/// Birdeye Standard setup.
Birdeye(crate::MarketPriceBirdeyeSettings),
/// Coinbase Exchange setup.
CoinbaseExchange(crate::MarketPriceCoinbaseExchangeSettings),
/// CoinGecko setup.
CoinGecko(crate::MarketPriceCoinGeckoSettings),
/// CoinMarketCap setup.
CoinMarketCap(crate::MarketPriceCoinMarketCapSettings),
/// CoinPaprika setup.
CoinPaprika(crate::MarketPriceCoinPaprikaSettings),
/// DexScreener setup bound to one explicit Solana pair.
DexScreener(crate::MarketPriceDexScreenerSettings),
/// Jupiter Price V3 setup.
Jupiter(crate::MarketPriceJupiterSettings),
/// Kraken Spot setup.
Kraken(crate::MarketPriceKrakenSettings),
}
/// Generic result of one explicit market-price refresh attempt or eligibility projection.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub struct MarketPriceRefreshOutcome {
observation: std::option::Option<crate::MarketPriceObservation>,
state: crate::MarketPriceProviderState,
}
impl crate::MarketPriceRefreshOutcome {
/// Returns the successful normalized observation when this refresh produced one.
#[must_use]
pub fn observation(&self) -> std::option::Option<&crate::MarketPriceObservation> {
return self.observation.as_ref();
}
/// Returns the provider identifier without exposing provider-specific runtime types.
#[must_use]
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
return self.state.provider_id();
}
/// Reports whether this refresh produced a new observation.
#[must_use]
pub fn refreshed(&self) -> bool {
return self.observation.is_some();
}
/// Returns the resulting provider-neutral runtime state.
#[must_use]
pub const fn state(&self) -> &crate::MarketPriceProviderState {
return &self.state;
}
fn from_state(state: crate::MarketPriceProviderState) -> Self {
return Self { observation: std::option::Option::None, state };
}
fn from_observation(observation: crate::MarketPriceObservation, state: crate::MarketPriceProviderState) -> Self {
return Self { observation: std::option::Option::Some(observation), state };
}
}
/// Provider-agnostic SOL/USD refresh service owning all configured provider adapters.
pub struct MarketPriceService {
providers: std::vec::Vec<MarketPriceProviderRuntime>,
registry: std::sync::Mutex<crate::MarketPriceProviderRegistry>,
}
impl crate::MarketPriceService {
/// Builds the service from provider-specific setup supplied by a composition layer.
///
/// Provider identifiers must be unique. Initial registry state is `Ready` for enabled providers and `Disabled` for disabled providers.
pub fn new(setups: std::vec::Vec<crate::MarketPriceProviderSetup>) -> ksp_core_lib::Result<Self> {
if setups.len() > MARKET_PRICE_REFRESH_MAX_PROVIDERS {
return std::result::Result::Err(refresh_error("provider_count"));
}
let mut providers = std::vec::Vec::with_capacity(setups.len());
let mut entries = std::vec::Vec::with_capacity(setups.len());
for setup in setups {
let runtime = match MarketPriceProviderRuntime::new(setup) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let availability = if runtime.enabled() {
crate::MarketPriceProviderAvailability::Ready
} else {
crate::MarketPriceProviderAvailability::Disabled
};
entries.push(crate::MarketPriceProviderRegistryEntry::new(runtime.descriptor().clone(), availability));
providers.push(runtime);
}
let registry = match crate::MarketPriceProviderRegistry::new(entries) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
providers.sort_by(|left, right| return left.provider_id().cmp(right.provider_id()));
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
provider_count = providers.len(),
"created generic market-price refresh service"
);
return std::result::Result::Ok(Self { providers, registry: std::sync::Mutex::new(registry) });
}
/// Returns a detached provider-neutral registry snapshot suitable for HID projection.
#[must_use]
pub fn registry(&self) -> crate::MarketPriceProviderRegistry {
let guard = lock_registry(&self.registry);
return guard.clone();
}
/// Refreshes one provider by generic provider identifier.
///
/// Non-eligible states are returned without network dispatch. Provider transport/application failures are normalized into availability and returned as an
/// outcome rather than forcing consumers to parse provider-specific errors.
pub async fn refresh(&self, provider_id: &crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceRefreshOutcome> {
let index = match self.provider_index(provider_id) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(provider_not_found_error(provider_id)),
};
let now = current_timestamp_or_zero();
let state = match self.state_snapshot(provider_id) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(provider_not_found_error(provider_id)),
};
if !state.availability().is_refresh_eligible_at(now) {
return std::result::Result::Ok(crate::MarketPriceRefreshOutcome::from_state(state));
}
let result = self.providers[index].fetch_sol_usd().await;
return match result {
std::result::Result::Ok(observation) => {
let state = self.update_availability(provider_id, crate::MarketPriceProviderAvailability::Ready);
std::result::Result::Ok(crate::MarketPriceRefreshOutcome::from_observation(observation, state))
},
std::result::Result::Err(error) => {
let availability = availability_from_error(&error, self.providers[index].descriptor().auth_mode());
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
provider_id = provider_id.as_str(),
error_domain = error.code().domain(),
error_code = error.code().code(),
"classified market-price refresh failure"
);
let state = self.update_availability(provider_id, availability);
std::result::Result::Ok(crate::MarketPriceRefreshOutcome::from_state(state))
},
};
}
/// Refreshes a caller-selected provider set in deterministic request order without fallback or consensus.
///
/// The service never sleeps to wait for a local rate limit. Duplicate or unknown identifiers are rejected before any provider request is attempted.
pub async fn refresh_many(&self, provider_ids: &[crate::MarketPriceProviderId]) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceRefreshOutcome>> {
if provider_ids.len() > MARKET_PRICE_REFRESH_MAX_PROVIDERS {
return std::result::Result::Err(refresh_error("provider_count"));
}
if let std::result::Result::Err(error) = validate_requested_provider_ids(self, provider_ids) {
return std::result::Result::Err(error);
}
let mut outcomes = std::vec::Vec::with_capacity(provider_ids.len());
for provider_id in provider_ids {
let outcome = match self.refresh(provider_id).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
outcomes.push(outcome);
}
return std::result::Result::Ok(outcomes);
}
/// Refreshes every currently eligible configured provider in stable provider-id order.
///
/// Disabled, authentication-unavailable, misconfigured, quota-unavailable and not-yet-expired cooldown states are projected without network dispatch.
pub async fn refresh_all(&self) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceRefreshOutcome>> {
let provider_ids = self.providers.iter().map(|provider| return provider.provider_id().clone()).collect::<std::vec::Vec<_>>();
return self.refresh_many(provider_ids.as_slice()).await;
}
fn provider_index(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<usize> {
let result = self.providers.binary_search_by(|provider| return provider.provider_id().cmp(provider_id));
return match result {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(_) => std::option::Option::None,
};
}
fn state_snapshot(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<crate::MarketPriceProviderState> {
let guard = lock_registry(&self.registry);
return guard.state(provider_id).cloned();
}
fn update_availability(
&self,
provider_id: &crate::MarketPriceProviderId,
availability: crate::MarketPriceProviderAvailability,
) -> crate::MarketPriceProviderState {
let mut guard = lock_registry(&self.registry);
if guard.set_availability(provider_id, availability)
&& let std::option::Option::Some(state) = guard.state(provider_id)
{
return state.clone();
}
return crate::MarketPriceProviderState::new(provider_id.clone(), availability);
}
}
impl std::fmt::Debug for crate::MarketPriceService {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("MarketPriceService").field("registry", &self.registry()).finish_non_exhaustive();
}
}
enum MarketPriceProviderRuntime {
Birdeye(crate::MarketPriceBirdeyeProvider),
CoinbaseExchange(crate::MarketPriceCoinbaseExchangeProvider),
CoinGecko(crate::MarketPriceCoinGeckoProvider),
CoinMarketCap(crate::MarketPriceCoinMarketCapProvider),
CoinPaprika(crate::MarketPriceCoinPaprikaProvider),
DexScreener(crate::MarketPriceDexScreenerProvider),
Jupiter(crate::MarketPriceJupiterProvider),
Kraken(crate::MarketPriceKrakenProvider),
}
impl MarketPriceProviderRuntime {
fn new(setup: crate::MarketPriceProviderSetup) -> ksp_core_lib::Result<Self> {
return match setup {
crate::MarketPriceProviderSetup::Birdeye(settings) => crate::MarketPriceBirdeyeProvider::new(settings).map(Self::Birdeye),
crate::MarketPriceProviderSetup::CoinbaseExchange(settings) => {
crate::MarketPriceCoinbaseExchangeProvider::new(settings).map(Self::CoinbaseExchange)
},
crate::MarketPriceProviderSetup::CoinGecko(settings) => crate::MarketPriceCoinGeckoProvider::new(settings).map(Self::CoinGecko),
crate::MarketPriceProviderSetup::CoinMarketCap(settings) => crate::MarketPriceCoinMarketCapProvider::new(settings).map(Self::CoinMarketCap),
crate::MarketPriceProviderSetup::CoinPaprika(settings) => crate::MarketPriceCoinPaprikaProvider::new(settings).map(Self::CoinPaprika),
crate::MarketPriceProviderSetup::DexScreener(settings) => crate::MarketPriceDexScreenerProvider::new(settings).map(Self::DexScreener),
crate::MarketPriceProviderSetup::Jupiter(settings) => crate::MarketPriceJupiterProvider::new(settings).map(Self::Jupiter),
crate::MarketPriceProviderSetup::Kraken(settings) => crate::MarketPriceKrakenProvider::new(settings).map(Self::Kraken),
};
}
fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
return match self {
Self::Birdeye(provider) => provider.descriptor(),
Self::CoinbaseExchange(provider) => provider.descriptor(),
Self::CoinGecko(provider) => provider.descriptor(),
Self::CoinMarketCap(provider) => provider.descriptor(),
Self::CoinPaprika(provider) => provider.descriptor(),
Self::DexScreener(provider) => provider.descriptor(),
Self::Jupiter(provider) => provider.descriptor(),
Self::Kraken(provider) => provider.descriptor(),
};
}
fn enabled(&self) -> bool {
return match self {
Self::Birdeye(provider) => provider.settings().common().enabled(),
Self::CoinbaseExchange(provider) => provider.settings().common().enabled(),
Self::CoinGecko(provider) => provider.settings().common().enabled(),
Self::CoinMarketCap(provider) => provider.settings().common().enabled(),
Self::CoinPaprika(provider) => provider.settings().common().enabled(),
Self::DexScreener(provider) => provider.settings().common().enabled(),
Self::Jupiter(provider) => provider.settings().common().enabled(),
Self::Kraken(provider) => provider.settings().common().enabled(),
};
}
async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
return match self {
Self::Birdeye(provider) => provider.fetch_sol_usd().await,
Self::CoinbaseExchange(provider) => provider.fetch_sol_usd().await,
Self::CoinGecko(provider) => provider.fetch_sol_usd().await,
Self::CoinMarketCap(provider) => provider.fetch_sol_usd().await,
Self::CoinPaprika(provider) => provider.fetch_sol_usd().await,
Self::DexScreener(provider) => provider.fetch_sol_usd().await,
Self::Jupiter(provider) => provider.fetch_sol_usd().await,
Self::Kraken(provider) => provider.fetch_sol_usd().await,
};
}
fn provider_id(&self) -> &crate::MarketPriceProviderId {
return self.descriptor().id();
}
}
fn availability_from_error(error: &ksp_core_lib::Error, auth_mode: crate::MarketPriceProviderAuthMode) -> crate::MarketPriceProviderAvailability {
let code = error.code();
if code == crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED {
return crate::MarketPriceProviderAvailability::Disabled;
}
if code == crate::ERROR_CODE_HTTP_ACCESS_DENIED {
return match auth_mode {
crate::MarketPriceProviderAuthMode::OptionalApiKey | crate::MarketPriceProviderAuthMode::RequiredApiKey => {
crate::MarketPriceProviderAvailability::AuthenticationUnavailable
},
crate::MarketPriceProviderAuthMode::None => crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None },
};
}
if code == crate::ERROR_CODE_HTTP_ADMISSION_DEFERRED {
let delay = context_u64(error, "retry_after_millis").unwrap_or(MARKET_PRICE_RATE_LIMIT_FALLBACK_MILLIS);
return crate::MarketPriceProviderAvailability::CoolingDown { retry_at: timestamp_after_millis(delay) };
}
if code == crate::ERROR_CODE_HTTP_RATE_LIMITED {
let delay = context_u64(error, "retry_after_seconds")
.and_then(|seconds| return seconds.checked_mul(1_000))
.unwrap_or(MARKET_PRICE_RATE_LIMIT_FALLBACK_MILLIS);
return crate::MarketPriceProviderAvailability::CoolingDown { retry_at: timestamp_after_millis(delay) };
}
if code == crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE {
let retry_at = context_u64(error, "retry_after_seconds").and_then(|seconds| return seconds.checked_mul(1_000)).map(timestamp_after_millis);
return crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at };
}
if code == crate::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED
|| code == crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID
|| code == crate::ERROR_CODE_HTTP_REQUEST_INVALID
|| code == crate::ERROR_CODE_HTTP_SETTINGS_INVALID
|| code == crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID
{
return crate::MarketPriceProviderAvailability::Misconfigured;
}
return crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None };
}
fn context_u64(error: &ksp_core_lib::Error, key: &'static str) -> std::option::Option<u64> {
for context in error.context() {
if context.key() == key {
return match context.value().parse::<u64>() {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(_) => std::option::Option::None,
};
}
}
return std::option::Option::None;
}
fn current_timestamp_or_zero() -> crate::MarketPriceTimestamp {
return match crate::current_timestamp() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => crate::MarketPriceTimestamp::from_unix_millis(0),
};
}
fn lock_registry(registry: &std::sync::Mutex<crate::MarketPriceProviderRegistry>) -> std::sync::MutexGuard<'_, crate::MarketPriceProviderRegistry> {
return match registry.lock() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(poisoned) => poisoned.into_inner(),
};
}
fn provider_not_found_error(provider_id: &crate::MarketPriceProviderId) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND, "Market-price provider is not configured")
.with_context("provider_id", provider_id.as_str());
}
fn refresh_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID, "Market-price refresh request is invalid").with_context("field", field);
}
fn timestamp_after_millis(delay_millis: u64) -> crate::MarketPriceTimestamp {
let now = current_timestamp_or_zero().unix_millis();
return crate::MarketPriceTimestamp::from_unix_millis(now.saturating_add(delay_millis));
}
fn validate_requested_provider_ids(service: &crate::MarketPriceService, provider_ids: &[crate::MarketPriceProviderId]) -> ksp_core_lib::Result<()> {
let mut seen = std::collections::BTreeSet::new();
for provider_id in provider_ids {
if service.provider_index(provider_id).is_none() {
return std::result::Result::Err(provider_not_found_error(provider_id));
}
if !seen.insert(provider_id) {
return std::result::Result::Err(refresh_error("provider_id"));
}
}
return std::result::Result::Ok(());
}
#[cfg(test)]
#[path = "../unit_tests/market_price_service.rs"]
mod tests;

View File

@@ -0,0 +1,37 @@
// file: crates/ksp-offchain-transport-lib/src/market_price_settings.rs
// version: 3
/// Common provider settings embedded by future provider-specific runtime settings.
///
/// Provider-specific credentials, pair selectors and access modes intentionally do not live here because providers without those capabilities must not be
/// forced into artificial fields.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub struct MarketPriceProviderCommonSettings {
enabled: bool,
provider_id: crate::MarketPriceProviderId,
}
impl MarketPriceProviderCommonSettings {
/// Creates common settings for one uniquely identified runtime provider instance.
#[must_use]
pub fn new(provider_id: crate::MarketPriceProviderId, enabled: bool) -> Self {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, enabled = enabled, "constructed common market-price provider settings");
return Self { enabled, provider_id };
}
/// Reports whether this provider instance is enabled.
#[must_use]
pub const fn enabled(&self) -> bool {
return self.enabled;
}
/// Returns the opaque runtime provider identifier.
#[must_use]
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
return &self.provider_id;
}
}
#[cfg(test)]
#[path = "../unit_tests/market_price_settings.rs"]
mod tests;

View File

@@ -0,0 +1,128 @@
// file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
// version: 10
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Dependency, observability, hardened HTTP and module-taxonomy canaries for Off-chain Transport.
#[test]
fn pre_004_manifest_uses_only_generic_runtime_crates_and_no_provider_sdk() {
let manifest = include_str!("../Cargo.toml");
assert!(manifest.contains("chrono = { workspace = true, features = [\"std\"] }"));
assert!(manifest.contains("ksp-core-lib"));
assert!(manifest.contains("ksp-logging-lib"));
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
assert!(manifest.contains("serde"));
assert!(manifest.contains("serde_json = { workspace = true, features = [\"raw_value\"] }"));
assert!(!manifest.contains("ksp-config-lib"));
assert!(!manifest.contains("coingecko"));
assert!(!manifest.contains("coinmarketcap"));
assert!(!manifest.contains("coinpaprika"));
assert!(!manifest.contains("jupiter"));
assert!(!manifest.contains("birdeye"));
assert!(!manifest.contains("dexscreener"));
assert!(!manifest.lines().any(|line| return line.trim_start().starts_with("tracing =")));
}
#[test]
fn pre_004_http_runtime_is_production_active_but_not_a_public_generic_client() {
let crate_root = include_str!("../src/lib.rs");
assert!(crate_root.contains("mod http_admission;"));
assert!(crate_root.contains("mod http_client;"));
assert!(crate_root.contains("mod http_settings;"));
assert!(!crate_root.contains("#[cfg(test)] // RUST-API-008: staged"));
assert!(!crate_root.contains("pub use self::http_client::HttpRestClient"));
let client = include_str!("../src/http_client.rs");
assert!(client.contains(".redirect(reqwest::redirect::Policy::none())"));
assert!(client.contains(".referer(false)"));
assert!(client.contains(".retry(reqwest::retry::never())"));
assert!(client.contains(".no_proxy()"));
assert!(client.contains("error.without_url()"));
}
#[test]
fn pre_005_provider_modules_are_market_price_scoped_and_fixed_origin() {
let crate_root = include_str!("../src/lib.rs");
assert!(crate_root.contains("mod market_price_coinbase_exchange;"));
assert!(crate_root.contains("mod market_price_coingecko;"));
assert!(crate_root.contains("mod market_price_coinmarketcap;"));
assert!(crate_root.contains("mod market_price_coinpaprika;"));
assert!(crate_root.contains("mod market_price_kraken;"));
let coinbase = include_str!("../src/market_price_coinbase_exchange.rs");
let coingecko = include_str!("../src/market_price_coingecko.rs");
let coinmarketcap = include_str!("../src/market_price_coinmarketcap.rs");
let coinpaprika = include_str!("../src/market_price_coinpaprika.rs");
let kraken = include_str!("../src/market_price_kraken.rs");
assert!(coinbase.contains("https://api.exchange.coinbase.com/products/SOL-USD/ticker"));
assert!(coingecko.contains("https://api.coingecko.com/api/v3/simple/price"));
assert!(coinmarketcap.contains("https://pro-api.coinmarketcap.com/public-api/v2/simple/price"));
assert!(coinmarketcap.contains("https://pro-api.coinmarketcap.com/v2/simple/price"));
assert!(coinpaprika.contains("https://api.coinpaprika.com/v1/tickers/sol-solana"));
assert!(kraken.contains("https://api.kraken.com/0/public/Ticker"));
assert!(kraken.contains("SOLUSD"));
assert!(!coinbase.contains("std::env"));
assert!(!coingecko.contains("std::env"));
assert!(!coinmarketcap.contains("std::env"));
assert!(!coinpaprika.contains("std::env"));
assert!(!kraken.contains("std::env"));
}
#[test]
fn pre_006_jupiter_and_dexscreener_stay_market_price_scoped_fixed_origin_and_discovery_free() {
let crate_root = include_str!("../src/lib.rs");
assert!(crate_root.contains("mod market_price_dexscreener;"));
assert!(crate_root.contains("mod market_price_jupiter;"));
let dexscreener = include_str!("../src/market_price_dexscreener.rs");
let jupiter = include_str!("../src/market_price_jupiter.rs");
assert!(dexscreener.contains("https://api.dexscreener.com/latest/dex/pairs/solana"));
assert!(dexscreener.contains("Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE") || dexscreener.contains("pair_address"));
assert!(!dexscreener.contains("latest/dex/search"));
assert!(!dexscreener.contains("token-pairs/v1"));
assert!(!dexscreener.contains("tokens/v1"));
assert!(jupiter.contains("https://api.jup.ag/price/v3"));
assert!(jupiter.contains("So11111111111111111111111111111111111111112"));
assert!(!jupiter.contains("/quote"));
assert!(!dexscreener.contains("std::env"));
assert!(!jupiter.contains("std::env"));
}
#[test]
fn pre_007_birdeye_and_registry_remain_provider_owned_generic_and_fixed_origin() {
let crate_root = include_str!("../src/lib.rs");
assert!(crate_root.contains("mod market_price_birdeye;"));
assert!(crate_root.contains("mod market_price_registry;"));
let birdeye = include_str!("../src/market_price_birdeye.rs");
let registry = include_str!("../src/market_price_registry.rs");
assert!(birdeye.contains("https://public-api.birdeye.so/defi/price"));
assert!(birdeye.contains("x-api-key"));
assert!(birdeye.contains("x-chain"));
assert!(birdeye.contains("So11111111111111111111111111111111111111112"));
assert!(!birdeye.contains("std::env"));
assert!(!registry.contains("CoinGecko"));
assert!(!registry.contains("CoinMarketCap"));
assert!(!registry.contains("CoinPaprika"));
assert!(!registry.contains("DexScreener"));
assert!(!registry.contains("Jupiter"));
assert!(!registry.contains("Kraken"));
}
#[test]
fn pre_008_refresh_service_owns_dispatch_while_registry_and_consumer_surface_stay_generic() {
let crate_root = include_str!("../src/lib.rs");
let registry = include_str!("../src/market_price_registry.rs");
let service = include_str!("../src/market_price_service.rs");
assert!(crate_root.contains("mod market_price_service;"));
assert!(crate_root.contains("pub use self::market_price_service::MarketPriceService;"));
assert!(!registry.contains("MarketPriceProviderSetup::"));
assert!(service.contains("pub async fn refresh("));
assert!(service.contains("pub async fn refresh_many("));
assert!(service.contains("pub async fn refresh_all("));
assert!(service.contains("is_refresh_eligible_at"));
assert!(!service.contains("std::thread::sleep"));
assert!(!service.contains("tokio::time::sleep"));
assert!(!service.contains("fallback_provider"));
assert!(!service.contains("fn aggregate("));
assert!(!service.contains("fn consensus("));
}

View File

@@ -0,0 +1,180 @@
// file: crates/ksp-offchain-transport-lib/tests/market_price_live_smoke.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Opt-in live SOL/USD smokes for the V1 Off-chain Transport market-price providers.
use std::io::IsTerminal; // rust-rules: trait-import
const DEXSCREENER_SOL_USDC_ORCA_PAIR: &str = "7qbRF6YsyGuLUVs6Y1q64bdVrfe4ZcUUz1JRdoVNUJnm";
fn smoke_error(message: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID, message);
}
fn keyless_setups() -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
let mut setups = std::vec::Vec::with_capacity(7);
let coinbase = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase));
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko));
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap));
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(coinpaprika));
let dexscreener =
match ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(true, std::option::Option::Some(DEXSCREENER_SOL_USDC_ORCA_PAIR.to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::DexScreener(dexscreener));
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter));
let kraken = match ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken));
return std::result::Result::Ok(setups);
}
fn read_secret_line(label: &'static str) -> ksp_core_lib::Result<std::string::String> {
let mut secret = std::string::String::new();
match std::io::stdin().read_line(&mut secret) {
std::result::Result::Ok(_) => {},
std::result::Result::Err(_) => return std::result::Result::Err(smoke_error("keyed market-price smoke stdin is unreadable")),
}
let secret = secret.trim().to_owned();
if secret.is_empty() {
return std::result::Result::Err(smoke_error(label));
}
return std::result::Result::Ok(secret);
}
fn keyed_setups() -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
if std::io::stdin().is_terminal() {
return std::result::Result::Err(smoke_error("pipe four provider API keys to the keyed market-price smoke on stdin"));
}
let birdeye_key = match read_secret_line("Birdeye API key from smoke stdin must not be empty") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coingecko_key = match read_secret_line("CoinGecko Demo API key from smoke stdin must not be empty") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap_key = match read_secret_line("CoinMarketCap Basic API key from smoke stdin must not be empty") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let jupiter_key = match read_secret_line("Jupiter Free API key from smoke stdin must not be empty") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut setups = std::vec::Vec::with_capacity(4);
let birdeye = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(true, std::option::Option::Some(birdeye_key)) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye));
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::Some(coingecko_key)) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko));
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::basic(true, std::option::Option::Some(coinmarketcap_key)) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap));
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::free(true, std::option::Option::Some(jupiter_key)) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter));
return std::result::Result::Ok(setups);
}
fn assert_live_outcomes(
registry: &ksp_offchain_transport_lib::MarketPriceProviderRegistry,
outcomes: &[ksp_offchain_transport_lib::MarketPriceRefreshOutcome],
) {
let mut failures = std::vec::Vec::new();
for outcome in outcomes {
let provider_id = outcome.provider_id();
let observation = match outcome.observation() {
std::option::Option::Some(value) => value,
std::option::Option::None => {
failures.push(std::format!("{}:{:?}", provider_id.as_str(), outcome.state().availability()));
continue;
},
};
assert_eq!(observation.provider_id(), provider_id);
assert_eq!(observation.pair(), ksp_offchain_transport_lib::MarketPricePair::SolUsd);
assert!(observation.price().coefficient() > 0);
assert!(observation.received_at() >= observation.request_started_at());
let descriptor = match registry.descriptor(provider_id) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
failures.push(std::format!("{}:missing_registry_descriptor", provider_id.as_str()));
continue;
},
};
assert_eq!(observation.semantics(), descriptor.semantics());
}
assert!(failures.is_empty(), "live market-price providers did not all refresh successfully after every provider was attempted: {failures:?}");
return;
}
async fn run_live_smoke(setups: std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>) -> ksp_core_lib::Result<()> {
let service = match ksp_offchain_transport_lib::MarketPriceService::new(setups) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = service.registry();
let outcomes = match service.refresh_all().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(outcomes.len(), registry.len());
assert_live_outcomes(&registry, outcomes.as_slice());
return std::result::Result::Ok(());
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "opt-in live SOL/USD smoke for seven keyless V1 providers; performs external HTTPS requests"]
async fn keyless_market_price_providers_live_smoke() -> ksp_core_lib::Result<()> {
let setups = match keyless_setups() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return run_live_smoke(setups).await;
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "opt-in live SOL/USD smoke for four free keyed V1 modes; reads API keys from stdin in Birdeye/CoinGecko/CoinMarketCap/Jupiter order"]
async fn keyed_market_price_providers_live_smoke() -> ksp_core_lib::Result<()> {
let setups = match keyed_setups() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return run_live_smoke(setups).await;
}

View File

@@ -0,0 +1,239 @@
// file: crates/ksp-offchain-transport-lib/tests/public_api.rs
// version: 10
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Public API canaries for the market-price foundation and stable Off-chain Transport error-code surface.
#[test]
fn public_pre_002_market_price_foundation_is_available_from_crate_root() -> ksp_core_lib::Result<()> {
assert_eq!(ksp_offchain_transport_lib::MarketPricePair::SolUsd.code(), "SOL/USD");
assert_eq!(ksp_offchain_transport_lib::MARKET_PRICE_DECIMAL_MAX_SCALE, 18);
let price = match ksp_offchain_transport_lib::MarketPriceDecimal::parse("201.2500") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(price.to_canonical_string(), "201.25");
let id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("provider-canary") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let common = ksp_offchain_transport_lib::MarketPriceProviderCommonSettings::new(id.clone(), true);
assert_eq!(common.provider_id(), &id);
let rate_limit = match ksp_offchain_transport_lib::MarketPriceProviderRateLimit::fixed(
1,
1,
std::option::Option::None,
ksp_offchain_transport_lib::MarketPriceProviderRateLimitScope::Ip,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let descriptor = match ksp_offchain_transport_lib::MarketPriceProviderDescriptor::new(
id.clone(),
"Provider Canary",
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
rate_limit,
std::option::Option::None,
true,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(descriptor.supports_sol_usd());
let provenance = match ksp_offchain_transport_lib::MarketPriceProvenance::new("canary") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let timestamp = ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(1);
let observation = match ksp_offchain_transport_lib::MarketPriceObservation::new(
id,
price,
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
timestamp,
timestamp,
std::option::Option::None,
provenance,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.price(), price);
return std::result::Result::Ok(());
}
#[test]
fn public_pre_004_aggregator_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coingecko_provider = match ksp_offchain_transport_lib::MarketPriceCoinGeckoProvider::new(coingecko) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(coingecko_provider.descriptor().id().as_str(), "coingecko");
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap_provider = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapProvider::new(coinmarketcap) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(coinmarketcap_provider.descriptor().id().as_str(), "coinmarketcap");
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinpaprika_provider = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaProvider::new(coinpaprika) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(coinpaprika_provider.descriptor().id().as_str(), "coinpaprika");
return std::result::Result::Ok(());
}
#[test]
fn public_pre_005_exchange_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
let kraken = match ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kraken_provider = match ksp_offchain_transport_lib::MarketPriceKrakenProvider::new(kraken) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(kraken_provider.descriptor().id().as_str(), "kraken");
assert_eq!(kraken_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade);
let coinbase = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinbase_provider = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeProvider::new(coinbase) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(coinbase_provider.descriptor().id().as_str(), "coinbase_exchange");
assert_eq!(coinbase_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade);
return std::result::Result::Ok(());
}
#[test]
fn public_pre_006_jupiter_and_dexscreener_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let jupiter_provider = match ksp_offchain_transport_lib::MarketPriceJupiterProvider::new(jupiter) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
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, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let dexscreener_provider = match ksp_offchain_transport_lib::MarketPriceDexScreenerProvider::new(dexscreener) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(dexscreener_provider.descriptor().id().as_str(), "dexscreener");
assert_eq!(dexscreener_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd);
return std::result::Result::Ok(());
}
#[test]
fn public_pre_007_birdeye_and_provider_registry_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
let settings = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider = match ksp_offchain_transport_lib::MarketPriceBirdeyeProvider::new(settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(provider.descriptor().id().as_str(), "birdeye");
assert_eq!(provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot);
let request_cost = match provider.descriptor().sol_usd_request_cost() {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(ksp_core_lib::Error::new(
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID,
"Birdeye descriptor is missing request-cost metadata",
));
},
};
assert_eq!(request_cost.amount(), 3);
assert_eq!(request_cost.unit(), ksp_offchain_transport_lib::MarketPriceProviderQuotaUnit::ComputeUnits);
let ready = ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready;
assert!(ready.is_refresh_eligible());
assert_eq!(ready.retry_at(), std::option::Option::None);
let entry = ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry::new(provider.descriptor().clone(), ready);
let registry = match ksp_offchain_transport_lib::MarketPriceProviderRegistry::new(std::vec![entry]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(registry.len(), 1);
assert_eq!(registry.entries()[0].descriptor().id().as_str(), "birdeye");
return std::result::Result::Ok(());
}
#[tokio::test]
async fn public_pre_008_generic_refresh_service_is_available_without_provider_branching_at_callsite() -> ksp_core_lib::Result<()> {
let settings = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service =
match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(settings),]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = service.registry();
let provider_id = registry.entries()[0].descriptor().id().clone();
let outcome = match service.refresh(&provider_id).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(outcome.provider_id(), &provider_id);
assert!(!outcome.refreshed());
assert_eq!(outcome.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
return std::result::Result::Ok(());
}
#[test]
fn offchain_error_codes_use_owned_domain() {
let codes = [
ksp_offchain_transport_lib::ERROR_CODE_HTTP_ACCESS_DENIED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_ADMISSION_DEFERRED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CONNECTION_FAILED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_INVALID_JSON,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_RATE_LIMIT_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_RATE_LIMITED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_REQUEST_FAILED,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_REQUEST_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_SETTINGS_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_TEMPORARY_FAILURE,
ksp_offchain_transport_lib::ERROR_CODE_HTTP_TIMEOUT,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID,
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID,
];
for code in codes {
assert_eq!(code.domain(), "offchain_transport");
}
}

View File

@@ -0,0 +1,161 @@
// file: crates/ksp-offchain-transport-lib/tests/release_completeness.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Release-level completeness canaries for the eight-provider SOL/USD V1 contract.
#[test]
fn pre_010_exact_eight_provider_inventory_and_semantics_are_stable() -> ksp_core_lib::Result<()> {
let setups = match all_disabled_setups() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service = match ksp_offchain_transport_lib::MarketPriceService::new(setups) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = service.registry();
assert_eq!(registry.len(), 8);
let expected = [
(
"birdeye",
ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot,
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::RequiredApiKey,
),
(
"coinbase_exchange",
ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade,
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
),
(
"coingecko",
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
),
(
"coinmarketcap",
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
),
(
"coinpaprika",
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
),
("dexscreener", ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
("jupiter", ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
("kraken", ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
];
for (entry, (provider_id, semantics, auth_mode)) in registry.entries().iter().zip(expected) {
assert_eq!(entry.descriptor().id().as_str(), provider_id);
assert_eq!(entry.descriptor().semantics(), semantics);
assert_eq!(entry.descriptor().auth_mode(), auth_mode);
assert!(entry.descriptor().supports_sol_usd());
assert_eq!(entry.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
}
return std::result::Result::Ok(());
}
#[test]
fn pre_010_evolutive_public_enums_are_non_exhaustive() -> std::io::Result<()> {
let root = crate_root();
let provider = match std::fs::read_to_string(root.join("src/market_price_provider.rs")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
for enum_name in [
"MarketPricePair",
"MarketPriceSemantics",
"MarketPriceProviderAuthMode",
"MarketPriceProviderRateLimitScope",
"MarketPriceProviderRateLimitKind",
"MarketPriceProviderQuotaPeriod",
"MarketPriceProviderQuotaUnit",
"MarketPriceProviderAvailability",
] {
assert_non_exhaustive(provider.as_str(), enum_name);
}
let coingecko = match std::fs::read_to_string(root.join("src/market_price_coingecko.rs")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_non_exhaustive(coingecko.as_str(), "MarketPriceCoinGeckoAccessMode");
let coinmarketcap = match std::fs::read_to_string(root.join("src/market_price_coinmarketcap.rs")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_non_exhaustive(coinmarketcap.as_str(), "MarketPriceCoinMarketCapAccessMode");
let jupiter = match std::fs::read_to_string(root.join("src/market_price_jupiter.rs")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_non_exhaustive(jupiter.as_str(), "MarketPriceJupiterAccessMode");
let service = match std::fs::read_to_string(root.join("src/market_price_service.rs")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_non_exhaustive(service.as_str(), "MarketPriceProviderSetup");
return std::result::Result::Ok(());
}
fn all_disabled_setups() -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
let birdeye = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinbase = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let dexscreener = match ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kraken = match ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(std::vec![
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
ksp_offchain_transport_lib::MarketPriceProviderSetup::DexScreener(dexscreener),
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
]);
}
fn assert_non_exhaustive(source: &str, enum_name: &str) {
let marker = std::format!("pub enum {enum_name}");
let position = source.find(marker.as_str());
assert!(position.is_some(), "public enum must remain present: {enum_name}");
if let std::option::Option::Some(position) = position {
let start = position.saturating_sub(256);
assert!(source[start..position].contains("#[non_exhaustive]"), "evolutive public enum must be non_exhaustive: {enum_name}");
}
}
fn crate_root() -> std::path::PathBuf {
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
}

View File

@@ -0,0 +1,89 @@
// file: crates/ksp-offchain-transport-lib/tests/security_hardening.rs
// version: 2
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Adversarial security canaries for Off-chain Transport diagnostics, credentials and ownership boundaries.
#[test]
fn pre_010_keyed_settings_and_service_debug_never_expose_credentials() -> ksp_core_lib::Result<()> {
let secret = "pre010-api-key-secret-canary";
let birdeye = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(true, std::option::Option::Some(secret.to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::Some(secret.to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::basic(true, std::option::Option::Some(secret.to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::free(true, std::option::Option::Some(secret.to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
for debug in [std::format!("{birdeye:?}"), std::format!("{coingecko:?}"), std::format!("{coinmarketcap:?}"), std::format!("{jupiter:?}")] {
assert!(!debug.contains(secret));
assert!(debug.contains("api_key_present"));
}
let service = match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!std::format!("{service:?}").contains(secret));
return std::result::Result::Ok(());
}
#[test]
fn pre_010_market_price_sources_do_not_read_environment_or_use_float_price_truth() -> std::io::Result<()> {
let src = crate_root().join("src");
let entries = match std::fs::read_dir(src) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
for entry in entries {
let entry = match entry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("market_price_") || !name.ends_with(".rs") {
continue;
}
let source = match std::fs::read_to_string(entry.path()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!source.contains("std::env"), "market-price source must not bypass Config environment ownership: {name}");
assert!(!source.contains("as_f64("), "market-price source must not use serde_json f64 as canonical price truth: {name}");
}
return std::result::Result::Ok(());
}
#[test]
fn pre_010_config_schema_exposes_no_provider_url_or_rate_limit_override() -> std::io::Result<()> {
let root = crate_root().join("../..");
let schema = match std::fs::read_to_string(root.join("config/schemas/std.offchain_transport.schema.json")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!schema.contains("base_url"));
assert!(!schema.contains("endpoint_url"));
assert!(!schema.contains("rate_limit"));
assert!(!schema.contains("requests_per"));
return std::result::Result::Ok(());
}
fn crate_root() -> std::path::PathBuf {
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
}

View File

@@ -0,0 +1,71 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs
// version: 2
#[test]
fn fixed_admission_smooths_undocumented_burst_and_refills_deterministically() -> ksp_core_lib::Result<()> {
let policy = match crate::HttpAdmissionPolicy::fixed(2, std::time::Duration::from_secs(1), std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let controller = match crate::HttpAdmissionController::new(policy, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let start = std::time::Instant::now();
assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Ready));
let deferred = controller.try_admit_at(start);
let delay = match deferred {
crate::HttpAdmissionDecision::Deferred(value) => value,
crate::HttpAdmissionDecision::Ready => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "test expected local deferral"));
},
};
assert!(delay > std::time::Duration::ZERO);
let half_second = match start.checked_add(std::time::Duration::from_millis(500)) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "test instant overflow"));
},
};
assert!(matches!(controller.try_admit_at(half_second), crate::HttpAdmissionDecision::Ready));
return std::result::Result::Ok(());
}
#[test]
fn documented_burst_may_exceed_average_window_budget_and_is_consumed_atomically() -> ksp_core_lib::Result<()> {
let policy = match crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::from_secs(1), std::option::Option::Some(2)) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let controller = match crate::HttpAdmissionController::new(policy, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let start = std::time::Instant::now();
assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Ready));
assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Ready));
assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Deferred(_)));
return std::result::Result::Ok(());
}
#[test]
fn provider_retry_after_extends_but_cannot_pathologically_lock_cooldown() -> ksp_core_lib::Result<()> {
let controller =
match crate::HttpAdmissionController::new(crate::HttpAdmissionPolicy::Dynamic, std::option::Option::Some(std::time::Duration::from_secs(2))) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let applied = controller.record_rate_limited(std::option::Option::Some(std::time::Duration::from_secs(99_999)));
assert_eq!(applied, crate::HTTP_MAX_RETRY_AFTER);
let remaining = controller.cooldown_remaining();
assert!(remaining.is_some());
assert!(matches!(controller.try_admit(), crate::HttpAdmissionDecision::Deferred(_)));
return std::result::Result::Ok(());
}
#[test]
fn admission_policy_rejects_zero_values_and_invalid_fallback() {
assert!(crate::HttpAdmissionPolicy::fixed(0, std::time::Duration::from_secs(1), std::option::Option::None).is_err());
assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::ZERO, std::option::Option::None).is_err());
assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::from_secs(1), std::option::Option::Some(0)).is_err());
}

View File

@@ -0,0 +1,236 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
// version: 2
#[tokio::test]
async fn rest_client_accepts_bounded_json_and_never_exposes_request_debug() -> ksp_core_lib::Result<()> {
let server_result =
spawn_single_response("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\nConnection: close\r\n\r\n{\"price\":123}").await;
let server = match server_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("symbol", "SOL/USD");
let secret = "sensitive-canary-value";
if let std::result::Result::Err(error) = request.insert_sensitive_header("x-api-key", secret) {
return std::result::Result::Err(error);
}
let debug = format!("{request:?}");
assert!(!debug.contains(secret));
assert!(!debug.contains(server.url.as_str()));
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = match client.get_json("test-provider", "sol_usd", request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(document.as_bytes(), br#"{"price":123}"#);
if let std::result::Result::Err(error) = finish_server(server.join).await {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
#[test]
fn https_request_builder_accepts_official_style_url_without_exposing_it() -> ksp_core_lib::Result<()> {
let mut request = match crate::HttpGetRequest::new_https("https://example.com/price") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
request.append_query_pair("symbol", "SOL/USD");
if let std::result::Result::Err(error) = request.insert_sensitive_header("x-api-key", "redaction-canary") {
return std::result::Result::Err(error);
}
let debug = format!("{request:?}");
assert!(!debug.contains("example.com"));
assert!(!debug.contains("redaction-canary"));
return std::result::Result::Ok(());
}
#[tokio::test]
async fn rest_client_rejects_redirects_instead_of_following_them() -> ksp_core_lib::Result<()> {
let server_result =
spawn_single_response("HTTP/1.1 302 Found\r\nLocation: https://example.invalid/secret\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await;
let server = match server_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let error = match client.get_json("test-provider", "redirect", request).await {
std::result::Result::Ok(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test expected redirect rejection"));
},
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED);
if let std::result::Result::Err(error) = finish_server(server.join).await {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
#[tokio::test]
async fn rest_client_bounds_chunked_body_before_json_deserialization() -> ksp_core_lib::Result<()> {
let server_result = spawn_single_response(concat!(
"HTTP/1.1 200 OK\r\n",
"Content-Type: application/json\r\n",
"Transfer-Encoding: chunked\r\n",
"Connection: close\r\n\r\n",
"8\r\n{\"aaaa\":\r\n",
"8\r\n\"bbbbbb\"\r\n",
"1\r\n}\r\n0\r\n\r\n",
))
.await;
let server = match server_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let settings = match crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 8) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let client = match crate::HttpRestClient::new(settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let error = match client.get_json("test-provider", "bounded", request).await {
std::result::Result::Ok(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, "test expected body bound"));
},
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE);
if let std::result::Result::Err(error) = finish_server(server.join).await {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
#[tokio::test]
async fn rest_client_classifies_429_retry_after_and_never_copies_remote_body() -> ksp_core_lib::Result<()> {
let remote_canary = "REMOTE_SECRET_CANARY";
let response =
format!("HTTP/1.1 429 Too Many Requests\r\nRetry-After: 7\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", remote_canary.len(), remote_canary);
let server_result = spawn_single_response(response.as_str()).await;
let server = match server_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let error = match client.get_json("test-provider", "rate_limited", request).await {
std::result::Result::Ok(_) => return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMITED, "test expected 429")),
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_RATE_LIMITED);
assert!(!format!("{error:?}").contains(remote_canary));
assert!(!error.to_string().contains(remote_canary));
if let std::result::Result::Err(error) = finish_server(server.join).await {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
#[tokio::test]
async fn rest_client_rejects_invalid_json_after_success_status() -> ksp_core_lib::Result<()> {
let server_result = spawn_single_response("HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nnot-json").await;
let server = match server_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let error = match client.get_json("test-provider", "invalid_json", request).await {
std::result::Result::Ok(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_INVALID_JSON, "test expected invalid JSON"));
},
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_INVALID_JSON);
if let std::result::Result::Err(error) = finish_server(server.join).await {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
struct TestServer {
join: tokio::task::JoinHandle<std::io::Result<()>>,
url: std::string::String,
}
async fn spawn_single_response(response: &str) -> ksp_core_lib::Result<TestServer> {
let listener_result = tokio::net::TcpListener::bind("127.0.0.1:0").await;
let listener = match listener_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(io_error(error)),
};
let address = match listener.local_addr() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(io_error(error)),
};
let response = response.as_bytes().to_vec();
let join = tokio::spawn(async move {
let accept_result = listener.accept().await;
let (mut socket, _) = match accept_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut request = [0_u8; 4096];
if let std::result::Result::Err(error) = tokio::io::AsyncReadExt::read(&mut socket, &mut request).await {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = tokio::io::AsyncWriteExt::write_all(&mut socket, response.as_slice()).await {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = tokio::io::AsyncWriteExt::shutdown(&mut socket).await {
return std::result::Result::Err(error);
}
return std::io::Result::Ok(());
});
return std::result::Result::Ok(TestServer { join, url: format!("http://{address}/price") });
}
async fn finish_server(join: tokio::task::JoinHandle<std::io::Result<()>>) -> ksp_core_lib::Result<()> {
let joined = join.await;
return match joined {
std::result::Result::Ok(std::result::Result::Ok(())) => std::result::Result::Ok(()),
std::result::Result::Ok(std::result::Result::Err(error)) => std::result::Result::Err(io_error(error)),
std::result::Result::Err(error) => std::result::Result::Err(join_error(error)),
};
}
fn io_error(error: std::io::Error) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test HTTP server failed").with_source(error);
}
fn join_error(error: tokio::task::JoinError) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test HTTP server task failed").with_source(error);
}

View File

@@ -0,0 +1,20 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs
// version: 1
#[test]
fn http_settings_defaults_are_bounded_and_ordered() {
let settings = crate::HttpClientSettings::default();
assert!(!settings.connect_timeout().is_zero());
assert!(settings.connect_timeout() <= settings.request_timeout());
assert!(settings.max_response_body_bytes() > 0);
}
#[test]
fn http_settings_reject_zero_reversed_and_pathological_bounds() {
assert!(crate::HttpClientSettings::new(std::time::Duration::ZERO, std::time::Duration::from_secs(1), 1024).is_err());
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(2), std::time::Duration::from_secs(1), 1024).is_err());
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 0).is_err());
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(31), std::time::Duration::from_secs(31), 1024).is_err());
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(121), 1024).is_err());
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 4_194_305).is_err());
}

View File

@@ -0,0 +1,24 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_adapter.rs
// version: 1
#[test]
fn timestamp_helpers_preserve_valid_values_and_reject_malformed_pre_epoch_and_overflow() {
let epoch_fraction = crate::market_price_timestamp_from_rfc3339("1970-01-01T00:00:00.123Z");
assert_eq!(epoch_fraction.map(|value| return value.unix_millis()), std::option::Option::Some(123));
assert!(crate::market_price_timestamp_from_rfc3339("1969-12-31T23:59:59Z").is_none());
assert!(crate::market_price_timestamp_from_rfc3339("not-a-timestamp").is_none());
assert_eq!(crate::market_price_timestamp_from_unix_seconds(1).map(|value| return value.unix_millis()), std::option::Option::Some(1_000));
assert!(crate::market_price_timestamp_from_unix_seconds(u64::MAX).is_none());
}
#[test]
fn invalid_provider_response_error_contains_only_safe_provider_and_field_context() {
let error = crate::invalid_provider_response("coingecko", "price");
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID);
let context = error.context();
assert_eq!(context.len(), 2);
assert_eq!(context[0].key(), "provider");
assert_eq!(context[0].value(), "coingecko");
assert_eq!(context[1].key(), "field");
assert_eq!(context[1].value(), "price");
}

View File

@@ -0,0 +1,85 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_birdeye.rs
// version: 1
#[test]
fn birdeye_fixture_maps_exact_spot_price_and_provider_update_time() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new(super::BIRDEYE_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let fixture = br#"{"data":{"value":151.987654321012345678,"updateUnixTime":1778248899,"updateHumanTime":"2026-05-08T19:01:39"},"success":true}"#;
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
let observation = match super::parse_response(fixture, provider_id, started, received) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::SolanaSpot);
assert_eq!(observation.provider_timestamp().map(|value| return value.unix_millis()), std::option::Option::Some(1_778_248_899_000));
assert_eq!(observation.provenance().as_str(), "birdeye:solana:wsol:value");
return std::result::Result::Ok(());
}
#[test]
fn birdeye_standard_descriptor_models_account_rate_compute_quota_and_request_cost() -> ksp_core_lib::Result<()> {
let settings = match crate::MarketPriceBirdeyeSettings::new(true, std::option::Option::Some("birdeye-standard-canary-secret".to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!std::format!("{settings:?}").contains("birdeye-standard-canary-secret"));
let request = match super::build_request(&settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(request.has_header_for_test(super::BIRDEYE_API_KEY_HEADER));
assert!(request.has_header_for_test(super::BIRDEYE_CHAIN_HEADER));
assert_eq!(request.url_for_test().query(), std::option::Option::Some("address=So11111111111111111111111111111111111111112"));
let provider = match crate::MarketPriceBirdeyeProvider::new(settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let descriptor = provider.descriptor();
assert_eq!(descriptor.auth_mode(), crate::MarketPriceProviderAuthMode::RequiredApiKey);
assert_eq!(descriptor.semantics(), crate::MarketPriceSemantics::SolanaSpot);
assert_eq!(descriptor.rate_limit().requests(), std::option::Option::Some(1));
assert_eq!(descriptor.rate_limit().window_seconds(), std::option::Option::Some(1));
assert_eq!(descriptor.rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Account);
let quota = match descriptor.long_term_quota() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(test_error("quota")),
};
assert_eq!(quota.amount(), 30_000);
assert_eq!(quota.period(), crate::MarketPriceProviderQuotaPeriod::Month);
assert_eq!(quota.unit(), crate::MarketPriceProviderQuotaUnit::ComputeUnits);
let request_cost = match descriptor.sol_usd_request_cost() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(test_error("request_cost")),
};
assert_eq!(request_cost.amount(), 3);
assert_eq!(request_cost.unit(), crate::MarketPriceProviderQuotaUnit::ComputeUnits);
return std::result::Result::Ok(());
}
#[test]
fn birdeye_rejects_missing_key_unsuccessful_missing_price_and_missing_update_time() -> ksp_core_lib::Result<()> {
assert!(crate::MarketPriceBirdeyeSettings::new(true, std::option::Option::None).is_err());
assert!(crate::MarketPriceBirdeyeSettings::new(false, std::option::Option::None).is_ok());
let provider_id = match crate::MarketPriceProviderId::new(super::BIRDEYE_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
let unsuccessful = br#"{"data":{"value":151.9,"updateUnixTime":1778248899},"success":false}"#;
assert!(super::parse_response(unsuccessful, provider_id.clone(), timestamp, timestamp).is_err());
let missing_price = br#"{"data":{"value":null,"updateUnixTime":1778248899},"success":true}"#;
assert!(super::parse_response(missing_price, provider_id.clone(), timestamp, timestamp).is_err());
let missing_update_time = br#"{"data":{"value":151.9},"success":true}"#;
assert!(super::parse_response(missing_update_time, provider_id, timestamp, timestamp).is_err());
return std::result::Result::Ok(());
}
fn test_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID, "Birdeye test expectation failed")
.with_context("field", field);
}

View File

@@ -0,0 +1,59 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coinbase_exchange.rs
// version: 1
#[test]
fn coinbase_exchange_fixture_maps_exact_last_trade_and_provider_timestamp() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new(super::COINBASE_EXCHANGE_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let fixture = concat!(
r#"{"trade_id":86326522,"price":"151.987654321012345678","size":"0.25","#,
r#""time":"2026-08-25T19:00:01.123Z","bid":"151.98","ask":"151.99","volume":"12345.67"}"#
)
.as_bytes();
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
let observation = match super::parse_response(fixture, provider_id, started, received) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
assert_eq!(observation.provider_timestamp().map(|value| return value.unix_millis()), std::option::Option::Some(1_787_684_401_123));
assert_eq!(observation.provenance().as_str(), "coinbase_exchange:SOL-USD:last_trade");
return std::result::Result::Ok(());
}
#[test]
fn coinbase_exchange_descriptor_matches_public_token_bucket() -> ksp_core_lib::Result<()> {
let settings = match crate::MarketPriceCoinbaseExchangeSettings::new(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider = match crate::MarketPriceCoinbaseExchangeProvider::new(settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(provider.descriptor().id().as_str(), super::COINBASE_EXCHANGE_PROVIDER_ID);
assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
assert_eq!(provider.descriptor().semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(10));
assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1));
assert_eq!(provider.descriptor().rate_limit().burst(), std::option::Option::Some(15));
assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
assert_eq!(provider.descriptor().long_term_quota(), std::option::Option::None);
return std::result::Result::Ok(());
}
#[test]
fn coinbase_exchange_rejects_invalid_timestamp() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new(super::COINBASE_EXCHANGE_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let fixture = br#"{"price":"151.98","time":"not-a-timestamp"}"#;
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
assert!(super::parse_response(fixture, provider_id, timestamp, timestamp).is_err());
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,71 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coingecko.rs
// version: 1
#[test]
fn coingecko_modes_map_exact_free_capabilities_and_redact_demo_key() -> ksp_core_lib::Result<()> {
let keyless = match crate::MarketPriceCoinGeckoSettings::keyless(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let keyless_provider = match crate::MarketPriceCoinGeckoProvider::new(keyless) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(keyless_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
assert_eq!(keyless_provider.descriptor().rate_limit().kind(), crate::MarketPriceProviderRateLimitKind::Dynamic);
assert_eq!(keyless_provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
assert_eq!(keyless_provider.descriptor().long_term_quota(), std::option::Option::None);
let demo = match crate::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::Some("demo-secret-canary".to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!format!("{demo:?}").contains("demo-secret-canary"));
let request = match super::build_request(&demo) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(request.url_for_test().host_str(), std::option::Option::Some("api.coingecko.com"));
assert!(request.url_for_test().as_str().contains("ids=solana"));
assert!(request.url_for_test().as_str().contains("vs_currencies=usd"));
assert!(request.has_header_for_test(super::COINGECKO_DEMO_API_KEY_HEADER));
let demo_provider = match crate::MarketPriceCoinGeckoProvider::new(demo) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(demo_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::RequiredApiKey);
assert_eq!(demo_provider.descriptor().rate_limit().requests(), std::option::Option::Some(100));
assert_eq!(demo_provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(60));
let quota = match demo_provider.descriptor().long_term_quota() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response("coingecko", "quota")),
};
assert_eq!(quota.amount(), 10_000);
return std::result::Result::Ok(());
}
#[test]
fn coingecko_fixture_maps_exact_price_and_real_provider_timestamp() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("coingecko") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let start = crate::MarketPriceTimestamp::from_unix_millis(1_800_000_000_000);
let received = crate::MarketPriceTimestamp::from_unix_millis(1_800_000_000_100);
let observation = match super::parse_response(br#"{"solana":{"usd":151.123456789012345678,"last_updated_at":1800000000}}"#, provider_id, start, received) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.price().to_canonical_string(), "151.123456789012345678");
assert_eq!(observation.provider_timestamp(), std::option::Option::Some(start));
assert_eq!(observation.provenance().as_str(), "coingecko:solana:usd");
return std::result::Result::Ok(());
}
#[test]
fn coingecko_keyless_rejects_credential_and_enabled_demo_requires_one() {
let keyless =
crate::MarketPriceCoinGeckoSettings::new(true, crate::MarketPriceCoinGeckoAccessMode::Keyless, std::option::Option::Some("unexpected".to_owned()));
assert!(keyless.is_err());
assert!(crate::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::None).is_err());
assert!(crate::MarketPriceCoinGeckoSettings::demo(false, std::option::Option::None).is_ok());
}

View File

@@ -0,0 +1,91 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coinmarketcap.rs
// version: 3
#[test]
fn coinmarketcap_modes_use_v2_and_map_exact_free_capabilities() -> ksp_core_lib::Result<()> {
let keyless = match crate::MarketPriceCoinMarketCapSettings::keyless(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match super::build_request(&keyless) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(request.url_for_test().path(), "/public-api/v2/simple/price");
assert_eq!(request.url_for_test().query(), std::option::Option::Some("id=5426&convert=USD&include_last_updated=true"));
assert!(!request.has_header_for_test(super::COINMARKETCAP_API_KEY_HEADER));
let keyless_provider = match crate::MarketPriceCoinMarketCapProvider::new(keyless) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(keyless_provider.descriptor().rate_limit().kind(), crate::MarketPriceProviderRateLimitKind::Dynamic);
let basic = match crate::MarketPriceCoinMarketCapSettings::basic(true, std::option::Option::Some("cmc-secret-canary".to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!format!("{basic:?}").contains("cmc-secret-canary"));
let request = match super::build_request(&basic) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(request.url_for_test().path(), "/v2/simple/price");
assert_eq!(request.url_for_test().query(), std::option::Option::Some("id=5426&convert=USD&include_last_updated=true"));
assert!(request.has_header_for_test(super::COINMARKETCAP_API_KEY_HEADER));
let basic_provider = match crate::MarketPriceCoinMarketCapProvider::new(basic) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(basic_provider.descriptor().rate_limit().requests(), std::option::Option::Some(50));
let quota = match basic_provider.descriptor().long_term_quota() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response("coinmarketcap", "quota")),
};
assert_eq!(quota.amount(), 15_000);
return std::result::Result::Ok(());
}
#[test]
fn coinmarketcap_v2_fixture_normalizes_string_status_and_exact_price() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("coinmarketcap") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let start = crate::MarketPriceTimestamp::from_unix_millis(1_775_000_000_000);
let received = crate::MarketPriceTimestamp::from_unix_millis(1_775_000_000_100);
let observation = match super::parse_response(
concat!(
r#"{"data":[{"id":5426,"symbol":"SOL","quotes":[{"symbol":"USD","price":151.987654321012345678,"#,
r#""last_updated":"2026-04-01T00:00:00.000Z"}]}],"status":{"error_code":"0"}}"#,
)
.as_bytes(),
provider_id,
start,
received,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
assert_eq!(observation.provenance().as_str(), "coinmarketcap:5426:usd:v2");
assert!(observation.provider_timestamp().is_some());
return std::result::Result::Ok(());
}
#[test]
fn coinmarketcap_rejects_nonzero_status_and_wrong_identity() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("coinmarketcap") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(1);
let nonzero = super::parse_response(br#"{"data":[],"status":{"error_code":1001}}"#, provider_id.clone(), timestamp, timestamp);
assert!(nonzero.is_err());
let wrong = super::parse_response(
br#"{"data":[{"id":1,"symbol":"BTC","quotes":[{"symbol":"USD","price":1,"last_updated":"2026-04-01T00:00:00Z"}]}],"status":{"error_code":0}}"#,
provider_id,
timestamp,
timestamp,
);
assert!(wrong.is_err());
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,60 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coinpaprika.rs
// version: 1
#[test]
fn coinpaprika_free_descriptor_maps_ip_rate_and_monthly_request_quota() -> ksp_core_lib::Result<()> {
let settings = match crate::MarketPriceCoinPaprikaSettings::new(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request = match super::build_request() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(request.url_for_test().host_str(), std::option::Option::Some("api.coinpaprika.com"));
assert_eq!(request.url_for_test().path(), "/v1/tickers/sol-solana");
assert!(request.url_for_test().as_str().contains("quotes=USD"));
let provider = match crate::MarketPriceCoinPaprikaProvider::new(settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(10));
assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1));
assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
let quota = match provider.descriptor().long_term_quota() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response("coinpaprika", "quota")),
};
assert_eq!(quota.amount(), 20_000);
assert_eq!(quota.unit(), crate::MarketPriceProviderQuotaUnit::Requests);
return std::result::Result::Ok(());
}
#[test]
fn coinpaprika_fixture_validates_sol_identity_and_preserves_timestamp() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("coinpaprika") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(1_775_000_000_000);
let observation = match super::parse_response(
br#"{"id":"sol-solana","symbol":"SOL","last_updated":"2026-04-01T00:00:00Z","quotes":{"USD":{"price":151.010203040506070809}}}"#,
provider_id.clone(),
timestamp,
timestamp,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.price().to_canonical_string(), "151.010203040506070809");
assert_eq!(observation.provenance().as_str(), "coinpaprika:sol-solana:usd");
let wrong = super::parse_response(
br#"{"id":"btc-bitcoin","symbol":"BTC","last_updated":"2026-04-01T00:00:00Z","quotes":{"USD":{"price":1}}}"#,
provider_id,
timestamp,
timestamp,
);
assert!(wrong.is_err());
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,102 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
// version: 5
#[test]
fn decimal_normalizes_fractional_and_scientific_forms_without_f64() -> ksp_core_lib::Result<()> {
let cases = [
("123.4500", "123.45", 12_345_u128, 2_u8),
("1.2345e2", "123.45", 12_345, 2),
("12345e-2", "123.45", 12_345, 2),
("1e3", "1000", 1_000, 0),
("1e-3", "0.001", 1, 3),
];
for (source, expected, coefficient, scale) in cases {
let value = match crate::MarketPriceDecimal::parse(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(value.to_canonical_string(), expected);
assert_eq!(value.coefficient(), coefficient);
assert_eq!(value.scale(), scale);
}
return std::result::Result::Ok(());
}
#[test]
fn decimal_rejects_zero_negative_nonfinite_excessive_scale_and_overflow() {
let invalid = ["0", "0.000", "-1", "+1", "NaN", "inf", "1e-19", "1e129", "340282366920938463463374607431768211456", " 1", "1 ", ".1", "1."];
for source in invalid {
let result = crate::MarketPriceDecimal::parse(source);
assert!(result.is_err());
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID);
}
}
}
#[test]
fn decimal_serde_is_canonical_string_and_round_trips_exactly() -> ksp_core_lib::Result<()> {
let value = match crate::MarketPriceDecimal::parse("123.4500") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let encoded = match serde_json::to_string(&value) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test serialization failed"));
},
};
assert_eq!(encoded, "\"123.45\"");
let decoded: crate::MarketPriceDecimal = match serde_json::from_str(encoded.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test deserialization failed"));
},
};
assert_eq!(decoded, value);
assert!(serde_json::from_str::<crate::MarketPriceDecimal>("123.45").is_err());
return std::result::Result::Ok(());
}
#[test]
fn decimal_parses_raw_json_number_and_string_without_f64_round_trip() -> ksp_core_lib::Result<()> {
let number = match serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>("151.123456789012345678") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test raw number failed"));
},
};
let parsed = match crate::MarketPriceDecimal::parse_json_raw(number.as_ref()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(parsed.to_canonical_string(), "151.123456789012345678");
let string = match serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>(r#""151.2300""#) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test raw string failed"));
},
};
let parsed = match crate::MarketPriceDecimal::parse_json_raw(string.as_ref()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(parsed.to_canonical_string(), "151.23");
return std::result::Result::Ok(());
}
#[test]
fn decimal_raw_json_rejects_non_numeric_and_pathological_values() {
let invalid = ["null", "true", "false", "{}", "[]", r#""0""#, r#""-1""#, r#""1e-19""#, r#""1e129""#];
for source in invalid {
let raw = serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>(source);
assert!(raw.is_ok(), "adversarial raw JSON fixture must itself be syntactically valid: {source}");
if let std::result::Result::Ok(raw) = raw {
let parsed = crate::MarketPriceDecimal::parse_json_raw(raw.as_ref());
assert!(parsed.is_err(), "non-price raw JSON must not become a successful decimal: {source}");
if let std::result::Result::Err(error) = parsed {
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID);
}
}
}
}

View File

@@ -0,0 +1,118 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs
// version: 2
const TEST_PAIR: &str = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE";
#[test]
fn dexscreener_fixture_validates_chain_pair_and_sol_identity_and_maps_exact_pair_usd() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new(super::DEXSCREENER_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pair_address = match TEST_PAIR.parse::<ksp_core_lib::Pubkey>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(super::provider_settings_error("test_pair")),
};
let fixture = concat!(
r#"{"schemaVersion":"1.0.0","pairs":[{"chainId":"solana","dexId":"orca","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
r#""baseToken":{"address":"So11111111111111111111111111111111111111112","name":"Wrapped SOL","symbol":"SOL"},"#,
r#""quoteToken":{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","name":"USD Coin","symbol":"USDC"},"#,
r#""priceNative":"151.987654321012345678","priceUsd":"151.987654321012345678"}]}"#
)
.as_bytes();
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
let observation = match super::parse_response(fixture, provider_id, &pair_address, started, received) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::DexPairUsd);
assert_eq!(observation.provider_timestamp(), std::option::Option::None);
assert_eq!(observation.provenance().as_str(), "dexscreener:solana:Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE:priceUsd");
return std::result::Result::Ok(());
}
#[test]
fn dexscreener_settings_and_descriptor_keep_pair_explicit_and_pair_endpoint_rate_limited() -> ksp_core_lib::Result<()> {
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),
};
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),
};
assert_eq!(request.url_for_test().as_str(), "https://api.dexscreener.com/latest/dex/pairs/solana/Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE");
let provider = match crate::MarketPriceDexScreenerProvider::new(settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
assert_eq!(provider.descriptor().semantics(), crate::MarketPriceSemantics::DexPairUsd);
assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(300));
assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(60));
assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Unspecified);
return std::result::Result::Ok(());
}
#[test]
fn dexscreener_rejects_wrong_chain_pair_base_identity_multiple_pairs_and_missing_price() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new(super::DEXSCREENER_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pair_address = match TEST_PAIR.parse::<ksp_core_lib::Pubkey>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(super::provider_settings_error("test_pair")),
};
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
let wrong_chain = concat!(
r#"{"pairs":[{"chainId":"ethereum","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"}]}"#
)
.as_bytes();
assert!(super::parse_response(wrong_chain, provider_id.clone(), &pair_address, timestamp, timestamp).is_err());
let wrong_pair = concat!(
r#"{"pairs":[{"chainId":"solana","pairAddress":"58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2","#,
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"}]}"#
)
.as_bytes();
assert!(super::parse_response(wrong_pair, provider_id.clone(), &pair_address, timestamp, timestamp).is_err());
let wrong_base = concat!(
r#"{"pairs":[{"chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
r#""baseToken":{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"},"priceUsd":"1.00"}]}"#
)
.as_bytes();
assert!(super::parse_response(wrong_base, provider_id.clone(), &pair_address, timestamp, timestamp).is_err());
let multiple = concat!(
r#"{"pairs":[{"chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"},{"#,
r#""chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"}]}"#
)
.as_bytes();
assert!(super::parse_response(multiple, provider_id.clone(), &pair_address, timestamp, timestamp).is_err());
let missing_price = concat!(
r#"{"pairs":[{"chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":null}]}"#
)
.as_bytes();
assert!(super::parse_response(missing_price, provider_id, &pair_address, timestamp, timestamp).is_err());
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,81 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs
// version: 1
#[test]
fn jupiter_fixture_maps_exact_heuristic_price_and_block_provenance_without_fake_timestamp() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new(super::JUPITER_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let fixture = concat!(
r#"{"So11111111111111111111111111111111111111112":{"createdAt":"2024-06-05T08:55:25.527Z","liquidity":621679197.67,"#,
r#""usdPrice":151.987654321012345678,"blockId":348004023,"decimals":9,"priceChange24h":1.29}}"#
)
.as_bytes();
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
let observation = match super::parse_response(fixture, provider_id, started, received) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::SolanaHeuristic);
assert_eq!(observation.provider_timestamp(), std::option::Option::None);
assert_eq!(observation.provenance().as_str(), "jupiter:price_v3:So11111111111111111111111111111111111111112:block:348004023");
return std::result::Result::Ok(());
}
#[test]
fn jupiter_modes_match_current_keyless_and_free_rate_limits_and_redact_key() -> ksp_core_lib::Result<()> {
let keyless = match crate::MarketPriceJupiterSettings::keyless(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let keyless_provider = match crate::MarketPriceJupiterProvider::new(keyless) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(keyless_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
assert_eq!(keyless_provider.descriptor().semantics(), crate::MarketPriceSemantics::SolanaHeuristic);
assert_eq!(keyless_provider.descriptor().rate_limit().requests(), std::option::Option::Some(1));
assert_eq!(keyless_provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(2));
assert_eq!(keyless_provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Unspecified);
let free = match crate::MarketPriceJupiterSettings::free(true, std::option::Option::Some("jup-free-canary-secret".to_owned())) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(!std::format!("{free:?}").contains("jup-free-canary-secret"));
let request = match super::build_request(&free) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(request.has_header_for_test(super::JUPITER_API_KEY_HEADER));
assert_eq!(request.url_for_test().query(), std::option::Option::Some("ids=So11111111111111111111111111111111111111112"));
let free_provider = match crate::MarketPriceJupiterProvider::new(free) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(free_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::RequiredApiKey);
assert_eq!(free_provider.descriptor().rate_limit().requests(), std::option::Option::Some(1));
assert_eq!(free_provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1));
assert_eq!(free_provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Account);
return std::result::Result::Ok(());
}
#[test]
fn jupiter_rejects_keyless_credential_missing_price_wrong_identity_and_wrong_decimals() -> ksp_core_lib::Result<()> {
assert!(crate::MarketPriceJupiterSettings::free(true, std::option::Option::None).is_err());
assert!(crate::MarketPriceJupiterSettings::keyless(true).is_ok());
let provider_id = match crate::MarketPriceProviderId::new(super::JUPITER_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
let missing_price = br#"{"So11111111111111111111111111111111111111112":{"usdPrice":null,"blockId":348004023,"decimals":9}}"#;
assert!(super::parse_response(missing_price, provider_id.clone(), timestamp, timestamp).is_err());
let wrong_identity = br#"{"JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN":{"usdPrice":1.0,"blockId":348004023,"decimals":6}}"#;
assert!(super::parse_response(wrong_identity, provider_id.clone(), timestamp, timestamp).is_err());
let wrong_decimals = br#"{"So11111111111111111111111111111111111111112":{"usdPrice":151.98,"blockId":348004023,"decimals":8}}"#;
assert!(super::parse_response(wrong_decimals, provider_id, timestamp, timestamp).is_err());
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,57 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs
// version: 1
#[test]
fn kraken_fixture_maps_exact_last_trade_without_inventing_provider_timestamp() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new(super::KRAKEN_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let fixture = br#"{"error":[],"result":{"SOLUSD":{"a":["152.10","1","1.0"],"b":["152.00","1","1.0"],"c":["152.012345678901234567","0.25"]}}}"#;
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
let observation = match super::parse_response(fixture, provider_id, started, received) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.price().to_canonical_string(), "152.012345678901234567");
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
assert_eq!(observation.provider_timestamp(), std::option::Option::None);
assert_eq!(observation.provenance().as_str(), "kraken:SOLUSD:last_trade");
return std::result::Result::Ok(());
}
#[test]
fn kraken_descriptor_is_keyless_exchange_last_trade_with_conservative_public_cadence() -> ksp_core_lib::Result<()> {
let settings = match crate::MarketPriceKrakenSettings::new(true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider = match crate::MarketPriceKrakenProvider::new(settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(provider.descriptor().id().as_str(), super::KRAKEN_PROVIDER_ID);
assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
assert_eq!(provider.descriptor().semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(1));
assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1));
assert_eq!(provider.descriptor().rate_limit().burst(), std::option::Option::None);
assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
assert_eq!(provider.descriptor().long_term_quota(), std::option::Option::None);
return std::result::Result::Ok(());
}
#[test]
fn kraken_rejects_provider_error_and_wrong_result_identity() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new(super::KRAKEN_PROVIDER_ID) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
let provider_error = br#"{"error":["EGeneral:Temporary lockout"],"result":{}}"#;
assert!(super::parse_response(provider_error, provider_id.clone(), timestamp, timestamp).is_err());
let wrong_identity = br#"{"error":[],"result":{"SOLEUR":{"c":["152.01","0.25"]}}}"#;
assert!(super::parse_response(wrong_identity, provider_id, timestamp, timestamp).is_err());
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,84 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs
// version: 4
#[test]
fn observation_preserves_pair_exact_price_semantics_timestamps_and_safe_provenance() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("kraken") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let price = match crate::MarketPriceDecimal::parse("204.125") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provenance = match crate::MarketPriceProvenance::new("market=SOL/USD") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let started = crate::MarketPriceTimestamp::from_unix_millis(10_000);
let received = crate::MarketPriceTimestamp::from_unix_millis(10_250);
let observation = match crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::ExchangeLastTrade,
started,
received,
std::option::Option::None,
provenance,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(observation.pair(), crate::MarketPricePair::SolUsd);
assert_eq!(observation.pair().code(), "SOL/USD");
assert_eq!(observation.price(), price);
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
assert_eq!(observation.request_started_at(), started);
assert_eq!(observation.received_at(), received);
assert_eq!(observation.provider_timestamp(), std::option::Option::None);
assert_eq!(observation.provenance().as_str(), "market=SOL/USD");
return std::result::Result::Ok(());
}
#[test]
fn observation_rejects_reversed_ksp_timestamps_and_unsafe_provenance() -> ksp_core_lib::Result<()> {
assert!(crate::MarketPriceProvenance::new("line\nbreak").is_err());
let provider_id = match crate::MarketPriceProviderId::new("coingecko") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let price = match crate::MarketPriceDecimal::parse("200") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provenance = match crate::MarketPriceProvenance::new("asset=solana") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = crate::MarketPriceObservation::new(
provider_id,
price,
crate::MarketPriceSemantics::AggregatedMarket,
crate::MarketPriceTimestamp::from_unix_millis(2),
crate::MarketPriceTimestamp::from_unix_millis(1),
std::option::Option::None,
provenance,
);
assert!(result.is_err());
return std::result::Result::Ok(());
}
#[test]
fn provenance_accepts_exact_boundary_and_rejects_trim_control_and_oversize() -> ksp_core_lib::Result<()> {
let exact = "a".repeat(crate::MARKET_PRICE_PROVENANCE_MAX_BYTES);
let provenance = crate::MarketPriceProvenance::new(exact.clone());
assert!(provenance.is_ok());
if let std::result::Result::Ok(provenance) = provenance {
assert_eq!(provenance.as_str(), exact);
}
assert!(crate::MarketPriceProvenance::new(format!("{exact}a")).is_err());
assert!(crate::MarketPriceProvenance::new(" leading").is_err());
assert!(crate::MarketPriceProvenance::new("trailing ").is_err());
assert!(crate::MarketPriceProvenance::new("tab\tvalue").is_err());
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,145 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
// version: 5
#[test]
fn provider_id_is_opaque_bounded_and_stable() -> ksp_core_lib::Result<()> {
let id = match crate::MarketPriceProviderId::new("coingecko-main") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(id.as_str(), "coingecko-main");
assert_eq!(id.to_string(), "coingecko-main");
for invalid in ["", "CoinGecko", "coin gecko", "coin/gecko", "é"] {
assert!(crate::MarketPriceProviderId::new(invalid).is_err());
}
return std::result::Result::Ok(());
}
#[test]
fn provider_descriptor_preserves_semantics_auth_limits_and_informational_quota() -> ksp_core_lib::Result<()> {
let id = match crate::MarketPriceProviderId::new("provider-a") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 2, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let quota = match crate::MarketPriceProviderLongTermQuota::new(
10_000,
crate::MarketPriceProviderQuotaPeriod::Month,
crate::MarketPriceProviderQuotaUnit::Credits,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let descriptor = match crate::MarketPriceProviderDescriptor::new(
id,
"Provider A",
crate::MarketPriceSemantics::AggregatedMarket,
crate::MarketPriceProviderAuthMode::OptionalApiKey,
rate_limit,
std::option::Option::Some(quota),
true,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(descriptor.display_name(), "Provider A");
assert_eq!(descriptor.sol_usd_request_cost(), std::option::Option::None);
assert_eq!(descriptor.semantics(), crate::MarketPriceSemantics::AggregatedMarket);
assert_eq!(descriptor.auth_mode(), crate::MarketPriceProviderAuthMode::OptionalApiKey);
assert_eq!(descriptor.rate_limit(), rate_limit);
assert_eq!(descriptor.long_term_quota(), std::option::Option::Some(quota));
assert!(descriptor.supports_sol_usd());
return std::result::Result::Ok(());
}
#[test]
fn provider_limit_descriptors_reject_zero_and_model_dynamic_scope() {
assert!(crate::MarketPriceProviderRateLimit::fixed(0, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip).is_err());
assert!(crate::MarketPriceProviderRateLimit::fixed(1, 0, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip).is_err());
assert!(crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::Some(0), crate::MarketPriceProviderRateLimitScope::Ip).is_err());
let dynamic = crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip);
assert_eq!(dynamic.kind(), crate::MarketPriceProviderRateLimitKind::Dynamic);
assert_eq!(dynamic.scope(), crate::MarketPriceProviderRateLimitScope::Ip);
assert_eq!(dynamic.requests(), std::option::Option::None);
assert_eq!(dynamic.window_seconds(), std::option::Option::None);
assert_eq!(dynamic.burst(), std::option::Option::None);
}
#[test]
fn provider_request_cost_matches_quota_unit_and_rejects_zero() -> ksp_core_lib::Result<()> {
assert!(crate::MarketPriceProviderRequestCost::new(0, crate::MarketPriceProviderQuotaUnit::ComputeUnits).is_err());
let id = match crate::MarketPriceProviderId::new("provider-cost") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let quota = match crate::MarketPriceProviderLongTermQuota::new(
30_000,
crate::MarketPriceProviderQuotaPeriod::Month,
crate::MarketPriceProviderQuotaUnit::ComputeUnits,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let descriptor = match crate::MarketPriceProviderDescriptor::new(
id,
"Provider Cost",
crate::MarketPriceSemantics::SolanaSpot,
crate::MarketPriceProviderAuthMode::RequiredApiKey,
rate_limit,
std::option::Option::Some(quota),
true,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request_cost = match crate::MarketPriceProviderRequestCost::new(3, crate::MarketPriceProviderQuotaUnit::ComputeUnits) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let descriptor = match descriptor.with_sol_usd_request_cost(request_cost) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(descriptor.sol_usd_request_cost(), std::option::Option::Some(request_cost));
let wrong_unit = match crate::MarketPriceProviderRequestCost::new(3, crate::MarketPriceProviderQuotaUnit::Credits) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(descriptor.with_sol_usd_request_cost(wrong_unit).is_err());
return std::result::Result::Ok(());
}
#[test]
fn provider_availability_keeps_cooldown_and_outage_distinct() -> ksp_core_lib::Result<()> {
let id = match crate::MarketPriceProviderId::new("jupiter") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let retry_at = crate::MarketPriceTimestamp::from_unix_millis(1_777_777_777_000);
let state = crate::MarketPriceProviderState::new(id, crate::MarketPriceProviderAvailability::CoolingDown { retry_at });
assert_eq!(state.availability(), crate::MarketPriceProviderAvailability::CoolingDown { retry_at });
assert!(!state.availability().is_refresh_eligible());
assert_eq!(state.availability().retry_at(), std::option::Option::Some(retry_at));
assert_ne!(state.availability(), crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(retry_at) });
return std::result::Result::Ok(());
}
#[test]
fn provider_availability_eligibility_at_respects_retry_deadlines_without_consumer_scheduling() {
let now = crate::MarketPriceTimestamp::from_unix_millis(100);
let future = crate::MarketPriceTimestamp::from_unix_millis(200);
let past = crate::MarketPriceTimestamp::from_unix_millis(50);
assert!(crate::MarketPriceProviderAvailability::Ready.is_refresh_eligible_at(now));
assert!(!crate::MarketPriceProviderAvailability::Disabled.is_refresh_eligible_at(now));
assert!(!crate::MarketPriceProviderAvailability::CoolingDown { retry_at: future }.is_refresh_eligible_at(now));
assert!(crate::MarketPriceProviderAvailability::CoolingDown { retry_at: past }.is_refresh_eligible_at(now));
assert!(crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None }.is_refresh_eligible_at(now));
assert!(crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(past) }.is_refresh_eligible_at(now));
}

View File

@@ -0,0 +1,157 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs
// version: 2
#[test]
fn registry_orders_all_eight_v1_providers_and_exposes_only_generic_entries() -> ksp_core_lib::Result<()> {
let entries = match all_v1_entries() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = match crate::MarketPriceProviderRegistry::new(entries) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(registry.len(), 8);
let ids = registry.entries().iter().map(|entry| return entry.descriptor().id().as_str()).collect::<std::vec::Vec<_>>();
assert_eq!(ids, std::vec!["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken",]);
assert!(registry.entries().iter().all(|entry| return entry.state().availability() == crate::MarketPriceProviderAvailability::Disabled));
return std::result::Result::Ok(());
}
#[test]
fn registry_rejects_duplicate_provider_ids_and_updates_generic_availability() -> ksp_core_lib::Result<()> {
let descriptor = match simple_descriptor("provider-a") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let duplicate = std::vec![
crate::MarketPriceProviderRegistryEntry::new(descriptor.clone(), crate::MarketPriceProviderAvailability::Disabled),
crate::MarketPriceProviderRegistryEntry::new(descriptor, crate::MarketPriceProviderAvailability::Ready),
];
assert!(crate::MarketPriceProviderRegistry::new(duplicate).is_err());
let descriptor = match simple_descriptor("provider-b") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_id = descriptor.id().clone();
let retry_at = crate::MarketPriceTimestamp::from_unix_millis(42);
let entry = crate::MarketPriceProviderRegistryEntry::new(descriptor, crate::MarketPriceProviderAvailability::CoolingDown { retry_at });
let registry = match crate::MarketPriceProviderRegistry::new(std::vec![entry]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let state = match registry.state(&provider_id) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(test_error("state")),
};
assert!(!state.availability().is_refresh_eligible());
assert_eq!(state.availability().retry_at(), std::option::Option::Some(retry_at));
assert_eq!(registry.descriptor(&provider_id).map(|value| return value.id()), std::option::Option::Some(&provider_id));
return std::result::Result::Ok(());
}
fn all_v1_entries() -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderRegistryEntry>> {
let birdeye_settings = match crate::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let birdeye = match crate::MarketPriceBirdeyeProvider::new(birdeye_settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinbase_settings = match crate::MarketPriceCoinbaseExchangeSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinbase = match crate::MarketPriceCoinbaseExchangeProvider::new(coinbase_settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coingecko_settings = match crate::MarketPriceCoinGeckoSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coingecko = match crate::MarketPriceCoinGeckoProvider::new(coingecko_settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap_settings = match crate::MarketPriceCoinMarketCapSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap = match crate::MarketPriceCoinMarketCapProvider::new(coinmarketcap_settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinpaprika_settings = match crate::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinpaprika = match crate::MarketPriceCoinPaprikaProvider::new(coinpaprika_settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let dexscreener_settings = match crate::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let dexscreener = match crate::MarketPriceDexScreenerProvider::new(dexscreener_settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let jupiter_settings = match crate::MarketPriceJupiterSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let jupiter = match crate::MarketPriceJupiterProvider::new(jupiter_settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kraken_settings = match crate::MarketPriceKrakenSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kraken = match crate::MarketPriceKrakenProvider::new(kraken_settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(std::vec![
disabled_entry(birdeye.descriptor()),
disabled_entry(coinbase.descriptor()),
disabled_entry(coingecko.descriptor()),
disabled_entry(coinmarketcap.descriptor()),
disabled_entry(coinpaprika.descriptor()),
disabled_entry(dexscreener.descriptor()),
disabled_entry(jupiter.descriptor()),
disabled_entry(kraken.descriptor()),
]);
}
fn disabled_entry(descriptor: &crate::MarketPriceProviderDescriptor) -> crate::MarketPriceProviderRegistryEntry {
return crate::MarketPriceProviderRegistryEntry::new(descriptor.clone(), crate::MarketPriceProviderAvailability::Disabled);
}
fn simple_descriptor(provider_id: &'static str) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
let provider_id = match crate::MarketPriceProviderId::new(provider_id) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::MarketPriceProviderDescriptor::new(
provider_id,
"Provider",
crate::MarketPriceSemantics::AggregatedMarket,
crate::MarketPriceProviderAuthMode::None,
rate_limit,
std::option::Option::None,
true,
);
}
fn test_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID, "Market-price registry test expectation failed")
.with_context("field", field);
}

View File

@@ -0,0 +1,148 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs
// version: 2
#[test]
fn service_registry_orders_all_eight_v1_setups_and_keeps_runtime_projection_generic() -> ksp_core_lib::Result<()> {
let setups = match all_disabled_setups() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service = match crate::MarketPriceService::new(setups) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = service.registry();
assert_eq!(registry.len(), 8);
let ids = registry.entries().iter().map(|entry| return entry.descriptor().id().as_str()).collect::<std::vec::Vec<_>>();
assert_eq!(ids, std::vec!["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken",]);
assert!(registry.entries().iter().all(|entry| return entry.state().availability() == crate::MarketPriceProviderAvailability::Disabled));
return std::result::Result::Ok(());
}
#[tokio::test]
async fn refresh_all_projects_disabled_providers_without_network_dispatch() -> ksp_core_lib::Result<()> {
let setups = match all_disabled_setups() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service = match crate::MarketPriceService::new(setups) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let outcomes = match service.refresh_all().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert_eq!(outcomes.len(), 8);
assert!(outcomes.iter().all(|outcome| return !outcome.refreshed()));
assert!(outcomes.iter().all(|outcome| return outcome.state().availability() == crate::MarketPriceProviderAvailability::Disabled));
let ids = outcomes.iter().map(|outcome| return outcome.provider_id().as_str()).collect::<std::vec::Vec<_>>();
assert_eq!(ids, std::vec!["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken",]);
return std::result::Result::Ok(());
}
#[tokio::test]
async fn refresh_many_rejects_duplicates_and_unknown_provider_before_dispatch() -> ksp_core_lib::Result<()> {
let settings = match crate::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service = match crate::MarketPriceService::new(std::vec![crate::MarketPriceProviderSetup::CoinPaprika(settings)]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let provider_id = match crate::MarketPriceProviderId::new("coinpaprika") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let duplicate = service.refresh_many(&[provider_id.clone(), provider_id]).await;
let duplicate_error = match duplicate {
std::result::Result::Err(error) => error,
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("duplicate")),
};
assert_eq!(duplicate_error.code(), crate::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID);
let unknown_id = match crate::MarketPriceProviderId::new("unknown") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let unknown = service.refresh(&unknown_id).await;
let unknown_error = match unknown {
std::result::Result::Err(error) => error,
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("unknown")),
};
assert_eq!(unknown_error.code(), crate::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND);
return std::result::Result::Ok(());
}
#[test]
fn refresh_error_classification_is_provider_neutral_and_preserves_known_retry_time() {
let auth = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_ACCESS_DENIED, "test");
assert_eq!(
super::availability_from_error(&auth, crate::MarketPriceProviderAuthMode::RequiredApiKey),
crate::MarketPriceProviderAvailability::AuthenticationUnavailable
);
assert!(matches!(
super::availability_from_error(&auth, crate::MarketPriceProviderAuthMode::None),
crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None }
));
let rate = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMITED, "test").with_context("retry_after_seconds", "2");
assert!(matches!(
super::availability_from_error(&rate, crate::MarketPriceProviderAuthMode::None),
crate::MarketPriceProviderAvailability::CoolingDown { .. }
));
let temporary = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE, "test").with_context("retry_after_seconds", "3");
let availability = super::availability_from_error(&temporary, crate::MarketPriceProviderAuthMode::None);
assert!(matches!(availability, crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(_) }));
let local = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "test");
assert_eq!(super::availability_from_error(&local, crate::MarketPriceProviderAuthMode::None), crate::MarketPriceProviderAvailability::Misconfigured);
}
fn all_disabled_setups() -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderSetup>> {
let birdeye = match crate::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinbase = match crate::MarketPriceCoinbaseExchangeSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coingecko = match crate::MarketPriceCoinGeckoSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap = match crate::MarketPriceCoinMarketCapSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinpaprika = match crate::MarketPriceCoinPaprikaSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
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),
};
let jupiter = match crate::MarketPriceJupiterSettings::keyless(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kraken = match crate::MarketPriceKrakenSettings::new(false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(std::vec![
crate::MarketPriceProviderSetup::Birdeye(birdeye),
crate::MarketPriceProviderSetup::CoinbaseExchange(coinbase),
crate::MarketPriceProviderSetup::CoinGecko(coingecko),
crate::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
crate::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
crate::MarketPriceProviderSetup::DexScreener(dexscreener),
crate::MarketPriceProviderSetup::Jupiter(jupiter),
crate::MarketPriceProviderSetup::Kraken(kraken),
]);
}
fn test_error(field: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID, "Market-price service test expectation failed")
.with_context("field", field);
}

View File

@@ -0,0 +1,23 @@
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_settings.rs
// version: 3
#[test]
fn common_settings_contain_only_generic_identity_and_enablement() -> ksp_core_lib::Result<()> {
let provider_id = match crate::MarketPriceProviderId::new("coinpaprika") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let settings = crate::MarketPriceProviderCommonSettings::new(provider_id, true);
assert!(settings.enabled());
assert_eq!(settings.provider_id().as_str(), "coinpaprika");
let serialized = match serde_json::to_value(&settings) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "test serialization failed"));
},
};
assert!(serialized.get("api_key").is_none());
assert!(serialized.get("endpoint").is_none());
assert!(serialized.get("rate_limit").is_none());
return std::result::Result::Ok(());
}

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/README.md --> <!-- file: crates/ksp-onchain-transport-lib/README.md -->
<!-- version: 22 --> <!-- version: 24 -->
# `ksp-onchain-transport-lib` # `ksp-onchain-transport-lib`
@@ -220,7 +220,6 @@ Pour Helius, lactor envoie automatiquement un control frame WebSocket `Ping`
LaserStream **gRPC** reste un backend distinct, hors de cette façade, de `WsProtocolKind` et de la Config WebSocket `helius_laserstream`. LaserStream **gRPC** reste un backend distinct, hors de cette façade, de `WsProtocolKind` et de la Config WebSocket `helius_laserstream`.
## Yellowstone gRPC standard ## Yellowstone gRPC standard
La foundation `0.2.9` ajoute un troisième backend réseau distinct de HTTP et WebSocket. Le moteur est KSP-owned : `yellowstone-grpc-proto` fournit le wire publié, tandis que Tonic reste encapsulé derrière les types crate-root KSP. Aucun client Tonic brut ni type protobuf upstream nest réexporté. La foundation `0.2.9` ajoute un troisième backend réseau distinct de HTTP et WebSocket. Le moteur est KSP-owned : `yellowstone-grpc-proto` fournit le wire publié, tandis que Tonic reste encapsulé derrière les types crate-root KSP. Aucun client Tonic brut ni type protobuf upstream nest réexporté.
@@ -246,6 +245,8 @@ Config Transport V3 peut mapper des `grpc_endpoints` vers ces settings sans inve
Le smoke live opt-in ouvre un `Subscribe` slots authentifié sur PublicNode Mainnet et Testnet, attend un update `Slot` non nul puis ferme de manière bornée. Les deux endpoints sont versionnés. Le harness reçoit ses personal tokens sur stdin ; aucun secret nest committé ni placé dans lURL. Le même token a été validé par lopérateur sur les deux réseaux, même si Config conserve deux variables distinctes pour laisser cette policy flexible. Le smoke live opt-in ouvre un `Subscribe` slots authentifié sur PublicNode Mainnet et Testnet, attend un update `Slot` non nul puis ferme de manière bornée. Les deux endpoints sont versionnés. Le harness reçoit ses personal tokens sur stdin ; aucun secret nest committé ni placé dans lURL. Le même token a été validé par lopérateur sur les deux réseaux, même si Config conserve deux variables distinctes pour laisser cette policy flexible.
`0.2.10` ajoute le profil `orbitflare_devnet` sur le même moteur provider-neutral. Lendpoint validé est `http://devnet.rpc.orbitflare.com:10000`; la License Key `ORBIT-*` est fournie par Config via `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` et devient la metadata secrète `x-token`. Le smoke live dédié a validé `Subscribe` slots à commitment confirmed, un `Slot` non nul et un `SubscribeUpdate::Ping` standard. Cette preuve confirme quaucune façade OrbitFlare, aucun heartbeat provider et aucune modification N1/N2 ne sont nécessaires.
## Résilience ## Résilience
L'admission est calculée par couple endpoint/rôle. Le pool applique : L'admission est calculée par couple endpoint/rôle. Le pool applique :
@@ -300,16 +301,20 @@ Transport WebSocket pur : settings programmatiques -> WsSession
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
-> getHealth/getGenesisHash/getVersion/getBalance -> getHealth/getGenesisHash/getVersion/getBalance
Transport Yellowstone gRPC : settings programmatiques + x-token secret Transport Yellowstone gRPC PublicNode : settings programmatiques + x-token secret
-> PublicNode Mainnet + Testnet -> Mainnet + Testnet
-> TLS -> Subscribe slots -> Slot non nul -> close borné -> TLS -> Subscribe slots -> Slot non nul -> close borné
Transport Yellowstone gRPC OrbitFlare : settings programmatiques + License Key x-token
-> Devnet
-> Subscribe slots -> Slot non nul + server Ping -> close borné
``` ```
Le smoke HTTP Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide. Le smoke HTTP Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
Le smoke WebSocket Transport cible uniquement la famille stable `slotSubscribe` sur l'endpoint public Devnet `wss://api.devnet.solana.com`. Il borne connexion, attente de notification, unsubscribe et fermeture ; il ne transforme aucune famille unstable en gate live. Le smoke WebSocket Transport cible uniquement la famille stable `slotSubscribe` sur l'endpoint public Devnet `wss://api.devnet.solana.com`. Il borne connexion, attente de notification, unsubscribe et fermeture ; il ne transforme aucune famille unstable en gate live.
Les quatre tests sont `ignored` par défaut ; le test Yellowstone contient deux cas live Mainnet/Testnet. Les trois familles de smokes Transport appartiennent durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée. Un rate-limit, refus externe ou incident Devnet n'est pas assimilé automatiquement à une régression locale. Les cinq tests sont `ignored` par défaut ; le test PublicNode Yellowstone contient deux cas live Mainnet/Testnet et le test OrbitFlare contient un cas Devnet. Les trois familles de smokes Transport appartiennent durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée. Un rate-limit, refus externe ou incident Devnet n'est pas assimilé automatiquement à une régression locale.
Aucun smoke Helius live supplémentaire nest committé en `0.2.8-pre.010`. Un tel test devrait à la fois obtenir `KSP_SECRET_HELIUS_API_KEY` via Config et exercer Transport ; lajouter dans Transport violerait lownership environnement/secret, tandis que lajouter dans Config étendrait lexception cross-crates que le projet veut au contraire résorber. La première surface KSP dintégration/orchestration dédiée devra héberger ce smoke. Le scénario live recommandé est alors `helius_devnet -> HeliusLaserStreamWsSession -> slotSubscribe -> notification -> unsubscribe -> close`; `transactionSubscribe` reste un smoke optionnel dépendant des droits provider et ne devient pas un gate stable de release. Aucun smoke Helius live supplémentaire nest committé en `0.2.8-pre.010`. Un tel test devrait à la fois obtenir `KSP_SECRET_HELIUS_API_KEY` via Config et exercer Transport ; lajouter dans Transport violerait lownership environnement/secret, tandis que lajouter dans Config étendrait lexception cross-crates que le projet veut au contraire résorber. La première surface KSP dintégration/orchestration dédiée devra héberger ce smoke. Le scénario live recommandé est alors `helius_devnet -> HeliusLaserStreamWsSession -> slotSubscribe -> notification -> unsubscribe -> close`; `transactionSubscribe` reste un smoke optionnel dépendant des droits provider et ne devient pas un gate stable de release.
@@ -325,4 +330,6 @@ Aucun smoke Helius live supplémentaire nest committé en `0.2.8-pre.010`. Un
- [`../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée `52/52 + 14/14` et audit `KSP-TRANSPORT-007` global ; - [`../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée `52/52 + 14/14` et audit `KSP-TRANSPORT-007` global ;
- [`../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md`](../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md) — plan Yellowstone gRPC standard/provider-neutral et PublicNode ; - [`../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md`](../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md) — plan Yellowstone gRPC standard/provider-neutral et PublicNode ;
- [`../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md`](../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md) — matrice de compliance Yellowstone ; - [`../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md`](../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md) — matrice de compliance Yellowstone ;
- [`../../docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md`](../../docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md) — composition OrbitFlare Devnet sur le standard Yellowstone ;
- [`../../docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md`](../../docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md) — validation finale OrbitFlare `x-token`, `Slot + Ping` et non-régression N1/N2 ;
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard Transport V3 HTTP + WebSocket + gRPC, avec lecture backward V1/V2. - [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard Transport V3 HTTP + WebSocket + gRPC, avec lecture backward V1/V2.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md --> <!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
<!-- version: 22 --> <!-- version: 23 -->
# Utilisation de `ksp-onchain-transport-lib` # Utilisation de `ksp-onchain-transport-lib`
@@ -331,6 +331,36 @@ let channel = ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(endpoin
`protocol = solana_yellowstone` est validé par Config et reste distinct du descripteur `provider`. Une valeur provider nautorise pas Transport à introduire une API provider-specific sans divergence réelle. `protocol = solana_yellowstone` est validé par Config et reste distinct du descripteur `provider`. Une valeur provider nautorise pas Transport à introduire une API provider-specific sans divergence réelle.
### Profil OrbitFlare Devnet
Le profil committé `orbitflare_devnet` réutilise exactement le même accès Config -> Transport :
```rust
let resolved = match engine.load_resolved_transport_config(Some("orbitflare_devnet"), &environment) {
Ok(value) => value,
Err(error) => return Err(error),
};
let grpc_settings = match resolved.grpc_settings() {
Some(value) => value,
None => return Err(ksp_core_lib::Error::new(
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
"selected OrbitFlare profile has no Yellowstone gRPC endpoint",
)),
};
let endpoint = match grpc_settings.endpoints().iter().find(|candidate| candidate.enabled()) {
Some(value) => value,
None => return Err(ksp_core_lib::Error::new(
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
"selected OrbitFlare profile has no enabled Yellowstone gRPC endpoint",
)),
};
let channel = ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(endpoint).await;
```
Config résout `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` vers la metadata secrète `x-token`. Sa valeur effective est la License Key `ORBIT-*` du produit Solana ; `X-ORBIT-KEY` et le Bearer du Customer API ne doivent pas être utilisés pour Yellowstone. Transport ne lit jamais cette variable lui-même.
Lendpoint validé par `0.2.10` est `http://devnet.rpc.orbitflare.com:10000`. Il reste volontairement en `http` : KSP ne remplace pas le transport provider par `https` sans endpoint TLS explicitement fourni.
### Subscribe bidirectionnel ### Subscribe bidirectionnel
Une session standard part dune requête typed complète : Une session standard part dune requête typed complète :
@@ -519,6 +549,26 @@ Testnet https://solana-testnet-yellowstone-grpc.publicnode.com:443
Les profils Config conservent deux variables secrètes distinctes afin d'autoriser des credentials différents si nécessaire ; cette séparation ne signifie pas que PublicNode impose actuellement un token différent par réseau. Un timeout KSP de half-close après réception du slot est accepté par le smoke comme fermeture bornée du provider ; aucune absence de slot ni autre erreur n'est masquée. Les profils Config conservent deux variables secrètes distinctes afin d'autoriser des credentials différents si nécessaire ; cette séparation ne signifie pas que PublicNode impose actuellement un token différent par réseau. Un timeout KSP de half-close après réception du slot est accepté par le smoke comme fermeture bornée du provider ; aucune absence de slot ni autre erreur n'est masquée.
Le smoke **Transport Yellowstone gRPC OrbitFlare** est lui aussi indépendant de Config. Il lit une seule License Key sur stdin, la classe comme metadata secrète `x-token`, ouvre le standard `Subscribe`, demande `slots` à commitment confirmed, attend un Slot non nul et un `SubscribeUpdate::Ping`, puis ferme la session de manière bornée :
```bash
read -rsp 'OrbitFlare License Key: ' ORBITFLARE_LICENSE_KEY
echo
printf '%s\n' "$ORBITFLARE_LICENSE_KEY" \
| cargo test -p ksp-onchain-transport-lib \
--test yellowstone_orbitflare_smoke \
-- --ignored --nocapture
unset ORBITFLARE_LICENSE_KEY
```
Endpoint validé :
```text
Devnet http://devnet.rpc.orbitflare.com:10000
```
Le gate `0.2.10-pre.003` a passé ce scénario en live avec `Slot + Ping`. Le Ping reçu est le message Yellowstone standard auquel N1 sait déjà répondre sans remplacer la dernière requête complète mémorisée ; aucun heartbeat OrbitFlare supplémentaire nest donc requis.
Le smoke de **composition Config -> Transport** reste également disponible : Le smoke de **composition Config -> Transport** reste également disponible :
```bash ```bash

View File

@@ -0,0 +1,150 @@
// file: crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
// version: 2
//! Opt-in live OrbitFlare Devnet characterization smoke for authenticated provider-neutral Yellowstone gRPC Subscribe.
use std::io::IsTerminal; // rust-rules: trait-import
fn orbitflare_license_x_token() -> std::string::String {
assert!(
!std::io::stdin().is_terminal(),
"pipe the OrbitFlare Solana Free License Key to this ignored smoke on stdin; never pass it as a command-line argument"
);
let mut token = std::string::String::new();
match std::io::stdin().read_line(&mut token) {
std::result::Result::Ok(_) => {},
std::result::Result::Err(error) => panic!("OrbitFlare License Key x-token must be readable from smoke stdin: {error}"),
}
let token = token.trim().to_owned();
assert!(!token.is_empty(), "OrbitFlare License Key x-token provided on smoke stdin must not be empty");
return token;
}
fn orbitflare_devnet_endpoint(x_token: &str) -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings> {
let url = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse("http://devnet.rpc.orbitflare.com:10000") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let session = ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::new(
std::time::Duration::from_secs(10),
std::time::Duration::from_secs(10),
std::time::Duration::from_secs(5),
ksp_onchain_transport_lib::YellowstoneGrpcReconnectSettings::new(0, std::time::Duration::from_millis(250), std::time::Duration::from_secs(2)),
8,
8,
16 * 1024 * 1024,
4 * 1024 * 1024,
);
let endpoint = ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
"orbitflare_devnet_yellowstone",
true,
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new("orbitflare"),
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new("devnet"),
url,
session,
);
let metadata = match ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret("x-token", x_token) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return endpoint.with_metadata(vec![metadata]);
}
fn slot_request() -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneSubscribeRequest> {
let mut request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
let name = match ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("slots") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
match request.insert_slot_filter(name, ksp_onchain_transport_lib::YellowstoneSubscribeSlotFilter::new()) {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
request.set_commitment(std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
return std::result::Result::Ok(request);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "opt-in live OrbitFlare Devnet Yellowstone gRPC characterization; reads one Solana Free License Key x-token from stdin, waits for Slot plus standard server Ping, then closes"]
async fn orbitflare_devnet_yellowstone_streams_slots_and_emits_standard_server_ping_with_license_x_token() {
let x_token = orbitflare_license_x_token();
let endpoint = match orbitflare_devnet_endpoint(x_token.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
panic!("programmatic OrbitFlare Devnet Yellowstone settings must accept the License Key as secret x-token metadata: {error:?}")
},
};
assert_eq!(endpoint.metadata().len(), 1);
assert_eq!(endpoint.metadata()[0].key(), "x-token");
assert!(endpoint.metadata()[0].is_secret());
assert!(!endpoint.url().uses_tls(), "the documented OrbitFlare shared Devnet endpoint is intentionally HTTP/2 plaintext");
let endpoint_debug = format!("{endpoint:?}");
assert!(!endpoint_debug.contains("orbitflare.com"), "OrbitFlare endpoint URL must not appear in endpoint Debug");
assert!(!endpoint_debug.contains(x_token.as_str()), "OrbitFlare License Key x-token must not appear in endpoint Debug");
assert!(!endpoint_debug.contains("X-ORBIT-KEY"), "OrbitFlare Customer API credentials must not enter Yellowstone settings");
let channel = match ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(&endpoint).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("OrbitFlare Devnet Yellowstone authenticated connection must succeed: {error:?}"),
};
assert_eq!(channel.endpoint_name(), "orbitflare_devnet_yellowstone");
assert_eq!(channel.provider().as_str(), "orbitflare");
assert_eq!(channel.cluster().as_str(), "devnet");
let request = match slot_request() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("OrbitFlare Yellowstone slot request must be valid: {error:?}"),
};
let mut session = match channel.open_standard_subscribe(request).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("OrbitFlare Devnet standard Subscribe with License Key x-token must open: {error:?}"),
};
let deadline = tokio::time::sleep(std::time::Duration::from_secs(45));
tokio::pin!(deadline);
let mut slot_seen = false;
let mut ping_seen = false;
loop {
tokio::select! {
() = &mut deadline => break,
next_update = session.next_update() => {
let next_update = match next_update {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("OrbitFlare Devnet Yellowstone Subscribe update must decode: {error:?}"),
};
match next_update {
std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneSubscribeUpdate::Slot(update)) => {
if update.slot() > 0 {
slot_seen = true;
}
},
std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneSubscribeUpdate::Ping(_)) => ping_seen = true,
std::option::Option::Some(_) => {},
std::option::Option::None => break,
}
if slot_seen && ping_seen {
break;
}
},
}
}
let close_timeout_result = tokio::time::timeout(std::time::Duration::from_secs(7), session.close()).await;
let close_result = match close_timeout_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
panic!("KSP Yellowstone Subscribe close must remain bounded beyond the configured five-second provider half-close deadline: {error}")
},
};
match close_result {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => {
assert_eq!(
error.code(),
ksp_onchain_transport_lib::ERROR_CODE_TIMEOUT,
"after live characterization, OrbitFlare close may time out waiting for provider half-close but must not fail for another reason: {error:?}"
);
},
}
assert!(slot_seen, "OrbitFlare Devnet Yellowstone must publish at least one non-zero confirmed slot during the 45-second characterization window");
assert!(
ping_seen,
"OrbitFlare Devnet must emit the standard Yellowstone server Ping during the 45-second characterization window before any provider-specific heartbeat is justified"
);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs // file: crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs
// version: 2 // version: 3
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum FixtureMode { enum FixtureMode {
@@ -27,7 +27,6 @@ struct FixtureGeyser {
#[tonic::async_trait] #[tonic::async_trait]
impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser { impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
type SubscribeStream = super::MpscStream<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>>; type SubscribeStream = super::MpscStream<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>>;
type SubscribeDeshredStream = futures_util::stream::Empty<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>>;
async fn subscribe( async fn subscribe(
&self, &self,
@@ -163,6 +162,8 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
return std::result::Result::Ok(tonic::Response::new(super::MpscStream::new(outbound_rx))); return std::result::Result::Ok(tonic::Response::new(super::MpscStream::new(outbound_rx)));
} }
type SubscribeDeshredStream = futures_util::stream::Empty<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>>;
async fn subscribe_deshred( async fn subscribe_deshred(
&self, &self,
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>, _request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs // file: crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs
// version: 2 // version: 3
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct FixtureGeyser; struct FixtureGeyser;
@@ -10,11 +10,6 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
type SubscribeStream = std::pin::Pin< type SubscribeStream = std::pin::Pin<
std::boxed::Box<dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>> + Send + 'static>, std::boxed::Box<dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>> + Send + 'static>,
>; >;
type SubscribeDeshredStream = std::pin::Pin<
std::boxed::Box<
dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>> + Send + 'static,
>,
>;
async fn subscribe( async fn subscribe(
&self, &self,
@@ -23,6 +18,12 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
return std::result::Result::Err(tonic::Status::unimplemented("streaming is outside the pre.003 fixture")); return std::result::Result::Err(tonic::Status::unimplemented("streaming is outside the pre.003 fixture"));
} }
type SubscribeDeshredStream = std::pin::Pin<
std::boxed::Box<
dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>> + Send + 'static,
>,
>;
async fn subscribe_deshred( async fn subscribe_deshred(
&self, &self,
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>, _request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>,

327
deltas/0.2.10/pre.001.md Normal file
View File

@@ -0,0 +1,327 @@
<!-- file: deltas/0.2.10/pre.001.md -->
<!-- version: 1 -->
# Delta `0.2.10-pre.001` — audit/sizing OrbitFlare Yellowstone gRPC
## 1. Base requise et vérifiée
Archive autoritaire fournie :
```text
khadhroony-solana-project-v0.2.9-full-from-gitea.zip
```
État vérifié :
```text
workspace.package.version = 0.2.9
deltas/0.2.9/rel.001.md présent
prompts/015-V0_2_10_START_PROMPT.md présent
metadata .git absente de l'archive Gitea
```
Cette livraison ouvre :
```text
workspace.package.version = 0.2.10-pre.1
commit attendu = v0.2.10-pre.001
aucun tag prerelease
```
## 2. Objet
`pre.001` exécute le gate documentaire obligatoire :
```text
lecture base/règles/architecture
baseline stable
réaudit OrbitFlare actuel
réaudit Yellowstone actuel
auth control-plane/data-plane
endpoints/network/security
Free Devnet gRPC
heartbeat
capabilities/unary/replay
limits/quotas
architecture N1/N2 immuable
threat model
sizing et forecast
```
Aucun code provider lourd, SDK OrbitFlare, metadata secrète ou heartbeat supplémentaire n'est ajouté.
## 3. Baseline opérateur acquise
Preuves fournies le 2026-08-25 sur `v0.2.9` :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py OK / clean
python3 scripts/audit_markdown_tables.py OK / 87 tables / 261 files
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-onchain-transport-lib --duplicates fourni
```
Transport pendant le workspace test :
```text
383 unit
49 public_api
43 release_completeness
4 doctests
```
Les live smokes restent opt-in/ignored par défaut.
Le `cargo tree -p ksp-onchain-transport-lib` complet n'a pas encore été fourni et doit être rejoué avant commit.
## 4. Graphe de dépendances inspecté
Versions utiles visibles :
```text
yellowstone-grpc-proto 12.6.0
tonic 0.14.6
tonic-prost 0.14.6
prost 0.14.4
tokio 1.53.1
http 1.5.0
reqwest 0.13.4
```
Aucune divergence Tonic/Prost/Yellowstone ne justifie une intervention. Les doublons restants sont sans impact matériel pour OrbitFlare.
## 5. Réaudit OrbitFlare du 2026-08-25
### Free Devnet
Le pricing actuel confirme :
```text
Free 0 USD/mois
RPC 10 RPS
transactions 1 TPS
gRPC Devnet only
credit limits unlimited
```
Le CLI documente :
```text
RPC Devnet = http://devnet.rpc.orbitflare.com
gRPC Devnet = http://devnet.rpc.orbitflare.com:10000
```
Objectif opérationnel fermé : utiliser OrbitFlare comme provider gRPC Devnet gratuit pour les tests KSP futurs, en complément de PublicNode Mainnet/Testnet.
### Auth
Classification :
```text
X-ORBIT-KEY Customer API only
Bearer Customer API v2
RPC api_key HTTP RPC only
account API key ne prouve pas une auth Yellowstone
gRPC Dashboard token possible selon licence/service
x-token mécanisme Yellowstone plausible si token gRPC distinct
aucune metadata documentée par SDK Go pour endpoints régionaux
```
La clé de compte opérateur ne doit pas être fournie au dépôt ni injectée par supposition dans gRPC.
### Heartbeat
OrbitFlare recommande un `SubscribeRequest::Ping` périodique. Yellowstone upstream envoie déjà un `SubscribeUpdate::Ping` périodique et KSP N1 répond automatiquement par un Ping standard sans modifier le dernier full request mémorisé.
Décision :
```text
aucun timer OrbitFlare ajouté au moteur
aucun changement YellowstoneGrpcSessionSettings
aucune modification grpc_stream.rs
pre.002 doit observer le Ping serveur live OrbitFlare Devnet
```
Si ce Ping est observé, la policy standard N1 est suffisante. Sinon, une divergence provider doit être démontrée puis composée au-dessus de N1 sans le modifier.
### Limits
```text
50 connexions gRPC simultanées par IP sur shared
subscriptions par connexion annoncées unlimited
idle timeout environ 10 minutes
RESOURCE_EXHAUSTED au dépassement
backoff exponentiel conseillé
```
Ces valeurs restent provider-owned.
## 6. Réaudit Yellowstone actuel
État observé :
```text
release GitHub courante v15.1.2+solana.4.2.0
yellowstone-grpc-proto publié 12.6.0
```
Aucune évolution matérielle ne remet en cause le standard N2 de `0.2.9`. Aucune dependency n'est modifiée.
## 7. Invariant architectural supersédant l'hypothèse initiale
Décision opérateur explicite :
```text
le moteur gRPC ne change jamais pour un provider
le moteur WebSocket ne change jamais pour un provider
les fonctionnalités provider se composent au-dessus du moteur
```
Pour `0.2.10` :
```text
N1 moteur Yellowstone = IMMUTABLE
N2 standard Solana = IMMUTABLE
N3 OrbitFlare = profile/capability/facade seulement si besoin démontré
```
Le prompt `015` reste autoritatif sauf sur toute formulation qui permettrait de mettre une policy OrbitFlare directement dans le moteur partagé.
## 8. Config V3
La shape existante suffit :
```text
profile_id = orbitflare_devnet
provider = orbitflare
cluster = devnet
protocol = solana_yellowstone
url = http://devnet.rpc.orbitflare.com:10000
metadata = []
secret_metadata = []
```
Pas de V4. Pas de secret ajouté avant preuve live. Pas de champ heartbeat moteur.
## 9. Smoke prévu
`pre.002` doit commencer par un canari standard N2 :
```text
connect OrbitFlare Devnet Free sans metadata
Subscribe slots confirmed
recevoir Slot non nul
observer SubscribeUpdate::Ping pendant une fenêtre bornée de caractérisation
fermer proprement
```
Puis sonder séparément les unary et replay sans rendre le smoke minimal dépendant d'entitlements provider non garantis.
## 10. Forecast recalibré
Chemin standard probable :
```text
pre.001 audit/sizing
pre.002 Config Devnet + characterization smoke standard N2
pre.003 gate technique/live final
pre.004 réconciliation documentaire finale
pre.005 publication minimale
rel.001 stable
```
Chemin avec divergence provider démontrée :
```text
pre.001 audit/sizing
pre.002 Config Devnet + characterization smoke
pre.003 provider overlay minimal au-dessus de N1/N2
pre.004 gate technique/live final
pre.005 réconciliation documentaire finale
pre.006 publication minimale
rel.001 stable
```
## 11. Fichiers ajoutés
```text
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
deltas/0.2.10/pre.001.md
```
## 12. Fichiers modifiés
```text
Cargo.toml
docs/000-README.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/validation/000-README.md
```
## 13. Fichiers volontairement inchangés
```text
ROADMAP.md
CHANGELOG.md
.env.example
config/**
crates/**
docs/architecture/**
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
```
Aucune implémentation provider ou Config runtime n'est introduite dans `pre.001`.
## 14. Sources externes réauditées
```text
https://docs.orbitflare.com/llms.txt
https://docs.orbitflare.com/cli
https://docs.orbitflare.com/authentication
https://docs.orbitflare.com/data-streaming/yellowstone
https://docs.orbitflare.com/data-streaming/yellowstone-quickstart
https://docs.orbitflare.com/sdk/go-grpc
https://orbitflare.com/pricing
https://orbitflare.com/products/rpc-nodes
https://orbitflare.com/products/solana-grpc
https://github.com/rpcpool/yellowstone-grpc
https://github.com/rpcpool/yellowstone-grpc/releases
https://github.com/rpcpool/yellowstone-grpc/blob/master/README.md
https://github.com/rpcpool/yellowstone-grpc/blob/master/yellowstone-grpc-proto/proto/geyser.proto
```
## 15. Validations de préparation
Le sandbox a exécuté :
```text
inspection archive/version/rel/prompt OK
lecture code N1/N2/Config et docs 0.2.9 OK
réaudit OrbitFlare/Yellowstone actuel OK
inspection cargo tree --duplicates fourni par opérateur OK
```
Le sandbox ne dispose pas de Cargo ; aucun `cargo check/clippy/test/tree` post-overlay n'est déclaré réussi localement.
## 16. Validation opérateur attendue avant commit
Comme `pre.001` change uniquement la version Cargo et la documentation :
```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.10
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
```
Attendu : aucun changement de dependency et aucun changement sous `crates/`.

View File

@@ -0,0 +1,196 @@
<!-- file: deltas/0.2.10/pre.002-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.10-pre.002-fix.001` — OrbitFlare License Key `x-token`
## 1. Base et identité
Base exacte :
```text
0.2.10-pre.002 commité
```
Le fix porte une correction runtime/configuration de lauth provider. La version Cargo devient donc :
```text
workspace.package.version = 0.2.10-pre.2.fix.1
commit attendu = v0.2.10-pre.002-fix.001
aucun tag prerelease
```
## 2. Défaut démontré par le live `pre.002`
Le gate statique/workspace `pre.002` est vert. Le smoke live sans metadata a atteint OrbitFlare puis louverture du stream a échoué de manière sûre :
```text
grpc_operation = SubscribeOpen
grpc_status = Unauthenticated
grpc_code = The request does not have valid authentication credentials
```
Le Dashboard opérateur montre un produit `Solana Free` avec License Key `ORBIT-*` et `API Key Mode Active`.
La référence Yellowstone OrbitFlare précise ensuite le contrat data-plane :
```text
ORBITFLARE_LICENSE_KEY
-> metadata gRPC x-token
-> Yellowstone
```
Le `X-ORBIT-KEY` et le Bearer Device Flow restent des credentials Customer API et ne sont pas utilisés par Transport Yellowstone.
## 3. Correction Config V3
`config/std.transport.json` conserve le même endpoint et ajoute uniquement la metadata secrète requise :
```text
provider = orbitflare
cluster = devnet
protocol = solana_yellowstone
url = http://devnet.rpc.orbitflare.com:10000
metadata = []
secret_metadata = x-token <- ${KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN}
```
Aucun format Config V4 nest créé.
`.env.example` inventorie désormais :
```text
KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN
```
La valeur locale attendue est la License Key `ORBIT-*`. Une `X-ORBIT-KEY` Customer API ne doit jamais être placée dans cette variable.
## 4. Preuve Config déterministe
Le test `committed_v3_orbitflare_devnet_profile_maps_provider_neutral_yellowstone_grpc_with_license_x_token` injecte un canari secret via `ConfigEnvironment` et prouve :
```text
profil explicite orbitflare_devnet
companions HTTP/WS Devnet inchangés
endpoint Yellowstone OrbitFlare inchangé
metadata key = x-token
metadata classée secret
validation Transport PASS
URL provider absente du Debug
canari secret absent du Debug
X-ORBIT-KEY absent du Transport
```
## 5. Smoke live corrigé
`yellowstone_orbitflare_smoke.rs` ne dépend toujours pas de Config et ne lit aucun environnement. Il suit le pattern de sécurité déjà utilisé par les smokes PublicNode :
```text
une License Key lue sur stdin
aucun secret en argument de ligne de commande
YellowstoneGrpcMetadataEntry::secret("x-token", ...)
reconnect max_retries = 0
Subscribe slots confirmed
fenêtre Slot + Ping = 45 s
close borné
```
Le smoke vérifie également que la License Key napparaît pas dans `Debug`.
Commande opérateur :
```bash
read -rsp 'OrbitFlare License Key: ' ORBITFLARE_LICENSE_KEY; echo
printf '%s\n' "$ORBITFLARE_LICENSE_KEY" | cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
unset ORBITFLARE_LICENSE_KEY
```
## 6. Invariant architectural
Toujours inchangés :
```text
src/grpc_settings.rs
src/grpc_channel.rs
src/grpc_unary.rs
src/grpc_subscribe.rs
src/grpc_stream.rs
```
Le standard Yellowstone N2 reste inchangé. Aucun SDK OrbitFlare runtime nest ajouté.
Lauth provider est entièrement composée par la surface metadata secrète déjà existante.
## 7. Heartbeat
Ce fix ne matérialise aucun heartbeat OrbitFlare.
Le rerun authentifié doit dabord qualifier :
```text
Slot non nul
SubscribeUpdate::Ping serveur
```
Si les deux sont observés, N1 répond déjà automatiquement au Ping standard et aucune couche provider heartbeat nest requise.
Si le stream authentifié fonctionne mais német aucun Ping serveur dans la fenêtre de caractérisation, `pre.003` devient une tranche provider-specific au-dessus de N1/N2. Il reste interdit dajouter un timer OrbitFlare au moteur gRPC.
## 8. Validation disponible dans lenvironnement de génération
Exécuté :
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
Markdown table audit: clean (99 table(s), 99 file(s))
config/std.transport.json
JSON parse PASS
JSON Schema validation PASS
comparaison N1 avec pre.002
grpc_settings.rs inchangé
grpc_channel.rs inchangé
grpc_unary.rs inchangé
grpc_subscribe.rs inchangé
grpc_stream.rs inchangé
```
Aucune commande Cargo nest déclarée exécutée dans lenvironnement de génération.
## 9. Gates opérateur
Après application du fix :
```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.10
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-config-lib
cargo test -p ksp-onchain-transport-lib
cargo test -p ksp-core-lib --test workspace_dependencies
cargo test --workspace
```
Puis exécuter le live authentifié décrit en section 5.
## 10. Fichiers ajoutés/modifiés
```text
Cargo.toml
.env.example
config/std.transport.json
crates/ksp-config-lib/unit_tests/transport.rs
crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
deltas/0.2.10/pre.002-fix.001.md
```
Le delta commité `deltas/0.2.10/pre.002.md` reste immuable.

287
deltas/0.2.10/pre.002.md Normal file
View File

@@ -0,0 +1,287 @@
<!-- file: deltas/0.2.10/pre.002.md -->
<!-- version: 1 -->
# Delta `0.2.10-pre.002` — OrbitFlare Devnet Config + characterization smoke
## 1. Base et version
Base de travail :
```text
0.2.10-pre.001
```
Validation opérateur fournie sur cette base le 2026-08-25 :
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py PASS
python3 scripts/audit_markdown_tables.py PASS / 97 tables / 233 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test --workspace PASS
cargo tree -p ksp-onchain-transport-lib fourni
cargo tree -p ksp-onchain-transport-lib --duplicates fourni
```
Le graphe confirme notamment :
```text
yellowstone-grpc-proto 12.6.0
tonic 0.14.6
tonic-prost 0.14.6
prost 0.14.4
tokio 1.53.1
```
Cette livraison passe à :
```text
workspace.package.version = 0.2.10-pre.2
commit attendu = v0.2.10-pre.002
aucun tag prerelease
```
## 2. Invariant architectural
Aucun moteur physique n'est modifié :
```text
src/grpc_settings.rs inchangé
src/grpc_channel.rs inchangé
src/grpc_unary.rs inchangé
src/grpc_subscribe.rs inchangé
src/grpc_stream.rs inchangé
```
Le standard Yellowstone N2 reste également inchangé.
OrbitFlare est matérialisé uniquement par :
```text
Config provider profile
provider-neutral Yellowstone smoke
provider validation/documentation
```
Aucun SDK OrbitFlare, second client Tonic, proto provider, heartbeat moteur ou metadata secrète n'est ajouté.
## 3. Profil Config V3 `orbitflare_devnet`
`config/std.transport.json` ajoute un profil dédié :
```text
profile_id = orbitflare_devnet
```
Il conserve les companions standards déjà utilisés sur Devnet :
```text
HTTP = https://api.devnet.solana.com
WS = wss://api.devnet.solana.com
```
et ajoute exactement un endpoint Yellowstone :
```text
name = orbitflare_solana_devnet_yellowstone
provider = orbitflare
cluster = devnet
protocol = solana_yellowstone
url = http://devnet.rpc.orbitflare.com:10000
metadata = []
secret_metadata = []
```
Le format reste V3. `.env.example` n'est pas modifié et aucune API key de compte OrbitFlare n'est reclassifiée comme credential gRPC.
`ksp-config-lib` ajoute un test déterministe qui prouve :
```text
sélection explicite orbitflare_devnet
companions HTTP/WS Devnet préservés
provider/cluster/protocol gRPC corrects
URL plaintext conservée telle quelle
aucune metadata inventée
Debug gRPC sans URL provider
absence de X-ORBIT-KEY dans Transport settings
```
## 4. Smoke OrbitFlare Devnet
Nouveau test opt-in :
```text
crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
```
Le test utilise directement les API publiques Yellowstone existantes de Transport, sans dépendre de Config :
```text
endpoint Devnet OrbitFlare sans metadata
reconnect max_retries = 0
Subscribe slots
commitment = confirmed
fenêtre de caractérisation = 45 s
preuve Slot non nul
preuve SubscribeUpdate::Ping serveur
close borné
```
Le `Ping` reçu reste traité par le moteur N1 déjà livré en `0.2.9`, qui répond automatiquement sur le même stream. Le test n'ajoute aucune policy heartbeat.
Commande live :
```bash
cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
```
Interprétation :
```text
Slot + Ping PASS
OrbitFlare reste standard ; aucune façade/policy provider heartbeat requise
connection/auth failure
classifier le service/licence réel avant toute metadata
Slot PASS mais Ping absent
qualifier une divergence heartbeat provider avant toute implémentation spécifique
```
## 5. Audit Markdown étendu
`scripts/audit_markdown_tables.py` passe en version 2 et contrôle désormais aussi l'équivalent structurel de :
```text
\n\s*\n\s*\n
```
c'est-à-dire au moins deux lignes vides consécutives hors fenced code block.
Le contrôle :
```text
considère une ligne whitespace-only comme vide
signale fichier + première ligne + longueur de la séquence
n'interprète pas les blancs internes aux ``` / ~~~ fenced blocks
ignore les répertoires générés ou tiers en parcours récursif
```
Répertoires récursifs exclus :
```text
.git
.idea
.venv
__pycache__
dist
node_modules
target
```
Cette exclusion rend le résultat déterministe entre une archive Gitea propre et un checkout développeur contenant des `node_modules`. Le nombre de fichiers audités peut donc diminuer par rapport à l'ancien script sans signifier une perte de couverture KSP-owned.
Une seule séquence de deux lignes vides existait dans le scope KSP actif de l'archive : elle est supprimée dans `crates/ksp-onchain-transport-lib/README.md`.
`docs/rules/RULES_DOCUMENTATION.md` formalise `DOC-BLANK-001` à `DOC-BLANK-003`, y compris l'immuabilité des anciens deltas publiés.
## 6. Documents de plan/validation
Le plan 017 et la validation 013 passent en version 2 pour enregistrer :
```text
pre.001 operator gate fermé
profil orbitflare_devnet matérialisé
smoke de caractérisation matérialisé
N1/N2 inchangés
live Devnet encore PENDING
```
Aucun README/USAGE OrbitFlare durable n'est finalisé ici ; cette responsabilité reste réservée au couloir documentaire final.
## 7. Validation disponible dans l'environnement de génération
Exécuté sans Cargo :
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
clean / 98 tables / 98 KSP-owned files
config/std.transport.json
JSON parse PASS
JSON Schema validation PASS
```
Le nouvel audit Markdown a aussi été caractérisé avec des fixtures temporaires :
```text
deux lignes vides hors fence FAIL attendu
plusieurs lignes vides dans fence PASS attendu
node_modules récursif ignoré attendu
```
Aucune commande Cargo n'est déclarée exécutée dans l'environnement de génération.
## 8. Gates opérateur `pre.002`
Exécuter :
```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.10
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-config-lib
cargo test -p ksp-onchain-transport-lib
cargo test -p ksp-core-lib --test workspace_dependencies
cargo test --workspace
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
```
Puis le live dédié :
```bash
cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
```
`pre.002` ne ferme son gate provider que lorsque le résultat live est classifié. Un échec d'auth ou l'absence de `Ping` ne doit jamais être corrigé par modification du moteur N1.
## 9. Fichiers ajoutés/modifiés
```text
Cargo.toml
config/std.transport.json
crates/ksp-config-lib/unit_tests/transport.rs
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
docs/rules/RULES_DOCUMENTATION.md
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
scripts/audit_markdown_tables.py
deltas/0.2.10/pre.002.md
```
## 10. Suite conditionnelle
Si le live retourne `Slot + Ping PASS` :
```text
pre.003 = gate technique/live final
```
Si une divergence provider réelle est démontrée :
```text
pre.003 = tranche provider-specific minimale au-dessus de N1/N2
pre.004 = gate technique/live final
```
Le couloir documentaire final puis la prerelease de publication restent distincts dans les deux cas.

252
deltas/0.2.10/pre.003.md Normal file
View File

@@ -0,0 +1,252 @@
<!-- file: deltas/0.2.10/pre.003.md -->
<!-- version: 1 -->
# Delta `0.2.10-pre.003` — gate technique/live final OrbitFlare Yellowstone
## 1. Base requise
Base directe attendue :
```text
livraison : 0.2.10-pre.002-fix.001
Cargo : 0.2.10-pre.2.fix.1
commit : v0.2.10-pre.002-fix.001
```
Identité de cette tranche :
```text
livraison : 0.2.10-pre.003
Cargo : 0.2.10-pre.3
commit : v0.2.10-pre.003
aucun tag prerelease
```
Cette prerelease est exclusivement le couloir **technique/live final** de `0.2.10`. Elle ne porte aucune réconciliation README/USAGE/plan/validation, aucune finalisation CHANGELOG/ROADMAP et aucun prompt `0.2.11`.
## 2. Gate acquis de `pre.002-fix.001`
La validation opérateur reçue le 2026-08-25 ferme le défaut d'auth classifié dans `pre.002`.
Le gate déterministe est intégralement vert :
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py clean / 0 export candidate / clean
python3 scripts/audit_markdown_tables.py PASS, 99 tables / 99 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test --workspace PASS
Config unit 114/114 PASS
Config ownership 5/5 PASS
Config public API 15/15 PASS
Transport unit 383/383 PASS
Transport public API 49/49 PASS
Transport release completeness 43/43 PASS
Transport doctests 4/4 PASS
workspace dependency canary 3/3 PASS
```
Le smoke OrbitFlare Devnet authentifié est également vert :
```text
endpoint http://devnet.rpc.orbitflare.com:10000
auth License Key ORBIT-* -> secret metadata x-token
Subscribe PASS
Slot > 0 PASS
server Ping PASS
close borné PASS
résultat 1 passed / 0 failed
```
Cette preuve ferme la branche de divergence heartbeat : OrbitFlare émet le `SubscribeUpdate::Ping` Yellowstone standard et le moteur N1 `0.2.9` possède déjà la réponse automatique déterministe. Aucun heartbeat provider, aucune façade OrbitFlare et aucune modification N1/N2 ne sont nécessaires.
## 3. Responsabilité de `pre.003`
`pre.003` ne matérialise aucun nouveau comportement. Elle rejoue et ferme les preuves techniques finales après le fix d'auth :
```text
version Cargo prerelease synchronisée
workspace complet sans warning
invariants Rust/Markdown
Config V3 OrbitFlare x-token non régressée
Yellowstone N1/N2 non régressés
HTTP 52 current + 14 historical non régressés
WebSocket standard et Helius non régressés
smoke live OrbitFlare Devnet final
smokes PublicNode Mainnet/Testnet finaux si credentials opérateur disponibles
graphes Cargo Transport et doublons inspectés
aucun SDK OrbitFlare runtime
aucune dépendance Transport -> Config
```
Le payload de `pre.003` ne change ni code runtime, ni test, ni Config, ni schema, ni dépendance. Le seul changement technique est le signal de version Cargo obligatoire pour une prerelease non-fix.
## 4. Invariants à fermer
Les fichiers moteur Yellowstone restent hors modification :
```text
crates/ksp-onchain-transport-lib/src/grpc_settings.rs
crates/ksp-onchain-transport-lib/src/grpc_channel.rs
crates/ksp-onchain-transport-lib/src/grpc_unary.rs
crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs
crates/ksp-onchain-transport-lib/src/grpc_stream.rs
```
Le gate doit confirmer :
```text
N1 gRPC provider-neutral inchangé
N2 Yellowstone standard inchangé
OrbitFlare composé par endpoint + provider + cluster + x-token Config V3
X-ORBIT-KEY absent du data-plane Yellowstone
License Key jamais exposée par Debug/CLI
pas de format Config V4
pas de provider proto
pas de second raw Tonic client
pas de orbitflare-sdk runtime
```
## 5. Gate déterministe final
Après application de `pre.003` :
```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.10
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-config-lib
cargo test -p ksp-onchain-transport-lib
cargo test -p ksp-core-lib --test workspace_dependencies
cargo test --workspace
```
Seuils minimaux attendus :
```text
Config unit 114
Config ownership 5
Config public API 15
Transport unit 383
Transport public API 49
Transport release completeness 43
Transport doctests 4
workspace dependency canary 3
```
Toute régression imputable à KSP ouvre `pre.003-fix.NNN`. Elle ne doit pas être repoussée dans le couloir documentaire `pre.004`.
## 6. Smoke OrbitFlare Devnet final
Saisir la License Key sans l'inscrire dans l'historique :
```bash
read -rsp 'OrbitFlare License Key: ' ORBITFLARE_LICENSE_KEY; echo
printf '%s\n' "$ORBITFLARE_LICENSE_KEY" \
| cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
unset ORBITFLARE_LICENSE_KEY
```
Attendu :
```text
orbitflare_devnet_yellowstone_streams_slots_and_emits_standard_server_ping_with_license_x_token ... ok
1 passed
0 failed
0 ignored
```
Le smoke doit encore prouver dans une même session bornée :
```text
auth x-token acceptée
Subscribe standard ouvert
Slot non nul reçu
SubscribeUpdate::Ping standard observé
fermeture bornée
```
Un incident externe OrbitFlare/réseau démontré comme tel est documenté comme `EXTERNAL BLOCK`. Un échec de mapping, metadata, moteur, protocol ou fermeture imputable à KSP ouvre un fix technique.
## 7. PublicNode et graphes finaux
Les smokes PublicNode Mainnet/Testnet restent les canaris provider du standard livré par `0.2.9`. Si les deux credentials opérateur sont disponibles pendant ce gate, les rejouer :
```bash
read -rsp 'PublicNode Mainnet Yellowstone x-token: ' PUBLICNODE_MAINNET_TOKEN; echo
read -rsp 'PublicNode Testnet Yellowstone x-token: ' PUBLICNODE_TESTNET_TOKEN; echo
printf '%s\n%s\n' "$PUBLICNODE_MAINNET_TOKEN" "$PUBLICNODE_TESTNET_TOKEN" \
| cargo test -p ksp-onchain-transport-lib --test yellowstone_publicnode_smoke -- --ignored --nocapture
unset PUBLICNODE_MAINNET_TOKEN PUBLICNODE_TESTNET_TOKEN
```
Attendu lorsque rejoué :
```text
2 passed
0 failed
0 ignored
```
Inspecter ensuite :
```bash
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
cargo tree --duplicates
```
Points obligatoires :
```text
yellowstone-grpc-proto 12.6.x sur la stack attendue
Tonic/Prost KSP-owned sans seconde génération incompatible
aucun yellowstone-grpc-client runtime
aucun orbitflare-sdk runtime
aucune dépendance ksp-onchain-transport-lib -> ksp-config-lib
aucun tracing direct Transport
aucun nouveau doublon introduit par 0.2.10
```
Les smokes PublicNode ne deviennent pas un blocker si leurs credentials ne sont simplement pas disponibles au moment du gate : leur non-régression déterministe reste couverte par les suites Transport/Config et le code live n'a pas changé. En revanche, tout échec effectivement observé lors d'un rerun doit être classifié, jamais ignoré.
## 8. Critère de fermeture technique
`pre.003` est fermée lorsque :
```text
gate déterministe final vert
OrbitFlare Devnet Subscribe -> Slot + Ping live PASS
graphes Cargo finaux inspectés
N1 inchangé
N2 inchangé
Config V3 x-token cohérente et redacted
aucun provider overlay nécessaire
aucun heartbeat provider nécessaire
aucun SDK OrbitFlare ajouté
aucun défaut technique reporté vers pre.004
```
Une fois ce gate acquis, la prochaine tranche est obligatoirement :
```text
0.2.10-pre.004 — réconciliation documentaire finale
```
Elle portera plan, validation, README/USAGE et références durables. Elle ne doit pas porter de nouveau correctif technique.
## 9. Modifications de cette livraison
```text
Cargo.toml
# version: 264 -> 265
workspace.package.version: 0.2.10-pre.2.fix.1 -> 0.2.10-pre.3
deltas/0.2.10/pre.003.md
ajout du présent gate technique/live final
```
Aucun autre fichier n'est modifié.

128
deltas/0.2.10/pre.004.md Normal file
View File

@@ -0,0 +1,128 @@
<!-- file: deltas/0.2.10/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.10-pre.004` — réconciliation documentaire finale OrbitFlare Yellowstone
## 1. Base
```text
0.2.10-pre.003
```
Cette tranche est exclusivement le couloir de réconciliation documentaire finale. Le gate technique/live de `pre.003` est vert ; aucun correctif runtime, Config exécutable, test, schema, dependency ou feature n'est introduit ici.
## 2. Version technique
Comme toute prerelease non-fix, la version Cargo est synchronisée mécaniquement :
```text
workspace.package.version = 0.2.10-pre.4
```
Aucune sémantique runtime ne change.
## 3. Preuve technique réconciliée
Le gate opérateur `pre.003` du 2026-08-25 est fermé avec :
```text
cargo fmt --all PASS
Rust workspace audit PASS / 0 export candidate
Markdown audit PASS / 99 tables / 100 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
Config unit 114/114 PASS
Config ownership 5/5 PASS
Config public API 15/15 PASS
Transport unit 383/383 PASS
Transport public API 49/49 PASS
Transport release completeness 43/43 PASS
Transport doctests 4/4 PASS
workspace dependency canary 3/3 PASS
cargo test --workspace PASS
OrbitFlare Devnet Subscribe + Slot + Ping 1/1 PASS, 5.19 s
```
Les graphes Cargo direct Transport, doublons Transport et doublons workspace ont été inspectés. La pile reste :
```text
yellowstone-grpc-proto 12.6.0
tonic / tonic-prost 0.14.6
prost / prost-types 0.14.4
```
Aucun `yellowstone-grpc-client` runtime, aucun `orbitflare-sdk` runtime et aucune dépendance `Transport -> Config` n'ont été ajoutés.
## 4. OrbitFlare final
Contrat durable réconcilié :
```text
Devnet endpoint http://devnet.rpc.orbitflare.com:10000
auth License Key ORBIT-* -> secret metadata x-token
Config secret KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN
Subscribe standard Yellowstone N2
live Slot non nul + server Ping
N1 inchangé
N2 inchangé
provider facade non nécessaire
heartbeat N3 non nécessaire
Config V4 non nécessaire
```
Le premier échec `pre.002` sans metadata reste conservé dans son delta historique comme caractérisation ayant conduit au fix d'auth `pre.002-fix.001`.
## 5. Documents réconciliés
```text
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
docs/000-README.md
```
Les anciennes affirmations `PENDING` sont remplacées par les résultats `pre.003` réellement observés. README/USAGE documentent désormais le profil OrbitFlare Devnet et son smoke live durable.
## 6. Frontière avec `pre.005`
Cette tranche ne touche pas :
```text
CHANGELOG.md
ROADMAP.md
prompt suivant
```
La décision sur la release suivante est prise pendant cette réconciliation, sans générer encore son prompt : Helius LaserStream gRPC est reporté dans les TODO Yellowstone sans numéro de release faute d'accès live raisonnable ; la séquence active avance et `0.2.11` devient `off-chain price transport`, suivie de `0.2.12` Price Desk + intégration Wallet Desk, `0.2.13` interface/wire foundation et `0.2.14` program-api foundation. Le futur `ROADMAP.md` de `pre.005` devra refléter exactement ce report et cette renumérotation.
Une fois cette décision prise, `pre.005` reste strictement publication-minimal : prompt suivant + `CHANGELOG.md` + `ROADMAP.md` + signal Cargo + delta.
Si un défaut technique de `0.2.10` est découvert après ce gate documentaire, il ne doit pas être glissé dans `pre.005`; ouvrir une nouvelle prerelease du couloir approprié puis rejouer les tranches finales nécessaires.
## 7. Fichiers modifiés/ajoutés
```text
Cargo.toml
docs/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
deltas/0.2.10/pre.004.md
```
## 8. Gate opérateur
```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.10
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
```
Aucun smoke live ni `cargo tree` n'est à rejouer si le delta est appliqué exactement : aucune surface technique ou dépendance n'est modifiée.

132
deltas/0.2.10/pre.005.md Normal file
View File

@@ -0,0 +1,132 @@
<!-- file: deltas/0.2.10/pre.005.md -->
<!-- version: 1 -->
# Delta `0.2.10-pre.005` — préparation de publication minimale
## 1. Base
```text
0.2.10-pre.004
```
Cette tranche est exclusivement le couloir de préparation de publication. Elle ne modifie aucun code, test, schema, config, README, USAGE, plan, validation ou règle normative.
## 2. Gate `pre.003` et réconciliation `pre.004`
Le gate technique final `pre.003` a confirmé :
```text
Rust workspace audit PASS
Markdown audit PASS
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test --workspace PASS
OrbitFlare live Subscribe -> Slot+Ping PASS en 5.19 s
graphes Cargo inspectés
```
`pre.004` a ensuite réconcilié les documents durables : plan/validation OrbitFlare, README/USAGE Transport, index documentaire et séquence fonctionnelle. Helius LaserStream gRPC a été reporté dans les TODO faute d'accès live raisonnable sans abonnement payant, sans remise en cause de sa compatibilité wire Yellowstone largement observée.
## 3. Version technique
```text
workspace.package.version = 0.2.10-pre.5
```
La modification est mécanique ; aucune sémantique runtime ne change.
## 4. `CHANGELOG.md`
Le changelog stable est préparé pour `0.2.10 — OrbitFlare Yellowstone gRPC` avec les faits consolidés :
```text
N1/N2 Yellowstone inchangés
Config V3 orbitflare_devnet
x-token = License Key via secret_metadata
premier live sans metadata -> Unauthenticated
fix auth documenté
live Subscribe -> Slot + standard Ping PASS
second live final PASS
aucun heartbeat/provider SDK/Config V4 ajouté
workspace + graphes finaux verts
```
## 5. `ROADMAP.md`
`0.2.10` passe à l'état réalisé. Helius LaserStream gRPC quitte la séquence numérotée et rejoint les TODO Yellowstone. La séquence active devient :
```text
0.2.11 off-chain price transport
0.2.12 Price Desk + intégration prix Wallet Desk
0.2.13 interface/wire foundation
0.2.14 program-api foundation
```
## 6. Audit préparatoire de la future `0.2.11`
Avant de rédiger le prompt suivant, un audit externe rapide au 2026-08-25 a comparé plusieurs familles de sources :
```text
CoinGecko agrégateur, REST Demo, multi quote USD/EUR
Jupiter V3 prix Solana heuristique issu des swaps, USD seulement
Pyth Hermes oracle, REST/SSE, publish_time/confidence, auth en transition immédiate
Birdeye market data Solana, spot/batch/historique, API key
DexScreener prix DEX par pair/pool, accès public courant
```
Ce snapshot ne choisit pas le provider. Il démontre surtout que les sémantiques diffèrent et que `0.2.11-pre.001` doit auditer/choisir explicitement le contrat prix, le modèle numérique, la fraîcheur et le provider initial avant code lourd.
Pyth annonce un changement d'authentification le 2026-08-26 à 16:00 UTC ; le prompt exige donc un réaudit immédiat de Pyth à l'ouverture et interdit de figer le snapshot de `pre.005`.
## 7. Prompt `0.2.11`
Ajout de :
```text
prompts/016-V0_2_11_START_PROMPT.md
```
Le prompt ouvre `0.2.11 — Off-chain price transport` uniquement depuis `v0.2.10` et impose `pre.001 = lecture + audit actuel providers + matrice sémantique + numeric model + threat model + sizing + planification`.
Le scope initial reste volontairement borné :
```text
ksp-offchain-transport-lib
prix SOL/USD + SOL/EUR
premier provider live-testable sans abonnement payant dédié
Config -> Off-chain Transport
pas de Price Desk
pas de Wallet Desk
pas d'OHLCV/historique/quotes/metadata/multi-provider complexe
```
## 8. Fichiers modifiés/ajoutés
```text
Cargo.toml
CHANGELOG.md
ROADMAP.md
prompts/016-V0_2_11_START_PROMPT.md
deltas/0.2.10/pre.005.md
```
Aucun autre fichier ne doit changer dans cette tranche.
## 9. Gate opérateur
Comme cette tranche ne modifie aucun code/runtime/config/schema :
```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.10
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
```
Aucun smoke live ni `cargo tree` n'est à rejouer si le delta est appliqué exactement.
## 10. Suite
Si ce gate est vert, `0.2.10-rel.001` est la seule étape restante. Elle est limitée à la mécanique de publication stable : version Cargo `0.2.10`, delta `rel.001`, commit final et tag stable `v0.2.10`, sans rattrapage technique ou documentaire.

197
deltas/0.2.10/rel.001.md Normal file
View File

@@ -0,0 +1,197 @@
<!-- file: deltas/0.2.10/rel.001.md -->
<!-- version: 1 -->
# Delta `0.2.10-rel.001` — publication stable OrbitFlare Yellowstone gRPC
## 1. Base requise
Base directe attendue :
```text
0.2.10-pre.005
workspace.package.version = 0.2.10-pre.5
```
Commit attendu pour cette livraison :
```text
v0.2.10-rel.001
```
Tag stable attendu après validation :
```text
v0.2.10
```
## 2. Responsabilité de `rel.001`
Cette livraison effectue uniquement la mécanique de publication stable définie par le cycle KSP.
Elle :
- passe `workspace.package.version` de `0.2.10-pre.5` à `0.2.10` ;
- ajoute le présent delta `rel.001` ;
- ne corrige ni code, ni test, ni config/schema, ni README/USAGE, ni plan/validation, ni règle, ni prompt, ni `CHANGELOG.md`, ni `ROADMAP.md`.
Tout défaut appartenant à un couloir antérieur doit renvoyer vers une prerelease appropriée ; `rel.001` n'est jamais une tranche de rattrapage.
## 3. Preuves techniques acquises avant publication
Le gate technique/live final `0.2.10-pre.003` a été validé le **25 août 2026** avec :
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py PASS
General Rust rule audit clean
Rust export completeness audit 0 candidate(s)
KSP workspace Rust rule audit clean
python3 scripts/audit_markdown_tables.py PASS
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test -p ksp-config-lib PASS
cargo test -p ksp-onchain-transport-lib PASS
cargo test -p ksp-core-lib --test workspace_dependencies PASS
cargo test --workspace PASS
```
Les suites directement concernées restent notamment :
```text
Config unit 114 passed
Transport unit 383 passed
Transport public API 49 passed
Transport release completeness 43 passed
Transport doctests 4 passed
```
Le smoke live final OrbitFlare a également été rejoué en `pre.003` :
```text
OrbitFlare Devnet Yellowstone
x-token = License Key
Subscribe -> Slot + standard server Ping
1/1 PASS en 5.19 s
```
Les graphes Cargo finaux ont été inspectés dans le même couloir. `pre.004` et `pre.005` n'ont ensuite modifié aucune source runtime, dépendance, config ou schema.
## 4. Version stable publiée
La version Cargo devient :
```text
workspace.package.version = 0.2.10
```
La release stable publiée est :
```text
0.2.10 — OrbitFlare Yellowstone gRPC
```
Les acquis fonctionnels et documentaires finalisés avant `rel.001` comprennent :
```text
réutilisation stricte du moteur N1 Yellowstone gRPC existant
réutilisation stricte du contrat N2 Yellowstone standard existant
aucune modification provider du moteur gRPC
profil Config V3 orbitflare_devnet
endpoint Devnet http://devnet.rpc.orbitflare.com:10000
authentification gRPC x-token via License Key secrète
premier smoke sans metadata -> Unauthenticated, caractérisé puis corrigé
live authentifié Subscribe -> Slot + standard Ping PASS
aucun heartbeat provider supplémentaire
aucun SDK OrbitFlare ajouté
aucun Config V4
```
La release confirme donc qu'OrbitFlare compose au-dessus de N1/N2 par configuration provider et metadata secrète, sans divergence protocolaire nécessitant une façade gRPC dédiée.
## 5. Fichiers ajoutés
```text
deltas/0.2.10/rel.001.md
```
## 6. Fichiers modifiés
```text
Cargo.toml
```
## 7. Fichiers supprimés
```text
aucun
```
## 8. Validations de préparation du delta
Contrôles statiques de l'overlay :
```text
Cargo.toml version 0.2.10
payload rel.001 2 fichiers exactement
aucun fichier de rattrapage présent
```
Aucune nouvelle compilation n'est requise pour construire l'archive elle-même. Le gate stable final doit toutefois être exécuté après application du delta et avant le commit/tag.
## 9. Validation stable après application
Exécuter avant publication :
```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.10
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
```
Le changement étant limité au signal de version Cargo et au présent delta :
- aucun smoke live OrbitFlare n'est à rejouer si l'overlay appliqué est exactement `0.2.10-rel.001` ;
- aucun `cargo tree` n'est à rejouer ;
- aucun smoke PublicNode n'est à rejouer.
## 10. Commit et tag stable
Après succès du gate stable :
```text
commit : v0.2.10-rel.001
tag : v0.2.10
```
Aucun tag intermédiaire `rel.001` n'est requis.
Commandes Git typiques :
```bash
git add Cargo.toml deltas/0.2.10/rel.001.md
git commit -m "v0.2.10-rel.001"
git tag v0.2.10
git push
git push origin v0.2.10
```
## 11. Suite
Après publication du tag stable `v0.2.10`, ouvrir :
```text
0.2.11-pre.001 — Off-chain price transport
```
avec :
```text
prompts/016-V0_2_11_START_PROMPT.md
```
`pre.001` reste une tranche d'audit actuel des providers, sémantiques de prix, modèle numérique, fraîcheur/provenance, threat model et sizing avant toute implémentation lourde.
Helius LaserStream gRPC reste reporté dans les TODO Yellowstone jusqu'à disponibilité d'un accès live pertinent ; il ne fait plus partie de la séquence numérotée active.

View File

@@ -0,0 +1,131 @@
<!-- file: deltas/0.2.11/pre.001-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.001-fix.001` — forecast souple éditable
## 1. Base
Ce fix s'applique exclusivement à :
```text
v0.2.11-pre.001
```
Commit attendu :
```text
v0.2.11-pre.001-fix.001
```
Aucun tag prerelease n'est attendu.
Le fix est documentaire :
```text
workspace.package.version reste 0.2.11-pre.1
aucun code Rust modifié
aucune dépendance modifiée
aucun provider ajouté ou retiré
aucun contrat technique modifié
```
## 2. Objet du fix
Le forecast de `docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md` était présenté sous forme de tableau. Cette forme est correcte pour une vue statique mais peu pratique pendant une release active : elle rend moins naturelle la modification du statut d'une tranche et l'ajout de fixes rattachés à une prerelease.
Le forecast est donc remplacé par une suite de petits paragraphes structurés par titres `###`.
Chaque prerelease possède désormais :
```text
un titre ### stable
un statut directement modifiable
un court paragraphe de responsabilité
la possibilité d'ajouter des #### pour pre.xxx-fix.yyy
```
Le présent fix est lui-même ajouté sous `pre.001` comme premier exemple de sous-section `####`.
## 3. Clarification de durée
Le plan précise désormais que la cible historique de :
```text
15 à 20 minutes
```
est une **cible de granularité**, pas une durée maximale contractuelle.
Une tranche peut dépasser cette durée lorsqu'un build, un live test, un diagnostic ou une difficulté réelle le justifie. À l'inverse, des tranches peuvent être fusionnées ou recalibrées si cela améliore le découpage sans mélanger des responsabilités incompatibles.
## 4. Clarification de session
Le nombre de prereleases du forecast n'implique pas une session de conversation par prerelease.
Le plan autorise explicitement plusieurs prereleases au cours d'une même session de travail. Aucune scission de session n'est requise structurellement par le forecast ; elle ne devient nécessaire que si une contrainte réelle l'impose, par exemple un blocage opérateur, un changement de scope ou une limite de contexte de l'outil de conversation.
## 5. Forecast technique inchangé
La séquence fonctionnelle reste la même :
```text
pre.001 audit/sizing
pre.002 crate + contrats + décimal
pre.003 HTTP commun + errors + rate limiter
pre.004 CoinGecko + CMC + CoinPaprika
pre.005 Kraken + Coinbase
pre.006 Jupiter + DexScreener
pre.007 Birdeye + registry availability
pre.008 refresh single/multiple cross-provider
pre.009 Config std.offchain_transport
pre.010 hardening/API/tests + docs techniques draft
pre.011 gate technique/live final
pre.012 réconciliation documentaire finale
pre.013 publication minimale prompt 0.2.12 + CHANGELOG + ROADMAP
rel.001 stable
```
Seule sa représentation éditoriale change.
## 6. Validations opérateur de la base `pre.001`
Les validations fournies par l'opérateur avant ce fix sont toutes positives :
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py PASS / clean / 0 candidate export
python3 scripts/audit_markdown_tables.py ... deltas/0.2.11 PASS / 115 tables / 100 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test --workspace PASS
```
Les tests explicitement `ignored` dans la sortie restent les smokes/live ou probes opt-in déjà prévus par leurs contrats ; aucun échec de test n'est signalé.
## 7. Fichiers modifiés
```text
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
```
## 8. Fichiers ajoutés
```text
deltas/0.2.11/pre.001-fix.001.md
```
## 9. Fichiers supprimés
```text
aucun
```
## 10. Validation attendue après application
Le fix étant documentaire, le minimum attendu est :
```bash
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
```
`cargo fmt/check/clippy/test` n'ont pas besoin d'être rejoués pour ce seul changement documentaire sauf choix opérateur de refaire un gate complet.

311
deltas/0.2.11/pre.001.md Normal file
View File

@@ -0,0 +1,311 @@
<!-- file: deltas/0.2.11/pre.001.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.001` — audit/sizing Off-chain price SOL/USD multi-provider
## 1. Base requise et vérifiée
Archive autoritaire fournie :
```text
khadhroony-solana-project-v0.2.10-full-from-gitea.zip
```
État vérifié :
```text
workspace.package.version = 0.2.10
deltas/0.2.10/rel.001.md présent
prompts/016-V0_2_11_START_PROMPT.md présent
ksp-offchain-transport-lib absente
metadata .git absente de l'archive Gitea
```
Cette livraison ouvre :
```text
workspace.package.version = 0.2.11-pre.1
commit attendu = v0.2.11-pre.001
aucun tag prerelease
```
## 2. Objet
`pre.001` exécute exclusivement le gate de cadrage obligatoire :
```text
lecture base/règles/architecture
inventaire primitives HTTP existantes
réaudit actuel des providers de prix
comparaison des sémantiques
scope SOL/USD
choix multi-provider gratuit
absence de SDK provider
numeric safety
provider registry et availability
rate limiting et refresh multiple
Config provider-capability-aware
cas DexScreener SOL/USD
frontière future ksp-app-solprices-desk
threat model
sizing et forecast
```
Aucun client provider, crate Off-chain Transport, Config runtime ou secret n'est ajouté dans cette tranche.
## 3. Décisions opérateur supersédant le prompt préparatoire
Le prompt `016` préparait SOL/USD + SOL/EUR et laissait le provider initial ouvert. Le brainstorming d'ouverture fixe désormais :
```text
V1 = SOL/USD uniquement
0.2.11 peut et doit implémenter plusieurs providers gratuits
HTTP REST via reqwest uniquement
aucun SDK provider
SOL/EUR reporté
aucun consensus/fallback automatique
future app = ksp-app-solprices-desk
future app = HID pure sans connaissance provider
Off-chain Transport possède limits/exceptions/availability
DexScreener V1 = paire SOL/USD explicite uniquement
```
Ces décisions sont répercutées dans le plan, la validation, l'architecture durable, la séquence fonctionnelle et le ROADMAP global d'ouverture.
## 4. Providers retenus
Audit officiel du 2026-08-25 :
| Provider | Gratuit V1 | Auth possible V1 | Contrainte principale | Verdict |
|-------------------|------------|----------------------|---------------------------------------------|---------|
| CoinGecko | Oui | keyless ou Demo key | dynamic IP ou Demo 100/min et 10k/mois | IN |
| CoinMarketCap | Oui | keyless ou Basic key | dynamic IP ou Basic 50/min et 15k/mois | IN |
| CoinPaprika | Oui | aucune | 10/s IP, 20k/mois, refresh Free moyen 5 min | IN |
| Kraken | Oui | aucune | 1/s ou moins recommandé sur public REST | IN |
| Coinbase Exchange | Oui | aucune | 10/s IP, burst 15 | IN |
| Jupiter Price V3 | Oui | keyless ou Free key | 0.5 RPS keyless, 1 RPS Free | IN |
| Birdeye | Oui | API key | Standard 1 RPS, 30k CU, Price Single 3 CU | IN |
| DexScreener | Oui | aucune | 300/min endpoints pair/token | IN |
Pyth est écarté de V1 à cause de la transition d'accès imminente et de l'absence de gratuité durable confirmée après trial. Binance est écarté tant que KSP n'a pas retenu un vrai marché SOL/USD ; SOL/USDT n'est pas assimilé à SOL/USD.
## 5. Sémantique et numérique
Décisions :
```text
une observation conserve provider + sémantique + timestamps KSP
provider update timestamp reste optionnel
aucune fraîcheur provider inventée
f64 n'est pas le canon public
prix canonique = décimal KSP borné sans nouvelle crate decimal
wire JSON number/string -> parser textuel sûr
zéro/négatif/overflow/forme invalide rejetés
```
Les classes de sémantique distingueront notamment agrégateur, exchange last trade, heuristique Solana, spot Solana et paire DEX.
## 6. Registry, availability et refresh
`ksp-offchain-transport-lib` sera propriétaire de :
```text
inventory/registry provider
descriptors/capabilities
adapters/wires privés
rate limits/cooldowns
classification 401/403/429/5xx/transport/schema
état provider ready/unavailable/misconfigured/quota/auth
refresh provider générique
refresh multiple/all générique
```
Un refresh multiple lance les providers actuellement éligibles sans attendre ceux en cooldown et retourne un outcome par provider. La future UI ne contient aucune logique de scheduling.
## 7. DexScreener V1
Décision :
```text
pair address Solana configurée
endpoint pair direct
chain et identité SOL validées
lecture de priceUsd uniquement pour le contrat commun
aucune découverte/selection automatique de pool
aucun tri liquidité
aucun consensus entre pools
```
Cette policy pourra être changée dans une release ultérieure sans exposer DexScreener à l'application.
## 8. Config et sécurité
Config reste propriétaire des fichiers/env/secrets et adaptera un document standard Off-chain Transport futur.
Les branches seront provider-capability-aware :
```text
providers keyless sans faux credential obligatoire
modes keyless/keyed représentés quand les deux existent
Birdeye credential obligatoire si activé
DexScreener pair address obligatoire si activé
origines HTTP provider fixes en V1
aucun base_url arbitraire
aucune limite provider librement augmentable par Config/UI
```
Le threat model couvre credential exfiltration, SSRF, redirects, proxy implicite, body non borné, numeric abuse, wrong pair, stale data, rate limit, provider outage et schema drift.
## 9. Future `ksp-app-solprices-desk`
Architecture fixée pour `0.2.12` :
```text
HID pure
aucune connaissance provider
aucune requête HTTP provider
aucun rate limit provider côté UI
liste issue du registry générique
prix/état/cooldown issus du service générique
refresh row générique
refresh multiple générique
```
`0.2.11` ne crée pas l'application.
## 10. Forecast
Chemin souple retenu :
```text
pre.001 audit/sizing
pre.002 crate + contrats + décimal
pre.003 HTTP commun + errors + rate limiter
pre.004 CoinGecko + CMC + CoinPaprika
pre.005 Kraken + Coinbase
pre.006 Jupiter + DexScreener
pre.007 Birdeye + registry availability
pre.008 refresh single/multiple cross-provider
pre.009 Config std.offchain_transport
pre.010 hardening/API/tests + docs techniques draft
pre.011 gate technique/live final
pre.012 réconciliation documentaire finale
pre.013 publication minimale prompt 0.2.12 + CHANGELOG + ROADMAP
rel.001 stable
```
## 11. Fichiers ajoutés
```text
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
deltas/0.2.11/pre.001.md
```
## 12. Fichiers modifiés
```text
Cargo.toml
ROADMAP.md
docs/000-README.md
docs/architecture/002-LAYERS_AND_DEPENDENCIES.md
docs/architecture/003-COMPONENT_CONTRACTS.md
docs/architecture/004-COMPONENT_INVENTORY.md
docs/architecture/005-DEPENDENCY_GRAPH.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/validation/000-README.md
```
## 13. Fichiers supprimés
```text
aucun
```
## 14. Fichiers volontairement inchangés
```text
CHANGELOG.md
prompts/016-V0_2_11_START_PROMPT.md
.env.example
config/**
crates/**
README/USAGE de crates existantes
```
Le prompt d'ouverture reste historique ; les supersessions sont portées par le plan/delta de la release active.
## 15. Sources externes réauditées
```text
CoinGecko keyless/simple-price/pricing
CoinMarketCap keyless/simple-price/pricing
CoinPaprika REST/ticker/plans
Kraken public market REST/rate limits
Coinbase Exchange ticker/rate limits
Jupiter Developer Platform/Price V3
Birdeye pricing/rate limiting/Price Single
DexScreener API reference
Pyth Core transition pour décision OUT V1
```
Les URLs détaillées sont conservées dans le plan 018.
Le gate distingue désormais explicitement accès gratuit technique et conditions d'usage : CoinGecko/CMC keyless sont orientés évaluation/prototypage selon leurs documents actuels et CoinPaprika Free est annoncé pour usage personnel. Ces points n'empêchent pas l'implémentation V1 mais seront réaudités avant la stable ; KSP ne promet aucune gratuité commerciale durable.
## 16. Validations exécutées dans le sandbox
```text
inspection archive/version/rel/prompt PASS
inspection absence metadata .git PASS
inspection absence ksp-offchain-transport-lib PASS
inventaire workspace dependencies PASS
python3 scripts/audit_rust_workspace_rules.py PASS / clean / 0 candidate export
audit Markdown ciblé des 12 fichiers touchés PASS / 16 tables
audit Markdown scope session complet PASS / 115 tables / 100 files
```
Le sandbox ne fournit ni `cargo` ni `rustc`. Aucun `cargo fmt/check/clippy/test` n'est donc déclaré exécuté localement. Aucun smoke provider live KSP n'est déclaré PASS dans `pre.001` puisque la crate n'existe pas encore.
## 17. Validations non exécutées dans `pre.001`
```text
cargo fmt --all non exécuté, Cargo absent du sandbox
cargo check --workspace non exécuté, Cargo absent du sandbox
cargo clippy --workspace --all-targets non exécuté, Cargo absent du sandbox
cargo test --workspace non exécuté, Cargo absent du sandbox
aucun test adapter provider code absent
aucun smoke live provider KSP code absent
aucun test Config Off-chain document absent
aucun build ksp-app-solprices-desk application hors scope
```
## 18. Validation opérateur attendue avant commit
```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 --workspace
```
Aucun smoke réseau provider n'est attendu pour cette tranche documentaire de cadrage.
## 19. Questions ouvertes
Les questions fonctionnelles majeures sont closes pour démarrer l'implémentation. Restent à fixer au niveau du code dans les prereleases prévues :
```text
noms Rust exacts des descriptors/outcomes/status
bornes exactes coefficient/scale du décimal KSP
body limit et timeouts par défaut
forme exacte du rate limiter générique
noms des variables KSP_SECRET provider keyed
pair address DexScreener opérateur à configurer et live-valider
endpoint/version CMC exact à figer après canari live courant
```
Ces points ne remettent pas en cause le gate de sizing.

View File

@@ -0,0 +1,193 @@
<!-- file: deltas/0.2.11/pre.002-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.002-fix.001` — Rustdoc, Logging et taxonomie Off-chain
## 1. Base requise
Ce correctif s'applique exclusivement après :
```text
v0.2.10
+ 0.2.11-pre.001
+ 0.2.11-pre.001-fix.001
+ 0.2.11-pre.002
```
Conformément à `VER-ID-007` et `VER-ID-010`, la version Cargo devient `0.2.11-pre.2.fix.1` car ce correctif modifie du code Rust et un manifeste de crate.
## 2. Motifs du correctif
La validation opérateur de `pre.002` a montré que `cargo check` et les tests passaient, mais que `cargo clippy --workspace --all-targets` émettait deux warnings `missing_docs` sur les crates d'intégration :
```text
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
```
Le gate n'était donc pas propre malgré le succès fonctionnel. Les résultats opérateur complets de `pre.002` restent l'autorité sur cette constatation.
Une relecture des règles a également confirmé les contrats suivants :
```text
RUST-DOC-001/003 rustdocs utiles sur les surfaces concernées
DEP-LOG-004 runtime KSP instrumenté via ksp-logging-lib
DEP-LOG-010 target explicite dans src/constants.rs
DEP-LOG-011 pas de target dérivé implicitement de Cargo
FILE_CONTRACTS modules séparés selon responsabilités/invariants
```
Enfin, la fondation initiale utilisait des noms internes trop génériques (`decimal.rs`, `observation.rs`, `provider.rs`, `settings.rs`) alors que `ksp-offchain-transport-lib` est destinée à porter plusieurs familles off-chain hétérogènes.
## 3. Corrections Rustdoc
Les deux tests d'intégration possèdent désormais une rustdoc de crate explicite, ce qui supprime les warnings `missing_docs` attendus sous `#![warn(missing_docs)]`.
Aucun lint n'est désactivé ou abaissé.
## 4. Logging KSP
`ksp-offchain-transport-lib` dépend désormais réellement de `ksp-logging-lib`.
Le fichier suivant est ajouté :
```text
crates/ksp-offchain-transport-lib/src/constants.rs
```
avec le target propriétaire :
```text
ksp-offchain-transport-lib
```
Les validations/constructions comportementales de la fondation émettent uniquement via les macros `ksp-logging-lib` et `crate::TRACING_TARGET`. Aucune dépendance directe à `tracing`, `tracing-subscriber` ou `tracing-appender` n'est ajoutée.
Les logs de validation ne recopient ni identifiant rejeté, ni display name rejeté, ni futur credential.
## 5. Taxonomie de modules
La crate est explicitement traitée comme un transport off-chain hétérogène et non comme une crate dédiée exclusivement aux prix.
Les modules V1 de prix sont renommés :
```text
decimal.rs -> market_price_decimal.rs
observation.rs -> market_price_observation.rs
provider.rs -> market_price_provider.rs
settings.rs -> market_price_settings.rs
```
Les tests unitaires en miroir suivent la même convention.
La façade publique reste au crate-root ; aucun `pub mod` n'est introduit. Les noms publics `Price*` restent ceux du contrat V1, tandis que le classement physique privé rend explicite leur appartenance à la famille `market_price`.
La taxonomie durable prévue est :
```text
constants.rs / error.rs partagé crate-wide
http_* mécanique HTTP partageable lorsqu'elle existe réellement
market_price_* prix de marché / spot
swap_quote_* futures quotes montant/route
autres familles ajoutées seulement avec un scope réel
```
Cette organisation suit le précédent KSP des familles `http_*`, `ws_*`, `grpc_*` et `rpc_*` de On-chain Transport.
## 6. Lecture/écriture
`market_price` V1 reste strictement read-only. La crate n'est pas déclarée définitivement read-only pour toute évolution future : la sémantique métier décide de l'ownership, pas le simple verbe HTTP.
Une future quote Jupiter appartient à une famille distincte `swap_quote`; une soumission ou signature de transaction Solana reste hors de cette crate. Toute vraie mutation d'un service distant demandera un contrat explicite avant implémentation.
## 7. Tests et canaries modifiés
`dependency_boundary.rs` vérifie désormais au minimum :
```text
ksp-logging-lib présent
aucune dépendance tracing directe
constants.rs et TRACING_TARGET explicites
modules V1 préfixés market_price_*
anciens modules internes génériques absents
Config, reqwest et SDK providers toujours absents de pre.002
```
`public_api.rs` conserve les canaries de façade de `pre.002` avec sa rustdoc de crate.
## 8. Validations demandées après application
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
Le résultat attendu de Clippy est **sans warning rustdoc** pour `ksp-offchain-transport-lib`.
## 9. Scope inchangé
Le correctif n'ajoute aucun client HTTP, limiter actif, adapter provider, credential Config, quote Jupiter ou nouvelle paire. `pre.003` reste propriétaire du premier runtime HTTP commun.
## 10. Validation opérateur de `pre.002` ayant déclenché le fix
La validation fournie par l'opérateur avant ce correctif donne :
```text
cargo fmt --all exécuté
python3 scripts/audit_rust_workspace_rules.py clean
python3 scripts/audit_markdown_tables.py clean, 115 tables / 102 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS fonctionnel avec 2 warnings missing_docs
cargo test -p ksp-offchain-transport-lib PASS, 10 unit + 1 boundary + 2 public API
cargo test --workspace PASS, ignored opt-in/diagnostic inchangés
```
Les deux warnings Clippy sont précisément la raison rustdoc du présent fix ; ils ne sont pas requalifiés en PASS propre.
## 11. Fichiers ajoutés
```text
crates/ksp-offchain-transport-lib/src/constants.rs
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
crates/ksp-offchain-transport-lib/src/market_price_observation.rs
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
crates/ksp-offchain-transport-lib/src/market_price_settings.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_settings.rs
deltas/0.2.11/pre.002-fix.001.md
```
## 12. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/Cargo.toml
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 13. Fichiers supprimés par renommage
```text
crates/ksp-offchain-transport-lib/src/decimal.rs
crates/ksp-offchain-transport-lib/src/observation.rs
crates/ksp-offchain-transport-lib/src/provider.rs
crates/ksp-offchain-transport-lib/src/settings.rs
crates/ksp-offchain-transport-lib/unit_tests/decimal.rs
crates/ksp-offchain-transport-lib/unit_tests/observation.rs
crates/ksp-offchain-transport-lib/unit_tests/provider.rs
crates/ksp-offchain-transport-lib/unit_tests/settings.rs
```
Ces suppressions font partie du correctif : les anciens fichiers génériques ne doivent pas coexister avec les fichiers `market_price_*` après application.

View File

@@ -0,0 +1,93 @@
<!-- file: deltas/0.2.11/pre.002-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.002-fix.002` — conformité Clippy `implicit_return`
## 1. Base requise
Ce delta s'applique après :
```text
v0.2.10
+ 0.2.11-pre.001
+ 0.2.11-pre.001-fix.001
+ 0.2.11-pre.002
+ 0.2.11-pre.002-fix.001
```
La version Cargo attendue avant application est :
```text
0.2.11-pre.2.fix.1
```
## 2. Motif du correctif
La validation opérateur de `pre.002-fix.001` est propre pour `cargo fmt`, les audits Rust/Markdown et `cargo check --workspace`. Le test ciblé `cargo test -p ksp-offchain-transport-lib` passe également avec 13 tests réussis.
Le gate :
```bash
cargo clippy --workspace --all-targets
```
échoue toutefois sur un unique diagnostic dans `crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs` :
```text
clippy::implicit-return
```
La closure passée à `Iterator::any` doit utiliser un `return` explicite conformément à la configuration Clippy du workspace.
## 3. Correction
Le canari de dépendance conserve exactement la même vérification — absence d'une dépendance directe `tracing = ...` — mais adopte la forme KSP déjà utilisée dans d'autres tests :
```rust
|line| return line.trim_start().starts_with("tracing =")
```
Aucune logique runtime, aucun contrat public et aucune taxonomie de module ne sont modifiés.
## 4. Version technique
Comme le correctif modifie un fichier Rust, `VER-ID-007` et `VER-ID-010` imposent la synchronisation du workspace sur :
```text
0.2.11-pre.2.fix.2
```
Le `Cargo.toml` racine est donc modifié dans ce delta.
## 5. Validations opérateur demandées
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
Le gate de sortie exige notamment un `cargo clippy --workspace --all-targets` sans warning ni erreur.
## 6. Scope inchangé
`pre.002-fix.002` ne fait pas avancer `pre.003`. Aucun client HTTP, aucun limiter actif, aucun adapter provider et aucune nouvelle dépendance réseau ne sont introduits.
## 7. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 8. Fichier ajouté
```text
deltas/0.2.11/pre.002-fix.002.md
```

View File

@@ -0,0 +1,185 @@
<!-- file: deltas/0.2.11/pre.002-fix.003.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.002-fix.003` — nommage public `MarketPrice*`
## 1. Base requise
Ce correctif s'applique exclusivement après :
```text
v0.2.10
+ 0.2.11-pre.001
+ 0.2.11-pre.001-fix.001
+ 0.2.11-pre.002
+ 0.2.11-pre.002-fix.001
+ 0.2.11-pre.002-fix.002
```
La version Cargo attendue avant application est :
```text
0.2.11-pre.2.fix.2
```
## 2. Motif du correctif
`pre.002-fix.001` a correctement classé la première capacité de `ksp-offchain-transport-lib` sous les modules privés `market_price_*`, mais sa façade crate-root conservait des noms publics génériques `Price*`.
Cette asymétrie créerait une dette publique dès l'apparition d'autres familles hétérogènes, notamment :
```text
swap_quote_*
metadata_*
autres données off-chain futures
```
Des noms tels que `PriceProviderId`, `PriceProviderState` ou `PriceTimestamp` pourraient alors être interprétés comme des abstractions crate-wide alors qu'ils ne sont actuellement prouvés que pour la famille `market_price`.
Le correctif applique donc la même frontière de responsabilité aux modules privés et à la façade publique avant l'introduction du runtime HTTP de `pre.003`.
## 3. Convention publique retenue
Toute API spécifique à la famille `market_price` porte désormais le préfixe :
```text
MarketPrice*
```
et toute constante correspondante :
```text
MARKET_PRICE_*
ERROR_CODE_MARKET_PRICE_*
```
Exemples principaux :
```text
PriceDecimal -> MarketPriceDecimal
SolUsdPriceObservation -> MarketPriceObservation
PriceTimestamp -> MarketPriceTimestamp
PriceProvenance -> MarketPriceProvenance
PricePair -> MarketPricePair
PriceSemantics -> MarketPriceSemantics
PriceProviderId -> MarketPriceProviderId
PriceProviderDescriptor -> MarketPriceProviderDescriptor
PriceProviderRateLimit -> MarketPriceProviderRateLimit
PriceProviderAvailability -> MarketPriceProviderAvailability
PriceProviderState -> MarketPriceProviderState
PriceProviderCommonSettings -> MarketPriceProviderCommonSettings
```
Les enums de rate-limit/quota/auth suivent la même règle.
Aucun alias public `Price*` n'est conservé. La release est encore en prerelease et conserver ces aliases figerait précisément l'ambiguïté que ce correctif doit supprimer.
## 4. Observation V1
`SolUsdPriceObservation` devient `MarketPriceObservation` plutôt que `MarketPriceSolUsdObservation`.
La paire réellement supportée reste portée explicitement par :
```text
MarketPricePair::SolUsd
```
Cette forme évite un nouveau renommage du type observation si une version ultérieure étend proprement la famille `market_price` à d'autres paires, tout en conservant le gate V1 strictement limité à SOL/USD.
## 5. Codes d'erreur
Les identifiants publics et les codes textuels spécifiques à cette famille sont également namespacés :
```text
market_price_decimal_invalid
market_price_provider_descriptor_invalid
market_price_provider_id_invalid
market_price_observation_invalid
market_price_provider_settings_invalid
```
Le domaine `ksp_core_lib::ErrorCode` reste :
```text
offchain_transport
```
Une future famille peut ainsi ajouter ses propres codes sans collision sémantique avec `market_price`.
## 6. Version technique
Le correctif modifie du Rust public. `VER-ID-007` et `VER-ID-010` imposent donc :
```text
workspace.package.version = 0.2.11-pre.2.fix.3
```
## 7. Validation opérateur acquise avant ce fix
L'état `0.2.11-pre.002-fix.002` a été validé par l'opérateur le `2026-08-25` avec :
```text
cargo fmt --all exécuté
python3 scripts/audit_rust_workspace_rules.py clean
python3 scripts/audit_markdown_tables.py clean, 116 tables / 104 files
cargo check --workspace PASS
cargo test -p ksp-offchain-transport-lib PASS, 10 unit + 1 boundary + 2 public API
cargo clippy --workspace --all-targets PASS
```
`cargo test --workspace` n'a pas été rejoué après `pre.002-fix.002` et n'est donc pas déclaré PASS pour cet état exact.
## 8. Validations demandées après application
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
Le canari public doit notamment compiler exclusivement avec les noms `MarketPrice*` et ne plus trouver de surface publique `Price*` historique.
## 9. Scope inchangé
Ce correctif reste strictement rattaché à la fondation `pre.002` :
```text
aucun reqwest
aucun client HTTP
aucun limiter actif
aucun adapter provider
aucun credential provider
aucune nouvelle paire
aucune quote Jupiter
```
`pre.003` reste propriétaire du premier runtime HTTP commun.
## 10. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
crates/ksp-offchain-transport-lib/src/market_price_observation.rs
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
crates/ksp-offchain-transport-lib/src/market_price_settings.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_settings.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 11. Fichier ajouté
```text
deltas/0.2.11/pre.002-fix.003.md
```

278
deltas/0.2.11/pre.002.md Normal file
View File

@@ -0,0 +1,278 @@
<!-- file: deltas/0.2.11/pre.002.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.002` — fondation Off-chain Transport SOL/USD
## 1. Base requise
Cette tranche s'applique exclusivement sur :
```text
v0.2.10
+ 0.2.11-pre.001
+ 0.2.11-pre.001-fix.001
```
La version Cargo d'entrée attendue est :
```text
0.2.11-pre.1
```
La version Cargo de sortie est :
```text
0.2.11-pre.2
```
## 2. Objectif
Créer la fondation de `ksp-offchain-transport-lib` sans avancer le client HTTP commun ni aucun adapter provider.
La tranche matérialise uniquement :
```text
surface publique SOL/USD V1
PriceDecimal exact sans f64 canonique
observation/provenance/timestamps provider-neutral
identifiant/descripteur provider opaque
sémantique de prix explicite
auth/rate-limit/quota descriptifs
settings communs minimaux
availability/state provider-neutral
```
`reqwest`, le limiter actif, les credentials provider et les DTOs wire restent réservés aux prereleases suivantes.
## 3. Décisions matérialisées
### 3.1 Décimal exact
`PriceDecimal` possède :
```text
coefficient positif u128
scale maximale 18
entrée textuelle maximale 96 octets
normalisation des zéros fractionnaires terminaux
notation scientifique bornée
serialization Serde canonique sous forme de string décimale
zéro, négatif, NaN/Inf, overflow et scale excessive rejetés
```
Le type ne passe jamais par `f64` comme vérité canonique.
Le zéro étant invalide pour un prix réussi, l'absence de prix ne peut pas être silencieusement transformée en `0`.
### 3.2 Paire et observation V1
La seule paire publique reste :
```text
PricePair::SolUsd
```
`SolUsdPriceObservation` conserve :
```text
provider_id opaque
PriceDecimal
PriceSemantics
request_started_at
received_at
provider_timestamp optionnel
PriceProvenance sûre et bornée
```
Les timestamps sont projetés en millisecondes UTC depuis Unix epoch via `PriceTimestamp`.
Le constructeur rejette une réception antérieure au départ de requête. Aucun timestamp provider n'est obligatoire ou inventé.
### 3.3 Provider-neutral descriptors
`PriceProviderId` est un identifiant opaque borné. Il n'expose pas les identifiants wire propriétaires des providers.
`PriceProviderDescriptor` conserve uniquement des informations génériques utiles aux consumers :
```text
id
nom d'affichage
PriceSemantics
PriceProviderAuthMode
PriceProviderRateLimit
PriceProviderLongTermQuota optionnel
support SOL/USD
```
Les limites fixes exigent un nombre de requêtes et une fenêtre non nuls. Une limite dynamique/server-driven est représentée séparément.
Les quotas longs termes sont informatifs : aucun compteur local ne prétend connaître le quota restant réel d'un compte partagé ou consommé par un autre processus.
### 3.4 Settings communs minimaux
`PriceProviderCommonSettings` ne contient que :
```text
provider_id
enabled
```
Il ne contient volontairement ni endpoint, ni API key, ni cadence configurable.
Les settings propres à CoinGecko, CoinMarketCap, Jupiter, Birdeye ou DexScreener seront ajoutés uniquement avec leurs adapters afin que chaque configuration corresponde aux capacités réelles du provider.
### 3.5 Availability générique
`PriceProviderAvailability` peut représenter :
```text
authentication unavailable
cooling down + retry_at
disabled
misconfigured
quota unavailable
ready
temporarily unavailable + retry_at optionnel
```
`PriceProviderState` associe cette projection à un `PriceProviderId` sans demander au consumer de parser une erreur provider.
Le registry et les transitions runtime effectives restent prévus en `pre.007`.
## 4. Tests ajoutés
La crate contient des tests unitaires physiquement séparés pour :
```text
parsing/normalisation/scientific notation PriceDecimal
rejets numeric safety
round-trip Serde PriceDecimal
validation PriceProviderId
descriptors auth/rate-limit/quota
availability générique
observation SOL/USD et ordre des timestamps
provenance sûre
settings communs sans endpoint/API key/rate-limit
```
Deux tests d'intégration vérifient :
```text
surface publique pre.002 au crate-root
error codes domaine offchain_transport
absence de dépendance Config
absence de reqwest/provider SDK dans le manifest pre.002
```
## 5. Fichiers ajoutés
```text
crates/ksp-offchain-transport-lib/Cargo.toml
crates/ksp-offchain-transport-lib/src/decimal.rs
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/src/observation.rs
crates/ksp-offchain-transport-lib/src/provider.rs
crates/ksp-offchain-transport-lib/src/settings.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
crates/ksp-offchain-transport-lib/unit_tests/decimal.rs
crates/ksp-offchain-transport-lib/unit_tests/observation.rs
crates/ksp-offchain-transport-lib/unit_tests/provider.rs
crates/ksp-offchain-transport-lib/unit_tests/settings.rs
deltas/0.2.11/pre.002.md
```
## 6. Fichiers modifiés
```text
Cargo.toml
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 7. Fichiers supprimés
```text
aucun
```
## 8. Fichiers volontairement inchangés
```text
CHANGELOG.md
ROADMAP.md
README.md
.env.example
config/**
crates/ksp-config-lib/**
crates/ksp-onchain-transport-lib/**
prompts/**
```
`ROADMAP.md` et `CHANGELOG.md` restent hors de cette tranche conformément à leur ownership de release.
## 9. Validations exécutées dans le sandbox
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
python3 scripts/audit_markdown_tables.py docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
clean / 14 tables / 2 files
contrôle lignes Rust > 160 sur la nouvelle crate
PASS / aucune
inspection manifest nouvelle crate
ksp-config-lib absent
reqwest absent en pre.002
SDK provider absent
```
## 10. Validations non exécutées dans le sandbox
Le sandbox de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`.
Les validations suivantes doivent donc être exécutées par l'opérateur après application du delta :
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
Les validations Cargo fournies par l'opérateur pour `pre.001` étaient vertes avant cette tranche ; elles constituent une baseline d'entrée mais ne sont pas comptées comme validation du nouveau code `pre.002`.
## 11. Questions ouvertes
Aucune question de design ne bloque `pre.003`.
Les points volontairement différés restent :
```text
HTTP reqwest commun
conversion JSON number/string provider vers PriceDecimal
classification HTTP/transport
Retry-After
limiter/cooldown actif
credentials provider
adapters provider
registry runtime effectif
```
## 12. Suite prévue
La tranche suivante reste :
```text
0.2.11-pre.003 — HTTP REST commun et rate limiting
```
Elle pourra ajouter `reqwest` et `ksp-logging-lib` à la nouvelle crate, les bornes HTTP, la redaction, la classification commune des erreurs et le limiter/cooldown générique, sans encore implémenter les adapters provider de `pre.004+`.

View File

@@ -0,0 +1,220 @@
<!-- file: deltas/0.2.11/pre.003-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.003-fix.001` — conformité crate-root, staging HTTP et burst documenté
## 1. Base requise
Ce correctif s'applique exclusivement après :
```text
v0.2.10
+ 0.2.11-pre.001
+ 0.2.11-pre.001-fix.001
+ 0.2.11-pre.002
+ 0.2.11-pre.002-fix.001
+ 0.2.11-pre.002-fix.002
+ 0.2.11-pre.002-fix.003
+ 0.2.11-pre.003
```
La version Cargo attendue à l'entrée est :
```text
0.2.11-pre.3
```
La version Cargo de sortie est :
```text
0.2.11-pre.3.fix.1
```
## 2. Motif du correctif
Le gate opérateur de `pre.003` a révélé plusieurs écarts avant poursuite vers `pre.004` :
```text
RUST-FMT-104 : 6 violations dans src/error.rs
cargo check : 6 warnings de réexports pub(crate) HTTP inutilisés
cargo clippy --workspace --all-targets : mêmes warnings de réexports
cargo test -p ksp-offchain-transport-lib : 20 PASS, 1 FAIL
```
Le test en échec était :
```text
http_admission::tests::documented_burst_is_consumed_atomically_before_refill
```
La cause était une validation incorrecte refusant `burst > requests`. Cette contrainte contredit un cas déjà retenu dans le plan : Coinbase documente `10 req/s` avec `burst 15`.
Une relecture de `docs/rules/RULES_RUST.md` confirme également deux règles applicables :
```text
RUST-IMPORT-009 / RUST-API-004
un item pub/pub(crate) crate-wide est consommé via la façade crate::Item,
y compris depuis son module de déclaration.
RUST-API-008
un helper préparatoire sans consumer de production reste sous #[cfg(test)]
jusqu'à sa première consommation réelle ; #[allow(dead_code)] n'est pas utilisé
pour conserver artificiellement du code mort.
```
## 3. Correction de façade crate-root
Les items HTTP partagés continuent d'être réexportés au crate-root dans le build de test, et leurs implémentations utilisent désormais leur chemin de façade :
```text
impl crate::HttpAdmissionPolicy
impl crate::HttpAdmissionController
impl crate::HttpJsonDocument
impl crate::HttpGetRequest
impl crate::HttpRestClient
impl crate::HttpClientSettings
```
Les signatures et références crate-wide correspondantes utilisent également `crate::Item` lorsqu'elles traversent un module.
Les types strictement privés, tels que `HttpTokenBucketState`, restent locaux et ne sont pas réexportés.
## 4. Staging `RUST-API-008`
Aucun adapter de production ne consomme encore les primitives `http_*` en `pre.003`.
Les trois modules préparatoires sont donc compilés sous :
```text
#[cfg(test)]
```
jusqu'à `pre.004`, où les premiers adapters de production les consommeront réellement.
Les anciens :
```text
#[allow(dead_code)]
```
sont supprimés.
Le staging concerne :
```text
http_admission
http_client
http_settings
```
Les codes d'erreur HTTP publics restent disponibles dans le build normal ; aucune API HTTP générique n'est rendue publique aux consumers.
## 5. Correction du burst
La policy fixe conserve les invariants suivants :
```text
requests > 0
window > 0
window <= borne défensive
burst > 0 lorsqu'il est explicite
burst absent -> capacité locale conservatrice de 1
```
En revanche :
```text
burst <= requests
```
n'est plus un invariant.
Un burst documenté est une capacité instantanée distincte de la cadence moyenne et peut légitimement lui être supérieur.
Le canari déterministe couvre explicitement :
```text
1 req/s
burst 2
2 admissions immédiates
3e admission différée
```
Cette propriété protège notamment la représentabilité future du cas Coinbase `10 req/s, burst 15`.
## 6. Ordre des constantes
Le bloc `const` de `src/error.rs` est réordonné alphabétiquement conformément à `RUST-FMT-005` / `RUST-FMT-104` sans modifier les valeurs des codes d'erreur.
Aucun code d'erreur n'est ajouté, supprimé ou renommé.
## 7. Test HTTP complémentaire
Un canari supplémentaire construit une requête HTTPS officielle-style sans effectuer de réseau, ajoute query pair et header sensible, puis vérifie que la représentation `Debug` ne contient ni host ni credential.
Ce canari consomme également le constructeur `HttpGetRequest::new_https` pendant le staging `cfg(test)`.
## 8. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/http_admission.rs
crates/ksp-offchain-transport-lib/src/http_client.rs
crates/ksp-offchain-transport-lib/src/http_settings.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs
crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 9. Fichier ajouté
```text
deltas/0.2.11/pre.003-fix.001.md
```
## 10. Fichiers volontairement inchangés
```text
crates/ksp-offchain-transport-lib/Cargo.toml
crates/ksp-offchain-transport-lib/tests/**
CHANGELOG.md
ROADMAP.md
README.md
.env.example
config/**
crates/ksp-config-lib/**
crates/ksp-onchain-transport-lib/**
prompts/**
```
Le manifeste de la crate hérite toujours de `workspace.package.version`; seul le `Cargo.toml` racine porte le bump technique.
## 11. Validations sandbox
Exécutées après le correctif :
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
```
Le sandbox ne fournit pas Cargo ; les gates opérateur restent à rejouer sur l'état exact du correctif.
## 12. Gate opérateur demandé
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
Le passage à `pre.004` est conditionné à un gate propre sans warning et au retour au vert du test de burst.

300
deltas/0.2.11/pre.003.md Normal file
View File

@@ -0,0 +1,300 @@
<!-- file: deltas/0.2.11/pre.003.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.003` — HTTP REST commun et rate limiting
## 1. Base requise
Cette tranche s'applique exclusivement après :
```text
v0.2.10
+ 0.2.11-pre.001
+ 0.2.11-pre.001-fix.001
+ 0.2.11-pre.002
+ 0.2.11-pre.002-fix.001
+ 0.2.11-pre.002-fix.002
+ 0.2.11-pre.002-fix.003
```
La version Cargo attendue à l'entrée est :
```text
0.2.11-pre.2.fix.3
```
La version Cargo de sortie est :
```text
0.2.11-pre.3
```
## 2. Gate d'entrée acquis
L'opérateur a validé `0.2.11-pre.002-fix.003` le `2026-08-25` avec :
```text
cargo fmt --all exécuté
python3 scripts/audit_rust_workspace_rules.py clean
python3 scripts/audit_markdown_tables.py clean, 116 tables / 105 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test -p ksp-offchain-transport-lib PASS, 10 unit + 1 boundary + 2 public API
```
`cargo test --workspace` n'a pas été fourni pour cet état exact et n'est pas déclaré PASS ici.
## 3. Objectif
Matérialiser les primitives HTTP REST communes de `ksp-offchain-transport-lib` et le rate limiting local sans avancer aucun provider.
La tranche doit préparer directement `pre.004+` tout en conservant les frontières :
```text
http_* = mécanique transport crate-wide et crate-private
market_price_* = première capacité métier, SOL/USD uniquement
aucun client HTTP générique exporté aux consumers
aucun SDK provider
aucun Config -> Off-chain ajouté avant pre.009
aucun refresh registry/service avant pre.007/pre.008
```
## 4. HTTP REST commun
`http_client.rs` matérialise un `reqwest::Client` réutilisable par les adapters internes.
La construction impose explicitement :
```text
rustls
redirects désactivés
Referer automatique désactivé
proxy système désactivé
retries implicites reqwest désactivés
User-Agent KSP explicite
connect timeout borné
request timeout borné
```
`reqwest` reste une dépendance directe de la crate et aucun SDK provider n'est ajouté.
La requête GET interne :
```text
accepte uniquement HTTPS pour les adapters runtime
refuse credentials dans l'URL
encode les query pairs via Url
permet des headers sensibles marqués sensitive
redacte URL et headers dans Debug
```
Un constructeur HTTP non HTTPS existe uniquement sous `cfg(test)` pour les serveurs loopback déterministes.
## 5. Bornes de réponse et JSON
Les valeurs initiales sont :
```text
connect timeout défaut = 5 s
connect timeout hard max = 30 s
request timeout défaut = 10 s
request timeout hard max = 120 s
body défaut = 1 MiB
body hard max = 4 MiB
```
Le body est borné pendant sa lecture par chunks. La présence ou l'absence de `Content-Length` ne permet donc pas de contourner la limite.
Après lecture, la syntaxe JSON est validée avec `serde::de::IgnoredAny`. Le document brut borné reste disponible pour le futur adapter typed, ce qui évite de faire passer un nombre provider par une représentation générique `f64` avant le parsing `MarketPriceDecimal`.
## 6. Classification HTTP
Les codes crate-wide ajoutés sous le domaine `offchain_transport` distinguent :
```text
http_settings_invalid
http_client_build_failed
http_request_invalid
http_connection_failed
http_timeout
http_request_failed
http_access_denied
http_rate_limited
http_temporary_failure
http_response_too_large
http_invalid_json
http_rate_limit_invalid
```
La classification de statut est :
```text
401/403 -> access denied
429 -> rate limited
408 et 5xx -> temporary failure
autre non-2xx -> request failed
2xx -> body borné puis JSON syntaxiquement validé
```
Les erreurs `reqwest` sont converties après `without_url()`. Aucun body remote n'est recopié dans `KspError`.
`Retry-After` est actuellement exploité sous sa forme `delta-seconds`, bornée défensivement à une heure. Une forme HTTP-date non parseable reste ignorée plutôt que devinée.
## 7. Admission et cooldown
`http_admission.rs` fournit un limiter non bloquant.
Les policies internes sont :
```text
Fixed
Dynamic
Unlimited
```
Une policy fixe utilise un token bucket lissé :
```text
requests/window
burst explicite uniquement lorsqu'il est documenté
burst absent -> capacité locale conservatrice de 1
```
Une policy dynamique n'invente aucune cadence locale. Elle applique seulement les cooldowns appris du provider.
`try_admit()` ne dort jamais : il retourne immédiatement `Ready` ou `Deferred(duration)`. Cette propriété prépare le `refresh multiple/all` de `pre.008`, qui devra continuer avec les autres providers au lieu d'attendre un provider en cooldown.
Un `429` peut étendre le cooldown avec `Retry-After`; la durée provider est bornée à une heure et comparée au fallback local.
## 8. Tests ajoutés
Les nouveaux tests unitaires couvrent :
```text
settings HTTP par défaut et bornes pathologiques
policy fixe invalide
burst absent lissé conservativement
burst documenté consommé atomiquement
refill déterministe du token bucket
cooldown provider et borne Retry-After
Debug request sans URL/credential
GET JSON valide
redirect refusé
body chunked dépassant la limite
429 + Retry-After sans propagation du body canari
JSON invalide après statut 2xx
```
Les canaries d'intégration vérifient aussi :
```text
reqwest rustls présent
Config toujours absent
SDK providers toujours absents
tracing direct toujours absent
modules http_* présents
HttpRestClient non exporté publiquement
redirect/no-proxy/no-retry/without_url explicites dans le client
codes d'erreur HTTP dans le domaine offchain_transport
```
## 9. Fichiers ajoutés
```text
crates/ksp-offchain-transport-lib/src/http_admission.rs
crates/ksp-offchain-transport-lib/src/http_client.rs
crates/ksp-offchain-transport-lib/src/http_settings.rs
crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs
crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs
deltas/0.2.11/pre.003.md
```
## 10. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/Cargo.toml
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 11. Fichiers supprimés
```text
aucun
```
## 12. Fichiers volontairement inchangés
```text
CHANGELOG.md
ROADMAP.md
README.md
.env.example
config/**
crates/ksp-config-lib/**
crates/ksp-onchain-transport-lib/**
prompts/**
```
`ROADMAP.md` et `CHANGELOG.md` restent hors de la tranche conformément à leur ownership de release.
## 13. Validations exécutées dans le sandbox
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
clean
contrôle lignes Rust > 160 sur ksp-offchain-transport-lib
PASS
inspection diff contre 0.2.11-pre.002-fix.003
aucun provider adapter ajouté
aucune dépendance ksp-config-lib ajoutée
aucune dépendance tracing directe ajoutée
```
## 14. Validations non exécutées dans le sandbox
Le sandbox de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`.
Après application, exécuter :
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
## 15. Décisions et points différés
Aucune question ne bloque `pre.004`.
Restent volontairement différés :
```text
mapping MarketPriceProviderRateLimit -> HttpAdmissionPolicy par provider
CoinGecko/CoinMarketCap/CoinPaprika wire DTOs et endpoints
credentials provider effectifs
registry et availability runtime
refresh single/multiple
Config std.offchain_transport
Retry-After HTTP-date si un provider retenu l'exige réellement
POST/écritures HTTP pour futures familles off-chain
```
`pre.004` reste propriétaire du premier lot d'adapters : CoinGecko, CoinMarketCap et CoinPaprika.

View File

@@ -0,0 +1,86 @@
<!-- file: deltas/0.2.11/pre.004-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.004-fix.001` — Gate statique, Clippy et fixture CoinMarketCap V2
## 1. Base requise
Ce correctif s'applique exclusivement après `0.2.11-pre.004`.
Version Cargo attendue à l'entrée :
```text
0.2.11-pre.4
```
Version Cargo de sortie :
```text
0.2.11-pre.4.fix.1
```
## 2. Motif du correctif
Le gate opérateur du `2026-08-25` a produit :
```text
cargo fmt --all exécuté
audit Rust workspace 1 x RUST-FMT-104
audit Markdown PASS, 118 tables / 108 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets 2 warnings collapsible_if
cargo test -p ksp-offchain-transport-lib FAIL, 30 PASS / 1 FAIL
```
L'unique test en échec était `coinmarketcap_v2_fixture_normalizes_string_status_and_exact_price`.
## 3. Corrections
### Ordre des constantes
Le bloc `ERROR_CODE_*` de `src/error.rs` est remis dans l'ordre alphabétique complet attendu par `RUST-FMT-104`. Aucun code textuel ni domaine d'erreur ne change.
### Clippy
Les builders CoinGecko et CoinMarketCap remplacent les deux `if let` imbriqués par des `let`-chains. La logique reste identique : le header sensible n'est ajouté que lorsqu'une clé est présente et toute erreur d'insertion est retournée immédiatement.
### Fixture CoinMarketCap V2
La fixture concaténée omettait le guillemet ouvrant de la clé JSON `last_updated` :
```text
avant : ...,151.987654321012345678,last_updated...
après : ...,151.987654321012345678,"last_updated"...
```
Le parser CoinMarketCap, les DTOs wire, l'endpoint Simple Price V2 et le contrat exact du prix ne sont pas modifiés.
## 4. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinmarketcap.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
deltas/0.2.11/pre.004-fix.001.md
```
## 5. 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-offchain-transport-lib
```
`cargo test --workspace` reste recommandé avant poursuite lorsque le temps opérateur le permet.
## 6. Hors scope
Aucun provider supplémentaire, registry, Config, refresh multiple, live smoke, changement d'endpoint ou changement de rate limit n'est introduit.

402
deltas/0.2.11/pre.004.md Normal file
View File

@@ -0,0 +1,402 @@
<!-- file: deltas/0.2.11/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.004` — CoinGecko, CoinMarketCap et CoinPaprika
## 1. Base requise
Cette tranche s'applique exclusivement après :
```text
v0.2.10
+ 0.2.11-pre.001
+ 0.2.11-pre.001-fix.001
+ 0.2.11-pre.002
+ 0.2.11-pre.002-fix.001
+ 0.2.11-pre.002-fix.002
+ 0.2.11-pre.002-fix.003
+ 0.2.11-pre.003
+ 0.2.11-pre.003-fix.001
```
La version Cargo attendue à l'entrée est :
```text
0.2.11-pre.3.fix.1
```
La version Cargo de sortie est :
```text
0.2.11-pre.4
```
## 2. Gate d'entrée et reprise volontaire de `RUST-FMT-108`
L'opérateur a validé `0.2.11-pre.003-fix.001` le `2026-08-25` avec :
```text
cargo fmt --all exécuté
python3 scripts/audit_rust_workspace_rules.py 1 violation RUST-FMT-108 uniquement
python3 scripts/audit_markdown_tables.py clean, 117 tables / 107 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test -p ksp-offchain-transport-lib PASS, 22 unit + 2 boundary + 2 public API
```
`RUST-FMT-108` provenait uniquement du staging `#[cfg(test)]` des modules `http_*` imposé en `pre.003-fix.001` tant qu'aucun consumer de production n'existait. L'opérateur a explicitement demandé de ne pas créer un fix artificiel supplémentaire puisque `pre.004` est précisément la tranche qui active ces modules avec leurs premiers consumers runtime.
`pre.004` retire donc le staging crate-root de :
```text
http_admission
http_client
http_settings
```
La cause structurelle de `RUST-FMT-108` disparaît sans exception ni `allow`.
## 3. Objectif
Implémenter les trois premiers providers SOL/USD de la famille `market_price_*` :
```text
CoinGecko
CoinMarketCap
CoinPaprika
```
Le contrat reste :
```text
SOL/USD uniquement
reqwest direct
aucun SDK provider
endpoints officiels fixes
DTOs wire privés
prix public exact, aucun canon f64
credentials absents des Debug/logs/errors
aucun Config dans Off-chain Transport
aucun registry global ni refresh multiple avant leurs tranches dédiées
```
## 4. Pont HTTP -> Market Price
`market_price_adapter.rs` matérialise uniquement les mécanismes réellement partagés par les adapters de prix :
```text
admission non bloquante
capture des timestamps KSP
GET JSON borné via le client HTTP commun
feedback 429 / Retry-After vers le cooldown local
conversion RFC3339 ou Unix seconds vers MarketPriceTimestamp
erreurs de schéma provider sûres sans copie du payload distant
construction du runtime HTTP + admission depuis le descriptor provider
```
Les primitives `http_*` restent crate-wide et crate-private afin d'être réutilisables plus tard par d'autres familles telles que `swap_quote_*`, sans devenir un client HTTP générique public pour les consumers.
## 5. Credential market-price partagé
`market_price_api_key.rs` ajoute un holder privé `MarketPriceApiKey` :
```text
longueur maximale 512 octets
valeur vide rejetée
whitespace externe rejeté
caractères de contrôle rejetés
Debug toujours redacted
aucun logging de la valeur
```
Ce holder ne lit aucun environnement. Config reste l'unique futur propriétaire de la résolution des secrets.
## 6. CoinGecko
Le provider public est :
```text
MarketPriceCoinGeckoProvider
MarketPriceCoinGeckoSettings
MarketPriceCoinGeckoAccessMode
```
Modes V1 :
```text
Keyless
Demo
```
La configuration respecte les capacités réelles :
```text
Keyless -> aucun credential accepté
Demo activé -> API key obligatoire
Demo désactivé -> credential optionnel pour permettre une config inactive
```
Le request contract est fixe :
```text
GET https://api.coingecko.com/api/v3/simple/price
ids=solana
vs_currencies=usd
include_last_updated_at=true
header x-cg-demo-api-key uniquement en mode Demo
```
Le descriptor conserve :
```text
Keyless -> auth none, rate limit dynamique scope IP
Demo -> API key requise, 100 requêtes / 60 s scope account, 10k credits / mois
semantics -> AggregatedMarket
```
La réponse exige `solana.usd` et `solana.last_updated_at`. Le prix est lu via `RawValue` puis `MarketPriceDecimal`; le timestamp provider Unix est conservé.
## 7. CoinMarketCap
Le provider public est :
```text
MarketPriceCoinMarketCapProvider
MarketPriceCoinMarketCapSettings
MarketPriceCoinMarketCapAccessMode
```
Modes V1 :
```text
Keyless
Basic
```
La configuration suit la même discipline : keyless refuse un credential et Basic activé exige une API key.
Le réaudit `pre.004` retient la surface **Simple Price V2 actuelle** et n'introduit pas la surface V1 deprecated.
Requests fixes :
```text
Keyless -> GET https://pro-api.coinmarketcap.com/public-api/v2/simple/price
Basic -> GET https://pro-api.coinmarketcap.com/v2/simple/price
ids=5426
convert=USD
include_last_updated=true
header x-cmc_pro_api_key uniquement en mode Basic
```
Le descriptor conserve :
```text
Keyless -> auth none, rate limit dynamique scope IP
Basic -> API key requise, 50 requêtes / 60 s scope account, 15k credits / mois
semantics -> AggregatedMarket
```
Le parser vérifie :
```text
status.error_code = 0
un seul item SOL id 5426
symbol = SOL
une seule quote USD
price exact via RawValue
last_updated RFC3339 valide
```
La provenance normalisée conserve explicitement la génération `v2`.
## 8. CoinPaprika
Le provider public est :
```text
MarketPriceCoinPaprikaProvider
MarketPriceCoinPaprikaSettings
```
Il est keyless uniquement : aucun faux champ credential n'est introduit.
Request fixe :
```text
GET https://api.coinpaprika.com/v1/tickers/sol-solana
quotes=USD
```
Le descriptor conserve :
```text
auth none
10 requêtes / seconde scope IP
20k requests / mois
semantics -> AggregatedMarket
```
Le parser vérifie `id = sol-solana`, `symbol = SOL`, lit `quotes.USD.price` exactement via `RawValue` et conserve `last_updated` RFC3339.
## 9. Numeric safety
`MarketPriceDecimal` gagne un parser crate-private de `serde_json::value::RawValue`.
Il accepte :
```text
nombre JSON exact
ou chaîne JSON contenant un décimal valide
```
Il ne passe jamais par `f64`. Les tests couvrent notamment une mantisse dont les derniers chiffres seraient perdus par une conversion flottante intermédiaire.
## 10. Availability et rate limiting dans cette tranche
Chaque provider possède son `HttpAdmissionController` local construit depuis son descriptor.
Le chemin runtime :
```text
fetch_sol_usd
-> enabled check
-> try_admit non bloquant
-> request timestamp
-> GET provider
-> si 429 : Retry-After enregistré dans le cooldown
-> receive timestamp
-> parser typed provider
-> MarketPriceObservation
```
Un provider localement non admissible retourne `http_admission_deferred` avec un délai safe ; aucun sleep n'est introduit dans l'adapter.
Le registry global et la projection générique d'availability restent réservés à `pre.007`, et l'orchestration multiple à `pre.008`.
## 11. Tests ajoutés ou étendus
Les tests déterministes couvrent :
```text
modes keyless/keyed et credentials requis/interdits
Debug settings sans secret
headers sensibles présents uniquement dans le bon mode
URLs et query fixes
capabilities descriptors et quotas
réponses SOL/USD valides
prix JSON exact sans f64
provider timestamps
identités provider incorrectes rejetées
schema/provider status incohérents rejetés
endpoint CMC V2 exact
absence de SDK provider
absence de Config et tracing direct
HTTP production actif mais non exporté publiquement
```
Aucun smoke réseau n'est ajouté dans cette tranche.
## 12. Fichiers ajoutés
```text
crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
crates/ksp-offchain-transport-lib/src/market_price_api_key.rs
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs
crates/ksp-offchain-transport-lib/src/market_price_coinpaprika.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_coingecko.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinmarketcap.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinpaprika.rs
deltas/0.2.11/pre.004.md
```
## 13. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/Cargo.toml
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/http_admission.rs
crates/ksp-offchain-transport-lib/src/http_client.rs
crates/ksp-offchain-transport-lib/src/http_settings.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 14. Fichiers supprimés
```text
aucun
```
## 15. Fichiers volontairement inchangés
```text
CHANGELOG.md
ROADMAP.md
README.md
.env.example
config/**
crates/ksp-config-lib/**
crates/ksp-onchain-transport-lib/**
prompts/**
```
`ROADMAP.md` et `CHANGELOG.md` restent hors de cette prerelease conformément à leur ownership de release.
## 16. Validations exécutées dans le sandbox
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
clean, 118 tables / 108 files
contrôle lignes Rust > 160 sur ksp-offchain-transport-lib
PASS, 0 ligne
```
Le sandbox de génération ne possède pas Cargo/Rust. `cargo fmt`, `cargo check`, `cargo clippy` et les tests Cargo ne sont donc pas déclarés exécutés ici.
## 17. Gate opérateur demandé
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
Les smokes live restent hors de `pre.004`.
## 18. Scope non avancé
```text
Kraken
Coinbase Exchange
Jupiter
DexScreener
Birdeye
registry provider global
availability service global
refresh single/multiple générique
Config std.offchain_transport
.env.example secrets provider
ksp-app-solprices-desk
fallback automatique
consensus de prix
SOL/EUR
prix SPL générique
```

202
deltas/0.2.11/pre.005.md Normal file
View File

@@ -0,0 +1,202 @@
<!-- file: deltas/0.2.11/pre.005.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.005` — Kraken + Coinbase Exchange
## 1. Base requise
Cette tranche s'applique exclusivement après `0.2.11-pre.004-fix.001` validé par l'opérateur.
Version Cargo attendue à l'entrée :
```text
0.2.11-pre.4.fix.1
```
Version Cargo de sortie :
```text
0.2.11-pre.5
```
Le gate d'entrée du `2026-08-25` est intégralement PASS : audit Rust clean, audit Markdown clean, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-offchain-transport-lib` et `cargo test --workspace`.
## 2. Objet strict de la tranche
`pre.005` ajoute uniquement les deux providers exchange prévus par le forecast :
```text
Kraken Spot
Coinbase Exchange
```
Le contrat reste :
```text
SOL/USD uniquement
HTTP REST direct via reqwest
aucun SDK provider
aucun credential provider pour Kraken/Coinbase Exchange
MarketPriceSemantics::ExchangeLastTrade
pas de registry global
pas de Config
pas de refresh multiple
pas de smoke live revendiqué
```
## 3. Kraken Spot
Ajout de :
```text
market_price_kraken.rs
unit_tests/market_price_kraken.rs
MarketPriceKrakenSettings
MarketPriceKrakenProvider
```
Le provider est keyless et utilise l'endpoint public fixe :
```text
https://api.kraken.com/0/public/Ticker?pair=SOLUSD
```
Le dernier trade est lu dans `c[0]` et normalisé par `MarketPriceDecimal::parse_json_raw` sans passage par `f64`.
Le ticker public Kraken ne porte pas de timestamp du dernier trade dans cette forme. KSP conserve donc :
```text
provider_timestamp = None
```
et n'invente aucune heure provider à partir de l'heure de réception locale.
La sémantique reste :
```text
MarketPriceSemantics::ExchangeLastTrade
```
La documentation Kraken indique qu'une cadence de `1 req/s` ou moins reste dans les limites des endpoints REST publics. Le descriptor V1 retient donc une limite locale conservatrice :
```text
1 req/s
scope IP
burst non documenté
```
## 4. Coinbase Exchange
Ajout de :
```text
market_price_coinbase_exchange.rs
unit_tests/market_price_coinbase_exchange.rs
MarketPriceCoinbaseExchangeSettings
MarketPriceCoinbaseExchangeProvider
```
Le provider est keyless et utilise l'endpoint public fixe :
```text
https://api.exchange.coinbase.com/products/SOL-USD/ticker
```
KSP conserve :
```text
price
provider timestamp RFC3339 time
```
Le prix est parsé exactement via `RawValue` et le timestamp est converti dans le contrat millisecondes UTC existant.
Le descriptor conserve le token bucket public documenté par Coinbase Exchange :
```text
10 req/s par IP
burst 15
```
La sémantique reste :
```text
MarketPriceSemantics::ExchangeLastTrade
```
## 5. Façade publique
La taxonomie publique reste capability-scoped :
```text
MarketPriceKraken*
MarketPriceCoinbaseExchange*
```
Le nom `CoinbaseExchange` est volontairement explicite afin de ne pas confondre cette surface avec Advanced Trade ou d'autres APIs Coinbase futures.
Aucun type wire provider n'est exporté. Les primitives `http_*` restent crate-private.
## 6. Tests déterministes ajoutés
Les nouveaux canaries couvrent :
```text
Kraken exact last trade decimal
Kraken provider_timestamp absent
Kraken descriptor keyless + 1 req/s IP
Kraken provider error rejetée
Kraken résultat d'une autre paire rejeté
Coinbase exact last trade decimal
Coinbase timestamp RFC3339 conservé
Coinbase descriptor keyless + 10 req/s IP + burst 15
Coinbase timestamp invalide rejeté
façade publique des deux adapters
endpoints fixes et absence de std::env
```
## 7. Documentation réconciliée
Mise à jour de :
```text
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
Le plan marque `pre.004-fix.001` comme gate opérateur complet PASS et `pre.005` comme réalisé en attente de son gate Cargo.
## 8. Fichiers ajoutés
```text
crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs
crates/ksp-offchain-transport-lib/src/market_price_kraken.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinbase_exchange.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs
deltas/0.2.11/pre.005.md
```
## 9. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 10. Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
`pre.006` ne doit commencer qu'après un gate propre de `pre.005` ou un delta fix explicite.

243
deltas/0.2.11/pre.006.md Normal file
View File

@@ -0,0 +1,243 @@
<!-- file: deltas/0.2.11/pre.006.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.006` — Jupiter Price V3 + DexScreener
## 1. Base requise
Cette tranche s'applique exclusivement après `0.2.11-pre.005` accepté par l'opérateur.
Version Cargo attendue à l'entrée :
```text
0.2.11-pre.5
```
Version Cargo de sortie :
```text
0.2.11-pre.6
```
Le gate d'entrée ciblé du `2026-08-25` est PASS : `cargo fmt --all`, audit Rust clean avec 0 candidate export, audit Markdown clean à 119 tables / 110 fichiers, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test -p ksp-offchain-transport-lib` avec 37 unitaires + 3 boundary + 4 public API. `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.005` avant le passage explicite à cette tranche.
## 2. Objet strict de la tranche
`pre.006` ajoute uniquement les deux providers prévus par le forecast :
```text
Jupiter Price V3
DexScreener
```
Le contrat reste :
```text
SOL/USD uniquement
HTTP REST direct via reqwest
aucun SDK provider
aucune découverte automatique de pool
aucun fallback/consensus/agrégation KSP
pas de registry global
pas de Config
pas de refresh multiple
pas de smoke live revendiqué
```
## 3. Jupiter Price V3
Ajout de :
```text
market_price_jupiter.rs
unit_tests/market_price_jupiter.rs
MarketPriceJupiterAccessMode
MarketPriceJupiterSettings
MarketPriceJupiterProvider
```
Le réaudit officiel courant confirme le Developer Platform unifié :
```text
base URL https://api.jup.ag
endpoint /price/v3
mint SOL So11111111111111111111111111111111111111112
keyless 0.5 RPS
Free avec API key 1 RPS
header keyed x-api-key
```
Les deux modes utilisent la même origine officielle. Le mode keyless n'accepte aucun credential ; le mode Free exige une clé lorsqu'il est activé. La clé reste dans `MarketPriceApiKey`, n'est jamais lue depuis l'environnement par cette crate et n'apparaît pas dans `Debug`, les logs ou les erreurs.
Price V3 expose `usdPrice` comme prix USD unique. KSP le lit via `serde_json::RawValue` puis `MarketPriceDecimal`, sans passage canonique par `f64`.
Le contrat V3 expose également `blockId`, qui peut servir à raisonner sur la récence du prix. KSP le conserve uniquement dans la provenance sûre :
```text
jupiter:price_v3:<mint>:block:<blockId>
```
`createdAt` décrit la création du token et n'est pas un timestamp de prix. `blockId` n'est pas un timestamp non plus. L'observation conserve donc explicitement :
```text
provider_timestamp = None
MarketPriceSemantics::SolanaHeuristic
```
Le parser exige la clé exacte du mint WSOL, `decimals = 9`, un `usdPrice` présent et positif, et un `blockId` présent. Une réponse sans prix est une réponse provider non exploitable pour une observation réussie ; le registry/availability générique sera matérialisé en `pre.007`.
## 4. DexScreener
Ajout de :
```text
market_price_dexscreener.rs
unit_tests/market_price_dexscreener.rs
MarketPriceDexScreenerSettings
MarketPriceDexScreenerProvider
```
Le V1 suit strictement la décision opérateur : un client SOL/USD lié à **une paire Solana explicitement configurée**.
L'endpoint officiel utilisé est uniquement :
```text
https://api.dexscreener.com/latest/dex/pairs/solana/{pairId}
```
La documentation DexScreener le borne actuellement à :
```text
300 requêtes / minute
```
Le pair address est validé comme `ksp_core_lib::Pubkey` dans les settings. Le client construit le chemin à partir de l'origine officielle fixe et du seul segment Pubkey validé ; aucune base URL configurable n'est introduite.
Avant d'accepter `priceUsd`, le parser vérifie :
```text
une seule paire retournée
chainId = solana
pairAddress = pair address configuré
baseToken.address = So11111111111111111111111111111111111111112
priceUsd présent et exact
```
KSP ne consulte pas :
```text
/latest/dex/search
/token-pairs/v1
/tokens/v1
```
et ne fait aucun pool discovery, ranking de liquidité, sélection automatique, moyenne ou consensus.
La provenance conserve le pair address, considéré comme sûr :
```text
dexscreener:solana:<pairAddress>:priceUsd
```
La sémantique publique reste :
```text
MarketPriceSemantics::DexPairUsd
provider_timestamp = None
```
## 5. HTTP commun
`HttpGetRequest` gagne uniquement une primitive crate-private :
```text
append_path_segment
```
Elle permet de construire un endpoint provider à origine fixe lorsque le contrat nécessite un segment dynamique validé, ici le Pubkey de la paire DexScreener. Cette évolution ne crée aucune API HTTP publique et ne permet pas au consumer de fournir une URL arbitraire.
## 6. Façade publique
La taxonomie reste capability-scoped :
```text
MarketPriceJupiter*
MarketPriceDexScreener*
```
Aucun type wire provider, client HTTP générique ou primitive d'admission n'est exporté aux consumers.
Le futur `ksp-app-solprices-desk` ne devra pas construire ces settings provider directement ; `pre.009` donnera cette responsabilité à Config et le service générique prévu en `pre.007`/`pre.008` masquera les variantes provider à l'HID.
## 7. Tests déterministes ajoutés
Les nouveaux canaries couvrent :
```text
Jupiter keyless 0.5 RPS
Jupiter Free 1 RPS + API key redacted
query Jupiter fixée au mint WSOL
Jupiter prix exact via RawValue
Jupiter blockId conservé en provenance
Jupiter provider_timestamp absent
Jupiter prix absent rejeté
Jupiter identité/decimals incohérents rejetés
DexScreener pair address Pubkey explicite
URL pair directe construite sans discovery
DexScreener 300 req/min
chainId Solana vérifié
pairAddress retourné vérifié
base token WSOL vérifié
priceUsd exact
réponse multi-pair rejetée
prix absent rejeté
absence des endpoints search/token discovery
façade publique des deux adapters
```
## 8. Documentation réconciliée
Mise à jour de :
```text
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
Le plan enregistre également le réaudit Jupiter/DexScreener courant et distingue explicitement `blockId` Jupiter d'un timestamp provider.
## 9. Fichiers ajoutés
```text
crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs
crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs
deltas/0.2.11/pre.006.md
```
## 10. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/src/http_client.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 11. Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
`pre.007` ne doit commencer qu'après un gate propre de `pre.006` ou un delta fix explicite.

280
deltas/0.2.11/pre.007.md Normal file
View File

@@ -0,0 +1,280 @@
<!-- file: deltas/0.2.11/pre.007.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.007` — Birdeye + registry + availability
## 1. Base requise
Cette tranche s'applique exclusivement après `0.2.11-pre.006` accepté par l'opérateur.
Version Cargo attendue à l'entrée :
```text
0.2.11-pre.6
```
Version Cargo de sortie :
```text
0.2.11-pre.7
```
Le gate d'entrée ciblé du `2026-08-25` est PASS : `cargo fmt --all`, audit Rust clean avec 0 candidate export, audit Markdown clean à 120 tables / 111 fichiers, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test -p ksp-offchain-transport-lib` avec 43 unitaires + 4 boundary + 5 public API. `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.006` avant le passage explicite à cette tranche.
## 2. Objet strict de la tranche
`pre.007` ferme l'inventaire V1 des huit providers et matérialise le registry provider-neutral :
```text
Birdeye Standard
MarketPriceProviderRegistry
MarketPriceProviderRegistryEntry
availability générique consultable
metadata de coût provider informatif
```
La tranche n'ajoute pas encore :
```text
refresh(provider_id)
refresh multiple/all
service de dispatch provider
Config Off-chain Transport
smoke live
fallback/consensus
```
## 3. Birdeye Standard
Ajout de :
```text
market_price_birdeye.rs
unit_tests/market_price_birdeye.rs
MarketPriceBirdeyeSettings
MarketPriceBirdeyeProvider
```
Le réaudit officiel courant confirme :
```text
base URL https://public-api.birdeye.so
endpoint GET /defi/price
mint SOL So11111111111111111111111111111111111111112
chain header x-chain: solana
auth header X-API-KEY
Standard 0 USD, 30 000 compute units inclus
limite compte 1 RPS
Price Single 3 compute units par appel
```
Une clé Birdeye est obligatoire lorsque le provider est activé. Un provider désactivé peut être construit sans secret afin que Config et le futur service puissent représenter un état non actif sans inventer de credential.
La clé reste dans `MarketPriceApiKey`, n'est jamais lue directement depuis l'environnement par Off-chain Transport et n'apparaît ni dans `Debug`, ni dans les logs, ni dans les erreurs.
Le request builder utilise uniquement l'origine officielle fixe, le mint WSOL fixe et `x-chain = solana`.
La réponse Price Single est normalisée depuis :
```text
data.value
data.updateUnixTime
success
```
`data.value` passe par `serde_json::RawValue` puis `MarketPriceDecimal` sans round-trip `f64`. `updateUnixTime` devient un vrai `provider_timestamp` en secondes Unix converties en millisecondes.
La sémantique retenue est :
```text
MarketPriceSemantics::SolanaSpot
```
La provenance sûre est :
```text
birdeye:solana:wsol:value
```
Une réponse `success = false`, un prix absent/null, un timestamp absent/pathologique ou un JSON incompatible sont rejetés sans copier le body distant dans les diagnostics.
## 4. Quota et coût provider informatifs
Birdeye utilise des compute units. Pour ne pas les confondre avec des crédits abstraits ou des requêtes HTTP, `MarketPriceProviderQuotaUnit` gagne :
```text
ComputeUnits
```
`MarketPriceProviderRequestCost` représente un coût unitaire documenté pour l'opération SOL/USD courante.
Le descriptor Birdeye expose :
```text
long_term_quota = 30 000 ComputeUnits / Month
sol_usd_request_cost = 3 ComputeUnits
```
Ces valeurs sont informatives. KSP ne décrémente pas localement le quota et ne prétend pas connaître un solde authoritative : un autre processus ou une autre API du même compte peut consommer les mêmes compute units.
`MarketPriceProviderDescriptor::with_sol_usd_request_cost` vérifie que le coût et le quota utilisent la même unité lorsqu'un quota est présent.
## 5. Registry provider-neutral
Ajout de :
```text
market_price_registry.rs
unit_tests/market_price_registry.rs
MarketPriceProviderRegistry
MarketPriceProviderRegistryEntry
ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID
```
Une entrée contient seulement :
```text
MarketPriceProviderDescriptor
MarketPriceProviderState
```
Le registry :
```text
est borné à 64 entrées
trie les entrées par provider_id
rejette les provider_id dupliqués
permet lookup descriptor/state/entry par identifiant opaque
expose une slice déterministe d'entrées
ne contient aucun nom ou dispatch provider spécifique
```
Le constructeur reste public et provider-neutral, comme `MarketPriceProviderDescriptor::new` déjà existant. Cela ne donne aucun accès à un endpoint ou à un client HTTP. Le futur service `pre.008` possédera les transitions runtime et le dispatch des adapters ; la HID ne devra pas construire ou matcher des providers spécifiques.
Un canari de complétude construit les huit adapters V1 en mode désactivé et vérifie l'inventaire ordonné :
```text
birdeye
coinbase_exchange
coingecko
coinmarketcap
coinpaprika
dexscreener
jupiter
kraken
```
## 6. Availability générique
`MarketPriceProviderAvailability` gagne deux helpers publics provider-neutral :
```text
is_refresh_eligible()
retry_at()
```
Ils permettent à un consumer d'interpréter `Ready`, `CoolingDown` ou `TemporarilyUnavailable` sans parser des erreurs ni connaître le fournisseur.
Les catégories existantes restent distinctes :
```text
AuthenticationUnavailable
CoolingDown
Disabled
Misconfigured
QuotaUnavailable
Ready
TemporarilyUnavailable
```
`pre.007` ne relie pas encore automatiquement les erreurs d'un refresh à ces transitions. Cette mutation runtime appartient au service de `pre.008`.
## 7. Façade publique
La taxonomie reste capability-scoped :
```text
MarketPriceBirdeye*
MarketPriceProviderRegistry*
MarketPriceProviderRequestCost
```
Aucun DTO wire Birdeye, `HttpRestClient`, limiter interne ou primitive `reqwest` n'est exporté.
Le registry public reste générique ; aucun consumer n'a besoin d'un enum CoinGecko/Birdeye/Jupiter pour l'inspecter.
## 8. Tests déterministes ajoutés
Les nouveaux canaries couvrent :
```text
Birdeye API key obligatoire si enabled
redaction du secret Birdeye
endpoint fixe et headers attendus
mint WSOL fixe
1 RPS par compte
30 000 compute units informatifs
3 compute units par Price Single
prix exact via RawValue
updateUnixTime -> provider_timestamp
success false rejeté
prix null rejeté
timestamp absent rejeté
request cost nul rejeté
mismatch unité quota/coût rejeté
registry des huit providers complet
ordre déterministe du registry
doublons provider_id rejetés
lookup descriptor/state générique
availability is_refresh_eligible/retry_at
absence de noms provider dans le module registry
```
## 9. Documentation réconciliée
Mise à jour de :
```text
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
Le plan enregistre le réaudit Birdeye courant et précise que quotas/cost metadata restent non-authoritative.
## 10. Fichiers ajoutés
```text
crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_birdeye.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs
deltas/0.2.11/pre.007.md
```
## 11. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 12. Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
`pre.008` ne doit commencer qu'après un gate propre de `pre.007` ou un delta fix explicite.

View File

@@ -0,0 +1,126 @@
<!-- file: deltas/0.2.11/pre.008-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.008-fix.001` — Clippy et canari boundary du service de refresh
## 1. Base requise
Ce correctif s'applique exclusivement après `0.2.11-pre.008`.
Version Cargo attendue à l'entrée :
```text
0.2.11-pre.8
```
Version Cargo de sortie :
```text
0.2.11-pre.8.fix.1
```
## 2. Motif du correctif
Le gate opérateur du `2026-08-26` a produit :
```text
cargo fmt --all exécuté
audit Rust workspace PASS
audit Markdown PASS, 122 tables / 113 files
cargo check --workspace PASS
cargo clippy --workspace --all-targets 1 warning collapsible_if
cargo test -p ksp-offchain-transport-lib 54 unit PASS, dependency boundary FAIL
```
Le canari en échec était :
```text
pre_008_refresh_service_owns_dispatch_while_registry_and_consumer_surface_stay_generic
```
Il utilisait une interdiction lexicale trop large :
```text
!service.contains("consensus")
```
alors que `market_price_service.rs` contient légitimement une rustdoc indiquant que `refresh_many` fonctionne « without fallback or consensus ».
## 3. Correction Clippy
`MarketPriceService::update_availability` collapse les deux `if` imbriqués en une `let`-chain conforme à `clippy::collapsible_if`.
Le comportement ne change pas :
```text
set_availability réussi + état retrouvé -> clone de l'état registry
sinon -> projection de secours avec la même availability
```
Aucune transition d'availability, classification d'erreur, cadence ou logique réseau n'est modifiée.
## 4. Correction du canari boundary
Le test ne bannit plus le mot documentaire `consensus`.
Il vérifie désormais l'absence de surfaces exécutables concrètes hors scope :
```text
fallback_provider
fn aggregate(
fn consensus(
```
Les rustdocs peuvent donc documenter explicitement l'absence de fallback/consensus sans faire échouer le canari, tandis qu'une implémentation réelle de ces mécanismes resterait détectée.
Les assertions existantes sur l'absence de `std::thread::sleep` et `tokio::time::sleep` sont conservées.
## 5. Scope fonctionnel inchangé
Ce fix ne modifie pas :
```text
les huit adapters V1
MarketPriceProviderSetup
MarketPriceService public
refresh / refresh_many / refresh_all
registry et availability
rate limits / cooldowns
classification HTTP/provider
Config
scheduler périodique
fallback
consensus
agrégation multi-provider
smoke live
```
## 6. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/src/market_price_service.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 7. Fichier ajouté
```text
deltas/0.2.11/pre.008-fix.001.md
```
## 8. 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-offchain-transport-lib
cargo test --workspace
```
`pre.009` ne doit commencer qu'après ce gate propre.

242
deltas/0.2.11/pre.008.md Normal file
View File

@@ -0,0 +1,242 @@
<!-- file: deltas/0.2.11/pre.008.md -->
<!-- version: 1 -->
# Delta `0.2.11-pre.008` — Refresh individuel + multiple/all
## 1. Base requise
Cette tranche s'applique exclusivement après `0.2.11-pre.007` accepté par l'opérateur.
Version Cargo attendue à l'entrée :
```text
0.2.11-pre.7
```
Version Cargo de sortie :
```text
0.2.11-pre.8
```
Le gate opérateur du `2026-08-26` est entièrement PASS : `cargo fmt --all`, audit Rust clean avec 0 candidate export, audit Markdown clean à 121 tables / 112 fichiers, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-offchain-transport-lib` avec 49 unitaires + 5 boundary + 6 public API, puis `cargo test --workspace` complet PASS avec uniquement les smokes/diagnostics explicitement ignorés.
## 2. Objet strict de la tranche
`pre.008` matérialise l'orchestrateur générique de la famille `market_price` :
```text
MarketPriceService
MarketPriceProviderSetup
MarketPriceRefreshOutcome
refresh(provider_id)
refresh_many(provider_ids)
refresh_all()
transitions runtime d'availability
```
La tranche n'ajoute pas :
```text
Config Off-chain Transport
lecture directe d'environnement
scheduler périodique
fallback provider
consensus / moyenne / agrégation de prix
smoke live provider
nouvelle famille off-chain
```
## 3. Frontière composition / runtime
`MarketPriceProviderSetup` est l'unique enum provider-specific introduit par l'orchestrateur. Il consomme les settings déjà publics des huit adapters V1 et est destiné à la couche de composition, notamment au futur adapter Config de `pre.009`.
Une fois le service construit, les opérations runtime ne prennent plus aucun type provider-specific :
```text
registry()
refresh(&MarketPriceProviderId)
refresh_many(&[MarketPriceProviderId])
refresh_all()
```
La future HID peut donc rester indépendante de CoinGecko, Birdeye, Kraken, Jupiter ou de tout autre provider concret.
## 4. Registry runtime et availability
`MarketPriceService` possède les adapters concrets et une copie mutable interne du `MarketPriceProviderRegistry`.
Le registry public reste une projection détachable `descriptor + state`. Deux helpers crate-private permettent au service de remplacer l'availability sans modifier l'identité du provider.
`MarketPriceProviderAvailability` gagne :
```text
is_refresh_eligible_at(now)
```
Cette méthode autorise :
```text
Ready
CoolingDown dont retry_at est expiré
TemporarilyUnavailable sans retry_at
TemporarilyUnavailable dont retry_at est expiré
```
et bloque :
```text
AuthenticationUnavailable
Disabled
Misconfigured
QuotaUnavailable
CoolingDown non expiré
TemporarilyUnavailable avec retry_at futur
```
Aucun timer/sleep n'est lancé pour rendre un provider éligible : l'état est simplement réévalué lors du prochain appel explicite.
## 5. Classification générique des erreurs
Le service absorbe les erreurs provider/HTTP après dispatch et retourne un `MarketPriceRefreshOutcome` générique.
Les classes principales sont :
```text
provider disabled -> Disabled
401/403 sur mode avec API key -> AuthenticationUnavailable
401/403 sur mode keyless -> TemporarilyUnavailable
admission locale deferred -> CoolingDown
HTTP 429 -> CoolingDown
HTTP 408/5xx -> TemporarilyUnavailable
client/settings/request localement invalides -> Misconfigured
autre erreur provider/transport -> TemporarilyUnavailable
```
Un `Retry-After` exploitable ou un délai local de defer est converti en `retry_at` absolu. Aucun message/body provider n'est projeté au consumer ; le code d'erreur est seulement journalisé via `ksp-logging-lib` avec le provider id validé.
`QuotaUnavailable` reste une catégorie publique mais n'est pas inventée automatiquement lorsque le provider ne fournit pas une preuve exploitable de quota épuisé.
## 6. Refresh individuel
`MarketPriceService::refresh` :
```text
résout le provider par identifiant opaque
projette immédiatement les états non éligibles
n'effectue aucun sleep
appelle uniquement l'adapter propriétaire du provider
met l'état à Ready après succès
classe un échec dans l'availability générique
retourne toujours une outcome provider-neutral pour un provider configuré
```
Un provider inconnu retourne `ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND` avant tout réseau.
## 7. Refresh multiple / all
`refresh_many` conserve l'ordre fourni par le caller et valide la liste complète avant dispatch :
```text
maximum 64 providers
doublons interdits
provider inconnu interdit
```
Chaque provider configuré retourne son outcome propre. Une erreur réseau/provider normalisée ne court-circuite donc pas les providers suivants.
`refresh_all` utilise l'ordre stable des provider ids du service. Les providers non éligibles sont inclus comme projections d'état mais ne déclenchent pas de requête réseau.
V1 reste volontairement séquentielle et déterministe. Cette décision ne constitue pas un scheduler : aucun provider n'est attendu par sleep et aucun fallback ou consensus n'est exécuté.
## 8. Façade publique
Ajouts publics :
```text
MarketPriceProviderSetup
MarketPriceRefreshOutcome
MarketPriceService
ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND
ERROR_CODE_MARKET_PRICE_REFRESH_INVALID
MarketPriceProviderAvailability::is_refresh_eligible_at
```
`MarketPriceRefreshOutcome` expose uniquement :
```text
provider_id()
state()
observation()
refreshed()
```
Le consumer n'a pas accès au dispatcher interne, aux clients HTTP, aux limiters ou aux DTOs wire.
## 9. Tests déterministes ajoutés
Les nouveaux canaries couvrent :
```text
construction du service avec les huit setups V1 désactivés
registry de service trié et provider-neutral
refresh_all de huit providers désactivés sans réseau
ordre déterministe des outcomes
doublon dans refresh_many rejeté avant dispatch
provider inconnu rejeté avant dispatch
401/403 keyed vs keyless
429 -> CoolingDown
5xx + retry_after -> TemporarilyUnavailable daté
erreur locale de request -> Misconfigured
éligibilité avant/après retry_at
absence de sleep / fallback / consensus dans le service
façade publique générique depuis la crate root
```
## 10. Documentation réconciliée
Mise à jour de :
```text
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
Le gate complet de `pre.007` fourni par l'opérateur est enregistré comme PASS.
## 11. Fichiers ajoutés
```text
crates/ksp-offchain-transport-lib/src/market_price_service.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs
deltas/0.2.11/pre.008.md
```
## 12. Fichiers modifiés
```text
Cargo.toml
crates/ksp-offchain-transport-lib/src/error.rs
crates/ksp-offchain-transport-lib/src/lib.rs
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
crates/ksp-offchain-transport-lib/tests/public_api.rs
crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
```
## 13. Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-offchain-transport-lib
cargo test --workspace
```
`pre.009` ne doit commencer qu'après un gate propre de `pre.008` ou un delta fix explicite.

Some files were not shown because too many files have changed in this diff Show More