Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db1f8d4a53 | |||
| 9852613426 | |||
| 81eb295a09 | |||
| b5a1293953 | |||
| 251f9f9ad8 | |||
| 98708df6e0 | |||
| 2db3c4be78 | |||
| 69294a153f | |||
| 2fdff17651 | |||
| 87d7314bf4 | |||
| 5aeff5ca14 | |||
| 98093d859b | |||
| 4021d08541 | |||
| 5c97a772be | |||
| 60afb51451 | |||
| f4413ebbb0 | |||
| be2a06bc80 | |||
| 2dbddfe367 | |||
| 126e8abfcd | |||
| 3fd540a9db | |||
| 4f92fb03f1 | |||
| f42fa8f4f1 | |||
| a8e980b225 | |||
| afaa9c0239 | |||
| 7d456b41dc | |||
| 0edeed1c48 | |||
| be5e3464ee | |||
| 1967e845b0 | |||
| af807afff5 | |||
| 7f567bc1bb | |||
| 09aacc36ab | |||
| 15b129ad04 | |||
| 6ddf2b4995 | |||
| dcabceb52f | |||
| 51bcf870d3 | |||
| c6c5793c61 | |||
| d98ee736be | |||
| 39dbd86f1e | |||
| c938b2409e | |||
| 5b30bb9948 | |||
| 813a45385a | |||
| 21ed5f88da | |||
| 3198cc5666 | |||
| 4568d94a62 | |||
| d32d8de3a5 | |||
| a9fb6a7ac8 | |||
| 433e69272a | |||
| affa18a6e9 | |||
| 05aec88178 | |||
| b32de48c0b | |||
| 15e3402e1c | |||
| dca0a4b809 | |||
| 20320dba7b | |||
| e4bbc78a41 | |||
| feb9befb35 | |||
| a038194679 | |||
| 3172cda241 | |||
| 61fba107ef | |||
| 835de48cb7 | |||
| 21de590aea | |||
| 3081bd1085 | |||
| bdcab1e1a7 | |||
| d70c3a1672 | |||
| 7cdf5e80c9 | |||
| d8bfd7cd2e | |||
| 9c0d4fc197 | |||
| 7c12ec886b | |||
| 7eb6dec809 | |||
| 68f4384c5b | |||
| 9cc140fb84 | |||
| 3b64d1e0ec | |||
| 56b9ce6abc | |||
| 1c8d69778b | |||
| 8e739b9e55 | |||
| 53dbb5bccd | |||
| 92224e5ac6 | |||
| cbb4e7b0de | |||
| 38fd62c256 | |||
| f2a3ec62aa | |||
| b3363073c4 | |||
| c94a54f3e3 | |||
| 0871b85df9 | |||
| 315e7e67e5 | |||
| df95f2f558 | |||
| f0865d5137 | |||
| 4d77b607ea | |||
| 307711f873 | |||
| 5aa7b45840 | |||
| 3c5786f273 | |||
| 3b4d355537 | |||
| 628b4f12f2 | |||
| 66deaf8245 | |||
| 0e256a8ecf | |||
| 9eb0e19d81 | |||
| 74686892e9 | |||
| 1391858972 | |||
| 98bf88e431 | |||
| f0f444bc86 | |||
| d17161234a | |||
| 93199d1856 | |||
| 6fefc64e75 | |||
| 4c540d67a7 | |||
| b67fa89f44 | |||
| 435126f67a | |||
| 8721e54b18 | |||
| 6e3a0fa034 | |||
| 34637848eb | |||
| 778ea58ee1 | |||
| b0461f15ec | |||
| d5df0fe9af | |||
| ab29dc51bb | |||
| b6908cb573 | |||
| b64a799c85 | |||
| cf4b28df2b | |||
| 9e8fd53291 |
46
.env.example
46
.env.example
@@ -1,5 +1,5 @@
|
|||||||
# file: .env.example
|
# file: .env.example
|
||||||
# version: 4
|
# 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.
|
||||||
@@ -22,10 +22,54 @@ KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
|
|||||||
# The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent.
|
# The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent.
|
||||||
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
|
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
|
||||||
|
|
||||||
|
# Optional public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
|
||||||
|
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
|
||||||
|
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
|
||||||
|
|
||||||
|
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
|
||||||
|
# The committed Transport document falls back to wss://api.mainnet-beta.solana.com when this variable is absent.
|
||||||
|
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
|
||||||
|
|
||||||
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
||||||
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
||||||
# KSP_SECRET_SOLANA_HTTP_URL=https://provider.example/?api-key=replace-me
|
# KSP_SECRET_SOLANA_HTTP_URL=https://provider.example/?api-key=replace-me
|
||||||
|
|
||||||
|
# PublicNode Mainnet personal token used as secret x-token metadata by the committed Mainnet Yellowstone gRPC profile.
|
||||||
|
# Keep the real value only in the process environment or local .env; do not assume that a Testnet token is authorized on Mainnet.
|
||||||
|
# KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN=replace-me
|
||||||
|
|
||||||
|
# PublicNode Testnet personal token used as secret x-token metadata by the committed Testnet Yellowstone gRPC profile.
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_HELIUS_API_KEY=replace-me
|
||||||
|
|
||||||
|
# Birdeye Standard API key used by the optional `all_free` profile in config/std.offchain_transport.json.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_BIRDEYE_API_KEY=replace-me
|
||||||
|
|
||||||
|
# CoinGecko Demo API key used by the optional `all_free` Off-chain Transport profile.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_COINGECKO_DEMO_API_KEY=replace-me
|
||||||
|
|
||||||
|
# CoinMarketCap Basic API key used by the optional `all_free` Off-chain Transport profile.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_COINMARKETCAP_API_KEY=replace-me
|
||||||
|
|
||||||
|
# Public Solana pair address used by DexScreener for explicit SOL/USD pair lookup in the optional `all_free` profile.
|
||||||
|
# This value is intentionally public and must identify the exact pair chosen by the operator.
|
||||||
|
# KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS=replace-with-solana-pair-pubkey
|
||||||
|
|
||||||
|
# Jupiter Free API key used by the optional `all_free` Off-chain Transport profile.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_JUPITER_API_KEY=replace-me
|
||||||
|
|
||||||
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
# 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
|
||||||
|
|
||||||
|
|||||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -1,10 +1,48 @@
|
|||||||
<!-- file: CHANGELOG.md -->
|
<!-- file: CHANGELOG.md -->
|
||||||
<!-- version: 10 -->
|
<!-- 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` ajoute à `ksp-onchain-transport-lib` un moteur Yellowstone gRPC partagé construit directement sur Tonic/Protobuf, avec `yellowstone-grpc-proto` comme contrat wire upstream et sans `yellowstone-grpc-client` dans le runtime KSP. La surface Solana standard reste provider-neutral et couvre les sept unary retenues (`SubscribeReplayInfo`, `Ping`, `GetLatestBlockhash`, `GetBlockHeight`, `GetSlot`, `IsBlockhashValid`, `GetVersion`), `Subscribe` bidi, les familles accounts/slots/transactions/transaction status/blocks/block meta/entry, les neuf variantes d'update standard retenues, les filtres/common fields actuels, le backpressure, les limites de messages, le half-close/shutdown, ainsi qu'un reconnect/replay prudent depuis le dernier slot observé. Aucune garantie exactly-once, lossless ou d'ordre global gapless n'est introduite ; gaps et duplicates restent observables. `SubscribeDeshred` reste explicitement hors du standard KSP de cette release.
|
||||||
|
|
||||||
|
Config passe à `std.transport` V3, backward-readable avec V1 HTTP et V2 HTTP+WebSocket, et sépare les axes `provider` / `protocol` ainsi que `metadata` / `secret_metadata` sans créer de dépendance Transport -> Config/env. PublicNode constitue la première intégration provider : profils Yellowstone Mainnet et Testnet sur `https://solana-yellowstone-grpc.publicnode.com:443` et `https://solana-testnet-yellowstone-grpc.publicnode.com:443`, avec authentification `x-token` résolue par Config depuis des variables `KSP_SECRET_*`. Le smoke Transport pur reçoit les credentials uniquement par stdin opérateur, n'en journalise aucun et valide en live `Subscribe -> Slot` sur les deux réseaux ; le même personal token a été utilisé avec succès pour Mainnet et Testnet, tout en conservant deux entrées Config distinctes par flexibilité opérationnelle.
|
||||||
|
|
||||||
|
La clôture conserve les surfaces antérieures HTTP (**52 current typed + 14 historiques**), WebSocket Solana standard (**9 familles / 18 opérations**) et Helius LaserStream WebSocket. Le gate final comprend **383/383** tests unit Transport, **49/49** public API, **43/43** release completeness, **4/4** doctests, `cargo test --workspace`, les canaris de dépendances, le smoke PublicNode live **2/2 PASS** et l'inspection des graphes Cargo (`yellowstone-grpc-proto 12.6.0`, `tonic` / `tonic-prost 0.14.6`, `prost` / `prost-types 0.14.4`). `prompts/015-V0_2_10_START_PROMPT.md` ouvre ensuite `0.2.10 — OrbitFlare Yellowstone gRPC` uniquement depuis le tag stable `v0.2.9`, avec audit provider actuel, threat model et sizing en `pre.001` avant toute extension provider lourde.
|
||||||
|
|
||||||
|
## 0.2.8 — Helius LaserStream WebSocket — 2026-08-23
|
||||||
|
|
||||||
|
`0.2.8` étend `ksp-onchain-transport-lib` avec une façade `HeliusLaserStreamWsSession` dédiée qui réutilise le même `WsSession` physique/actor que le WebSocket Solana standard, sans second client, socket, registry ou scheduler. La surface stable Helius réutilise les sept familles standard actuellement retenues (`account`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates`) et ajoute l’extension typée `transactionSubscribe` / `transactionUnsubscribe`; `block` et `vote` restent absents de la façade Helius et `slotsUpdates` conserve son statut unstable. Le heartbeat provider est possédé par l’actor partagé et émet un WebSocket Ping control frame toutes les 60 secondes uniquement pour `WsProtocolKind::HeliusLaserStream`.
|
||||||
|
|
||||||
|
La release ajoute le mapping Config V2 `helius_laserstream`, les profils Helius mainnet/devnet et le secret `KSP_SECRET_HELIUS_API_KEY` avec provenance/redaction segmentaire, sans dépendance inverse Transport -> Config ni lecture directe de l’environnement par Transport. Les canaris couvrent erreurs RPC provider, payload oversized, mismatch de notification, reconnect/remap/unsubscribe races, backpressure isolé et diagnostics sans payload brut. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, **9 familles / 18 opérations WebSocket Solana standard**, et la surface Helius `7 standard + transaction`. Le smoke Helius live cross-crates est volontairement reporté vers une future surface d’intégration/orchestration afin de préserver l’ownership Config du secret. Les graphes Cargo finaux n’introduisent aucun SDK Helius/gRPC ni nouvelle duplication bloquante. `prompts/014-V0_2_9_START_PROMPT.md` ouvre ensuite `0.2.9 — Yellowstone gRPC standard/provider-neutral` uniquement depuis le tag stable `v0.2.8`, avec audit service/proto/crates/licences/MSRV/features et sizing strict en `pre.001` avant toute implémentation lourde.
|
||||||
|
|
||||||
|
## 0.2.7 — WebSocket Solana standard — 2026-08-23
|
||||||
|
|
||||||
|
`0.2.7` stabilise dans `ksp-onchain-transport-lib` le moteur WebSocket Solana standard en complément de la surface HTTP déjà complète. La release couvre exactement les **9 familles subscribe + 9 unsubscribe** de l’inventaire officiel ciblé : `account`, `block`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates` et `vote`. Les wrappers sont typés, les IDs KSP de session/subscription restent locaux et stables, les IDs serveur restent internes/remappables, et plusieurs sessions physiques peuvent coexister explicitement sur la même URL sans introduire de pool/scheduler automatique. Les familles `block`, `slotsUpdates` et `vote` restent identifiées comme unstable selon l’audit normatif courant et utilisent le warning KSP centralisé.
|
||||||
|
|
||||||
|
Le lifecycle WebSocket est borné : actor unique propriétaire du socket, pending JSON-RPC et queues de notifications bornés, `WsSession::close().await`, control frames Ping/Pong/Close, reconnect fini avec backoff borné, resubscribe déterministe par ID local, `continuity_gap_count`, isolation des erreurs applicatives et du backpressure par subscription, nettoyage des late ACK/notifications et terminaison one-shot de `signatureSubscribe`. Config passe à `std.transport` V2 pour composer HTTP + WebSocket tout en gardant la lecture V1 HTTP-only ; la direction reste `Config -> Transport`. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, les canaries Transport (`309` unit, `36` public API, `24` release completeness), le smoke WebSocket Devnet opt-in `slotSubscribe -> notification -> unsubscribe -> close`, les frontières de dépendances et la redaction des URLs/credentials. `prompts/013-V0_2_8_START_PROMPT.md`, renforcé par `0.2.7-pre.014-fix.001`, ouvre ensuite `0.2.8 — Helius LaserStream WebSocket` uniquement depuis le tag stable `v0.2.7`.
|
||||||
|
|
||||||
## 0.2.6 — Wallet Desk + `.kspwallet` V2 — 2026-08-22
|
## 0.2.6 — Wallet Desk + `.kspwallet` V2 — 2026-08-22
|
||||||
|
|
||||||
`0.2.6` stabilise `ksp-app-wallet-desk` comme seconde application Tauri KSP spécialisée et étend `ksp-wallet-lib` avec le wire binaire `.kspwallet` V2. Wallet Desk compose Config, Wallet, Transport HTTP et Logging sans déplacer leurs responsabilités : inventory root-scoped et symlink-safe, création/import V2 par défaut, ouverture VIEW/OWNER V1/V2, candidats secrets résolus exclusivement par Config, `getBalance` Devnet via Transport, administration alias/notes, rotations OWNER/VIEW, disable/recreate VIEW fort et export Solana CLI JSON/Base58. Les secrets, keypairs, handles, chemins complets import/export et credentials Config restent côté Rust ; le frontend ne reçoit que des projections sûres et utilise des modals Bootstrap pour les opérations privilégiées.
|
`0.2.6` stabilise `ksp-app-wallet-desk` comme seconde application Tauri KSP spécialisée et étend `ksp-wallet-lib` avec le wire binaire `.kspwallet` V2. Wallet Desk compose Config, Wallet, Transport HTTP et Logging sans déplacer leurs responsabilités : inventory root-scoped et symlink-safe, création/import V2 par défaut, ouverture VIEW/OWNER V1/V2, candidats secrets résolus exclusivement par Config, `getBalance` Devnet via Transport, administration alias/notes, rotations OWNER/VIEW, disable/recreate VIEW fort et export Solana CLI JSON/Base58. Les secrets, keypairs, handles, chemins complets import/export et credentials Config restent côté Rust ; le frontend ne reçoit que des projections sûres et utilise des modals Bootstrap pour les opérations privilégiées.
|
||||||
|
|||||||
32
Cargo.toml
32
Cargo.toml
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 191
|
# 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.6"
|
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"
|
||||||
@@ -15,29 +15,35 @@ publish = false
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
argon2 = { version = "^0.5", default-features = false }
|
argon2 = { version = "^0.5", default-features = false }
|
||||||
|
base64 = { version = "^0.23" }
|
||||||
chacha20poly1305 = { version = "^0.11", default-features = false }
|
chacha20poly1305 = { version = "^0.11", default-features = false }
|
||||||
|
chrono = { version = "^0.4", default-features = false }
|
||||||
directories = { version = "^6.0" }
|
directories = { version = "^6.0" }
|
||||||
ed25519-dalek = { version = "^3.0", default-features = false }
|
ed25519-dalek = { version = "^3.0", default-features = false }
|
||||||
getrandom = { version = "^0.4", default-features = false }
|
|
||||||
base64 = { version = "^0.23" }
|
|
||||||
fs2 = { version = "^0.4" }
|
fs2 = { version = "^0.4" }
|
||||||
|
futures-util = { version = "^0.3", default-features = false }
|
||||||
|
getrandom = { version = "^0.4", default-features = false }
|
||||||
|
http = { version = "^1.5", default-features = false }
|
||||||
|
jsonschema = { version = "^0.51", default-features = false }
|
||||||
|
reqwest = { version = "^0.13", default-features = false }
|
||||||
serde = { version = "^1.0" }
|
serde = { version = "^1.0" }
|
||||||
serde_json = { version = "^1.0" }
|
serde_json = { version = "^1.0" }
|
||||||
jsonschema = { version = "^0.50", default-features = false }
|
|
||||||
reqwest = { version = "^0.13", default-features = false }
|
|
||||||
solana-keypair = { version = "^3.1", default-features = false }
|
solana-keypair = { version = "^3.1", default-features = false }
|
||||||
solana-pubkey = { version = "^4.3", default-features = false }
|
solana-pubkey = { version = "^4.3", default-features = false }
|
||||||
tracing = { version = "^0.1", default-features = false }
|
|
||||||
tracing-subscriber = { version = "^0.3", default-features = false }
|
|
||||||
tracing-appender = { version = "^0.2", default-features = false }
|
|
||||||
tokio = { version = "^1.53", default-features = false }
|
|
||||||
tempfile = { version = "^3.27" }
|
|
||||||
chrono = { version = "^0.4", default-features = false }
|
|
||||||
tauri = { version = "^2.11" }
|
tauri = { version = "^2.11" }
|
||||||
tauri-build = { version = "^2.6" }
|
tauri-build = { version = "^2.6" }
|
||||||
tauri-plugin-dialog = { version = "^2.7" }
|
tauri-plugin-dialog = { version = "^2.7" }
|
||||||
tauri-plugin-tracing = { version = "^0.3" }
|
tauri-plugin-tracing = { version = "^0.3" }
|
||||||
|
tempfile = { version = "^3.27" }
|
||||||
|
tracing = { version = "^0.1", default-features = false }
|
||||||
|
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||||
|
tracing-appender = { version = "^0.2", default-features = false }
|
||||||
|
tokio = { version = "^1.53", default-features = false }
|
||||||
|
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||||
|
tonic = { version = "^0.14", default-features = false }
|
||||||
|
tonic-prost = { version = "^0.14", default-features = false }
|
||||||
ts-rs = { version = "^12.0" }
|
ts-rs = { version = "^12.0" }
|
||||||
|
yellowstone-grpc-proto = { version = "^12.6", default-features = false }
|
||||||
zeroize = { version = "^1.9" }
|
zeroize = { version = "^1.9" }
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
|
|||||||
27
ROADMAP.md
27
ROADMAP.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 80 -->
|
<!-- version: 89 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -51,13 +51,24 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
- [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication.
|
- [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication.
|
||||||
- [X] `0.2.5` — Wallet foundation stable : `.kspwallet` V1, VIEW/OWNER indépendants, Argon2id/XChaCha20-Poly1305, autorité Ed25519 OWNER, persistence no-clobber, signature, administration/rotations/révocation VIEW forte, import/export Solana CLI JSON + Base58, canaris adversariaux, interop externe et documentation durable publiés. La clôture `pre.010-fix.001`–`fix.003` ajoute `ed25519-dalek 3.0.0` direct, normalise le Rust workspace et installe l’audit structurel Python complémentaire à rustfmt/Clippy. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet.
|
- [X] `0.2.5` — Wallet foundation stable : `.kspwallet` V1, VIEW/OWNER indépendants, Argon2id/XChaCha20-Poly1305, autorité Ed25519 OWNER, persistence no-clobber, signature, administration/rotations/révocation VIEW forte, import/export Solana CLI JSON + Base58, canaris adversariaux, interop externe et documentation durable publiés. La clôture `pre.010-fix.001`–`fix.003` ajoute `ed25519-dalek 3.0.0` direct, normalise le Rust workspace et installe l’audit structurel Python complémentaire à rustfmt/Clippy. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet.
|
||||||
- [X] `0.2.6` — `ksp-app-wallet-desk` + `.kspwallet` V2 stables : composition Config/Wallet/HTTP/Logging, lifecycle VIEW/OWNER, balance, administration/import/export, wire binaire V2, APIs multi-version, migration V1 -> V2 explicite et runtime Tauri packagé user-writable validés ; bundles Linux `.deb`/`.rpm`/`.AppImage` produits avant publication. Plan clôturé : `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`.
|
- [X] `0.2.6` — `ksp-app-wallet-desk` + `.kspwallet` V2 stables : composition Config/Wallet/HTTP/Logging, lifecycle VIEW/OWNER, balance, administration/import/export, wire binaire V2, APIs multi-version, migration V1 -> V2 explicite et runtime Tauri packagé user-writable validés ; bundles Linux `.deb`/`.rpm`/`.AppImage` produits avant publication. Plan clôturé : `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`.
|
||||||
- [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe.
|
- [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.
|
||||||
- [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.
|
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur l’actor 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.
|
||||||
- [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
|
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes d’update, 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` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
- [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` — 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é.
|
- [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 n’est introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
|
||||||
- [ ] `0.2.12` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
- [ ] `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 `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
- [ ] `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 `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
||||||
|
|
||||||
|
### 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 d’implémentation.
|
||||||
|
- [ ] **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** — QuickNode : réauditer auth, compression, `from_slot`, filtres et limites de plan.
|
||||||
|
- [ ] **TODO** — Chainstack : réauditer networks, auth, add-on et capabilities Yellowstone.
|
||||||
|
- [ ] **IDEAS** — Tatum, Shyft, Solinfra, NodeFlare et autres providers : conserver comme candidats exploratoires sans numéro de release ni engagement d’implémentation.
|
||||||
|
|
||||||
### Règles Transport pour toute la série
|
### Règles Transport pour toute la série
|
||||||
|
|
||||||
|
|||||||
43
config/examples/std.offchain_transport.example.json
Normal file
43
config/examples/std.offchain_transport.example.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
{
|
{
|
||||||
"format_version": 1,
|
"format_version": 2,
|
||||||
"retry": {
|
"retry": {
|
||||||
"max_retries": 3,
|
"max_retries": 3,
|
||||||
"initial_backoff_ms": 150,
|
"initial_backoff_ms": 150,
|
||||||
"max_backoff_ms": 3000
|
"max_backoff_ms": 3000
|
||||||
},
|
},
|
||||||
|
"ws_defaults": {
|
||||||
|
"command_timeout_ms": 10000,
|
||||||
|
"close_timeout_ms": 5000,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 5,
|
||||||
|
"initial_backoff_ms": 250,
|
||||||
|
"max_backoff_ms": 5000
|
||||||
|
},
|
||||||
|
"resubscribe": "active_subscriptions",
|
||||||
|
"command_queue_capacity": 128,
|
||||||
|
"notification_queue_capacity": 256,
|
||||||
|
"max_active_subscriptions": 1024,
|
||||||
|
"max_pending_requests": 128,
|
||||||
|
"max_message_size_bytes": 67108864,
|
||||||
|
"max_frame_size_bytes": 16777216,
|
||||||
|
"max_write_buffer_size_bytes": 1048576
|
||||||
|
},
|
||||||
"default_profile": "mainnet_mixed",
|
"default_profile": "mainnet_mixed",
|
||||||
"profiles": [
|
"profiles": [
|
||||||
{
|
{
|
||||||
@@ -23,7 +40,9 @@
|
|||||||
{
|
{
|
||||||
"role": "default",
|
"role": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"request_kinds": ["*"],
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
"priority": 200,
|
"priority": 200,
|
||||||
"limits": {
|
"limits": {
|
||||||
"requests_per_second": 5,
|
"requests_per_second": 5,
|
||||||
@@ -47,7 +66,9 @@
|
|||||||
{
|
{
|
||||||
"role": "default",
|
"role": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"request_kinds": ["*"],
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"limits": {
|
"limits": {
|
||||||
"requests_per_second": 20,
|
"requests_per_second": 20,
|
||||||
@@ -58,6 +79,69 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "mainnet_public_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mainnet_helius_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "helius",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"kind": "helius_laserstream",
|
||||||
|
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
||||||
|
"session": {
|
||||||
|
"notification_queue_capacity": 512,
|
||||||
|
"max_active_subscriptions": 2048
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "devnet_helius",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "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": "devnet_helius_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "helius",
|
||||||
|
"cluster": "devnet",
|
||||||
|
"kind": "helius_laserstream",
|
||||||
|
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
366
config/schemas/std.offchain_transport.schema.json
Normal file
366
config/schemas/std.offchain_transport.schema.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "urn:ksp:schema:std.transport:v1",
|
"$id": "urn:ksp:schema:std.transport:v3",
|
||||||
"title": "KSP standard HTTP Transport configuration",
|
"title": "KSP standard HTTP + WebSocket + Yellowstone gRPC Transport configuration",
|
||||||
"type": "object",
|
"oneOf": [
|
||||||
"additionalProperties": false,
|
{
|
||||||
"required": ["format_version", "retry", "default_profile", "profiles"],
|
"$ref": "#/$defs/documentV1"
|
||||||
"properties": {
|
},
|
||||||
"format_version": {"const": 1},
|
{
|
||||||
"retry": {"$ref": "#/$defs/retry"},
|
"$ref": "#/$defs/documentV2"
|
||||||
"default_profile": {"$ref": "#/$defs/profileId"},
|
},
|
||||||
"profiles": {
|
{
|
||||||
"type": "array",
|
"$ref": "#/$defs/documentV3"
|
||||||
"minItems": 1,
|
|
||||||
"items": {"$ref": "#/$defs/profile"}
|
|
||||||
}
|
}
|
||||||
},
|
],
|
||||||
"$defs": {
|
"$defs": {
|
||||||
"profileId": {
|
"profileId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -35,50 +33,99 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 4294967295
|
"maximum": 4294967295
|
||||||
},
|
},
|
||||||
|
"positiveUsize": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 4294967295
|
||||||
|
},
|
||||||
"retry": {
|
"retry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["max_retries", "initial_backoff_ms", "max_backoff_ms"],
|
"required": [
|
||||||
|
"max_retries",
|
||||||
|
"initial_backoff_ms",
|
||||||
|
"max_backoff_ms"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"max_retries": {"type": "integer", "minimum": 0, "maximum": 100},
|
"max_retries": {
|
||||||
"initial_backoff_ms": {"$ref": "#/$defs/positiveMs"},
|
"type": "integer",
|
||||||
"max_backoff_ms": {"$ref": "#/$defs/positiveMs"}
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"initial_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
},
|
||||||
|
"max_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"requests_per_second": {"$ref": "#/$defs/positiveU32"},
|
"requests_per_second": {
|
||||||
"burst_capacity": {"$ref": "#/$defs/positiveU32"},
|
"$ref": "#/$defs/positiveU32"
|
||||||
"max_concurrent_requests": {"$ref": "#/$defs/positiveU32"},
|
},
|
||||||
"pause_after_rate_limit_ms": {"$ref": "#/$defs/positiveMs"}
|
"burst_capacity": {
|
||||||
|
"$ref": "#/$defs/positiveU32"
|
||||||
|
},
|
||||||
|
"max_concurrent_requests": {
|
||||||
|
"$ref": "#/$defs/positiveU32"
|
||||||
|
},
|
||||||
|
"pause_after_rate_limit_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["role", "enabled", "request_kinds", "priority", "limits"],
|
"required": [
|
||||||
|
"role",
|
||||||
|
"enabled",
|
||||||
|
"request_kinds",
|
||||||
|
"priority",
|
||||||
|
"limits"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"role": {"$ref": "#/$defs/descriptor"},
|
"role": {
|
||||||
"enabled": {"type": "boolean"},
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"request_kinds": {
|
"request_kinds": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
"items": {"$ref": "#/$defs/descriptor"},
|
"items": {
|
||||||
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
"if": {"contains": {"const": "*"}},
|
"if": {
|
||||||
"then": {"maxItems": 1}
|
"contains": {
|
||||||
|
"const": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"maxItems": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"priority": {"type": "integer", "minimum": 0, "maximum": 4294967295},
|
"priority": {
|
||||||
"limits": {"$ref": "#/$defs/limits"}
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 4294967295
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"$ref": "#/$defs/limits"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"endpoint": {
|
"httpEndpoint": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": [
|
"required": [
|
||||||
@@ -92,31 +139,592 @@
|
|||||||
"roles"
|
"roles"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"$ref": "#/$defs/descriptor"},
|
"name": {
|
||||||
"enabled": {"type": "boolean"},
|
"$ref": "#/$defs/descriptor"
|
||||||
"provider": {"$ref": "#/$defs/descriptor"},
|
},
|
||||||
"cluster": {"$ref": "#/$defs/descriptor"},
|
"enabled": {
|
||||||
"url": {"type": "string", "minLength": 1},
|
"type": "boolean"
|
||||||
"connect_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
},
|
||||||
"request_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
"provider": {
|
||||||
"max_idle_connections_per_host": {"type": "integer", "minimum": 1},
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
|
"cluster": {
|
||||||
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"connect_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
},
|
||||||
|
"request_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
},
|
||||||
|
"max_idle_connections_per_host": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"items": {"$ref": "#/$defs/role"}
|
"items": {
|
||||||
|
"$ref": "#/$defs/role"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"wsReconnect": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["profile_id", "endpoints"],
|
"required": [
|
||||||
|
"max_retries",
|
||||||
|
"initial_backoff_ms",
|
||||||
|
"max_backoff_ms"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"profile_id": {"$ref": "#/$defs/profileId"},
|
"max_retries": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"initial_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
},
|
||||||
|
"max_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wsReconnectOverride": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"properties": {
|
||||||
|
"max_retries": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"initial_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
},
|
||||||
|
"max_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wsSession": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"command_timeout_ms",
|
||||||
|
"close_timeout_ms",
|
||||||
|
"reconnect",
|
||||||
|
"resubscribe",
|
||||||
|
"command_queue_capacity",
|
||||||
|
"notification_queue_capacity",
|
||||||
|
"max_active_subscriptions",
|
||||||
|
"max_pending_requests",
|
||||||
|
"max_message_size_bytes",
|
||||||
|
"max_frame_size_bytes",
|
||||||
|
"max_write_buffer_size_bytes"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"command_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
},
|
||||||
|
"close_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
},
|
||||||
|
"reconnect": {
|
||||||
|
"$ref": "#/$defs/wsReconnect"
|
||||||
|
},
|
||||||
|
"resubscribe": {
|
||||||
|
"enum": [
|
||||||
|
"never",
|
||||||
|
"active_subscriptions"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"command_queue_capacity": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"notification_queue_capacity": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_active_subscriptions": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_pending_requests": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_message_size_bytes": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_frame_size_bytes": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_write_buffer_size_bytes": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wsSessionOverride": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"properties": {
|
||||||
|
"command_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
},
|
||||||
|
"close_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/positiveMs"
|
||||||
|
},
|
||||||
|
"reconnect": {
|
||||||
|
"$ref": "#/$defs/wsReconnectOverride"
|
||||||
|
},
|
||||||
|
"resubscribe": {
|
||||||
|
"enum": [
|
||||||
|
"never",
|
||||||
|
"active_subscriptions"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"command_queue_capacity": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"notification_queue_capacity": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_active_subscriptions": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_pending_requests": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_message_size_bytes": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_frame_size_bytes": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
},
|
||||||
|
"max_write_buffer_size_bytes": {
|
||||||
|
"$ref": "#/$defs/positiveUsize"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wsEndpoint": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
"provider",
|
||||||
|
"cluster",
|
||||||
|
"kind",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
|
"cluster": {
|
||||||
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"enum": [
|
||||||
|
"solana_standard",
|
||||||
|
"helius_laserstream"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"$ref": "#/$defs/wsSessionOverride"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profileV1": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"endpoints"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"items": {"$ref": "#/$defs/endpoint"}
|
"items": {
|
||||||
|
"$ref": "#/$defs/httpEndpoint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profileV2": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"endpoints",
|
||||||
|
"ws_endpoints"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"endpoints": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/httpEndpoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ws_endpoints": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/wsEndpoint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"documentV1": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"retry",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"$ref": "#/$defs/retry"
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profileV1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"documentV2": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"retry",
|
||||||
|
"ws_defaults",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 2
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"$ref": "#/$defs/retry"
|
||||||
|
},
|
||||||
|
"ws_defaults": {
|
||||||
|
"$ref": "#/$defs/wsSession"
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profileV2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grpcPositiveMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 300000
|
||||||
|
},
|
||||||
|
"grpcCapacity": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 65536
|
||||||
|
},
|
||||||
|
"grpcMessageSize": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 536870912
|
||||||
|
},
|
||||||
|
"grpcReconnect": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"max_retries",
|
||||||
|
"initial_backoff_ms",
|
||||||
|
"max_backoff_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"max_retries": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"initial_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
},
|
||||||
|
"max_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grpcReconnectOverride": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"properties": {
|
||||||
|
"max_retries": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"initial_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
},
|
||||||
|
"max_backoff_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grpcSession": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"connect_timeout_ms",
|
||||||
|
"unary_timeout_ms",
|
||||||
|
"close_timeout_ms",
|
||||||
|
"reconnect",
|
||||||
|
"request_channel_capacity",
|
||||||
|
"update_channel_capacity",
|
||||||
|
"max_inbound_message_size_bytes",
|
||||||
|
"max_outbound_message_size_bytes"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"connect_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
},
|
||||||
|
"unary_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
},
|
||||||
|
"close_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
},
|
||||||
|
"reconnect": {
|
||||||
|
"$ref": "#/$defs/grpcReconnect"
|
||||||
|
},
|
||||||
|
"request_channel_capacity": {
|
||||||
|
"$ref": "#/$defs/grpcCapacity"
|
||||||
|
},
|
||||||
|
"update_channel_capacity": {
|
||||||
|
"$ref": "#/$defs/grpcCapacity"
|
||||||
|
},
|
||||||
|
"max_inbound_message_size_bytes": {
|
||||||
|
"$ref": "#/$defs/grpcMessageSize"
|
||||||
|
},
|
||||||
|
"max_outbound_message_size_bytes": {
|
||||||
|
"$ref": "#/$defs/grpcMessageSize"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grpcSessionOverride": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"properties": {
|
||||||
|
"connect_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
},
|
||||||
|
"unary_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
},
|
||||||
|
"close_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/grpcPositiveMs"
|
||||||
|
},
|
||||||
|
"reconnect": {
|
||||||
|
"$ref": "#/$defs/grpcReconnectOverride"
|
||||||
|
},
|
||||||
|
"request_channel_capacity": {
|
||||||
|
"$ref": "#/$defs/grpcCapacity"
|
||||||
|
},
|
||||||
|
"update_channel_capacity": {
|
||||||
|
"$ref": "#/$defs/grpcCapacity"
|
||||||
|
},
|
||||||
|
"max_inbound_message_size_bytes": {
|
||||||
|
"$ref": "#/$defs/grpcMessageSize"
|
||||||
|
},
|
||||||
|
"max_outbound_message_size_bytes": {
|
||||||
|
"$ref": "#/$defs/grpcMessageSize"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grpcMetadata": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"key",
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128,
|
||||||
|
"pattern": "^[0-9a-z_.-]+$"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 8192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grpcEndpoint": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
"provider",
|
||||||
|
"cluster",
|
||||||
|
"protocol",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
|
"cluster": {
|
||||||
|
"$ref": "#/$defs/descriptor"
|
||||||
|
},
|
||||||
|
"protocol": {
|
||||||
|
"const": "solana_yellowstone"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 8192
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 64,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/grpcMetadata"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"secret_metadata": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 64,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/grpcMetadata"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"$ref": "#/$defs/grpcSessionOverride"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profileV3": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"endpoints",
|
||||||
|
"ws_endpoints"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"endpoints": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/httpEndpoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ws_endpoints": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/wsEndpoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grpc_endpoints": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 128,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/grpcEndpoint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"documentV3": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"retry",
|
||||||
|
"ws_defaults",
|
||||||
|
"grpc_defaults",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 3
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"$ref": "#/$defs/retry"
|
||||||
|
},
|
||||||
|
"ws_defaults": {
|
||||||
|
"$ref": "#/$defs/wsSession"
|
||||||
|
},
|
||||||
|
"grpc_defaults": {
|
||||||
|
"$ref": "#/$defs/grpcSession"
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profileV3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
config/std.offchain_transport.json
Normal file
75
config/std.offchain_transport.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,10 +1,41 @@
|
|||||||
{
|
{
|
||||||
"format_version": 1,
|
"format_version": 3,
|
||||||
"retry": {
|
"retry": {
|
||||||
"max_retries": 2,
|
"max_retries": 2,
|
||||||
"initial_backoff_ms": 100,
|
"initial_backoff_ms": 100,
|
||||||
"max_backoff_ms": 2000
|
"max_backoff_ms": 2000
|
||||||
},
|
},
|
||||||
|
"ws_defaults": {
|
||||||
|
"command_timeout_ms": 10000,
|
||||||
|
"close_timeout_ms": 5000,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 5,
|
||||||
|
"initial_backoff_ms": 250,
|
||||||
|
"max_backoff_ms": 5000
|
||||||
|
},
|
||||||
|
"resubscribe": "active_subscriptions",
|
||||||
|
"command_queue_capacity": 128,
|
||||||
|
"notification_queue_capacity": 256,
|
||||||
|
"max_active_subscriptions": 1024,
|
||||||
|
"max_pending_requests": 128,
|
||||||
|
"max_message_size_bytes": 67108864,
|
||||||
|
"max_frame_size_bytes": 16777216,
|
||||||
|
"max_write_buffer_size_bytes": 1048576
|
||||||
|
},
|
||||||
|
"grpc_defaults": {
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"unary_timeout_ms": 10000,
|
||||||
|
"close_timeout_ms": 5000,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 5,
|
||||||
|
"initial_backoff_ms": 250,
|
||||||
|
"max_backoff_ms": 5000
|
||||||
|
},
|
||||||
|
"request_channel_capacity": 128,
|
||||||
|
"update_channel_capacity": 256,
|
||||||
|
"max_inbound_message_size_bytes": 67108864,
|
||||||
|
"max_outbound_message_size_bytes": 67108864
|
||||||
|
},
|
||||||
"default_profile": "devnet_public",
|
"default_profile": "devnet_public",
|
||||||
"profiles": [
|
"profiles": [
|
||||||
{
|
{
|
||||||
@@ -23,7 +54,9 @@
|
|||||||
{
|
{
|
||||||
"role": "default",
|
"role": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"request_kinds": ["*"],
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"limits": {
|
"limits": {
|
||||||
"requests_per_second": 5,
|
"requests_per_second": 5,
|
||||||
@@ -34,6 +67,74 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"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}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,7 +153,9 @@
|
|||||||
{
|
{
|
||||||
"role": "default",
|
"role": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"request_kinds": ["*"],
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"limits": {
|
"limits": {
|
||||||
"requests_per_second": 5,
|
"requests_per_second": 5,
|
||||||
@@ -63,6 +166,130 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "publicnode_mainnet",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.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_mainnet_public_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grpc_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "publicnode_solana_mainnet_yellowstone",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "publicnode",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"protocol": "solana_yellowstone",
|
||||||
|
"url": "https://solana-yellowstone-grpc.publicnode.com:443",
|
||||||
|
"secret_metadata": [
|
||||||
|
{
|
||||||
|
"key": "x-token",
|
||||||
|
"value": "${KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "publicnode_testnet",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_testnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "testnet",
|
||||||
|
"url": "https://api.testnet.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_testnet_public_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "testnet",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "wss://api.testnet.solana.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grpc_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "publicnode_solana_testnet_yellowstone",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "publicnode",
|
||||||
|
"cluster": "testnet",
|
||||||
|
"protocol": "solana_yellowstone",
|
||||||
|
"url": "https://solana-testnet-yellowstone-grpc.publicnode.com:443",
|
||||||
|
"secret_metadata": [
|
||||||
|
{
|
||||||
|
"key": "x-token",
|
||||||
|
"value": "${KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-app-config-desk/Cargo.toml
|
# file: crates/ksp-app-config-desk/Cargo.toml
|
||||||
# version: 8
|
# version: 9
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-app-config-desk"
|
name = "ksp-app-config-desk"
|
||||||
@@ -22,6 +22,7 @@ path = "src/main.rs"
|
|||||||
tauri-build.workspace = true
|
tauri-build.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { workspace = true, features = ["std", "now"] }
|
||||||
fs2.workspace = true
|
fs2.workspace = true
|
||||||
ksp-config-lib = { path = "../ksp-config-lib" }
|
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
@@ -30,7 +31,6 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tauri.workspace = true
|
tauri.workspace = true
|
||||||
tauri-plugin-tracing.workspace = true
|
tauri-plugin-tracing.workspace = true
|
||||||
chrono = { workspace = true, features = ["std", "now"] }
|
|
||||||
tokio = { workspace = true, features = ["time"] }
|
tokio = { workspace = true, features = ["time"] }
|
||||||
ts-rs.workspace = true
|
ts-rs.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-app-config-desk/README.md -->
|
<!-- file: crates/ksp-app-config-desk/README.md -->
|
||||||
<!-- version: 27 -->
|
<!-- version: 28 -->
|
||||||
|
|
||||||
# `ksp-app-config-desk`
|
# `ksp-app-config-desk`
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ Les DTO Rust applicatifs restent la source de vérité et les bindings généré
|
|||||||
|
|
||||||
## Robustesse desktop et extensibilité
|
## Robustesse desktop et extensibilité
|
||||||
|
|
||||||
`pre.018` rend le fallback de bootstrap Logging testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink.
|
Le fallback de bootstrap Logging est testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink.
|
||||||
|
|
||||||
Le frontend possède désormais `shell_registry.ts`. Le registre décrit les vues du shell et les adapters d'éditeurs spécialisés par `file_id`. Le panneau Documents reste générique ; lorsqu'un document possède un adapter enregistré, **Ouvrir l'éditeur spécialisé** déclenche la navigation par événement de registre. `cfg.std.logging -> Logging` est le premier adapter. Ajouter un futur éditeur ne demande donc pas de réécrire le moteur Documents ni la logique générale d'activation des panneaux.
|
Le frontend possède désormais `shell_registry.ts`. Le registre décrit les vues du shell et les adapters d'éditeurs spécialisés par `file_id`. Le panneau Documents reste générique ; lorsqu'un document possède un adapter enregistré, **Ouvrir l'éditeur spécialisé** déclenche la navigation par événement de registre. `cfg.std.logging -> Logging` est le premier adapter. Ajouter un futur éditeur ne demande donc pas de réécrire le moteur Documents ni la logique générale d'activation des panneaux.
|
||||||
|
|
||||||
@@ -123,11 +123,11 @@ main -> ksp-app-config-desk.frontend.main
|
|||||||
splash -> ksp-app-config-desk.frontend.splash
|
splash -> ksp-app-config-desk.frontend.splash
|
||||||
```
|
```
|
||||||
|
|
||||||
Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit est volontairement hors périmètre de `0.1.4` : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes.
|
Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit reste volontairement hors de la surface actuelle : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes.
|
||||||
|
|
||||||
## Panneau Test Logging
|
## Panneau Test Logging
|
||||||
|
|
||||||
`pre.017` ajoute une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime.
|
Le panneau fournit une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime.
|
||||||
|
|
||||||
**Log via bridge frontend** réutilise le bridge déjà installé. Son contrat reste volontairement fixe : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. Cela permet de comparer dans le même panneau le routing backend et le chemin WebView -> Rust -> `ksp-logging-lib`, avant et après modification/hot reload des filtres.
|
**Log via bridge frontend** réutilise le bridge déjà installé. Son contrat reste volontairement fixe : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. Cela permet de comparer dans le même panneau le routing backend et le chemin WebView -> Rust -> `ksp-logging-lib`, avant et après modification/hot reload des filtres.
|
||||||
|
|
||||||
@@ -171,13 +171,13 @@ La vue **Logging** charge le document standard exclusivement avec `ConfigManagem
|
|||||||
|
|
||||||
Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. Le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder et appliquer**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Après persistence, Config Desk recharge un `ConfigEnvironment` frais, résout le `default_profile`, puis appelle `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` actif. Le hot reload est immédiat et `logging_generation` avance uniquement après succès. Si l'application runtime échoue, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** ne modifie que le brouillon/source persistée.
|
Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. Le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder et appliquer**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Après persistence, Config Desk recharge un `ConfigEnvironment` frais, résout le `default_profile`, puis appelle `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` actif. Le hot reload est immédiat et `logging_generation` avance uniquement après succès. Si l'application runtime échoue, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** ne modifie que le brouillon/source persistée.
|
||||||
|
|
||||||
`pre.016` distingue en plus le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés.
|
Le panneau distingue le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés.
|
||||||
|
|
||||||
Chaque lancement de Config Desk crée aussi une `LoggingRuntimeIdentity` stable : `application_id` + timestamp UTC de démarrage + PID. `ksp-logging-lib` utilise cette identité pour préfixer les noms des fichiers actifs et la conserve pendant tous les hot reloads du même processus. Deux lancements distincts ne partagent donc plus le même fichier persistant, même avec une rotation `daily`. Avec la configuration de release `info/ksp-info.log`, un prefix effectif peut être `ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log`. Le path Config reste inchangé ; l'identité appartient au runtime, pas au document source.
|
Chaque lancement de Config Desk crée aussi une `LoggingRuntimeIdentity` stable : `application_id` + timestamp UTC de démarrage + PID. `ksp-logging-lib` utilise cette identité pour préfixer les noms des fichiers actifs et la conserve pendant tous les hot reloads du même processus. Deux lancements distincts ne partagent donc plus le même fichier persistant, même avec une rotation `daily`. Avec la configuration de release `info/ksp-info.log`, un prefix effectif peut être `ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log`. Le path Config reste inchangé ; l'identité appartient au runtime, pas au document source.
|
||||||
|
|
||||||
## Baseline Logging de release
|
## Baseline Logging de release
|
||||||
|
|
||||||
La configuration canonique livrée avec `0.1.4` revient à une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors d’un développement ou diagnostic, puis redescendus avant la release suivante.
|
La configuration canonique de release utilise une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors d’un développement ou diagnostic, puis redescendus avant la release suivante.
|
||||||
|
|
||||||
## Traçabilité frontend
|
## Traçabilité frontend
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
||||||
<!-- version: 27 -->
|
<!-- version: 28 -->
|
||||||
|
|
||||||
# Utilisation de `ksp-app-config-desk`
|
# Utilisation de `ksp-app-config-desk`
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ splash
|
|||||||
|
|
||||||
Ils sont convertis côté Rust vers des targets KSP statiques `ksp-app-config-desk.frontend*` et émis uniquement via `ksp-logging-lib`. Un niveau différent de `trace`, `debug`, `info`, `warn` ou `error`, ou un `targetId` non whitelisté, est rejeté avec un `CommandErrorDto` sûr.
|
Ils sont convertis côté Rust vers des targets KSP statiques `ksp-app-config-desk.frontend*` et émis uniquement via `ksp-logging-lib`. Un niveau différent de `trace`, `debug`, `info`, `warn` ou `error`, ou un `targetId` non whitelisté, est rejeté avec un `CommandErrorDto` sûr.
|
||||||
|
|
||||||
`main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit est reporté hors `0.1.4`; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge.
|
`main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit reste hors de la surface actuelle ; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge.
|
||||||
|
|
||||||
## Bindings TS-RS
|
## Bindings TS-RS
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ Si la résolution ou la préparation du profil explicite échoue, `ksp_logging_l
|
|||||||
|
|
||||||
## Baseline Logging de release
|
## Baseline Logging de release
|
||||||
|
|
||||||
Après les tests `debug`/`trace`, la configuration canonique `0.1.4` revient à :
|
Après les tests `debug`/`trace`, la configuration canonique revient à :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
default_filter = warn
|
default_filter = warn
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 6
|
// version: 8
|
||||||
|
|
||||||
//! Desktop build/shell contract audits for Config Desk.
|
//! Desktop build/shell contract audits for Config Desk.
|
||||||
|
|
||||||
@@ -26,6 +26,43 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
||||||
|
let core = match value.split_once('-') {
|
||||||
|
std::option::Option::Some((core, _)) => core,
|
||||||
|
std::option::Option::None => value,
|
||||||
|
};
|
||||||
|
let mut parts = core.split('.');
|
||||||
|
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return std::option::Option::Some((major, minor, patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
||||||
|
assert!(value.is_some(), "{field} packaged version must exist");
|
||||||
|
let value = match value {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let parsed = parse_semver_core(value);
|
||||||
|
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
||||||
|
if let std::option::Option::Some(parsed) = parsed {
|
||||||
|
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tauri_and_frontend_build_contracts_remain_explicit() {
|
fn tauri_and_frontend_build_contracts_remain_explicit() {
|
||||||
let root = app_root();
|
let root = app_root();
|
||||||
@@ -103,16 +140,26 @@ fn pre_014_template_uses_sidebar_navigation_and_kbot_style_splash_contract() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
|
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
|
||||||
let root = app_root();
|
let root = app_root();
|
||||||
let expected_version = env!("CARGO_PKG_VERSION");
|
|
||||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||||
assert_eq!(tauri.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
||||||
|
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
||||||
let package = read_json(root.join("package.json").as_path());
|
let package = read_json(root.join("package.json").as_path());
|
||||||
assert_eq!(package.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
||||||
|
assert_packaged_version_floor(package_version, "package.json");
|
||||||
|
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
||||||
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"),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- file: crates/ksp-app-wallet-desk/README.md -->
|
<!-- file: crates/ksp-app-wallet-desk/README.md -->
|
||||||
<!-- version: 2 -->
|
<!-- version: 3 -->
|
||||||
|
|
||||||
# `ksp-app-wallet-desk`
|
# `ksp-app-wallet-desk`
|
||||||
|
|
||||||
`ksp-app-wallet-desk` est l'application desktop spécialisée stable d'administration et de validation des wallets KSP depuis KSP `0.2.6`.
|
`ksp-app-wallet-desk` est l'application desktop spécialisée stable d'administration et de validation des wallets KSP.
|
||||||
|
|
||||||
La crate est un package Tauri mixte :
|
La crate est un package Tauri mixte :
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ Le frontend ne reçoit jamais les keypairs, ciphertexts, passwords Config, paths
|
|||||||
|
|
||||||
## `.kspwallet` V1/V2
|
## `.kspwallet` V1/V2
|
||||||
|
|
||||||
La release `0.2.6` conserve V1 et ajoute V2 :
|
La surface courante conserve V1 et utilise V2 comme format natif par défaut :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
V1 : JSON UTF-8 historique, lecture explicite toujours supportée
|
V1 : JSON UTF-8 historique, lecture explicite toujours supportée
|
||||||
@@ -45,9 +45,9 @@ Wallet Desk consomme uniquement les APIs non versionnées de `ksp-wallet-lib` :
|
|||||||
|
|
||||||
`ksp-wallet-lib` conserve parallèlement les APIs explicites `_v1` / `_v2` pour les consumers qui doivent imposer un format. `DEFAULT_WALLET_FORMAT` et `LATEST_SUPPORTED_WALLET_FORMAT` sont des politiques distinctes ; l'apparition future d'un V3 n'impose donc pas de modifier automatiquement le format créé par les APIs génériques.
|
`ksp-wallet-lib` conserve parallèlement les APIs explicites `_v1` / `_v2` pour les consumers qui doivent imposer un format. `DEFAULT_WALLET_FORMAT` et `LATEST_SUPPORTED_WALLET_FORMAT` sont des politiques distinctes ; l'apparition future d'un V3 n'impose donc pas de modifier automatiquement le format créé par les APIs génériques.
|
||||||
|
|
||||||
La migration V1 -> V2 est une opération explicite OWNER-authentifiée possédée par `ksp-wallet-lib`. Wallet Desk `0.2.6` ne migre jamais silencieusement un wallet lors de sa sélection, inspection ou ouverture.
|
La migration V1 -> V2 est une opération explicite OWNER-authentifiée possédée par `ksp-wallet-lib`. Wallet Desk ne migre jamais silencieusement un wallet lors de sa sélection, inspection ou ouverture.
|
||||||
|
|
||||||
## Capacités fonctionnelles `0.2.6`
|
## Capacités fonctionnelles
|
||||||
|
|
||||||
La surface validée comprend :
|
La surface validée comprend :
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 21
|
// version: 23
|
||||||
|
|
||||||
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
||||||
|
|
||||||
@@ -31,6 +31,43 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
||||||
|
let core = match value.split_once('-') {
|
||||||
|
std::option::Option::Some((core, _)) => core,
|
||||||
|
std::option::Option::None => value,
|
||||||
|
};
|
||||||
|
let mut parts = core.split('.');
|
||||||
|
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return std::option::Option::Some((major, minor, patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
||||||
|
assert!(value.is_some(), "{field} packaged version must exist");
|
||||||
|
let value = match value {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let parsed = parse_semver_core(value);
|
||||||
|
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
||||||
|
if let std::option::Option::Some(parsed) = parsed {
|
||||||
|
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() {
|
fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() {
|
||||||
let root = app_root();
|
let root = app_root();
|
||||||
@@ -419,17 +456,23 @@ fn pre_017_wallet_desk_open_paths_remain_non_migrating() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_current() {
|
fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_coherent() {
|
||||||
let root = app_root();
|
let root = app_root();
|
||||||
let expected_version = env!("CARGO_PKG_VERSION");
|
|
||||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||||
assert_eq!(tauri.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
||||||
|
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
||||||
let package = read_json(root.join("package.json").as_path());
|
let package = read_json(root.join("package.json").as_path());
|
||||||
assert_eq!(package.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
||||||
|
assert_packaged_version_floor(package_version, "package.json");
|
||||||
|
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
||||||
|
let packaged_version = match package_version {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
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"),
|
||||||
@@ -438,9 +481,17 @@ 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(expected_version));
|
assert!(main.contains(packaged_version));
|
||||||
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
|
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
|
||||||
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
|
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
|
||||||
assert!(tauri_source.contains("tauri::utils::platform::resource_dir"));
|
assert!(tauri_source.contains("tauri::utils::platform::resource_dir"));
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-config-lib/Cargo.toml
|
# file: crates/ksp-config-lib/Cargo.toml
|
||||||
# version: 7
|
# version: 9
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-config-lib"
|
name = "ksp-config-lib"
|
||||||
@@ -9,12 +9,13 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
directories.workspace = true
|
directories.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
|
||||||
jsonschema.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-config-lib/README.md -->
|
<!-- file: crates/ksp-config-lib/README.md -->
|
||||||
<!-- version: 6 -->
|
<!-- 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 HTTP Transport effectif vers `ksp_onchain_transport_lib::HttpTransportSettings`, 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 n’introduit aucune dépendance Config -> `ksp-wallet-lib`.
|
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, de `ksp-onchain-transport-lib` pour construire le contrat runtime On-chain Transport et de `ksp-offchain-transport-lib` pour construire le service market-price dans la direction Config -> Transport. Le document Wallet reste un contrat de chemins/profils Config et n’introduit aucune dépendance Config -> `ksp-wallet-lib`.
|
||||||
|
|
||||||
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib` et `ksp-onchain-transport-lib` ne dépendent pas de Config.
|
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib`, `ksp-onchain-transport-lib` et `ksp-offchain-transport-lib` ne dépendent pas de Config.
|
||||||
|
|
||||||
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
|
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,15 +84,16 @@ 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 les accepte pour les URLs endpoint : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. `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
|
||||||
|
|
||||||
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
|
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
|
||||||
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ;
|
- [`TODO.md`](TODO.md) — points explicitement différés ;
|
||||||
- [`../../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 HTTP Transport ;
|
- [`../../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.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||||
<!-- version: 6 -->
|
<!-- version: 13 -->
|
||||||
|
|
||||||
# Utilisation de ksp-config-lib
|
# Utilisation de ksp-config-lib
|
||||||
|
|
||||||
@@ -125,9 +125,9 @@ Un `logs_directory` relatif est ancré sur le current working directory du proce
|
|||||||
|
|
||||||
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
|
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
|
||||||
|
|
||||||
### 4.1 Construire le Transport HTTP depuis Config
|
### 4.1 Construire le Transport HTTP + WebSocket + Yellowstone gRPC depuis Config
|
||||||
|
|
||||||
Config possède également l'adapter du document `std.transport` vers le contrat runtime de `ksp-onchain-transport-lib` :
|
Config possède l'adapter du document `std.transport` vers les contrats runtime de `ksp-onchain-transport-lib` :
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let transport = match engine.load_resolved_transport_config(std::option::Option::None, &environment) {
|
let transport = match engine.load_resolved_transport_config(std::option::Option::None, &environment) {
|
||||||
@@ -135,12 +135,32 @@ let transport = match engine.load_resolved_transport_config(std::option::Option:
|
|||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
};
|
};
|
||||||
|
|
||||||
let transport_settings = transport.into_settings();
|
let http_settings = transport.http_settings();
|
||||||
|
let ws_settings = transport.ws_settings();
|
||||||
|
let grpc_settings = transport.grpc_settings();
|
||||||
|
let _ = (http_settings, ws_settings, grpc_settings);
|
||||||
```
|
```
|
||||||
|
|
||||||
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. Les URLs peuvent provenir de `KSP_PUBLIC_*` ou de `KSP_SECRET_*`; dans ce dernier cas la valeur réelle reste disponible au runtime Transport, mais `ResolvedTransportConfig::effective().safe_value()` et les représentations `Debug` sont redacted.
|
`std.transport` V3 conserve intégralement les shapes V1/V2, ajoute `grpc_defaults` au niveau global et permet `profiles[].grpc_endpoints[]`. Les profils V3 peuvent rester HTTP + WebSocket seulement : l'absence de `grpc_endpoints` mappe vers `None` et n'invente aucun endpoint. La lecture V1 HTTP-only et V2 HTTP + WebSocket reste stricte et backward-compatible.
|
||||||
|
|
||||||
La dépendance reste unidirectionnelle : Config connaît le contrat Transport pour le construire ; Transport ne connaît ni Config, ni `.env`, ni les variables KSP.
|
Un endpoint gRPC V3 sépare explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider = description de l'opérateur/exécution, par exemple publicnode
|
||||||
|
protocol = solana_yellowstone
|
||||||
|
metadata = metadata non secrète
|
||||||
|
secret_metadata = metadata dont la valeur doit avoir une provenance KSP_SECRET_*/KSPB_SECRET_*
|
||||||
|
```
|
||||||
|
|
||||||
|
`protocol` n'est pas un nouveau `WsProtocolKind` et ne transforme pas PublicNode en protocole. Config résout les placeholders, vérifie la classe de sensibilité des metadata puis construit `YellowstoneGrpcTransportSettings`. Transport ne lit jamais l'environnement. Les valeurs de `secret_metadata` sont disponibles au runtime mais redacted dans `safe_value` et dans les représentations `Debug`.
|
||||||
|
|
||||||
|
Le provider affiche `solana-yellowstone-grpc.publicnode.com:443` ; le profil versionné `publicnode_mainnet` le représente sous la forme URL TLS requise par Transport, `https://solana-yellowstone-grpc.publicnode.com:443`, sans credential. Aucun hostname Testnet n'est versionné tant que sa valeur exacte n'est pas confirmée depuis une source opérateur/officielle.
|
||||||
|
|
||||||
|
L'accesseur historique `into_transport_settings()` conserve volontairement son tuple `(HTTP, Option<WS>)`. Un consumer ayant besoin des trois backends utilise `into_all_transport_settings()` ou les accesseurs séparés afin de ne pas casser silencieusement les consumers V2.
|
||||||
|
|
||||||
|
Les protocoles WebSocket restent `kind = "solana_standard"` et `kind = "helius_laserstream"`. Helius LaserStream WebSocket conserve ses URLs Config-owned et sa clé `KSP_SECRET_HELIUS_API_KEY`; cette surface est indépendante de Yellowstone gRPC.
|
||||||
|
|
||||||
|
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. La dépendance reste unidirectionnelle : Config connaît les contrats Transport pour les construire ; Transport ne connaît ni Config, ni `.env`, ni les variables KSP.
|
||||||
|
|
||||||
### 4.2 Résoudre le répertoire Wallet depuis Config
|
### 4.2 Résoudre le répertoire Wallet depuis Config
|
||||||
|
|
||||||
@@ -162,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é :
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/lib.rs
|
// file: crates/ksp-config-lib/src/lib.rs
|
||||||
// version: 16
|
// version: 19
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
//!
|
//!
|
||||||
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
||||||
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
||||||
//! provenance. Standard Logging, HTTP Transport and Wallet documents map explicitly to their runtime consumer contracts, while the management surface
|
//! provenance. Standard Logging, on-chain Transport (HTTP/WebSocket/Yellowstone gRPC) and Wallet documents map explicitly to their runtime consumer
|
||||||
//! provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
|
//! contracts, while the management surface provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic
|
||||||
|
//! JSON/`.env` persistence.
|
||||||
|
|
||||||
mod bootstrap;
|
mod bootstrap;
|
||||||
mod composite;
|
mod composite;
|
||||||
@@ -20,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;
|
||||||
@@ -126,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.
|
||||||
@@ -154,9 +158,13 @@ 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 HTTP Transport configuration document.
|
/// Default physical filename for the standard Off-chain Transport configuration document.
|
||||||
|
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME;
|
||||||
|
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
||||||
|
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME;
|
||||||
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
||||||
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
|
pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
|
||||||
/// Default physical filename for the standard Wallet configuration document.
|
/// Default physical filename for the standard Wallet configuration document.
|
||||||
pub use self::registry::DEFAULT_STD_WALLET_FILENAME;
|
pub use self::registry::DEFAULT_STD_WALLET_FILENAME;
|
||||||
@@ -168,13 +176,17 @@ 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 HTTP Transport JSON Schema document.
|
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
||||||
|
pub use self::registry::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT;
|
||||||
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
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 HTTP Transport configuration document.
|
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||||
|
pub use self::registry::FILE_ID_STD_OFFCHAIN_TRANSPORT;
|
||||||
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
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.
|
||||||
pub use self::registry::FILE_ID_STD_WALLET;
|
pub use self::registry::FILE_ID_STD_WALLET;
|
||||||
@@ -188,7 +200,7 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
|
|||||||
pub use self::sensitivity::ResolvedConfigJson;
|
pub use self::sensitivity::ResolvedConfigJson;
|
||||||
/// One resolved Config string preserving real/safe representations and provenance.
|
/// One resolved Config string preserving real/safe representations and provenance.
|
||||||
pub use self::sensitivity::ResolvedConfigText;
|
pub use self::sensitivity::ResolvedConfigText;
|
||||||
/// Effective standard HTTP Transport configuration mapped to `ksp_onchain_transport_lib::HttpTransportSettings`.
|
/// Effective standard Transport configuration mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime settings.
|
||||||
pub use self::transport::ResolvedTransportConfig;
|
pub use self::transport::ResolvedTransportConfig;
|
||||||
/// Effective standard Wallet configuration resolved to validated filesystem roots.
|
/// Effective standard Wallet configuration resolved to validated filesystem roots.
|
||||||
pub use self::wallet::ResolvedWalletConfig;
|
pub use self::wallet::ResolvedWalletConfig;
|
||||||
|
|||||||
520
crates/ksp-config-lib/src/offchain_transport.rs
Normal file
520
crates/ksp-config-lib/src/offchain_transport.rs
Normal 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", ®istry)
|
||||||
|
.field("selection_source", &self.selection_source)
|
||||||
|
.field("source_path", &self.source_path)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads the standard Off-chain Transport document and maps one profile to the generic market-price runtime service.
|
||||||
|
pub fn load_resolved_offchain_transport_config(
|
||||||
|
&self,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
environment: &crate::ConfigEnvironment,
|
||||||
|
) -> ksp_core_lib::Result<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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/registry.rs
|
// file: crates/ksp-config-lib/src/registry.rs
|
||||||
// version: 8
|
// 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,9 +11,13 @@ 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 HTTP Transport configuration document.
|
/// Default physical filename for the standard Off-chain Transport configuration document.
|
||||||
|
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME: &str = "std.offchain_transport.json";
|
||||||
|
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
||||||
|
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME: &str = "std.offchain_transport.schema.json";
|
||||||
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
||||||
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
|
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
|
||||||
/// Default physical filename for the standard Wallet configuration document.
|
/// Default physical filename for the standard Wallet configuration document.
|
||||||
pub const DEFAULT_STD_WALLET_FILENAME: &str = "std.wallet.json";
|
pub const DEFAULT_STD_WALLET_FILENAME: &str = "std.wallet.json";
|
||||||
@@ -25,13 +29,17 @@ 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 HTTP Transport JSON Schema document.
|
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
||||||
|
pub const FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT: &str = "schema.std.offchain_transport";
|
||||||
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
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 HTTP Transport configuration document.
|
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||||
|
pub const FILE_ID_STD_OFFCHAIN_TRANSPORT: &str = "cfg.std.offchain_transport";
|
||||||
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
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.
|
||||||
pub const FILE_ID_STD_WALLET: &str = "cfg.std.wallet";
|
pub const FILE_ID_STD_WALLET: &str = "cfg.std.wallet";
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// file: crates/ksp-config-lib/src/transport.rs
|
// file: crates/ksp-config-lib/src/transport.rs
|
||||||
// version: 2
|
// version: 5
|
||||||
|
|
||||||
/// Effective standard HTTP Transport configuration resolved from Config and mapped to the Transport runtime contract.
|
/// Effective standard on-chain Transport configuration resolved from Config and mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime contracts.
|
||||||
#[derive(Clone, Eq, PartialEq)]
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
pub struct ResolvedTransportConfig {
|
pub struct ResolvedTransportConfig {
|
||||||
file_id: crate::ConfigFileId,
|
file_id: crate::ConfigFileId,
|
||||||
@@ -10,6 +10,8 @@ pub struct ResolvedTransportConfig {
|
|||||||
selection_source: crate::ConfigProfileSelectionSource,
|
selection_source: crate::ConfigProfileSelectionSource,
|
||||||
effective: crate::ResolvedConfigJson,
|
effective: crate::ResolvedConfigJson,
|
||||||
settings: ksp_onchain_transport_lib::HttpTransportSettings,
|
settings: ksp_onchain_transport_lib::HttpTransportSettings,
|
||||||
|
ws_settings: std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>,
|
||||||
|
grpc_settings: std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedTransportConfig {
|
impl ResolvedTransportConfig {
|
||||||
@@ -47,16 +49,63 @@ impl ResolvedTransportConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the validated runtime HTTP Transport settings.
|
/// Returns the validated runtime HTTP Transport settings.
|
||||||
|
///
|
||||||
|
/// This compatibility accessor keeps the HTTP contract introduced before Transport V2.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
||||||
return &self.settings;
|
return &self.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the validated runtime HTTP Transport settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn http_settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns validated WebSocket Transport settings when the selected document uses format V2 or V3.
|
||||||
|
///
|
||||||
|
/// Backward-compatible V1 HTTP-only documents return [`std::option::Option::None`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn ws_settings(&self) -> std::option::Option<&ksp_onchain_transport_lib::WsTransportSettings> {
|
||||||
|
return self.ws_settings.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns validated Yellowstone gRPC Transport settings when the selected V3 profile declares gRPC endpoints.
|
||||||
|
///
|
||||||
|
/// V1/V2 documents and V3 profiles without `grpc_endpoints` return [`std::option::Option::None`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn grpc_settings(&self) -> std::option::Option<&ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings> {
|
||||||
|
return self.grpc_settings.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
/// Consumes this resolved Config and returns the mapped runtime HTTP Transport settings.
|
/// Consumes this resolved Config and returns the mapped runtime HTTP Transport settings.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
|
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
|
||||||
return self.settings;
|
return self.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consumes this resolved Config and returns HTTP plus optional WebSocket runtime settings.
|
||||||
|
///
|
||||||
|
/// This compatibility accessor intentionally preserves the V2 tuple shape. Use [`Self::into_all_transport_settings`] when Yellowstone gRPC settings are
|
||||||
|
/// also required.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_transport_settings(
|
||||||
|
self,
|
||||||
|
) -> (ksp_onchain_transport_lib::HttpTransportSettings, std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>) {
|
||||||
|
return (self.settings, self.ws_settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes this resolved Config and returns HTTP plus optional WebSocket and Yellowstone gRPC runtime settings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_all_transport_settings(
|
||||||
|
self,
|
||||||
|
) -> (
|
||||||
|
ksp_onchain_transport_lib::HttpTransportSettings,
|
||||||
|
std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>,
|
||||||
|
std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings>,
|
||||||
|
) {
|
||||||
|
return (self.settings, self.ws_settings, self.grpc_settings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for ResolvedTransportConfig {
|
impl std::fmt::Debug for ResolvedTransportConfig {
|
||||||
@@ -68,16 +117,18 @@ impl std::fmt::Debug for ResolvedTransportConfig {
|
|||||||
.field("profile_id", &self.profile_id)
|
.field("profile_id", &self.profile_id)
|
||||||
.field("selection_source", &self.selection_source)
|
.field("selection_source", &self.selection_source)
|
||||||
.field("effective", &self.effective)
|
.field("effective", &self.effective)
|
||||||
|
.field("has_ws_settings", &self.ws_settings.is_some())
|
||||||
|
.field("has_grpc_settings", &self.grpc_settings.is_some())
|
||||||
.finish_non_exhaustive();
|
.finish_non_exhaustive();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::ConfigDocumentEngine {
|
impl crate::ConfigDocumentEngine {
|
||||||
/// Loads the standard HTTP Transport document, selects a profile, resolves environment placeholders and maps it to Transport runtime settings.
|
/// Loads the standard Transport document, selects a profile, resolves environment placeholders and maps HTTP plus optional WebSocket and Yellowstone
|
||||||
|
/// gRPC runtime settings.
|
||||||
///
|
///
|
||||||
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed
|
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed
|
||||||
/// because the Transport URL wrapper owns runtime redaction. Invalid environment-resolved values are reported as
|
/// because Transport URL wrappers own runtime redaction. V1 remains HTTP-only, V2 adds WebSocket and V3 can additionally map Yellowstone gRPC.
|
||||||
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] without copying endpoint URL values into ordinary error context.
|
|
||||||
pub fn load_resolved_transport_config(
|
pub fn load_resolved_transport_config(
|
||||||
&self,
|
&self,
|
||||||
requested_profile: std::option::Option<&str>,
|
requested_profile: std::option::Option<&str>,
|
||||||
@@ -96,7 +147,8 @@ impl crate::ConfigDocumentEngine {
|
|||||||
return resolve_transport_profile(&profile, environment);
|
return resolve_transport_profile(&profile, environment);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps an already resolved standard Transport profile to the runtime HTTP Transport adapter while preserving its selection provenance.
|
/// Maps an already resolved standard Transport profile to HTTP plus optional WebSocket and Yellowstone gRPC runtime adapters while preserving
|
||||||
|
/// selection provenance.
|
||||||
///
|
///
|
||||||
/// This entry point is intended for profiles selected by a composite. The profile must reference `cfg.std.transport`.
|
/// This entry point is intended for profiles selected by a composite. The profile must reference `cfg.std.transport`.
|
||||||
pub fn resolve_transport_config_profile(
|
pub fn resolve_transport_config_profile(
|
||||||
@@ -121,7 +173,15 @@ struct EffectiveTransportSource {
|
|||||||
format_version: u32,
|
format_version: u32,
|
||||||
profile_id: String,
|
profile_id: String,
|
||||||
retry: EffectiveRetrySource,
|
retry: EffectiveRetrySource,
|
||||||
|
#[serde(default)]
|
||||||
|
ws_defaults: std::option::Option<EffectiveWsSessionSource>,
|
||||||
|
#[serde(default)]
|
||||||
|
grpc_defaults: std::option::Option<EffectiveGrpcSessionSource>,
|
||||||
endpoints: std::vec::Vec<EffectiveEndpointSource>,
|
endpoints: std::vec::Vec<EffectiveEndpointSource>,
|
||||||
|
#[serde(default)]
|
||||||
|
ws_endpoints: std::option::Option<std::vec::Vec<EffectiveWsEndpointSource>>,
|
||||||
|
#[serde(default)]
|
||||||
|
grpc_endpoints: std::option::Option<std::vec::Vec<EffectiveGrpcEndpointSource>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -165,7 +225,135 @@ struct EffectiveLimitsSource {
|
|||||||
pause_after_rate_limit_ms: std::option::Option<u64>,
|
pause_after_rate_limit_ms: std::option::Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveWsSessionSource {
|
||||||
|
command_timeout_ms: u64,
|
||||||
|
close_timeout_ms: u64,
|
||||||
|
reconnect: EffectiveWsReconnectSource,
|
||||||
|
resubscribe: String,
|
||||||
|
command_queue_capacity: usize,
|
||||||
|
notification_queue_capacity: usize,
|
||||||
|
max_active_subscriptions: usize,
|
||||||
|
max_pending_requests: usize,
|
||||||
|
max_message_size_bytes: usize,
|
||||||
|
max_frame_size_bytes: usize,
|
||||||
|
max_write_buffer_size_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveWsReconnectSource {
|
||||||
|
max_retries: u32,
|
||||||
|
initial_backoff_ms: u64,
|
||||||
|
max_backoff_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveWsEndpointSource {
|
||||||
|
name: String,
|
||||||
|
enabled: bool,
|
||||||
|
provider: String,
|
||||||
|
cluster: String,
|
||||||
|
kind: String,
|
||||||
|
url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
session: std::option::Option<EffectiveWsSessionOverrideSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveWsSessionOverrideSource {
|
||||||
|
command_timeout_ms: std::option::Option<u64>,
|
||||||
|
close_timeout_ms: std::option::Option<u64>,
|
||||||
|
reconnect: std::option::Option<EffectiveWsReconnectOverrideSource>,
|
||||||
|
resubscribe: std::option::Option<String>,
|
||||||
|
command_queue_capacity: std::option::Option<usize>,
|
||||||
|
notification_queue_capacity: std::option::Option<usize>,
|
||||||
|
max_active_subscriptions: std::option::Option<usize>,
|
||||||
|
max_pending_requests: std::option::Option<usize>,
|
||||||
|
max_message_size_bytes: std::option::Option<usize>,
|
||||||
|
max_frame_size_bytes: std::option::Option<usize>,
|
||||||
|
max_write_buffer_size_bytes: std::option::Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveWsReconnectOverrideSource {
|
||||||
|
max_retries: std::option::Option<u32>,
|
||||||
|
initial_backoff_ms: std::option::Option<u64>,
|
||||||
|
max_backoff_ms: std::option::Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveGrpcReconnectSource {
|
||||||
|
max_retries: u32,
|
||||||
|
initial_backoff_ms: u64,
|
||||||
|
max_backoff_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveGrpcReconnectOverrideSource {
|
||||||
|
max_retries: std::option::Option<u32>,
|
||||||
|
initial_backoff_ms: std::option::Option<u64>,
|
||||||
|
max_backoff_ms: std::option::Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveGrpcSessionSource {
|
||||||
|
connect_timeout_ms: u64,
|
||||||
|
unary_timeout_ms: u64,
|
||||||
|
close_timeout_ms: u64,
|
||||||
|
reconnect: EffectiveGrpcReconnectSource,
|
||||||
|
request_channel_capacity: usize,
|
||||||
|
update_channel_capacity: usize,
|
||||||
|
max_inbound_message_size_bytes: usize,
|
||||||
|
max_outbound_message_size_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveGrpcSessionOverrideSource {
|
||||||
|
connect_timeout_ms: std::option::Option<u64>,
|
||||||
|
unary_timeout_ms: std::option::Option<u64>,
|
||||||
|
close_timeout_ms: std::option::Option<u64>,
|
||||||
|
reconnect: std::option::Option<EffectiveGrpcReconnectOverrideSource>,
|
||||||
|
request_channel_capacity: std::option::Option<usize>,
|
||||||
|
update_channel_capacity: std::option::Option<usize>,
|
||||||
|
max_inbound_message_size_bytes: std::option::Option<usize>,
|
||||||
|
max_outbound_message_size_bytes: std::option::Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveGrpcMetadataSource {
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveGrpcEndpointSource {
|
||||||
|
name: String,
|
||||||
|
enabled: bool,
|
||||||
|
provider: String,
|
||||||
|
cluster: String,
|
||||||
|
protocol: String,
|
||||||
|
url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
metadata: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||||
|
#[serde(default)]
|
||||||
|
secret_metadata: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||||
|
#[serde(default)]
|
||||||
|
session: std::option::Option<EffectiveGrpcSessionOverrideSource>,
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapping standard Transport Config profile");
|
||||||
let effective = profile.resolve_effective_environment_detailed(environment);
|
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||||
let effective = match effective {
|
let effective = match effective {
|
||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
@@ -180,12 +368,10 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if source.format_version != 1 {
|
|
||||||
return std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported"));
|
|
||||||
}
|
|
||||||
if source.profile_id != profile.profile_id() {
|
if source.profile_id != profile.profile_id() {
|
||||||
return std::result::Result::Err(effective_error(profile, "effective Transport profile_id does not match the selected profile"));
|
return std::result::Result::Err(effective_error(profile, "effective Transport profile_id does not match the selected profile"));
|
||||||
}
|
}
|
||||||
|
let format_version = source.format_version;
|
||||||
let retry = ksp_onchain_transport_lib::HttpRetrySettings::new(
|
let retry = ksp_onchain_transport_lib::HttpRetrySettings::new(
|
||||||
source.retry.max_retries,
|
source.retry.max_retries,
|
||||||
std::time::Duration::from_millis(source.retry.initial_backoff_ms),
|
std::time::Duration::from_millis(source.retry.initial_backoff_ms),
|
||||||
@@ -197,14 +383,34 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
|||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
};
|
};
|
||||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(endpoints, retry);
|
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(endpoints, retry);
|
||||||
let validation = settings.validate();
|
if let std::result::Result::Err(error) = settings.validate() {
|
||||||
if let std::result::Result::Err(error) = validation {
|
return std::result::Result::Err(transport_contract_error(profile, "effective HTTP Transport settings fail the Transport runtime contract", &error));
|
||||||
return std::result::Result::Err(transport_contract_error(profile, "effective Transport settings fail the Transport runtime contract", &error));
|
|
||||||
}
|
}
|
||||||
|
let ws_settings = map_optional_ws_settings(format_version, source.ws_defaults, source.ws_endpoints, profile);
|
||||||
|
let ws_settings = match ws_settings {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let grpc_settings = map_optional_grpc_settings(format_version, source.grpc_defaults, source.grpc_endpoints, &effective, profile);
|
||||||
|
let grpc_settings = match grpc_settings {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let ws_endpoint_count = match ws_settings.as_ref() {
|
||||||
|
std::option::Option::Some(value) => value.endpoints().len(),
|
||||||
|
std::option::Option::None => 0_usize,
|
||||||
|
};
|
||||||
|
let grpc_endpoint_count = match grpc_settings.as_ref() {
|
||||||
|
std::option::Option::Some(value) => value.endpoints().len(),
|
||||||
|
std::option::Option::None => 0_usize,
|
||||||
|
};
|
||||||
ksp_logging_lib::debug!(
|
ksp_logging_lib::debug!(
|
||||||
target: crate::TRACING_TARGET,
|
target: crate::TRACING_TARGET,
|
||||||
profile_id = profile.profile_id(),
|
profile_id = profile.profile_id(),
|
||||||
endpoint_count = settings.endpoints().len(),
|
format_version,
|
||||||
|
http_endpoint_count = settings.endpoints().len(),
|
||||||
|
ws_endpoint_count,
|
||||||
|
grpc_endpoint_count,
|
||||||
"mapped standard Transport Config to runtime settings"
|
"mapped standard Transport Config to runtime settings"
|
||||||
);
|
);
|
||||||
return std::result::Result::Ok(ResolvedTransportConfig {
|
return std::result::Result::Ok(ResolvedTransportConfig {
|
||||||
@@ -214,9 +420,335 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
|||||||
selection_source: profile.selection_source(),
|
selection_source: profile.selection_source(),
|
||||||
effective,
|
effective,
|
||||||
settings,
|
settings,
|
||||||
|
ws_settings,
|
||||||
|
grpc_settings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_optional_ws_settings(
|
||||||
|
format_version: u32,
|
||||||
|
defaults: std::option::Option<EffectiveWsSessionSource>,
|
||||||
|
sources: std::option::Option<std::vec::Vec<EffectiveWsEndpointSource>>,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>> {
|
||||||
|
return match format_version {
|
||||||
|
1 => {
|
||||||
|
if defaults.is_some() || sources.is_some() {
|
||||||
|
std::result::Result::Err(effective_error(profile, "Transport V1 must remain HTTP-only"))
|
||||||
|
} else {
|
||||||
|
ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
profile_id = profile.profile_id(),
|
||||||
|
"mapped backward-compatible Transport V1 without WebSocket settings"
|
||||||
|
);
|
||||||
|
std::result::Result::Ok(std::option::Option::None)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
2 | 3 => {
|
||||||
|
let defaults = match defaults {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V2/V3 requires ws_defaults")),
|
||||||
|
};
|
||||||
|
let sources = match sources {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V2/V3 profile requires ws_endpoints")),
|
||||||
|
};
|
||||||
|
let endpoints = map_ws_endpoints(sources, &defaults, profile);
|
||||||
|
let endpoints = match endpoints {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let settings = ksp_onchain_transport_lib::WsTransportSettings::new(endpoints);
|
||||||
|
if let std::result::Result::Err(error) = settings.validate() {
|
||||||
|
return std::result::Result::Err(transport_contract_error(
|
||||||
|
profile,
|
||||||
|
"effective WebSocket Transport settings fail the Transport runtime contract",
|
||||||
|
&error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(settings))
|
||||||
|
},
|
||||||
|
_ => std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_optional_grpc_settings(
|
||||||
|
format_version: u32,
|
||||||
|
defaults: std::option::Option<EffectiveGrpcSessionSource>,
|
||||||
|
sources: std::option::Option<std::vec::Vec<EffectiveGrpcEndpointSource>>,
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings>> {
|
||||||
|
return match format_version {
|
||||||
|
1 | 2 => {
|
||||||
|
if defaults.is_some() || sources.is_some() {
|
||||||
|
std::result::Result::Err(effective_error(profile, "Transport V1/V2 must not define Yellowstone gRPC settings"))
|
||||||
|
} else {
|
||||||
|
std::result::Result::Ok(std::option::Option::None)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
3 => {
|
||||||
|
let defaults = match defaults {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V3 requires grpc_defaults")),
|
||||||
|
};
|
||||||
|
let sources = match sources {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||||
|
};
|
||||||
|
let endpoints = map_grpc_endpoints(sources, &defaults, effective, profile);
|
||||||
|
let endpoints = match endpoints {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let settings = ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings::new(endpoints);
|
||||||
|
if let std::result::Result::Err(error) = settings.validate() {
|
||||||
|
return std::result::Result::Err(transport_contract_error(
|
||||||
|
profile,
|
||||||
|
"effective Yellowstone gRPC Transport settings fail the Transport runtime contract",
|
||||||
|
&error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(settings))
|
||||||
|
},
|
||||||
|
_ => std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_grpc_endpoints(
|
||||||
|
sources: std::vec::Vec<EffectiveGrpcEndpointSource>,
|
||||||
|
defaults: &EffectiveGrpcSessionSource,
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings>> {
|
||||||
|
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings>::with_capacity(sources.len());
|
||||||
|
for (endpoint_index, source) in sources.into_iter().enumerate() {
|
||||||
|
let endpoint_name = source.name.clone();
|
||||||
|
if source.protocol != "solana_yellowstone" {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective gRPC protocol is unsupported")
|
||||||
|
.with_context("endpoint_name", endpoint_name)
|
||||||
|
.with_context("grpc_protocol", source.protocol),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let url = ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse(source.url);
|
||||||
|
let url = match url {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
transport_contract_error(profile, "effective Yellowstone gRPC endpoint URL is invalid", &error)
|
||||||
|
.with_context("endpoint_name", endpoint_name),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let session = map_grpc_session_settings(defaults, source.session.as_ref(), profile, endpoint_name.as_str());
|
||||||
|
let session = match session {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let metadata = map_grpc_metadata(source.metadata, source.secret_metadata, effective, profile, endpoint_name.as_str(), endpoint_index);
|
||||||
|
let metadata = match metadata {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let endpoint = ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||||
|
source.name,
|
||||||
|
source.enabled,
|
||||||
|
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new(source.provider),
|
||||||
|
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new(source.cluster),
|
||||||
|
url,
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
let endpoint = endpoint.with_metadata(metadata);
|
||||||
|
let endpoint = match endpoint {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
transport_contract_error(profile, "effective Yellowstone gRPC metadata fail the Transport runtime contract", &error)
|
||||||
|
.with_context("endpoint_name", endpoint_name),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
endpoints.push(endpoint);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(endpoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_grpc_session_settings(
|
||||||
|
defaults: &EffectiveGrpcSessionSource,
|
||||||
|
overrides: std::option::Option<&EffectiveGrpcSessionOverrideSource>,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
endpoint_name: &str,
|
||||||
|
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings> {
|
||||||
|
let mut connect_timeout_ms = defaults.connect_timeout_ms;
|
||||||
|
let mut unary_timeout_ms = defaults.unary_timeout_ms;
|
||||||
|
let mut close_timeout_ms = defaults.close_timeout_ms;
|
||||||
|
let mut reconnect_max_retries = defaults.reconnect.max_retries;
|
||||||
|
let mut reconnect_initial_backoff_ms = defaults.reconnect.initial_backoff_ms;
|
||||||
|
let mut reconnect_max_backoff_ms = defaults.reconnect.max_backoff_ms;
|
||||||
|
let mut request_channel_capacity = defaults.request_channel_capacity;
|
||||||
|
let mut update_channel_capacity = defaults.update_channel_capacity;
|
||||||
|
let mut max_inbound_message_size_bytes = defaults.max_inbound_message_size_bytes;
|
||||||
|
let mut max_outbound_message_size_bytes = defaults.max_outbound_message_size_bytes;
|
||||||
|
if let std::option::Option::Some(overrides) = overrides {
|
||||||
|
if let std::option::Option::Some(value) = overrides.connect_timeout_ms {
|
||||||
|
connect_timeout_ms = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.unary_timeout_ms {
|
||||||
|
unary_timeout_ms = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.close_timeout_ms {
|
||||||
|
close_timeout_ms = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(reconnect) = overrides.reconnect.as_ref() {
|
||||||
|
if let std::option::Option::Some(value) = reconnect.max_retries {
|
||||||
|
reconnect_max_retries = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = reconnect.initial_backoff_ms {
|
||||||
|
reconnect_initial_backoff_ms = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = reconnect.max_backoff_ms {
|
||||||
|
reconnect_max_backoff_ms = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.request_channel_capacity {
|
||||||
|
request_channel_capacity = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.update_channel_capacity {
|
||||||
|
update_channel_capacity = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.max_inbound_message_size_bytes {
|
||||||
|
max_inbound_message_size_bytes = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.max_outbound_message_size_bytes {
|
||||||
|
max_outbound_message_size_bytes = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reconnect = ksp_onchain_transport_lib::YellowstoneGrpcReconnectSettings::new(
|
||||||
|
reconnect_max_retries,
|
||||||
|
std::time::Duration::from_millis(reconnect_initial_backoff_ms),
|
||||||
|
std::time::Duration::from_millis(reconnect_max_backoff_ms),
|
||||||
|
);
|
||||||
|
let settings = ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::new(
|
||||||
|
std::time::Duration::from_millis(connect_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(unary_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(close_timeout_ms),
|
||||||
|
reconnect,
|
||||||
|
request_channel_capacity,
|
||||||
|
update_channel_capacity,
|
||||||
|
max_inbound_message_size_bytes,
|
||||||
|
max_outbound_message_size_bytes,
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = settings.validate() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
transport_contract_error(profile, "effective Yellowstone gRPC session settings fail the Transport runtime contract", &error)
|
||||||
|
.with_context("endpoint_name", endpoint_name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_grpc_metadata(
|
||||||
|
public_sources: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||||
|
secret_sources: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
endpoint_name: &str,
|
||||||
|
endpoint_index: usize,
|
||||||
|
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry>> {
|
||||||
|
let mut metadata = std::vec::Vec::<ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry>::with_capacity(public_sources.len() + secret_sources.len());
|
||||||
|
for (metadata_index, source) in public_sources.into_iter().enumerate() {
|
||||||
|
let pointer = format!("/grpc_endpoints/{endpoint_index}/metadata/{metadata_index}/value");
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_metadata_provenance(effective, pointer.as_str(), false, profile, endpoint_name) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let entry = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::public(source.key, source.value);
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
transport_contract_error(profile, "effective public Yellowstone gRPC metadata are invalid", &error)
|
||||||
|
.with_context("endpoint_name", endpoint_name),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
metadata.push(entry);
|
||||||
|
}
|
||||||
|
for (metadata_index, source) in secret_sources.into_iter().enumerate() {
|
||||||
|
let pointer = format!("/grpc_endpoints/{endpoint_index}/secret_metadata/{metadata_index}/value");
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_metadata_provenance(effective, pointer.as_str(), true, profile, endpoint_name) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let entry = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret(source.key, source.value);
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
transport_contract_error(profile, "effective secret Yellowstone gRPC metadata are invalid", &error)
|
||||||
|
.with_context("endpoint_name", endpoint_name),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
metadata.push(entry);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_grpc_metadata_provenance(
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
pointer: &str,
|
||||||
|
secret_class: bool,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
endpoint_name: &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, "Yellowstone gRPC metadata provenance is unavailable")
|
||||||
|
.with_context("endpoint_name", endpoint_name)
|
||||||
|
.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() {
|
||||||
|
has_secret_environment = true;
|
||||||
|
if !secret_class {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "public Yellowstone gRPC metadata must not derive from a secret environment variable")
|
||||||
|
.with_context("endpoint_name", endpoint_name)
|
||||||
|
.with_context("field", pointer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if secret_class {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "secret Yellowstone gRPC metadata may reference only secret environment variables")
|
||||||
|
.with_context("endpoint_name", endpoint_name)
|
||||||
|
.with_context("field", pointer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secret_class && !has_secret_environment {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "secret Yellowstone gRPC metadata require secret environment provenance")
|
||||||
|
.with_context("endpoint_name", endpoint_name)
|
||||||
|
.with_context("field", pointer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
fn map_endpoints(
|
fn map_endpoints(
|
||||||
sources: std::vec::Vec<EffectiveEndpointSource>,
|
sources: std::vec::Vec<EffectiveEndpointSource>,
|
||||||
profile: &crate::ResolvedConfigProfile,
|
profile: &crate::ResolvedConfigProfile,
|
||||||
@@ -229,7 +761,7 @@ fn map_endpoints(
|
|||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => {
|
std::result::Result::Err(error) => {
|
||||||
return std::result::Result::Err(
|
return std::result::Result::Err(
|
||||||
transport_contract_error(profile, "effective endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
transport_contract_error(profile, "effective HTTP endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -253,6 +785,172 @@ fn map_endpoints(
|
|||||||
return std::result::Result::Ok(endpoints);
|
return std::result::Result::Ok(endpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_ws_endpoints(
|
||||||
|
sources: std::vec::Vec<EffectiveWsEndpointSource>,
|
||||||
|
defaults: &EffectiveWsSessionSource,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::WsEndpointSettings>> {
|
||||||
|
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::WsEndpointSettings>::with_capacity(sources.len());
|
||||||
|
for source in sources {
|
||||||
|
let endpoint_name = source.name.clone();
|
||||||
|
let protocol = map_ws_protocol_kind(source.kind.as_str(), profile, endpoint_name.as_str());
|
||||||
|
let protocol = match protocol {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let url = ksp_onchain_transport_lib::WsEndpointUrl::parse(source.url);
|
||||||
|
let url = match url {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
transport_contract_error(profile, "effective WebSocket endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let session = map_ws_session_settings(defaults, source.session.as_ref(), profile, endpoint_name.as_str());
|
||||||
|
let session = match session {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
endpoints.push(ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||||
|
source.name,
|
||||||
|
source.enabled,
|
||||||
|
ksp_onchain_transport_lib::WsProviderName::new(source.provider),
|
||||||
|
ksp_onchain_transport_lib::WsClusterName::new(source.cluster),
|
||||||
|
protocol,
|
||||||
|
url,
|
||||||
|
session,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(endpoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ws_protocol_kind(
|
||||||
|
value: &str,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
endpoint_name: &str,
|
||||||
|
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsProtocolKind> {
|
||||||
|
return match value {
|
||||||
|
"solana_standard" => std::result::Result::Ok(ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard),
|
||||||
|
"helius_laserstream" => std::result::Result::Ok(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream),
|
||||||
|
_ => std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective WebSocket protocol kind is unsupported")
|
||||||
|
.with_context("endpoint_name", endpoint_name)
|
||||||
|
.with_context("ws_kind", value),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ws_session_settings(
|
||||||
|
defaults: &EffectiveWsSessionSource,
|
||||||
|
overrides: std::option::Option<&EffectiveWsSessionOverrideSource>,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
endpoint_name: &str,
|
||||||
|
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsSessionSettings> {
|
||||||
|
let mut command_timeout_ms = defaults.command_timeout_ms;
|
||||||
|
let mut close_timeout_ms = defaults.close_timeout_ms;
|
||||||
|
let mut reconnect_max_retries = defaults.reconnect.max_retries;
|
||||||
|
let mut reconnect_initial_backoff_ms = defaults.reconnect.initial_backoff_ms;
|
||||||
|
let mut reconnect_max_backoff_ms = defaults.reconnect.max_backoff_ms;
|
||||||
|
let mut resubscribe_text = defaults.resubscribe.clone();
|
||||||
|
let mut command_queue_capacity = defaults.command_queue_capacity;
|
||||||
|
let mut notification_queue_capacity = defaults.notification_queue_capacity;
|
||||||
|
let mut max_active_subscriptions = defaults.max_active_subscriptions;
|
||||||
|
let mut max_pending_requests = defaults.max_pending_requests;
|
||||||
|
let mut max_message_size_bytes = defaults.max_message_size_bytes;
|
||||||
|
let mut max_frame_size_bytes = defaults.max_frame_size_bytes;
|
||||||
|
let mut max_write_buffer_size_bytes = defaults.max_write_buffer_size_bytes;
|
||||||
|
if let std::option::Option::Some(overrides) = overrides {
|
||||||
|
if let std::option::Option::Some(value) = overrides.command_timeout_ms {
|
||||||
|
command_timeout_ms = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.close_timeout_ms {
|
||||||
|
close_timeout_ms = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(reconnect) = overrides.reconnect.as_ref() {
|
||||||
|
if let std::option::Option::Some(value) = reconnect.max_retries {
|
||||||
|
reconnect_max_retries = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = reconnect.initial_backoff_ms {
|
||||||
|
reconnect_initial_backoff_ms = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = reconnect.max_backoff_ms {
|
||||||
|
reconnect_max_backoff_ms = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.resubscribe.as_ref() {
|
||||||
|
resubscribe_text = value.clone();
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.command_queue_capacity {
|
||||||
|
command_queue_capacity = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.notification_queue_capacity {
|
||||||
|
notification_queue_capacity = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.max_active_subscriptions {
|
||||||
|
max_active_subscriptions = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.max_pending_requests {
|
||||||
|
max_pending_requests = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.max_message_size_bytes {
|
||||||
|
max_message_size_bytes = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.max_frame_size_bytes {
|
||||||
|
max_frame_size_bytes = value;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = overrides.max_write_buffer_size_bytes {
|
||||||
|
max_write_buffer_size_bytes = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reconnect = ksp_onchain_transport_lib::WsReconnectSettings::new(
|
||||||
|
reconnect_max_retries,
|
||||||
|
std::time::Duration::from_millis(reconnect_initial_backoff_ms),
|
||||||
|
std::time::Duration::from_millis(reconnect_max_backoff_ms),
|
||||||
|
);
|
||||||
|
let resubscribe = map_ws_resubscribe_policy(resubscribe_text.as_str(), profile, endpoint_name);
|
||||||
|
let resubscribe = match resubscribe {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let settings = ksp_onchain_transport_lib::WsSessionSettings::new(
|
||||||
|
std::time::Duration::from_millis(command_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(close_timeout_ms),
|
||||||
|
reconnect,
|
||||||
|
resubscribe,
|
||||||
|
command_queue_capacity,
|
||||||
|
notification_queue_capacity,
|
||||||
|
max_active_subscriptions,
|
||||||
|
max_pending_requests,
|
||||||
|
max_message_size_bytes,
|
||||||
|
max_frame_size_bytes,
|
||||||
|
max_write_buffer_size_bytes,
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = settings.validate() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
transport_contract_error(profile, "effective WebSocket session settings fail the Transport runtime contract", &error)
|
||||||
|
.with_context("endpoint_name", endpoint_name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ws_resubscribe_policy(
|
||||||
|
value: &str,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
endpoint_name: &str,
|
||||||
|
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsResubscribePolicy> {
|
||||||
|
return match value {
|
||||||
|
"never" => std::result::Result::Ok(ksp_onchain_transport_lib::WsResubscribePolicy::Never),
|
||||||
|
"active_subscriptions" => std::result::Result::Ok(ksp_onchain_transport_lib::WsResubscribePolicy::ActiveSubscriptions),
|
||||||
|
_ => std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective WebSocket resubscribe policy is unsupported")
|
||||||
|
.with_context("endpoint_name", endpoint_name)
|
||||||
|
.with_context("resubscribe", value),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fn map_roles(
|
fn map_roles(
|
||||||
sources: std::vec::Vec<EffectiveRoleSource>,
|
sources: std::vec::Vec<EffectiveRoleSource>,
|
||||||
profile: &crate::ResolvedConfigProfile,
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
|||||||
@@ -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\"",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/tests/public_api.rs
|
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||||
// version: 21
|
// 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);
|
||||||
@@ -252,6 +254,11 @@ fn transport_adapter_contract_is_available_from_crate_root() {
|
|||||||
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_transport_config;
|
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_transport_config;
|
||||||
let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_transport_config_profile;
|
let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_transport_config_profile;
|
||||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedTransportConfig>() > 0);
|
assert!(std::mem::size_of::<ksp_config_lib::ResolvedTransportConfig>() > 0);
|
||||||
|
let _http_settings = ksp_config_lib::ResolvedTransportConfig::http_settings;
|
||||||
|
let _ws_settings = ksp_config_lib::ResolvedTransportConfig::ws_settings;
|
||||||
|
let _grpc_settings = ksp_config_lib::ResolvedTransportConfig::grpc_settings;
|
||||||
|
let _into_transport_settings = ksp_config_lib::ResolvedTransportConfig::into_transport_settings;
|
||||||
|
let _into_all_transport_settings = ksp_config_lib::ResolvedTransportConfig::into_all_transport_settings;
|
||||||
assert_eq!(ksp_config_lib::FILE_ID_STD_TRANSPORT, "cfg.std.transport");
|
assert_eq!(ksp_config_lib::FILE_ID_STD_TRANSPORT, "cfg.std.transport");
|
||||||
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT, "schema.std.transport");
|
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT, "schema.std.transport");
|
||||||
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json");
|
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json");
|
||||||
@@ -271,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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
{
|
{
|
||||||
"format_version": 1,
|
"format_version": 2,
|
||||||
"retry": {
|
"retry": {
|
||||||
"max_retries": 4,
|
"max_retries": 4,
|
||||||
"initial_backoff_ms": 125,
|
"initial_backoff_ms": 125,
|
||||||
"max_backoff_ms": 2500
|
"max_backoff_ms": 2500
|
||||||
},
|
},
|
||||||
|
"ws_defaults": {
|
||||||
|
"command_timeout_ms": 8000,
|
||||||
|
"close_timeout_ms": 4000,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 6,
|
||||||
|
"initial_backoff_ms": 200,
|
||||||
|
"max_backoff_ms": 4000
|
||||||
|
},
|
||||||
|
"resubscribe": "active_subscriptions",
|
||||||
|
"command_queue_capacity": 64,
|
||||||
|
"notification_queue_capacity": 96,
|
||||||
|
"max_active_subscriptions": 256,
|
||||||
|
"max_pending_requests": 48,
|
||||||
|
"max_message_size_bytes": 33554432,
|
||||||
|
"max_frame_size_bytes": 8388608,
|
||||||
|
"max_write_buffer_size_bytes": 524288
|
||||||
|
},
|
||||||
"default_profile": "secret_test",
|
"default_profile": "secret_test",
|
||||||
"profiles": [
|
"profiles": [
|
||||||
{
|
{
|
||||||
@@ -23,7 +40,9 @@
|
|||||||
{
|
{
|
||||||
"role": "default",
|
"role": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"request_kinds": ["*"],
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
"priority": 7,
|
"priority": 7,
|
||||||
"limits": {
|
"limits": {
|
||||||
"requests_per_second": 9,
|
"requests_per_second": 9,
|
||||||
@@ -34,6 +53,74 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_private_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "fixture-cluster",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "${KSP_SECRET_TRANSPORT_TEST_WS_URL:-wss://fallback.invalid}",
|
||||||
|
"session": {
|
||||||
|
"command_timeout_ms": 4500,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 7
|
||||||
|
},
|
||||||
|
"resubscribe": "never",
|
||||||
|
"notification_queue_capacity": 48,
|
||||||
|
"max_pending_requests": 24
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fixture_helius_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "helius",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"kind": "helius_laserstream",
|
||||||
|
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "helius_devnet",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_devnet_http",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "devnet",
|
||||||
|
"url": "https://api.devnet.solana.com",
|
||||||
|
"connect_timeout_ms": 750,
|
||||||
|
"request_timeout_ms": 2500,
|
||||||
|
"max_idle_connections_per_host": 3,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 7,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 9,
|
||||||
|
"burst_capacity": 12,
|
||||||
|
"max_concurrent_requests": 4,
|
||||||
|
"pause_after_rate_limit_ms": 650
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_helius_devnet_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "helius",
|
||||||
|
"cluster": "devnet",
|
||||||
|
"kind": "helius_laserstream",
|
||||||
|
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"retry": {
|
||||||
|
"max_retries": 1,
|
||||||
|
"initial_backoff_ms": 90,
|
||||||
|
"max_backoff_ms": 900
|
||||||
|
},
|
||||||
|
"default_profile": "legacy_http",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "legacy_http",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "legacy_http",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "legacy-provider",
|
||||||
|
"cluster": "devnet",
|
||||||
|
"url": "https://legacy.invalid",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 2200,
|
||||||
|
"max_idle_connections_per_host": 2,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 10,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 2,
|
||||||
|
"burst_capacity": 3,
|
||||||
|
"max_concurrent_requests": 2,
|
||||||
|
"pause_after_rate_limit_ms": 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
242
crates/ksp-config-lib/unit_tests/fixtures_v3/std.transport.json
Normal file
242
crates/ksp-config-lib/unit_tests/fixtures_v3/std.transport.json
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
{
|
||||||
|
"format_version": 3,
|
||||||
|
"retry": {
|
||||||
|
"max_retries": 2,
|
||||||
|
"initial_backoff_ms": 100,
|
||||||
|
"max_backoff_ms": 2000
|
||||||
|
},
|
||||||
|
"ws_defaults": {
|
||||||
|
"command_timeout_ms": 8000,
|
||||||
|
"close_timeout_ms": 4000,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 5,
|
||||||
|
"initial_backoff_ms": 250,
|
||||||
|
"max_backoff_ms": 5000
|
||||||
|
},
|
||||||
|
"resubscribe": "active_subscriptions",
|
||||||
|
"command_queue_capacity": 64,
|
||||||
|
"notification_queue_capacity": 96,
|
||||||
|
"max_active_subscriptions": 256,
|
||||||
|
"max_pending_requests": 48,
|
||||||
|
"max_message_size_bytes": 33554432,
|
||||||
|
"max_frame_size_bytes": 8388608,
|
||||||
|
"max_write_buffer_size_bytes": 524288
|
||||||
|
},
|
||||||
|
"grpc_defaults": {
|
||||||
|
"connect_timeout_ms": 6000,
|
||||||
|
"unary_timeout_ms": 7000,
|
||||||
|
"close_timeout_ms": 4000,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 4,
|
||||||
|
"initial_backoff_ms": 200,
|
||||||
|
"max_backoff_ms": 3000
|
||||||
|
},
|
||||||
|
"request_channel_capacity": 64,
|
||||||
|
"update_channel_capacity": 96,
|
||||||
|
"max_inbound_message_size_bytes": 33554432,
|
||||||
|
"max_outbound_message_size_bytes": 16777216
|
||||||
|
},
|
||||||
|
"default_profile": "grpc_secret_ok",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "grpc_secret_ok",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_http",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"url": "https://fallback.invalid",
|
||||||
|
"connect_timeout_ms": 1000,
|
||||||
|
"request_timeout_ms": 3000,
|
||||||
|
"max_idle_connections_per_host": 2,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 1,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 5,
|
||||||
|
"max_concurrent_requests": 2,
|
||||||
|
"pause_after_rate_limit_ms": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "wss://fallback.invalid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grpc_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_grpc",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"protocol": "solana_yellowstone",
|
||||||
|
"url": "https://grpc.invalid",
|
||||||
|
"metadata": [
|
||||||
|
{
|
||||||
|
"key": "x-ksp-label",
|
||||||
|
"value": "${KSP_PUBLIC_GRPC_LABEL:-fixture-public}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secret_metadata": [
|
||||||
|
{
|
||||||
|
"key": "x-ksp-token",
|
||||||
|
"value": "Bearer ${KSP_SECRET_GRPC_TOKEN:-fixture-secret}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"session": {
|
||||||
|
"connect_timeout_ms": 4500,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 7
|
||||||
|
},
|
||||||
|
"update_channel_capacity": 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "grpc_public_from_secret_invalid",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_http",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"url": "https://fallback.invalid",
|
||||||
|
"connect_timeout_ms": 1000,
|
||||||
|
"request_timeout_ms": 3000,
|
||||||
|
"max_idle_connections_per_host": 2,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 1,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 5,
|
||||||
|
"max_concurrent_requests": 2,
|
||||||
|
"pause_after_rate_limit_ms": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "wss://fallback.invalid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grpc_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_grpc",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"protocol": "solana_yellowstone",
|
||||||
|
"url": "https://grpc.invalid",
|
||||||
|
"metadata": [
|
||||||
|
{
|
||||||
|
"key": "x-ksp-public",
|
||||||
|
"value": "${KSP_SECRET_GRPC_TOKEN:-fixture-secret}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secret_metadata": [],
|
||||||
|
"session": {
|
||||||
|
"connect_timeout_ms": 4500,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 7
|
||||||
|
},
|
||||||
|
"update_channel_capacity": 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "grpc_secret_from_public_invalid",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_http",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"url": "https://fallback.invalid",
|
||||||
|
"connect_timeout_ms": 1000,
|
||||||
|
"request_timeout_ms": 3000,
|
||||||
|
"max_idle_connections_per_host": 2,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 1,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 5,
|
||||||
|
"max_concurrent_requests": 2,
|
||||||
|
"pause_after_rate_limit_ms": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "wss://fallback.invalid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grpc_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_grpc",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"protocol": "solana_yellowstone",
|
||||||
|
"url": "https://grpc.invalid",
|
||||||
|
"metadata": [],
|
||||||
|
"secret_metadata": [
|
||||||
|
{
|
||||||
|
"key": "x-ksp-secret",
|
||||||
|
"value": "${KSP_PUBLIC_GRPC_LABEL:-fixture-public}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"session": {
|
||||||
|
"connect_timeout_ms": 4500,
|
||||||
|
"reconnect": {
|
||||||
|
"max_retries": 7
|
||||||
|
},
|
||||||
|
"update_channel_capacity": 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
284
crates/ksp-config-lib/unit_tests/offchain_transport.rs
Normal file
284
crates/ksp-config-lib/unit_tests/offchain_transport.rs
Normal 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(®istry, "birdeye", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
|
||||||
|
assert_provider_availability(®istry, "dexscreener", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
|
||||||
|
for provider_id in ["coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "jupiter", "kraken"] {
|
||||||
|
assert_provider_availability(®istry, provider_id, ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready);
|
||||||
|
}
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains("api_key"), "safe Debug should not expose credential field contents from the selected keyless profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_all_free_profile_requires_config_owned_secrets_and_public_pair_provenance() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canaries = [
|
||||||
|
("KSP_SECRET_BIRDEYE_API_KEY", "birdeye-secret-canary"),
|
||||||
|
("KSP_SECRET_COINGECKO_DEMO_API_KEY", "coingecko-secret-canary"),
|
||||||
|
("KSP_SECRET_COINMARKETCAP_API_KEY", "coinmarketcap-secret-canary"),
|
||||||
|
("KSP_SECRET_JUPITER_API_KEY", "jupiter-secret-canary"),
|
||||||
|
("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS", TEST_PAIR),
|
||||||
|
];
|
||||||
|
let mut process = std::collections::BTreeMap::<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("../..");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/unit_tests/transport.rs
|
// file: crates/ksp-config-lib/unit_tests/transport.rs
|
||||||
// version: 2
|
// version: 12
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fixture_transport_profile_maps_complete_runtime_contract() {
|
fn fixture_transport_profile_maps_complete_runtime_contract() {
|
||||||
@@ -41,6 +41,58 @@ fn fixture_transport_profile_maps_complete_runtime_contract() {
|
|||||||
assert_eq!(role.limits().burst_capacity().map(std::num::NonZeroU32::get), std::option::Option::Some(12));
|
assert_eq!(role.limits().burst_capacity().map(std::num::NonZeroU32::get), std::option::Option::Some(12));
|
||||||
assert_eq!(role.limits().max_concurrent_requests().map(std::num::NonZeroU32::get), std::option::Option::Some(4));
|
assert_eq!(role.limits().max_concurrent_requests().map(std::num::NonZeroU32::get), std::option::Option::Some(4));
|
||||||
assert_eq!(role.limits().pause_after_rate_limit(), std::option::Option::Some(std::time::Duration::from_millis(650)));
|
assert_eq!(role.limits().pause_after_rate_limit(), std::option::Option::Some(std::time::Duration::from_millis(650)));
|
||||||
|
assert!(resolved.grpc_settings().is_none(), "V2 fixture must remain Yellowstone gRPC-free");
|
||||||
|
let ws = resolved.ws_settings();
|
||||||
|
assert!(ws.is_some(), "V2 fixture should expose WebSocket settings");
|
||||||
|
if let std::option::Option::Some(ws) = ws {
|
||||||
|
assert_eq!(ws.endpoints().len(), 2);
|
||||||
|
let endpoint = &ws.endpoints()[0];
|
||||||
|
assert_eq!(endpoint.name(), "fixture_private_ws");
|
||||||
|
assert_eq!(endpoint.provider().as_str(), "fixture-provider");
|
||||||
|
assert_eq!(endpoint.cluster().as_str(), "fixture-cluster");
|
||||||
|
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
|
||||||
|
assert_eq!(endpoint.url().as_str(), "wss://fallback.invalid");
|
||||||
|
assert_eq!(endpoint.session().command_timeout(), std::time::Duration::from_millis(4500));
|
||||||
|
assert_eq!(endpoint.session().close_timeout(), std::time::Duration::from_millis(4000));
|
||||||
|
assert_eq!(endpoint.session().reconnect().max_retries(), 7);
|
||||||
|
assert_eq!(endpoint.session().reconnect().initial_backoff(), std::time::Duration::from_millis(200));
|
||||||
|
assert_eq!(endpoint.session().reconnect().max_backoff(), std::time::Duration::from_millis(4000));
|
||||||
|
assert_eq!(endpoint.session().resubscribe(), ksp_onchain_transport_lib::WsResubscribePolicy::Never);
|
||||||
|
assert_eq!(endpoint.session().command_queue_capacity(), 64);
|
||||||
|
assert_eq!(endpoint.session().notification_queue_capacity(), 48);
|
||||||
|
assert_eq!(endpoint.session().max_active_subscriptions(), 256);
|
||||||
|
assert_eq!(endpoint.session().max_pending_requests(), 24);
|
||||||
|
assert_eq!(endpoint.session().max_message_size_bytes(), 33_554_432);
|
||||||
|
assert_eq!(endpoint.session().max_frame_size_bytes(), 8_388_608);
|
||||||
|
assert_eq!(endpoint.session().max_write_buffer_size_bytes(), 524_288);
|
||||||
|
let helius = &ws.endpoints()[1];
|
||||||
|
assert_eq!(helius.name(), "fixture_helius_ws");
|
||||||
|
assert_eq!(helius.provider().as_str(), "helius");
|
||||||
|
assert_eq!(helius.cluster().as_str(), "mainnet-beta");
|
||||||
|
assert_eq!(helius.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||||
|
assert_eq!(helius.url().as_str(), "wss://mainnet.helius-rpc.com/?api-key=fixture-helius-key");
|
||||||
|
let _connect_future = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(helius.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v1_transport_fixture_remains_backward_readable_and_http_only() {
|
||||||
|
let engine = v1_fixture_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_transport_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "strict Transport V1 fixture should remain readable: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.profile_id(), "legacy_http");
|
||||||
|
assert_eq!(resolved.settings().retry().max_retries(), 1);
|
||||||
|
assert_eq!(resolved.settings().endpoints().len(), 1);
|
||||||
|
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), "https://legacy.invalid");
|
||||||
|
assert!(resolved.ws_settings().is_none(), "V1 must not invent WebSocket runtime settings");
|
||||||
|
assert!(resolved.grpc_settings().is_none(), "V1 must not invent Yellowstone gRPC runtime settings");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -59,12 +111,235 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
|
|||||||
assert_eq!(default.profile_id(), "devnet_public");
|
assert_eq!(default.profile_id(), "devnet_public");
|
||||||
assert_eq!(default.settings().endpoints()[0].cluster().as_str(), "devnet");
|
assert_eq!(default.settings().endpoints()[0].cluster().as_str(), "devnet");
|
||||||
assert_eq!(default.settings().endpoints()[0].url().as_str(), "https://api.devnet.solana.com");
|
assert_eq!(default.settings().endpoints()[0].url().as_str(), "https://api.devnet.solana.com");
|
||||||
|
assert!(default.grpc_settings().is_none(), "Devnet profile must not invent a Yellowstone gRPC endpoint");
|
||||||
|
let ws = default.ws_settings();
|
||||||
|
assert!(ws.is_some(), "committed V3 Devnet profile should expose WebSocket settings");
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let std::result::Result::Ok(mainnet) = mainnet {
|
if let std::result::Result::Ok(mainnet) = mainnet {
|
||||||
assert_eq!(mainnet.profile_id(), "mainnet_public");
|
assert_eq!(mainnet.profile_id(), "mainnet_public");
|
||||||
assert_eq!(mainnet.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
assert_eq!(mainnet.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||||
assert_eq!(mainnet.settings().endpoints()[0].cluster().as_str(), "mainnet-beta");
|
assert_eq!(mainnet.settings().endpoints()[0].cluster().as_str(), "mainnet-beta");
|
||||||
assert_eq!(mainnet.settings().endpoints()[0].url().as_str(), "https://api.mainnet-beta.solana.com");
|
assert_eq!(mainnet.settings().endpoints()[0].url().as_str(), "https://api.mainnet-beta.solana.com");
|
||||||
|
assert!(mainnet.grpc_settings().is_none(), "generic Mainnet profile must stay distinct from the PublicNode gRPC profile");
|
||||||
|
let ws = mainnet.ws_settings();
|
||||||
|
assert!(ws.is_some(), "committed V3 Mainnet profile should expose WebSocket settings");
|
||||||
|
if let std::option::Option::Some(ws) = ws {
|
||||||
|
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.mainnet-beta.solana.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
||||||
|
fn committed_v3_publicnode_profiles_map_provider_neutral_yellowstone_grpc_with_network_scoped_secret_x_tokens() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for (profile_id, endpoint_name, cluster, url, environment_name, canary) in [
|
||||||
|
(
|
||||||
|
"publicnode_mainnet",
|
||||||
|
"publicnode_solana_mainnet_yellowstone",
|
||||||
|
"mainnet-beta",
|
||||||
|
"https://solana-yellowstone-grpc.publicnode.com:443",
|
||||||
|
"KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN",
|
||||||
|
"PUBLICNODE-MAINNET-GRPC-X-TOKEN-CANARY",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"publicnode_testnet",
|
||||||
|
"publicnode_solana_testnet_yellowstone",
|
||||||
|
"testnet",
|
||||||
|
"https://solana-testnet-yellowstone-grpc.publicnode.com:443",
|
||||||
|
"KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN",
|
||||||
|
"PUBLICNODE-TESTNET-GRPC-X-TOKEN-CANARY",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert(environment_name.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(profile_id), &environment);
|
||||||
|
assert!(resolved.is_ok(), "committed PublicNode V3 profile {profile_id} should map from its network-scoped token: {resolved:?}");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.profile_id(), profile_id);
|
||||||
|
assert!(resolved.ws_settings().is_some());
|
||||||
|
let grpc = resolved.grpc_settings();
|
||||||
|
assert!(grpc.is_some(), "PublicNode profile {profile_id} should expose Yellowstone gRPC settings");
|
||||||
|
if let std::option::Option::Some(grpc) = grpc {
|
||||||
|
assert_eq!(grpc.endpoints().len(), 1);
|
||||||
|
let endpoint = &grpc.endpoints()[0];
|
||||||
|
assert_eq!(endpoint.name(), endpoint_name);
|
||||||
|
assert_eq!(endpoint.provider().as_str(), "publicnode");
|
||||||
|
assert_eq!(endpoint.cluster().as_str(), cluster);
|
||||||
|
assert_eq!(endpoint.url().as_str(), url);
|
||||||
|
assert!(endpoint.url().uses_tls());
|
||||||
|
assert_eq!(endpoint.metadata().len(), 1);
|
||||||
|
assert_eq!(endpoint.metadata()[0].key(), "x-token");
|
||||||
|
assert!(endpoint.metadata()[0].is_secret());
|
||||||
|
let endpoint_debug = format!("{endpoint:?}");
|
||||||
|
assert!(!endpoint_debug.contains(canary), "PublicNode {cluster} x-token must stay redacted from endpoint Debug");
|
||||||
|
assert_eq!(endpoint.session().connect_timeout(), std::time::Duration::from_millis(10_000));
|
||||||
|
assert_eq!(endpoint.session().unary_timeout(), std::time::Duration::from_millis(10_000));
|
||||||
|
assert_eq!(endpoint.session().close_timeout(), std::time::Duration::from_millis(5_000));
|
||||||
|
assert_eq!(endpoint.session().reconnect().max_retries(), 5);
|
||||||
|
assert!(grpc.validate().is_ok(), "Config-produced Yellowstone gRPC settings should satisfy Transport validation");
|
||||||
|
let debug = format!("{grpc:?}");
|
||||||
|
assert!(!debug.contains("publicnode.com"), "Transport settings Debug must not expose gRPC endpoint URLs");
|
||||||
|
assert!(!debug.contains(canary), "Transport settings Debug must not expose the network-scoped PublicNode x-token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v3_grpc_metadata_separates_public_and_secret_provenance() {
|
||||||
|
let engine = v3_fixture_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "grpc-secret-canary";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_GRPC_TOKEN".to_owned(), canary.to_owned());
|
||||||
|
process.insert("KSP_PUBLIC_GRPC_LABEL".to_owned(), "operator-public".to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "V3 gRPC metadata fixture should map: {resolved:?}");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let grpc = resolved.grpc_settings();
|
||||||
|
assert!(grpc.is_some());
|
||||||
|
if let std::option::Option::Some(grpc) = grpc {
|
||||||
|
let endpoint = &grpc.endpoints()[0];
|
||||||
|
assert_eq!(endpoint.metadata().len(), 2);
|
||||||
|
assert_eq!(endpoint.metadata()[0].key(), "x-ksp-label");
|
||||||
|
assert!(!endpoint.metadata()[0].is_secret());
|
||||||
|
assert_eq!(endpoint.metadata()[1].key(), "x-ksp-token");
|
||||||
|
assert!(endpoint.metadata()[1].is_secret());
|
||||||
|
assert_eq!(endpoint.session().connect_timeout(), std::time::Duration::from_millis(4_500));
|
||||||
|
assert_eq!(endpoint.session().unary_timeout(), std::time::Duration::from_millis(7_000));
|
||||||
|
assert_eq!(endpoint.session().reconnect().max_retries(), 7);
|
||||||
|
assert_eq!(endpoint.session().update_channel_capacity(), 32);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
resolved.effective().safe_value().pointer("/grpc_endpoints/0/metadata/0/value").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("operator-public")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.effective().safe_value().pointer("/grpc_endpoints/0/secret_metadata/0/value").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("Bearer ********")
|
||||||
|
);
|
||||||
|
let secret_provenance = resolved.effective().provenance_at("/grpc_endpoints/0/secret_metadata/0/value");
|
||||||
|
assert!(secret_provenance.is_some());
|
||||||
|
if let std::option::Option::Some(provenance) = secret_provenance {
|
||||||
|
assert_eq!(provenance.len(), 2);
|
||||||
|
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_GRPC_TOKEN"));
|
||||||
|
}
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains(canary));
|
||||||
|
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v3_grpc_metadata_rejects_crossed_sensitivity_classes() {
|
||||||
|
let engine = v3_fixture_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_GRPC_TOKEN".to_owned(), "secret-canary".to_owned());
|
||||||
|
process.insert("KSP_PUBLIC_GRPC_LABEL".to_owned(), "public-canary".to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let public_from_secret = engine.load_resolved_transport_config(std::option::Option::Some("grpc_public_from_secret_invalid"), &environment);
|
||||||
|
let secret_from_public = engine.load_resolved_transport_config(std::option::Option::Some("grpc_secret_from_public_invalid"), &environment);
|
||||||
|
assert!(public_from_secret.is_err(), "public metadata must reject KSP_SECRET provenance");
|
||||||
|
assert!(secret_from_public.is_err(), "secret_metadata must reject non-secret environment provenance");
|
||||||
|
if let std::result::Result::Err(error) = public_from_secret {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
assert!(!format!("{error:?}").contains("secret-canary"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = secret_from_public {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
assert!(!format!("{error:?}").contains("public-canary"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_v3_websocket_endpoint_preserves_v2_compatibility_constructor_without_polling() {
|
||||||
|
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_transport_config(std::option::Option::Some("devnet_public"), &environment);
|
||||||
|
assert!(resolved.is_ok(), "committed V3 Transport profile should map: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
let (http, ws) = resolved.into_transport_settings();
|
||||||
|
assert_eq!(http.endpoints().len(), 1);
|
||||||
|
assert!(ws.is_some(), "committed V3 Transport profile should expose WebSocket settings");
|
||||||
|
if let std::option::Option::Some(ws) = ws {
|
||||||
|
assert!(ws.validate().is_ok(), "Config-produced WebSocket settings should satisfy Transport validation");
|
||||||
|
assert_eq!(ws.endpoints().len(), 1);
|
||||||
|
let endpoint = ws.endpoints()[0].clone();
|
||||||
|
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
|
||||||
|
let _connect_future = ksp_onchain_transport_lib::WsSession::connect(endpoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +359,10 @@ fn transport_profile_preserves_global_and_profile_origin() {
|
|||||||
assert!(profile.is_ok(), "committed Transport profile should resolve: {profile:?}");
|
assert!(profile.is_ok(), "committed Transport profile should resolve: {profile:?}");
|
||||||
if let std::result::Result::Ok(profile) = profile {
|
if let std::result::Result::Ok(profile) = profile {
|
||||||
assert_eq!(profile.origin("retry"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
assert_eq!(profile.origin("retry"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
|
assert_eq!(profile.origin("ws_defaults"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
|
assert_eq!(profile.origin("grpc_defaults"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
assert_eq!(profile.origin("endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
|
assert_eq!(profile.origin("endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
|
||||||
|
assert_eq!(profile.origin("ws_endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
|
||||||
assert_eq!(profile.origin("format_version"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
assert_eq!(profile.origin("format_version"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +427,108 @@ fn secret_transport_url_is_runtime_available_but_safe_projection_is_redacted() {
|
|||||||
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_websocket_url_is_runtime_available_but_safe_projection_is_redacted() {
|
||||||
|
let engine = fixture_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "wss://user:pass@secret-provider.invalid/path?api-key=transport-ws-secret-canary";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_TRANSPORT_TEST_WS_URL".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::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "secret WebSocket endpoint should map without being exposed: {resolved:?}");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let ws = resolved.ws_settings();
|
||||||
|
assert!(ws.is_some(), "V2 fixture should expose WebSocket settings");
|
||||||
|
if let std::option::Option::Some(ws) = ws {
|
||||||
|
assert_eq!(ws.endpoints()[0].url().as_str(), canary);
|
||||||
|
}
|
||||||
|
let safe_url = resolved.effective().safe_value().pointer("/ws_endpoints/0/url").and_then(serde_json::Value::as_str);
|
||||||
|
assert_eq!(safe_url, std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains("transport-ws-secret-canary"));
|
||||||
|
assert!(!debug.contains("user:pass"));
|
||||||
|
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn helius_laserstream_mainnet_and_devnet_api_key_map_to_protocol_and_safe_redaction() {
|
||||||
|
let engine = fixture_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "helius-api-key-canary";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_HELIUS_API_KEY".to_owned(), canary.to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let mainnet = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||||
|
assert!(mainnet.is_ok(), "Helius mainnet WebSocket endpoint should map without exposing its API key: {mainnet:?}");
|
||||||
|
let mainnet = match mainnet {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let mainnet_ws = mainnet.ws_settings();
|
||||||
|
assert!(mainnet_ws.is_some(), "V2 fixture should expose mainnet WebSocket settings");
|
||||||
|
if let std::option::Option::Some(ws) = mainnet_ws {
|
||||||
|
assert_eq!(ws.endpoints().len(), 2);
|
||||||
|
let endpoint = &ws.endpoints()[1];
|
||||||
|
assert_eq!(endpoint.provider().as_str(), "helius");
|
||||||
|
assert_eq!(endpoint.cluster().as_str(), "mainnet-beta");
|
||||||
|
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||||
|
assert_eq!(endpoint.url().as_str(), "wss://mainnet.helius-rpc.com/?api-key=helius-api-key-canary");
|
||||||
|
}
|
||||||
|
let mainnet_safe_url = mainnet.effective().safe_value().pointer("/ws_endpoints/1/url").and_then(serde_json::Value::as_str);
|
||||||
|
assert_eq!(mainnet_safe_url, std::option::Option::Some("wss://mainnet.helius-rpc.com/?api-key=********"));
|
||||||
|
let mainnet_provenance = mainnet.effective().provenance_at("/ws_endpoints/1/url");
|
||||||
|
assert!(mainnet_provenance.is_some(), "Helius mainnet endpoint URL should retain secret environment provenance");
|
||||||
|
if let std::option::Option::Some(provenance) = mainnet_provenance {
|
||||||
|
assert_eq!(provenance.len(), 2);
|
||||||
|
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
assert_eq!(provenance[1].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||||
|
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_HELIUS_API_KEY"));
|
||||||
|
}
|
||||||
|
let devnet = engine.load_resolved_transport_config(std::option::Option::Some("helius_devnet"), &environment);
|
||||||
|
assert!(devnet.is_ok(), "Helius devnet WebSocket endpoint should map without exposing its API key: {devnet:?}");
|
||||||
|
let devnet = match devnet {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let devnet_ws = devnet.ws_settings();
|
||||||
|
assert!(devnet_ws.is_some(), "V2 fixture should expose devnet WebSocket settings");
|
||||||
|
if let std::option::Option::Some(ws) = devnet_ws {
|
||||||
|
assert_eq!(ws.endpoints().len(), 1);
|
||||||
|
let endpoint = &ws.endpoints()[0];
|
||||||
|
assert_eq!(endpoint.provider().as_str(), "helius");
|
||||||
|
assert_eq!(endpoint.cluster().as_str(), "devnet");
|
||||||
|
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||||
|
assert_eq!(endpoint.url().as_str(), "wss://devnet.helius-rpc.com/?api-key=helius-api-key-canary");
|
||||||
|
let _connect_future = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(endpoint.clone());
|
||||||
|
}
|
||||||
|
let devnet_safe_url = devnet.effective().safe_value().pointer("/ws_endpoints/0/url").and_then(serde_json::Value::as_str);
|
||||||
|
assert_eq!(devnet_safe_url, std::option::Option::Some("wss://devnet.helius-rpc.com/?api-key=********"));
|
||||||
|
let devnet_provenance = devnet.effective().provenance_at("/ws_endpoints/0/url");
|
||||||
|
assert!(devnet_provenance.is_some(), "Helius devnet endpoint URL should retain secret environment provenance");
|
||||||
|
if let std::option::Option::Some(provenance) = devnet_provenance {
|
||||||
|
assert_eq!(provenance.len(), 2);
|
||||||
|
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
assert_eq!(provenance[1].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||||
|
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_HELIUS_API_KEY"));
|
||||||
|
}
|
||||||
|
let mainnet_debug = format!("{mainnet:?}");
|
||||||
|
let devnet_debug = format!("{devnet:?}");
|
||||||
|
assert!(!mainnet_debug.contains(canary));
|
||||||
|
assert!(!devnet_debug.contains(canary));
|
||||||
|
assert!(mainnet_debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
assert!(devnet_debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transport_secret_url_provenance_uses_process_and_process_beats_dotenv() {
|
fn transport_secret_url_provenance_uses_process_and_process_beats_dotenv() {
|
||||||
let engine = fixture_engine();
|
let engine = fixture_engine();
|
||||||
@@ -216,6 +596,38 @@ fn fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
|||||||
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn v1_fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures_v1");
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, 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 v3_fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures_v3");
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, 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 committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
let workspace = workspace_root();
|
let workspace = workspace_root();
|
||||||
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
|||||||
135
crates/ksp-core-lib/README.md
Normal file
135
crates/ksp-core-lib/README.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<!-- file: crates/ksp-core-lib/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# `ksp-core-lib`
|
||||||
|
|
||||||
|
`ksp-core-lib` porte les contrats fondamentaux partagés par les couches KSP sans dépendre des domaines de plus haut niveau.
|
||||||
|
|
||||||
|
La crate possède actuellement trois responsabilités :
|
||||||
|
|
||||||
|
- le type d'erreur commun KSP ;
|
||||||
|
- le type Solana `Pubkey` réexporté comme primitive d'adresse canonique ;
|
||||||
|
- le registre KSP des Program IDs Solana fondamentaux et leur taxonomie.
|
||||||
|
|
||||||
|
## Frontière architecturale
|
||||||
|
|
||||||
|
Core reste une couche basse. Elle ne possède ni configuration, ni logging runtime, ni transport réseau, ni wallet, ni stockage, ni logique de décodage/exécution.
|
||||||
|
|
||||||
|
Les crates de niveau supérieur peuvent dépendre de Core et réutiliser ses contrats ; Core ne doit pas introduire de dépendance inverse vers ces couches.
|
||||||
|
|
||||||
|
La seule dépendance runtime externe directe actuelle est `solana-pubkey`, utilisée pour la primitive `Pubkey`.
|
||||||
|
|
||||||
|
## Erreur commune KSP
|
||||||
|
|
||||||
|
Le contrat d'erreur public repose sur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ErrorCode
|
||||||
|
ErrorContext
|
||||||
|
Error
|
||||||
|
Result<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
`ErrorCode` sépare un `domain` stable d'un `code` stable. `Error` ajoute :
|
||||||
|
|
||||||
|
- un message humain ;
|
||||||
|
- des champs de contexte ordonnés ;
|
||||||
|
- une source d'erreur standard optionnelle compatible `Send + Sync`.
|
||||||
|
|
||||||
|
L'affichage d'une erreur reste compact :
|
||||||
|
|
||||||
|
```text
|
||||||
|
<domain>.<code>: <message>
|
||||||
|
```
|
||||||
|
|
||||||
|
Les consommateurs ajoutent uniquement des contextes sûrs. Les secrets, credentials, key material, URLs sensibles ou payloads massifs ne doivent pas être copiés dans le message ou le contexte d'une erreur.
|
||||||
|
|
||||||
|
## `Pubkey`
|
||||||
|
|
||||||
|
La crate réexporte :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ksp_core_lib::Pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
Les autres crates KSP utilisent cette primitive lorsqu'un contrat public a besoin d'une adresse Solana générique. Elles évitent ainsi de multiplier les propriétaires de type pour la même notion.
|
||||||
|
|
||||||
|
## Program IDs fondamentaux
|
||||||
|
|
||||||
|
Core possède 18 Program IDs Solana fondamentaux sous deux formes cohérentes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRGID_* -> Base58 &str
|
||||||
|
PRGIDPK_* -> Pubkey typé
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux formes sont déclarées depuis une même valeur grâce à `declare_program_id!`.
|
||||||
|
|
||||||
|
Le registre couvre notamment :
|
||||||
|
|
||||||
|
- System, Vote, Stake, Config et Feature ;
|
||||||
|
- Address Lookup Table et Compute Budget ;
|
||||||
|
- les loaders natif, BPF v1, BPF v2, BPF upgradeable et Loader v4 ;
|
||||||
|
- les precompiles Ed25519, Secp256k1 et Secp256r1 ;
|
||||||
|
- Slashing ;
|
||||||
|
- ZK ElGamal Proof et ZK Token Proof.
|
||||||
|
|
||||||
|
Le registre n'est pas un catalogue général de tous les programmes Solana. Les protocoles, applications et Program IDs de domaines futurs sont ajoutés dans les couches propriétaires appropriées lorsqu'un besoin réel existe.
|
||||||
|
|
||||||
|
## Registre et taxonomie
|
||||||
|
|
||||||
|
Chaque entrée est exposée sous `ProgramIdEntry` avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
code
|
||||||
|
name
|
||||||
|
program_id
|
||||||
|
pubkey
|
||||||
|
domain
|
||||||
|
family
|
||||||
|
protocol
|
||||||
|
subfamily
|
||||||
|
program_version
|
||||||
|
kind
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProgramIdKind` distingue actuellement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Program
|
||||||
|
Loader
|
||||||
|
Precompile
|
||||||
|
EnshrinedProgram
|
||||||
|
```
|
||||||
|
|
||||||
|
La lecture du registre s'effectue par :
|
||||||
|
|
||||||
|
```text
|
||||||
|
entries()
|
||||||
|
native_program_ids()
|
||||||
|
program_ids(filter)
|
||||||
|
program_ids_by_domain(...)
|
||||||
|
program_ids_by_family(...)
|
||||||
|
program_ids_by_protocol(...)
|
||||||
|
find_program_id(...)
|
||||||
|
find_program_pubkey(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProgramIdFilter` permet de combiner les axes `domain`, `family`, `protocol`, `subfamily`, `program_version` et `kind`.
|
||||||
|
|
||||||
|
## Garanties validées
|
||||||
|
|
||||||
|
Les tests publics et unitaires verrouillent notamment :
|
||||||
|
|
||||||
|
- la correspondance Base58 / `Pubkey` des constantes ;
|
||||||
|
- l'unicité des codes, Program IDs et pubkeys du registre ;
|
||||||
|
- les 18 entrées fondamentales ;
|
||||||
|
- les recherches textuelles et typées ;
|
||||||
|
- les vues et filtres taxonomiques ;
|
||||||
|
- l'absence des comptes well-known qui ne sont pas des programmes ;
|
||||||
|
- le contrat `Error`, son ordre de contexte et sa chaîne `source()` ;
|
||||||
|
- `Error: Send + Sync`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`USAGE.md`](USAGE.md) — exemples d'utilisation de l'erreur commune, de `Pubkey`, des Program IDs et du registre.
|
||||||
265
crates/ksp-core-lib/USAGE.md
Normal file
265
crates/ksp-core-lib/USAGE.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
<!-- file: crates/ksp-core-lib/USAGE.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Utilisation de `ksp-core-lib`
|
||||||
|
|
||||||
|
## Utiliser le type d'erreur commun
|
||||||
|
|
||||||
|
Une crate KSP définit des codes stables puis retourne `ksp_core_lib::Result<T>` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const ERROR_CODE_LOAD_FAILED: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("store", "load_failed");
|
||||||
|
|
||||||
|
fn load_value() -> ksp_core_lib::Result<u64> {
|
||||||
|
let error = ksp_core_lib::Error::new(
|
||||||
|
ERROR_CODE_LOAD_FAILED,
|
||||||
|
"unable to load value",
|
||||||
|
)
|
||||||
|
.with_context("operation", "load_value");
|
||||||
|
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le domaine et le code sont accessibles séparément :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let code = ERROR_CODE_LOAD_FAILED;
|
||||||
|
assert_eq!(code.domain(), "store");
|
||||||
|
assert_eq!(code.code(), "load_failed");
|
||||||
|
```
|
||||||
|
|
||||||
|
Le message et les champs de contexte restent accessibles sans parser `Display` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let error = ksp_core_lib::Error::new(
|
||||||
|
ERROR_CODE_LOAD_FAILED,
|
||||||
|
"unable to load value",
|
||||||
|
)
|
||||||
|
.with_context("component", "postgres")
|
||||||
|
.with_context("operation", "load_value");
|
||||||
|
|
||||||
|
assert_eq!(error.code(), ERROR_CODE_LOAD_FAILED);
|
||||||
|
assert_eq!(error.message(), "unable to load value");
|
||||||
|
assert_eq!(error.context()[0].key(), "component");
|
||||||
|
assert_eq!(error.context()[0].value(), "postgres");
|
||||||
|
```
|
||||||
|
|
||||||
|
Un `ErrorContext` peut aussi être construit indépendamment lorsqu’un caller prépare explicitement son contexte :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let context = ksp_core_lib::ErrorContext::new(
|
||||||
|
"operation",
|
||||||
|
"load_value",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(context.key(), "operation");
|
||||||
|
assert_eq!(context.value(), "load_value");
|
||||||
|
```
|
||||||
|
|
||||||
|
Les valeurs de contexte doivent être sûres à exposer. Ne pas y placer de secret, credential, seed, key material, URL contenant une clé API ou payload brut volumineux.
|
||||||
|
|
||||||
|
## Conserver une erreur source
|
||||||
|
|
||||||
|
Une erreur externe compatible `Send + Sync + 'static` peut rester dans la chaîne standard :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SourceError;
|
||||||
|
|
||||||
|
impl std::fmt::Display for SourceError {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str("source failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SourceError {}
|
||||||
|
|
||||||
|
let error = ksp_core_lib::Error::new(
|
||||||
|
ERROR_CODE_LOAD_FAILED,
|
||||||
|
"unable to load value",
|
||||||
|
)
|
||||||
|
.with_source(SourceError);
|
||||||
|
|
||||||
|
let source = std::error::Error::source(&error);
|
||||||
|
assert!(source.is_some());
|
||||||
|
```
|
||||||
|
|
||||||
|
La source sert au chaînage d'erreurs ; son contenu ne doit pas être recopié sans contrôle dans un contexte ou un log public.
|
||||||
|
|
||||||
|
## Utiliser `Pubkey`
|
||||||
|
|
||||||
|
Core réexporte la primitive Solana utilisée par les contrats KSP :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let system = ksp_core_lib::Pubkey::from_str_const(
|
||||||
|
ksp_core_lib::PRGID_SOLANA_SYSTEM,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(system, ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
|
||||||
|
```
|
||||||
|
|
||||||
|
Une crate consommatrice peut donc utiliser `ksp_core_lib::Pubkey` dans sa propre API sans dépendre directement de `solana-pubkey` lorsque Core est déjà le propriétaire architectural de cette primitive.
|
||||||
|
|
||||||
|
## Utiliser les constantes Program ID
|
||||||
|
|
||||||
|
Chaque Program ID Core possède une forme texte et une forme typée :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let system_text: &str = ksp_core_lib::PRGID_SOLANA_SYSTEM;
|
||||||
|
let system_pubkey: ksp_core_lib::Pubkey =
|
||||||
|
ksp_core_lib::PRGIDPK_SOLANA_SYSTEM;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
system_pubkey,
|
||||||
|
ksp_core_lib::Pubkey::from_str_const(system_text),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Les constantes `PRGID_*` sont utiles pour les wires, diagnostics sûrs ou comparaisons texte. Les constantes `PRGIDPK_*` sont préférées dès qu'un contrat manipule une adresse Solana typée.
|
||||||
|
|
||||||
|
## Déclarer une paire texte / `Pubkey`
|
||||||
|
|
||||||
|
`declare_program_id!` permet à une couche KSP propriétaire d'un Program ID de déclarer les deux représentations depuis une seule valeur Base58 :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ksp_core_lib::declare_program_id!(
|
||||||
|
PRGID_EXAMPLE,
|
||||||
|
PRGIDPK_EXAMPLE,
|
||||||
|
"11111111111111111111111111111111"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(PRGID_EXAMPLE, ksp_core_lib::PRGID_SOLANA_SYSTEM);
|
||||||
|
assert_eq!(PRGIDPK_EXAMPLE, ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
|
||||||
|
```
|
||||||
|
|
||||||
|
La macro ne signifie pas que toute nouvelle constante doit être ajoutée au registre Core. La couche propriétaire du domaine décide où vit le nouveau Program ID.
|
||||||
|
|
||||||
|
## Parcourir le registre canonique
|
||||||
|
|
||||||
|
`entries()` retourne le registre complet en ordre déterministe :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
for entry in ksp_core_lib::entries() {
|
||||||
|
let code = entry.code();
|
||||||
|
let name = entry.name();
|
||||||
|
let text = entry.program_id();
|
||||||
|
let pubkey = entry.pubkey();
|
||||||
|
let domain = entry.domain();
|
||||||
|
let family = entry.family();
|
||||||
|
let protocol = entry.protocol();
|
||||||
|
let subfamily = entry.subfamily();
|
||||||
|
let program_version = entry.program_version();
|
||||||
|
let kind = entry.kind();
|
||||||
|
|
||||||
|
let _ = (
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
text,
|
||||||
|
pubkey,
|
||||||
|
domain,
|
||||||
|
family,
|
||||||
|
protocol,
|
||||||
|
subfamily,
|
||||||
|
program_version,
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`native_program_ids()` fournit la vue des Program IDs Solana fondamentaux actuellement enregistrés :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let count = ksp_core_lib::native_program_ids().count();
|
||||||
|
assert_eq!(count, 18);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rechercher une entrée
|
||||||
|
|
||||||
|
Recherche par représentation Base58 :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let entry = ksp_core_lib::find_program_id(
|
||||||
|
ksp_core_lib::PRGID_SOLANA_SYSTEM,
|
||||||
|
);
|
||||||
|
|
||||||
|
let entry = match entry {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(entry.code(), "solana.system");
|
||||||
|
```
|
||||||
|
|
||||||
|
Recherche par `Pubkey` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let entry = ksp_core_lib::find_program_pubkey(
|
||||||
|
&ksp_core_lib::PRGIDPK_SOLANA_VOTE,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(entry.is_some());
|
||||||
|
```
|
||||||
|
|
||||||
|
Une recherche inconnue retourne `None`; le registre n'invente pas une entrée générique.
|
||||||
|
|
||||||
|
## Filtrer par axe simple
|
||||||
|
|
||||||
|
Les helpers spécialisés conviennent aux filtres simples :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let loaders = ksp_core_lib::program_ids_by_family("loader");
|
||||||
|
for loader in loaders {
|
||||||
|
assert_eq!(loader.family(), "loader");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Les vues disponibles peuvent être consommées directement :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let solana_entries = ksp_core_lib::program_ids_by_domain("solana").count();
|
||||||
|
let loaders = ksp_core_lib::program_ids_by_family("loader").count();
|
||||||
|
let solana_protocol = ksp_core_lib::program_ids_by_protocol("solana").count();
|
||||||
|
|
||||||
|
assert_eq!(solana_entries, 18);
|
||||||
|
assert_eq!(loaders, 5);
|
||||||
|
assert_eq!(solana_protocol, 18);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Combiner plusieurs axes
|
||||||
|
|
||||||
|
`ProgramIdFilter` compose les contraintes sans allocation de collection intermédiaire :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let filter = ksp_core_lib::ProgramIdFilter::new()
|
||||||
|
.with_domain("solana")
|
||||||
|
.with_family("loader")
|
||||||
|
.with_protocol("solana")
|
||||||
|
.with_subfamily("bpf")
|
||||||
|
.with_program_version("v2")
|
||||||
|
.with_kind(ksp_core_lib::ProgramIdKind::Loader);
|
||||||
|
|
||||||
|
for entry in ksp_core_lib::program_ids(filter) {
|
||||||
|
assert_eq!(entry.domain(), "solana");
|
||||||
|
assert_eq!(entry.family(), "loader");
|
||||||
|
assert_eq!(entry.protocol(), "solana");
|
||||||
|
assert_eq!(entry.subfamily(), std::option::Option::Some("bpf"));
|
||||||
|
assert_eq!(entry.program_version(), std::option::Option::Some("v2"));
|
||||||
|
assert_eq!(entry.kind(), ksp_core_lib::ProgramIdKind::Loader);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Un `ProgramIdFilter::new()` vide correspond à toutes les entrées du registre.
|
||||||
|
|
||||||
|
## Choisir entre texte, `Pubkey` et descriptor
|
||||||
|
|
||||||
|
Utiliser :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRGID_* / &str pour une représentation Base58 canonique
|
||||||
|
PRGIDPK_* / Pubkey pour un contrat Solana typé
|
||||||
|
ProgramIdEntry lorsqu'il faut aussi la taxonomie KSP
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne pas reparcourir les constantes ou reconstruire une taxonomie parallèle dans une crate consommatrice lorsque le registre Core fournit déjà l'information requise.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
|
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||||
// version: 1
|
// version: 7
|
||||||
|
|
||||||
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
|
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
|
||||||
|
|
||||||
@@ -61,8 +61,83 @@ fn transport_manifest_preserves_ksp_dependency_firewall() {
|
|||||||
}
|
}
|
||||||
assert!(manifest.contains("ksp-core-lib"));
|
assert!(manifest.contains("ksp-core-lib"));
|
||||||
assert!(manifest.contains("ksp-logging-lib"));
|
assert!(manifest.contains("ksp-logging-lib"));
|
||||||
|
assert!(manifest.contains("futures-util = { workspace = true, features = [\"sink\", \"std\"] }"));
|
||||||
|
assert!(manifest.contains("http.workspace = true"));
|
||||||
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }"));
|
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"net\", \"rt\", \"sync\", \"time\"] }"));
|
||||||
|
assert!(manifest.contains("tokio-tungstenite = { workspace = true, features = [\"connect\", \"rustls-tls-webpki-roots\"] }"));
|
||||||
|
assert!(manifest.contains("tonic = { workspace = true, features = [\"channel\", \"tls-aws-lc\", \"tls-webpki-roots\"] }"));
|
||||||
|
assert!(manifest.contains("tonic-prost.workspace = true"));
|
||||||
|
assert!(manifest.contains("yellowstone-grpc-proto.workspace = true"));
|
||||||
assert!(manifest.contains("[dev-dependencies]"));
|
assert!(manifest.contains("[dev-dependencies]"));
|
||||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }"));
|
assert!(manifest.contains("tokio = { workspace = true, features = [\"io-util\", \"net\", \"rt\", \"test-util\"] }"));
|
||||||
|
assert!(manifest.contains("tonic = { workspace = true, features = [\"codegen\", \"server\"] }"));
|
||||||
|
assert!(manifest.contains("yellowstone-grpc-proto = { workspace = true, features = [\"tonic\"] }"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_manifest_runtime_and_dev_dependency_names_are_exact() {
|
||||||
|
let manifest_path = workspace_root().join("crates/ksp-onchain-transport-lib/Cargo.toml");
|
||||||
|
let manifest = std::fs::read_to_string(manifest_path).expect("transport manifest must be readable during workspace integration tests");
|
||||||
|
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||||
|
assert!(dependencies_tail.is_some(), "transport dependencies section must exist");
|
||||||
|
let dependencies_tail = match dependencies_tail {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let dependencies = match dependencies_tail.split("[dev-dependencies]").next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let dependency_names = manifest_dependency_names(dependencies);
|
||||||
|
assert_eq!(
|
||||||
|
dependency_names,
|
||||||
|
std::vec![
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"ksp-core-lib",
|
||||||
|
"ksp-logging-lib",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
"tonic",
|
||||||
|
"tonic-prost",
|
||||||
|
"yellowstone-grpc-proto",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let dev_dependencies_tail = manifest.split("[dev-dependencies]").nth(1);
|
||||||
|
assert!(dev_dependencies_tail.is_some(), "transport dev-dependencies section must exist");
|
||||||
|
let dev_dependencies_tail = match dev_dependencies_tail {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let dev_dependencies = match dev_dependencies_tail.split("[lints]").next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert_eq!(manifest_dependency_names(dev_dependencies), std::vec!["tokio", "tonic", "yellowstone-grpc-proto"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for line in section.lines() {
|
||||||
|
let content = match line.split('#').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim(),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if content.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = match content.split('=').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if !name.is_empty() {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
return names;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-logging-lib/Cargo.toml
|
# file: crates/ksp-logging-lib/Cargo.toml
|
||||||
# version: 5
|
# version: 6
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-logging-lib"
|
name = "ksp-logging-lib"
|
||||||
@@ -10,8 +10,8 @@ repository.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
tracing = { workspace = true, features = ["std"] }
|
tracing = { workspace = true, features = ["std"] }
|
||||||
tracing-subscriber = { workspace = true, features = ["fmt", "json", "ansi"] }
|
|
||||||
tracing-appender.workspace = true
|
tracing-appender.workspace = true
|
||||||
|
tracing-subscriber = { workspace = true, features = ["fmt", "json", "ansi"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-logging-lib/USAGE.md -->
|
<!-- file: crates/ksp-logging-lib/USAGE.md -->
|
||||||
<!-- version: 7 -->
|
<!-- version: 8 -->
|
||||||
|
|
||||||
# Utilisation de ksp-logging-lib
|
# Utilisation de ksp-logging-lib
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Les fichiers persistants interdisent `ansi = true`.
|
|||||||
|
|
||||||
## Runtime multi-output et routing `domain`
|
## Runtime multi-output et routing `domain`
|
||||||
|
|
||||||
`0.1.3-pre.005` active le multi-sink, les formats et le routing niveau/target ; `0.1.3-pre.006` complète le routing structuré `domain`. Le runtime supporte donc réellement :
|
Le runtime supporte le multi-sink, les formats, le routing niveau/target et le routing structuré `domain` :
|
||||||
|
|
||||||
- plusieurs fichiers simultanés ;
|
- plusieurs fichiers simultanés ;
|
||||||
- les formats `Human`, `Compact`, `Pretty` et `Json` ;
|
- les formats `Human`, `Compact`, `Pretty` et `Json` ;
|
||||||
|
|||||||
22
crates/ksp-offchain-transport-lib/Cargo.toml
Normal file
22
crates/ksp-offchain-transport-lib/Cargo.toml
Normal 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
|
||||||
146
crates/ksp-offchain-transport-lib/README.md
Normal file
146
crates/ksp-offchain-transport-lib/README.md
Normal 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.
|
||||||
181
crates/ksp-offchain-transport-lib/USAGE.md
Normal file
181
crates/ksp-offchain-transport-lib/USAGE.md
Normal 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.
|
||||||
7
crates/ksp-offchain-transport-lib/src/constants.rs
Normal file
7
crates/ksp-offchain-transport-lib/src/constants.rs
Normal 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";
|
||||||
57
crates/ksp-offchain-transport-lib/src/error.rs
Normal file
57
crates/ksp-offchain-transport-lib/src/error.rs
Normal 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");
|
||||||
209
crates/ksp-offchain-transport-lib/src/http_admission.rs
Normal file
209
crates/ksp-offchain-transport-lib/src/http_admission.rs
Normal 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;
|
||||||
364
crates/ksp-offchain-transport-lib/src/http_client.rs
Normal file
364
crates/ksp-offchain-transport-lib/src/http_client.rs
Normal 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;
|
||||||
84
crates/ksp-offchain-transport-lib/src/http_settings.rs
Normal file
84
crates/ksp-offchain-transport-lib/src/http_settings.rs
Normal 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;
|
||||||
218
crates/ksp-offchain-transport-lib/src/lib.rs
Normal file
218
crates/ksp-offchain-transport-lib/src/lib.rs
Normal 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;
|
||||||
158
crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
Normal file
158
crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
Normal 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;
|
||||||
@@ -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>)");
|
||||||
|
}
|
||||||
|
}
|
||||||
238
crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
Normal file
238
crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
Normal 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;
|
||||||
@@ -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;
|
||||||
264
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
Normal file
264
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
238
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
Normal file
238
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
Normal 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;
|
||||||
@@ -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;
|
||||||
269
crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs
Normal file
269
crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs
Normal 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;
|
||||||
179
crates/ksp-offchain-transport-lib/src/market_price_kraken.rs
Normal file
179
crates/ksp-offchain-transport-lib/src/market_price_kraken.rs
Normal 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;
|
||||||
@@ -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;
|
||||||
514
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
Normal file
514
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
Normal 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;
|
||||||
123
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
Normal file
123
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
Normal 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;
|
||||||
387
crates/ksp-offchain-transport-lib/src/market_price_service.rs
Normal file
387
crates/ksp-offchain-transport-lib/src/market_price_service.rs
Normal 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;
|
||||||
@@ -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;
|
||||||
128
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
Normal file
128
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
Normal 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("));
|
||||||
|
}
|
||||||
@@ -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(®istry, 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;
|
||||||
|
}
|
||||||
239
crates/ksp-offchain-transport-lib/tests/public_api.rs
Normal file
239
crates/ksp-offchain-transport-lib/tests/public_api.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
161
crates/ksp-offchain-transport-lib/tests/release_completeness.rs
Normal file
161
crates/ksp-offchain-transport-lib/tests/release_completeness.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/tests/release_completeness.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Release-level completeness canaries for the eight-provider SOL/USD V1 contract.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_exact_eight_provider_inventory_and_semantics_are_stable() -> ksp_core_lib::Result<()> {
|
||||||
|
let setups = match all_disabled_setups() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let service = match ksp_offchain_transport_lib::MarketPriceService::new(setups) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = service.registry();
|
||||||
|
assert_eq!(registry.len(), 8);
|
||||||
|
let expected = [
|
||||||
|
(
|
||||||
|
"birdeye",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::RequiredApiKey,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"coinbase_exchange",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"coingecko",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"coinmarketcap",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"coinpaprika",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||||
|
),
|
||||||
|
("dexscreener", ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
|
||||||
|
("jupiter", ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
|
||||||
|
("kraken", ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
|
||||||
|
];
|
||||||
|
for (entry, (provider_id, semantics, auth_mode)) in registry.entries().iter().zip(expected) {
|
||||||
|
assert_eq!(entry.descriptor().id().as_str(), provider_id);
|
||||||
|
assert_eq!(entry.descriptor().semantics(), semantics);
|
||||||
|
assert_eq!(entry.descriptor().auth_mode(), auth_mode);
|
||||||
|
assert!(entry.descriptor().supports_sol_usd());
|
||||||
|
assert_eq!(entry.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_evolutive_public_enums_are_non_exhaustive() -> std::io::Result<()> {
|
||||||
|
let root = crate_root();
|
||||||
|
let provider = match std::fs::read_to_string(root.join("src/market_price_provider.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
for enum_name in [
|
||||||
|
"MarketPricePair",
|
||||||
|
"MarketPriceSemantics",
|
||||||
|
"MarketPriceProviderAuthMode",
|
||||||
|
"MarketPriceProviderRateLimitScope",
|
||||||
|
"MarketPriceProviderRateLimitKind",
|
||||||
|
"MarketPriceProviderQuotaPeriod",
|
||||||
|
"MarketPriceProviderQuotaUnit",
|
||||||
|
"MarketPriceProviderAvailability",
|
||||||
|
] {
|
||||||
|
assert_non_exhaustive(provider.as_str(), enum_name);
|
||||||
|
}
|
||||||
|
let coingecko = match std::fs::read_to_string(root.join("src/market_price_coingecko.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_non_exhaustive(coingecko.as_str(), "MarketPriceCoinGeckoAccessMode");
|
||||||
|
let coinmarketcap = match std::fs::read_to_string(root.join("src/market_price_coinmarketcap.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_non_exhaustive(coinmarketcap.as_str(), "MarketPriceCoinMarketCapAccessMode");
|
||||||
|
let jupiter = match std::fs::read_to_string(root.join("src/market_price_jupiter.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_non_exhaustive(jupiter.as_str(), "MarketPriceJupiterAccessMode");
|
||||||
|
let service = match std::fs::read_to_string(root.join("src/market_price_service.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_non_exhaustive(service.as_str(), "MarketPriceProviderSetup");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_disabled_setups() -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
|
||||||
|
let birdeye = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinbase = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let dexscreener = match ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kraken = match ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(std::vec![
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::DexScreener(dexscreener),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_non_exhaustive(source: &str, enum_name: &str) {
|
||||||
|
let marker = std::format!("pub enum {enum_name}");
|
||||||
|
let position = source.find(marker.as_str());
|
||||||
|
assert!(position.is_some(), "public enum must remain present: {enum_name}");
|
||||||
|
if let std::option::Option::Some(position) = position {
|
||||||
|
let start = position.saturating_sub(256);
|
||||||
|
assert!(source[start..position].contains("#[non_exhaustive]"), "evolutive public enum must be non_exhaustive: {enum_name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crate_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"));
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
236
crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
Normal file
236
crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-onchain-transport-lib/Cargo.toml
|
# file: crates/ksp-onchain-transport-lib/Cargo.toml
|
||||||
# version: 3
|
# version: 9
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-onchain-transport-lib"
|
name = "ksp-onchain-transport-lib"
|
||||||
@@ -8,15 +8,23 @@ edition.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
futures-util = { workspace = true, features = ["sink", "std"] }
|
||||||
|
http.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" }
|
||||||
reqwest = { workspace = true, features = ["rustls"] }
|
reqwest = { workspace = true, features = ["rustls"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio = { workspace = true, features = ["macros", "sync", "time"] }
|
tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] }
|
||||||
|
tokio-tungstenite = { workspace = true, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||||
|
tonic = { workspace = true, features = ["channel", "tls-aws-lc", "tls-webpki-roots"] }
|
||||||
|
tonic-prost.workspace = true
|
||||||
|
yellowstone-grpc-proto.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt"] }
|
tokio = { workspace = true, features = ["io-util", "net", "rt", "test-util"] }
|
||||||
|
tonic = { workspace = true, features = ["codegen", "server"] }
|
||||||
|
yellowstone-grpc-proto = { workspace = true, features = ["tonic"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||||
<!-- version: 9 -->
|
<!-- version: 24 -->
|
||||||
|
|
||||||
# `ksp-onchain-transport-lib`
|
# `ksp-onchain-transport-lib`
|
||||||
|
|
||||||
`ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Sa première surface est le transport HTTP JSON-RPC ; les extensions WebSocket et gRPC sont introduites séparément lorsque leur release les cible.
|
`ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Elle fournit le transport HTTP JSON-RPC complet, le moteur WebSocket Solana standard et la foundation Yellowstone gRPC standard/provider-neutral. Les extensions provider-specific restent ajoutées séparément lorsqu’une release les cible.
|
||||||
|
|
||||||
## Responsabilités
|
## Responsabilités
|
||||||
|
|
||||||
@@ -19,7 +19,10 @@ La crate possède :
|
|||||||
- les enveloppes JSON-RPC 2.0 et leur validation ;
|
- les enveloppes JSON-RPC 2.0 et leur validation ;
|
||||||
- le registre audité des méthodes Solana HTTP ;
|
- le registre audité des méthodes Solana HTTP ;
|
||||||
- l'exécution générique des méthodes standard supportées ;
|
- l'exécution générique des méthodes standard supportées ;
|
||||||
- les wrappers typés explicitement livrés par KSP ;
|
- les wrappers typés HTTP et WebSocket explicitement livrés par KSP ;
|
||||||
|
- les sessions physiques WebSocket, subscriptions logiques, reconnect/resubscribe et backpressure bornés ;
|
||||||
|
- les settings, channels, unary et sessions `Subscribe` Yellowstone gRPC standard ;
|
||||||
|
- le reconnect/replay Yellowstone prudent avec observabilité de gaps/duplicates sans promesse lossless ;
|
||||||
- les snapshots runtime sûrs ;
|
- les snapshots runtime sûrs ;
|
||||||
- l'observabilité Transport via `ksp-logging-lib`.
|
- l'observabilité Transport via `ksp-logging-lib`.
|
||||||
|
|
||||||
@@ -35,6 +38,8 @@ ksp-config-lib
|
|||||||
-> ksp-core-lib
|
-> ksp-core-lib
|
||||||
-> ksp-logging-lib
|
-> ksp-logging-lib
|
||||||
-> reqwest / tokio / serde
|
-> reqwest / tokio / serde
|
||||||
|
-> tokio-tungstenite / futures-util
|
||||||
|
-> tonic / tonic-prost / yellowstone-grpc-proto
|
||||||
```
|
```
|
||||||
|
|
||||||
La direction inverse est interdite :
|
La direction inverse est interdite :
|
||||||
@@ -69,22 +74,179 @@ Le registre porte notamment :
|
|||||||
- remplacement historique éventuel ;
|
- remplacement historique éventuel ;
|
||||||
- release de couverture typée KSP.
|
- release de couverture typée KSP.
|
||||||
|
|
||||||
La release stable `0.2.4` complète la surface typée des **52 méthodes courantes** :
|
La surface HTTP typée couvre les **52 méthodes courantes** :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
0.2.1 foundation : 4
|
foundation : 4
|
||||||
0.2.2 Accounts/Tokens/Cluster : 22
|
Accounts/Tokens/Cluster : 22
|
||||||
0.2.3 Transactions : 11
|
Transactions : 11
|
||||||
0.2.4 Blocks/Economics : 15
|
Blocks/Economics : 15
|
||||||
total : 52
|
total : 52
|
||||||
```
|
```
|
||||||
|
|
||||||
`0.2.4` ajoute les dix wrappers Blocks et les cinq wrappers Economics. Les points sensibles restent notamment `getBlock` moderne + bare encoding legacy deprecated, les quatre `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions`, `commissionBps`, les overloads de `getBlocks`, les ranges de production, les valeurs d'inflation/minimum de délégation fournies par le runtime et les `null` positionnels de `getInflationReward`.
|
Les dix wrappers Blocks et les cinq wrappers Economics couvrent notamment `getBlock` moderne + bare encoding legacy deprecated, les quatre `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions`, `commissionBps`, les overloads de `getBlocks`, les ranges de production, les valeurs d'inflation/minimum de délégation fournies par le runtime et les `null` positionnels de `getInflationReward`.
|
||||||
|
|
||||||
`KSP-TRANSPORT-007` impose qu'un wrapper typé couvre toutes les possibilités RPC supportées retenues par l'audit : paramètres/options, overloads et formes legacy encore supportées, contraintes déterministes utiles et variantes de réponse pertinentes sans perte. Le réaudit final `0.2.4` agrège le réaudit 37/37 de `0.2.3` avec les 15 nouveaux wrappers et porte la preuve stable à **52/52**.
|
`KSP-TRANSPORT-007` impose qu'un wrapper typé couvre toutes les possibilités RPC supportées retenues par l'audit : paramètres/options, overloads et formes legacy encore supportées, contraintes déterministes utiles et variantes de réponse pertinentes sans perte. Les canaris de release portent la preuve globale à **52/52** méthodes courantes typées.
|
||||||
|
|
||||||
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
|
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
|
||||||
|
|
||||||
|
## Moteur WebSocket standard
|
||||||
|
|
||||||
|
La première session physique WebSocket est matérialisée sans introduire de pool/scheduler automatique ni de registry de subscriptions anticipé.
|
||||||
|
|
||||||
|
`WsSession::connect(WsEndpointSettings)` :
|
||||||
|
|
||||||
|
- ouvre exactement une connexion physique pour un appel ;
|
||||||
|
- confie le socket à une tâche actor unique ;
|
||||||
|
- sérialise les commandes internes par un canal `mpsc` borné ;
|
||||||
|
- maintient une map bornée de requests JSON-RPC en attente ;
|
||||||
|
- publie `WsSessionSnapshot` via un état compact `watch` ;
|
||||||
|
- applique aux sockets les plafonds KSP de message, frame et write buffer ;
|
||||||
|
- ne projette jamais l'URL dans `Debug`, snapshot, erreurs KSP ou logs ;
|
||||||
|
- répond aux `Ping` reçus et tolère les `Pong`; le lifecycle `Close`/shutdown est borné et explicite.
|
||||||
|
|
||||||
|
Le chemin JSON-RPC générique reste `pub(crate)`. Il sert de primitive au moteur typed de subscriptions et **ne constitue pas une API publique raw provider-extension**. Le registry de subscriptions et le mapping remote/local, le reconnect/resubscribe et le backpressure borné par subscription font partie du moteur standard.
|
||||||
|
|
||||||
|
Les tests déterministes utilisent un serveur WebSocket local et prouvent le handshake, le round-trip JSON-RPC, le dispatch de réponses hors ordre, l'isolation des erreurs RPC applicatives, deux sessions physiques distinctes sur la même URL et la redaction des erreurs de connexion.
|
||||||
|
|
||||||
|
### Limites, control frames et shutdown
|
||||||
|
|
||||||
|
La session physique dispose désormais de `WsSession::close().await`. Le signal de shutdown est distinct de la command queue, passe l'état en `Closing`, annule les requests JSON-RPC en attente, envoie un Close WebSocket best-effort sous `close_timeout`, puis publie `Closed`. Un peer qui ne répond pas au Close ne peut donc pas bloquer indéfiniment le shutdown.
|
||||||
|
|
||||||
|
Les limites `max_message_size`, `max_frame_size`, `max_write_buffer_size` et `max_pending_requests` sont couvertes par des fixtures adversariales locales. Les requests outbound qui dépassent les bornes message/frame sont rejetées avant écriture ; les frames/messages inbound surdimensionnés sont rejetés par Tungstenite avant parse JSON. Les timeouts pending libèrent leur capacité sans faire tomber une session encore saine.
|
||||||
|
|
||||||
|
Ping/Pong/Close sont traités comme control frames : le Pong automatique Tungstenite est flushé et aucun heartbeat applicatif périodique n'est ajouté. Un Close distant inattendu, EOF, erreur I/O/TLS/WebSocket ou violation protocolaire structurelle entre dans le reconnect borné ; `Closed` reste réservé au shutdown local explicite ou à la disparition des handles.
|
||||||
|
|
||||||
|
### Registry de subscriptions
|
||||||
|
|
||||||
|
Le même actor possède maintenant le registre des subscriptions logiques, sans exposer les IDs numériques distants. Chaque subscription reçoit un `WsSubscriptionId` local stable, et le mapping `remote_subscription_id -> WsSubscriptionId` reste strictement runtime/interne.
|
||||||
|
|
||||||
|
La création générique typed reste `pub(crate)` et n'est jamais exposée comme API raw provider-extension. Les wrappers standard publics l’utilisent derrière leurs DTOs et paramètres typés. Le handle public `WsSubscription<T>` expose uniquement :
|
||||||
|
|
||||||
|
- `id()` et `kind()` ;
|
||||||
|
- `state()` ;
|
||||||
|
- `recv()` sur un canal typed borné ;
|
||||||
|
- `unsubscribe()` qui conserve le booléen retourné par l'unsubscribe Solana standard.
|
||||||
|
|
||||||
|
L'ACK de subscribe est traité atomiquement dans l'actor : le remote ID est lié au local ID avant que la notification suivante puisse être dispatchée. Les notifications inconnues/stale sont ignorées avec un diagnostic sûr. Un mismatch de méthode de notification ou un échec de décodage typed termine uniquement la subscription concernée ; la session physique reste `Active`.
|
||||||
|
|
||||||
|
### Reconnect et resubscribe
|
||||||
|
|
||||||
|
Une perte de connexion physique invalide immédiatement les remote subscription IDs et incrémente `continuity_gap_count`. Le runtime utilise `WsReconnectSettings` pour appliquer un nombre fini de tentatives avec backoff exponentiel borné et sans jitter. Le shutdown surveille les phases de backoff et de handshake et interrompt la reprise sans reconnecter uniquement pour nettoyer des subscriptions.
|
||||||
|
|
||||||
|
Avec `WsResubscribePolicy::ActiveSubscriptions`, les subscriptions encore désirées passent en `Resubscribing` et sont restaurées dans l'ordre croissant de leur `WsSubscriptionId`. Les paramètres de subscribe conservés par l'actor sont rejoués, puis chaque nouvel ACK remappe un remote ID sans changer l'identité locale. Le retour à `Active` ne se produit qu'après la fin de cette restauration, ce qui réinitialise alors le budget de reconnect.
|
||||||
|
|
||||||
|
Avec `WsResubscribePolicy::Never`, la session physique peut se reconnecter mais les subscriptions précédentes deviennent terminales. Une cancellation locale reçue pendant reconnect gagne toujours : elle retire la subscription de la restauration ; si un ACK distant arrive après cette cancellation, l'actor envoie un unsubscribe best-effort du nouvel ID sans réactiver le handle local.
|
||||||
|
|
||||||
|
Le compteur de continuity gaps est un signal d'observabilité, pas une garantie de livraison. Transport n'ajoute aucun backfill HTTP et ne promet aucune continuité lossless pendant l'intervalle de déconnexion.
|
||||||
|
|
||||||
|
### Backpressure et libération de capacité
|
||||||
|
|
||||||
|
Chaque subscription dispose de sa propre queue typed bornée par `notification_queue_capacity`. Le runtime ne droppe jamais silencieusement une notification lorsque cette queue est pleine : il incrémente `WsSessionSnapshot::overflow_count()`, fait passer uniquement le handle lent à `Failed`, publie `ERROR_CODE_WS_BACKPRESSURE_OVERFLOW` via `WsSubscription::terminal_error_code()` et programme un `*Unsubscribe` distant best-effort. Les autres subscriptions et la session physique restent utilisables.
|
||||||
|
|
||||||
|
Les autres terminaisons en échec publient également un code KSP sûr sur le handle : erreur protocolaire, timeout, erreur RPC applicative ou perte physique terminale. Les fermetures normales et les unsubscriptions réussis conservent `terminal_error_code() == None`. Aucun payload distant, remote subscription ID ou endpoint URL n'est projeté dans cette cause.
|
||||||
|
|
||||||
|
`max_active_subscriptions` reste une limite d'admission distincte du compteur d'overflow de notifications : un rejet de création ne l'incrémente pas. Lorsqu'une subscription est fermée, échoue ou que son receiver est abandonné puis détecté sur la notification suivante, son entrée runtime et son binding distant sont nettoyés et la capacité locale redevient réutilisable.
|
||||||
|
|
||||||
|
Les fixtures adversariales prouvent l'isolation d'un consumer lent, la survie d'une subscription saine, le cleanup distant best-effort, la réutilisation de capacité après unsubscribe ou abandon du receiver et la conservation du compteur d'overflow à travers les snapshots. Aucune promesse de livraison lossless n'est ajoutée.
|
||||||
|
|
||||||
|
### Wrappers stables : account, program et logs
|
||||||
|
|
||||||
|
Les trois premiers wrappers WebSocket standards sont publics sur `WsSession` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
account_subscribe -> WsSubscription<SolanaRpcResponse<SolanaAccount>>
|
||||||
|
program_subscribe -> WsSubscription<SolanaProgramNotification>
|
||||||
|
logs_subscribe -> WsSubscription<SolanaRpcResponse<SolanaLogsNotification>>
|
||||||
|
```
|
||||||
|
|
||||||
|
`SolanaAccountSubscribeConfig` expose uniquement les options réellement effectives du PubSub audité : `encoding`, `dataSlice` et `commitment`. `minContextSlot` reste volontairement absent car le handler Agave ciblé l'ignore pour `accountSubscribe`; KSP ne transforme donc pas un champ partagé mais inopérant en promesse WebSocket.
|
||||||
|
|
||||||
|
`SolanaProgramSubscribeConfig` réutilise les encodings, slices et commitments account, ajoute les filtres programme et conserve `withContext`. Le décodeur `SolanaProgramNotification` accepte aussi bien le keyed account non contexté que la forme `RpcResponse` contextée afin de préserver les deux formes retenues par l'audit sans perte. `sortResults`, présent sur la surface HTTP `getProgramAccounts`, n'est pas exposé ici car le handler PubSub audité ne le consomme pas.
|
||||||
|
|
||||||
|
`SolanaLogsSubscribeFilter` rend les trois filtres upstream explicites : `All`, `AllWithVotes` et `Mentions(Pubkey)`. La variante `Mentions` encode par construction exactement une adresse. `SolanaLogsNotification` conserve la signature opaque, le `err` nullable et l'ordre des messages `logs`, enveloppés dans `SolanaRpcResponse`.
|
||||||
|
|
||||||
|
L'unsubscribe de ces trois familles passe toujours par `WsSubscription::unsubscribe()`: le caller ne voit ni ne fournit l'ID serveur. Les paramètres initiaux restent conservés par l’actor pour le resubscribe déterministe, et toutes les règles de backpressure/terminal error s’appliquent sans branche spéciale aux DTOs publics.
|
||||||
|
|
||||||
|
### Wrappers stables : signature, slot et root
|
||||||
|
|
||||||
|
Le second lot stable complète les subscriptions standard non instables :
|
||||||
|
|
||||||
|
```text
|
||||||
|
signature_subscribe -> WsSubscription<SolanaRpcResponse<SolanaSignatureNotification>>
|
||||||
|
slot_subscribe -> WsSubscription<SolanaSlotNotification>
|
||||||
|
root_subscribe -> WsSubscription<u64>
|
||||||
|
```
|
||||||
|
|
||||||
|
`SolanaSignatureSubscribeConfig` conserve séparément `commitment` et `enableReceivedNotification`, y compris la différence entre option omise et booléen explicitement faux. `SolanaSignatureNotification` représente les deux formes wire : `ReceivedSignature` pour l'événement précoce optionnel et `Processed { err }` pour la notification terminale. Après livraison de `Processed`, l'actor ferme localement la subscription avec `terminal_error_code() == None`, retire son binding et ne la remet jamais dans le set de resubscribe, conformément au caractère one-shot du serveur Solana.
|
||||||
|
|
||||||
|
Une cancellation effectuée avant cette terminaison continue d'utiliser `WsSubscription::unsubscribe()` et émet `signatureUnsubscribe` avec le remote ID détenu uniquement par l'actor. Après la notification terminale, `unsubscribe()` devient local-only et retourne `false`, puisque la subscription est déjà fermée côté serveur et côté KSP.
|
||||||
|
|
||||||
|
`slot_subscribe()` et `root_subscribe()` n'acceptent aucun paramètre. `SolanaSlotNotification` conserve exactement `slot`, `parent` et `root`; `root_subscribe()` délivre directement le root `u64`. Ces deux subscriptions restent continues et utilisent donc le reconnect/resubscribe standard.
|
||||||
|
|
||||||
|
### Wrappers unstable : block, slotsUpdates et vote
|
||||||
|
|
||||||
|
Les trois familles standard restantes complètent désormais l'inventaire **9/9 subscribe + 9/9 unsubscribe via handles** :
|
||||||
|
|
||||||
|
```text
|
||||||
|
block_subscribe -> WsSubscription<SolanaRpcResponse<SolanaBlockNotification>>
|
||||||
|
slots_updates_subscribe -> WsSubscription<SolanaSlotUpdate>
|
||||||
|
vote_subscribe -> WsSubscription<SolanaVoteNotification>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces familles restent explicitement **unstable**. Le moteur commun `subscribe_typed_with_completion` émet un warning KSP centralisé pour `Block`, `SlotsUpdates` et `Vote` avant l'ouverture logique, sans recopier filtres, pubkeys, payloads ou URL dans les logs.
|
||||||
|
|
||||||
|
`SolanaBlockSubscribeConfig` conserve `commitment`, `encoding`, `transactionDetails`, `maxSupportedTransactionVersion` et `showRewards`. Le filtre représente `All` ou `MentionsAccountOrProgram(Pubkey)`. Un commitment `processed` explicitement fourni est rejeté avant I/O ; la notification réutilise `SolanaConfirmedBlock` pour le block nullable et conserve l'erreur publication nullable sans interprétation métier. Le numéro de version transaction supporté reste un `u8` générique et n'est pas durci à `0`.
|
||||||
|
|
||||||
|
`SolanaSlotUpdate` représente les sept variantes courantes `firstShredReceived`, `completed`, `createdBank`, `frozen`, `dead`, `optimisticConfirmation` et `root`. Une variante upstream inconnue devient `Unknown { update_type, raw }` au lieu de faire tomber la session. Le `raw` reste borné par `max_message_size_bytes` avant le parse JSON.
|
||||||
|
|
||||||
|
`SolanaVoteNotification` conserve `votePubkey`, `slots`, `hash`, `timestamp` et `signature`. Le timestamp reste optionnel : omission et `null` deviennent `None`, tandis qu'une valeur `i64` est préservée. Transport ne transforme pas ces votes gossip pre-consensus en vérité ledger.
|
||||||
|
|
||||||
|
## Helius LaserStream WebSocket
|
||||||
|
|
||||||
|
La façade `HeliusLaserStreamWsSession` utilise le même actor physique `WsSession` mais expose uniquement la surface provider actuellement retenue par l’audit Helius :
|
||||||
|
|
||||||
|
```text
|
||||||
|
standard réutilisé : account / logs / program / root / signature / slot / slotsUpdates
|
||||||
|
extension Helius : transactionSubscribe / transactionUnsubscribe
|
||||||
|
absent Helius : block / vote
|
||||||
|
```
|
||||||
|
|
||||||
|
`slotsUpdates` reste **unstable** et conserve le warning centralisé du moteur standard. `block` et `vote` restent absents de la façade Helius même s’ils existent sur la façade Solana standard. `transactionSubscribe` reste provider-specific et n’est jamais ajouté à `SolanaStandardWsSession`.
|
||||||
|
|
||||||
|
Les endpoints Helius mainnet/devnet utilisent un `api-key` dans l’URL. KSP recommande de les construire via `ksp-config-lib` et `KSP_SECRET_HELIUS_API_KEY`; la valeur réelle atteint Transport mais les projections sûres, `Debug`, snapshots, erreurs et diagnostics n’exposent pas le credential. Transport ne lit jamais l’environnement et ne dépend jamais de Config.
|
||||||
|
|
||||||
|
Pour Helius, l’actor envoie automatiquement un control frame WebSocket `Ping` toutes les 60 secondes sur une session active. Cette policy est provider-owned, non configurable et ne s’applique pas aux sessions `SolanaStandard`. Une perte physique suit le reconnect/resubscribe borné déjà décrit; aucun replay/lossless n’est promis par la couche WebSocket.
|
||||||
|
|
||||||
|
LaserStream **gRPC** reste un backend distinct, hors de cette façade, de `WsProtocolKind` et de la Config WebSocket `helius_laserstream`.
|
||||||
|
|
||||||
|
## 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 n’est réexporté.
|
||||||
|
|
||||||
|
La surface publique principale comprend :
|
||||||
|
|
||||||
|
```text
|
||||||
|
YellowstoneGrpcEndpointUrl / YellowstoneGrpcEndpointSettings
|
||||||
|
YellowstoneGrpcSessionSettings / YellowstoneGrpcReconnectSettings
|
||||||
|
YellowstoneGrpcTransportSettings
|
||||||
|
YellowstoneGrpcChannel
|
||||||
|
SolanaYellowstoneGrpcUnaryClient
|
||||||
|
YellowstoneSubscribeRequest
|
||||||
|
SolanaYellowstoneGrpcSubscribeSession
|
||||||
|
YellowstoneGrpcSubscribeSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Les sept unary standards retenus sont `SubscribeReplayInfo`, `Ping`, `GetLatestBlockhash`, `GetBlockHeight`, `GetSlot`, `IsBlockhashValid` et `GetVersion`. `Subscribe` couvre accounts, slots, transactions, transaction status, blocks, block metadata et entries, avec `commitment`, `accounts_data_slice`, `ping` et `from_slot`. `SubscribeDeshred` reste hors scope de la foundation standard.
|
||||||
|
|
||||||
|
Le stream bidirectionnel est borné : request/update queues, tailles inbound/outbound, half-close, close timeout et reconnect budget sont explicites. Après reconnect, KSP rejoue la dernière requête complète acceptée et avance prudemment `from_slot` selon le dernier slot observé et `SubscribeReplayInfo.first_available`. Les compteurs de gap et duplicate sont de l’observabilité ; ils ne constituent jamais une garantie exactly-once ou lossless.
|
||||||
|
|
||||||
|
Config Transport V3 peut mapper des `grpc_endpoints` vers ces settings sans inverser la dépendance. `protocol = solana_yellowstone` décrit le wire standard, tandis que `provider` reste un descripteur distinct. Les profils committés `publicnode_mainnet` et `publicnode_testnet` utilisent le standard sans façade PublicNode spécifique. Leur personal token est injecté comme metadata secrète `x-token` depuis Config ; Transport ne lit jamais les variables KSP.
|
||||||
|
|
||||||
|
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 n’est committé ni placé dans l’URL. Le même token a été validé par l’opé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. L’endpoint 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 qu’aucune 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 :
|
||||||
@@ -121,31 +283,53 @@ La configuration Logging de référence conserve un fichier dédié Transport à
|
|||||||
|
|
||||||
Les tests par défaut sont déterministes et n'exigent pas Internet : fixtures JSON et serveur HTTP local couvrent requêtes, réponses, retry, 429, timeout, redaction et routing.
|
Les tests par défaut sont déterministes et n'exigent pas Internet : fixtures JSON et serveur HTTP local couvrent requêtes, réponses, retry, 429, timeout, redaction et routing.
|
||||||
|
|
||||||
Deux smokes Devnet opt-in sont séparés par responsabilité :
|
Quatre smokes réseau opt-in sont séparés par responsabilité :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Transport pur : settings programmatiques -> HttpTransportPool
|
Transport HTTP pur : settings programmatiques -> HttpTransportPool
|
||||||
-> Accounts/Tokens/Cluster représentatifs
|
-> Accounts/Tokens/Cluster représentatifs
|
||||||
-> trois reads Transactions
|
-> trois reads Transactions
|
||||||
-> getBlockHeight
|
-> getBlockHeight
|
||||||
-> getInflationRate/getStakeMinimumDelegation
|
-> getInflationRate/getStakeMinimumDelegation
|
||||||
|
|
||||||
|
Transport WebSocket pur : settings programmatiques -> WsSession
|
||||||
|
-> slotSubscribe
|
||||||
|
-> une slotNotification sous timeout
|
||||||
|
-> slotUnsubscribe
|
||||||
|
-> close
|
||||||
|
|
||||||
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 PublicNode : settings programmatiques + x-token secret
|
||||||
|
-> Mainnet + Testnet
|
||||||
|
-> 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 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.
|
||||||
|
|
||||||
Les deux sont `ignored` par défaut. Le smoke Transport appartient 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.
|
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 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 n’est committé en `0.2.8-pre.010`. Un tel test devrait à la fois obtenir `KSP_SECRET_HELIUS_API_KEY` via Config et exercer Transport ; l’ajouter dans Transport violerait l’ownership environnement/secret, tandis que l’ajouter dans Config étendrait l’exception cross-crates que le projet veut au contraire résorber. La première surface KSP d’inté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.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw, smokes et inspection runtime ;
|
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw, smokes et inspection runtime ;
|
||||||
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — foundation HTTP stable ;
|
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — foundation HTTP stable ;
|
||||||
- [`../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — extension typed Accounts/Tokens/Cluster ;
|
- [`../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — extension typed Accounts/Tokens/Cluster ;
|
||||||
- [`../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée `0.2.2` ;
|
- [`../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée Accounts/Tokens/Cluster ;
|
||||||
- [`../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé de `0.2.3` ;
|
- [`../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé Transactions ;
|
||||||
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée `0.2.3` ;
|
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée Transactions ;
|
||||||
- [`../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) — plan Blocks/Economics et compliance HTTP finale ;
|
- [`../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) — plan Blocks/Economics et compliance HTTP finale ;
|
||||||
- [`../../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 ;
|
||||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP.
|
- [`../../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/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.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||||
<!-- version: 9 -->
|
<!-- version: 23 -->
|
||||||
|
|
||||||
# Utilisation de `ksp-onchain-transport-lib`
|
# Utilisation de `ksp-onchain-transport-lib`
|
||||||
|
|
||||||
@@ -65,7 +65,333 @@ let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into
|
|||||||
|
|
||||||
Le document standard peut contenir une URL provenant d'un `KSP_SECRET_*`. La valeur réelle est transmise au runtime, mais les projections sûres et `Debug` restent redacted.
|
Le document standard peut contenir une URL provenant d'un `KSP_SECRET_*`. La valeur réelle est transmise au runtime, mais les projections sûres et `Debug` restent redacted.
|
||||||
|
|
||||||
## 3. Appels typés
|
## 3. Session physique WebSocket
|
||||||
|
|
||||||
|
Un consumer peut créer explicitement une session physique WebSocket :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let ws_url = match ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com") {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let endpoint = ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||||
|
"devnet_public",
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
|
||||||
|
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
|
||||||
|
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
|
||||||
|
ws_url,
|
||||||
|
ksp_onchain_transport_lib::WsSessionSettings::default(),
|
||||||
|
);
|
||||||
|
let session = match ksp_onchain_transport_lib::WsSession::connect(endpoint).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let snapshot = session.snapshot();
|
||||||
|
```
|
||||||
|
|
||||||
|
Deux appels `WsSession::connect` avec le même endpoint créent volontairement deux connexions physiques distinctes. Il n'existe encore aucun pool de sessions automatique.
|
||||||
|
|
||||||
|
Le socket brut et la primitive JSON-RPC générique ne sont pas publics. Le moteur générique de subscription typed reste `pub(crate)` ; il ne constitue donc pas une escape hatch provider-specific.
|
||||||
|
|
||||||
|
`WsSubscription<T>` est le handle public commun retourné par les wrappers standards. Il porte un `WsSubscriptionId` local stable, jamais le remote ID numérique du serveur. Les notifications arrivent via un receiver typed borné et `unsubscribe().await` exécute le `*Unsubscribe` correspondant en préservant son résultat booléen.
|
||||||
|
|
||||||
|
Le snapshot de session expose les subscriptions actuellement enregistrées via `WsSubscriptionSnapshot`, avec `remote_bound: bool` seulement. Le remote ID réel n'est jamais projeté.
|
||||||
|
|
||||||
|
### Premiers wrappers standards publics
|
||||||
|
|
||||||
|
Trois familles stables peuvent être créées directement sur la session :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let account = match "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let account_config = ksp_onchain_transport_lib::SolanaAccountSubscribeConfig::new(
|
||||||
|
Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64),
|
||||||
|
None,
|
||||||
|
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||||
|
);
|
||||||
|
let mut account_subscription = match session.account_subscribe(&account, Some(&account_config)).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let next = account_subscription.recv().await;
|
||||||
|
let removed = account_subscription.unsubscribe().await;
|
||||||
|
```
|
||||||
|
|
||||||
|
La même session expose `program_subscribe()` avec `SolanaProgramSubscribeConfig` et `logs_subscribe()` avec `SolanaLogsSubscribeFilter` plus `SolanaCommitmentConfig`. Pour `logsSubscribe`, `Mentions(pubkey)` représente exactement une adresse, conformément à la contrainte upstream retenue par l'audit.
|
||||||
|
|
||||||
|
`accountSubscribe` ne propose pas `minContextSlot`: le champ existe dans un config partagé upstream mais est ignoré par le handler PubSub audité. `programSubscribe` conserve en revanche `withContext`; `SolanaProgramNotification` permet au consumer de traiter explicitement une notification contextée ou non contextée.
|
||||||
|
|
||||||
|
Les trois wrappers retournent le même handle `WsSubscription<T>` : reconnect, resubscribe, overflow, cause terminale et unsubscribe restent donc uniformes. Aucun wrapper public n'accepte un nom de méthode JSON-RPC arbitraire ni un remote subscription ID.
|
||||||
|
|
||||||
|
### Lot stable B : signature, slot et root
|
||||||
|
|
||||||
|
La session expose également les familles stables suivantes :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new(
|
||||||
|
Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
|
||||||
|
Some(true),
|
||||||
|
);
|
||||||
|
let mut signature_subscription = match session.signature_subscribe("<base58-signature>", Some(&signature_config)).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
while let Some(notification) = signature_subscription.recv().await {
|
||||||
|
let notification = match notification {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
if notification.value().is_terminal() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Avec `enableReceivedNotification = true`, `ReceivedSignature` peut arriver avant la variante terminale `Processed { err }`. La variante terminale ferme automatiquement le handle KSP, sans `signatureUnsubscribe` supplémentaire et sans resubscribe lors d'une reconnexion ultérieure. Une cancellation explicite avant cette notification terminale reste possible via `unsubscribe().await`.
|
||||||
|
|
||||||
|
`slot_subscribe().await` retourne un `WsSubscription<SolanaSlotNotification>` dont les getters exposent `slot`, `parent` et `root`. `root_subscribe().await` retourne un `WsSubscription<u64>`. Ces deux méthodes n'acceptent aucune configuration ni aucun paramètre RPC.
|
||||||
|
|
||||||
|
### Familles unstable : block, slotsUpdates et vote
|
||||||
|
|
||||||
|
Les trois familles unstable standard sont également typées. Leur utilisation déclenche un warning KSP centralisé :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let block_config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new(
|
||||||
|
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||||
|
Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
|
||||||
|
Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures),
|
||||||
|
Some(0),
|
||||||
|
Some(false),
|
||||||
|
);
|
||||||
|
let mut blocks = match session
|
||||||
|
.block_subscribe(&ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::All, Some(&block_config))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`blockSubscribe` requiert un validator qui active la capability upstream correspondante. Une erreur applicative RPC liée à cette capability est renvoyée au caller sans reconnect de la session. `processed` est refusé localement ; `confirmed` et `finalized` sont admis. `maxSupportedTransactionVersion` n'est pas limité artificiellement à `0`.
|
||||||
|
|
||||||
|
`slots_updates_subscribe().await` délivre `SolanaSlotUpdate`. Les sept variantes courantes sont structurées ; une variante inconnue reste consommable via `Unknown` et `unknown_raw()`, sous la borne de taille WebSocket déjà appliquée avant décodage.
|
||||||
|
|
||||||
|
`vote_subscribe().await` délivre `SolanaVoteNotification`. `timestamp()` retourne `Option<i64>` pour conserver omission/null/value. Ce flux reste gossip et pre-consensus : le consumer ne doit pas l'assimiler à une confirmation ledger.
|
||||||
|
|
||||||
|
Les trois familles utilisent le même `WsSubscription::unsubscribe().await`; aucun remote subscription ID n'entre dans l'API publique.
|
||||||
|
|
||||||
|
### Façade Helius LaserStream WebSocket
|
||||||
|
|
||||||
|
Pour un endpoint Config `kind = "helius_laserstream"`, le consumer doit sélectionner l’endpoint WebSocket résolu puis ouvrir la façade Helius, sans reconstruire ni journaliser l’URL contenant l’API key :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resolved = match engine.load_resolved_transport_config(Some("helius_devnet"), &environment) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let ws_settings = match resolved.ws_settings() {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return Err(ksp_core_lib::Error::new(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS, "Helius profile requires WebSocket settings")),
|
||||||
|
};
|
||||||
|
let endpoint = match ws_settings
|
||||||
|
.endpoints()
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.enabled() && candidate.protocol() == ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream)
|
||||||
|
{
|
||||||
|
Some(value) => value.clone(),
|
||||||
|
None => return Err(ksp_core_lib::Error::new(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS, "Helius WebSocket endpoint is unavailable")),
|
||||||
|
};
|
||||||
|
let session = match ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(endpoint).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let mut slots = match session.slot_subscribe().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let notification = slots.recv().await;
|
||||||
|
let removed = slots.unsubscribe().await;
|
||||||
|
let closed = session.close().await;
|
||||||
|
```
|
||||||
|
|
||||||
|
La façade Helius réutilise `account`, `logs`, `program`, `root`, `signature`, `slot` et `slotsUpdates`. `slotsUpdates` reste unstable. `block` et `vote` ne sont pas exposés. `transaction_subscribe()` prend `HeliusTransactionSubscribeRequest` et retourne `WsSubscription<HeliusTransactionNotification>` ; son unsubscribe reste porté par le handle et produit `transactionUnsubscribe` sans exposer l’ID distant.
|
||||||
|
|
||||||
|
Le heartbeat Helius est automatique : `WsSession` envoie un control frame `Ping` toutes les 60 secondes tant que la session Helius est active. Le consumer ne configure pas un second timer et ne lance pas un task heartbeat parallèle. Cette règle ne vaut pas pour `SolanaStandardWsSession`.
|
||||||
|
|
||||||
|
`KSP_SECRET_HELIUS_API_KEY` appartient à Config. Ne pas lire l’environnement dans Transport, ne pas recopier l’URL résolue dans un log et ne pas ajouter un dev-dependency inverse `Transport -> Config`. LaserStream gRPC reste un backend différent et ne doit pas réutiliser `WsProtocolKind::HeliusLaserStream`.
|
||||||
|
|
||||||
|
### Reconnect automatique borné
|
||||||
|
|
||||||
|
Les settings de session contrôlent le reconnect physique. Une perte de socket publie `Reconnecting { attempt }`, invalide les remote IDs et incrémente `continuity_gap_count`. Avec la policy par défaut `ActiveSubscriptions`, les handles logiques gardent leur `WsSubscriptionId` et passent temporairement en `Resubscribing`; l'actor recrée leurs subscriptions dans l'ordre local avant de republier `Active`.
|
||||||
|
|
||||||
|
`WsResubscribePolicy::Never` reconnecte uniquement la session physique : les subscriptions existantes deviennent terminales et doivent être recréées explicitement par le consumer. Dans les deux modes, les requests applicatives qui étaient en vol lors de la coupure échouent et ne sont pas rejouées implicitement.
|
||||||
|
|
||||||
|
`unsubscribe().await` peut être appelé pendant `Reconnecting` ou `Resubscribing`. La cancellation locale gagne et le handle ne redevient jamais `Active`. Un ACK distant tardif est nettoyé best-effort par l'actor. Aucun backfill HTTP n'est déclenché automatiquement ; le consumer doit traiter `continuity_gap_count` comme un signal de réconciliation éventuelle.
|
||||||
|
|
||||||
|
### Backpressure par subscription
|
||||||
|
|
||||||
|
`WsSessionSettings::notification_queue_capacity()` borne la queue de chaque `WsSubscription<T>`. Le consumer doit donc drainer `recv()` selon son débit métier. Une queue pleine ne bloque pas l'actor et n'affecte pas les autres subscriptions : la subscription lente devient terminale avec `state() == Failed` et `terminal_error_code() == Some(ERROR_CODE_WS_BACKPRESSURE_OVERFLOW)`, tandis que `WsSessionSnapshot::overflow_count()` est incrémenté.
|
||||||
|
|
||||||
|
Un échec terminal non lié à l'overflow expose lui aussi un `ErrorCode` KSP sûr via `terminal_error_code()`. Une fermeture normale conserve `None`. Cette projection ne contient ni payload de notification, ni remote subscription ID, ni URL d'endpoint.
|
||||||
|
|
||||||
|
`max_active_subscriptions` borne séparément le nombre d'entrées logiques enregistrées. Son rejet utilise le même domaine d'erreur de capacité mais n'incrémente pas `overflow_count`, réservé aux queues de notifications saturées. Une subscription fermée ou nettoyée après abandon de son receiver libère sa capacité locale ; l'actor tente aussi de supprimer son binding distant sans rendre ce cleanup bloquant.
|
||||||
|
|
||||||
|
Le consumer doit traiter `overflow_count` et `continuity_gap_count` comme deux signaux distincts : le premier indique une perte locale par saturation d'un consumer, le second une interruption de continuité liée à une reconnexion. Aucun des deux n'implique un replay ou un backfill automatique.
|
||||||
|
|
||||||
|
### Fermeture explicite
|
||||||
|
|
||||||
|
Fermer explicitement la session est la voie normale de shutdown :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let session = ksp_onchain_transport_lib::WsSession::connect(endpoint).await?;
|
||||||
|
// ... wrappers standard puis recv()/unsubscribe() ...
|
||||||
|
session.close().await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
`close()` agit sur toute la session physique, y compris les clones du handle. Il annule les requests en attente, publie `Closing`, tente le Close WebSocket dans le budget configuré, puis publie `Closed`. Une session `Closed` refuse les nouvelles requests internes.
|
||||||
|
|
||||||
|
Les limites de taille et de capacité sont des policies KSP configurables par `WsSessionSettings`; elles ne doivent pas être interprétées comme des limites protocolaires Solana officielles.
|
||||||
|
|
||||||
|
Le snapshot expose seulement l'identité locale, les metadata logiques de l'endpoint, l'état, les compteurs sûrs et les projections locales de subscriptions. L'URL et les remote subscription IDs ne sont jamais projetés. La disparition de tous les handles de session déclenche le cleanup actor best-effort ; `close().await` reste la voie normale de shutdown.
|
||||||
|
|
||||||
|
## 4. Yellowstone gRPC standard
|
||||||
|
|
||||||
|
### Construction programmatique et unary
|
||||||
|
|
||||||
|
Transport peut ouvrir directement un endpoint Yellowstone sans Config :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let grpc_url = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse(
|
||||||
|
"https://solana-yellowstone-grpc.publicnode.com:443",
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let x_token_metadata = match ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret(
|
||||||
|
"x-token",
|
||||||
|
x_token,
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let grpc_endpoint = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||||
|
"publicnode_mainnet_yellowstone",
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new("publicnode"),
|
||||||
|
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new("mainnet-beta"),
|
||||||
|
grpc_url,
|
||||||
|
ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::default(),
|
||||||
|
)
|
||||||
|
.with_metadata(vec![x_token_metadata])
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let grpc_channel = match ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(&grpc_endpoint).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let grpc = grpc_channel.standard_unary_client();
|
||||||
|
let version = grpc.get_version().await;
|
||||||
|
let slot = grpc
|
||||||
|
.get_slot(Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed))
|
||||||
|
.await;
|
||||||
|
```
|
||||||
|
|
||||||
|
L’URL reste sensible : `Debug`, erreurs KSP et snapshots n’en exposent pas la valeur. Les metadata publiques/secrètes se construisent avec `YellowstoneGrpcMetadataEntry`; Transport ne lit jamais l’environnement. PublicNode requiert actuellement une metadata secrète `x-token` pour les endpoints Yellowstone validés ; ici `x_token` représente une valeur déjà obtenue par un caller sécurisé. En usage normal, Config construit cette metadata depuis les variables `KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN` ou `KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN`. Les appels unary montrés ci-dessus illustrent la surface KSP standard ; le smoke PublicNode de release ne prétend pas valider leur entitlement provider et gate uniquement `Subscribe` slots.
|
||||||
|
|
||||||
|
### Config Transport V3
|
||||||
|
|
||||||
|
Avec `ksp-config-lib`, un profil V3 peut exposer les trois transports sans casser l’accesseur historique HTTP + WS :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resolved = match engine.load_resolved_transport_config(Some("publicnode_mainnet"), &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 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 profile has no enabled Yellowstone gRPC endpoint",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let channel = ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(endpoint).await;
|
||||||
|
```
|
||||||
|
|
||||||
|
`protocol = solana_yellowstone` est validé par Config et reste distinct du descripteur `provider`. Une valeur provider n’autorise 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.
|
||||||
|
|
||||||
|
L’endpoint 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
|
||||||
|
|
||||||
|
Une session standard part d’une requête typed complète :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
|
||||||
|
let name = match ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("slots") {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
if let Err(error) = request.insert_slot_filter(
|
||||||
|
name,
|
||||||
|
ksp_onchain_transport_lib::YellowstoneSubscribeSlotFilter::new(),
|
||||||
|
) {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
request.set_commitment(Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||||
|
let mut stream = match grpc_channel.open_standard_subscribe(request).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let update = stream.next_update().await;
|
||||||
|
let snapshot = stream.snapshot();
|
||||||
|
let closed = stream.close().await;
|
||||||
|
```
|
||||||
|
|
||||||
|
`try_update()` remplace dynamiquement la requête complète tant que la session est `Active`. Une mutation pendant `Reconnecting` est refusée pour éviter une application ambiguë. Le snapshot expose reconnects, replay attempts, gaps, duplicates, dernier `from_slot` demandé et dernier slot observé, sans endpoint ni payload arbitraire.
|
||||||
|
|
||||||
|
Le reconnect réutilise la dernière requête acceptée et peut avancer `from_slot`, mais le consumer doit traiter cette reprise comme best-effort. KSP ne promet ni exactly-once, ni replay historique complet, ni absence de fork/equivocation entre nœuds.
|
||||||
|
|
||||||
|
## 5. Appels typés
|
||||||
|
|
||||||
Les wrappers typés se trouvent directement sur `HttpTransportPool`.
|
Les wrappers typés se trouvent directement sur `HttpTransportPool`.
|
||||||
|
|
||||||
@@ -86,7 +412,7 @@ let balance = pool
|
|||||||
.await;
|
.await;
|
||||||
```
|
```
|
||||||
|
|
||||||
Les quatre canaris `0.2.1` restent disponibles. `0.2.2` ajoute les wrappers typés Accounts, Tokens et Cluster. Exemples représentatifs :
|
Les wrappers foundation, Accounts, Tokens et Cluster sont disponibles directement sur le pool. Exemples représentatifs :
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let account = pool
|
let account = pool
|
||||||
@@ -96,7 +422,7 @@ let epoch = pool.get_epoch_info(&role, None).await;
|
|||||||
let vote_accounts = pool.get_vote_accounts(&role, None).await;
|
let vote_accounts = pool.get_vote_accounts(&role, None).await;
|
||||||
```
|
```
|
||||||
|
|
||||||
La release stable `0.2.4` contient les **52 wrappers typés courants** : 4 foundation + 22 Accounts/Tokens/Cluster + 11 Transactions + 10 Blocks + 5 Economics. Les DTOs Transport conservent les `null`, options, overloads et formes wire sans décodage Program/SPL métier.
|
La surface HTTP contient **52 wrappers typés courants** : 4 foundation + 22 Accounts/Tokens/Cluster + 11 Transactions + 10 Blocks + 5 Economics. Les DTOs Transport conservent les `null`, options, overloads et formes wire sans décodage Program/SPL métier.
|
||||||
|
|
||||||
Exemples Transaction représentatifs :
|
Exemples Transaction représentatifs :
|
||||||
|
|
||||||
@@ -125,7 +451,7 @@ let stake_minimum = pool.get_stake_minimum_delegation(&role, Some(&context)).awa
|
|||||||
|
|
||||||
`getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant.
|
`getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant.
|
||||||
|
|
||||||
## 4. Exécution JSON-RPC standard générique
|
## 6. Exécution JSON-RPC standard générique
|
||||||
|
|
||||||
Une méthode courante auditée peut être appelée via son descriptor :
|
Une méthode courante auditée peut être appelée via son descriptor :
|
||||||
|
|
||||||
@@ -139,7 +465,7 @@ Cette API retourne un `serde_json::Value`. Elle reste utile pour les extensions
|
|||||||
|
|
||||||
Avant exécution, `ensure_runtime_supported()` est appliqué. Une méthode historique `Removed` retourne `ERROR_CODE_METHOD_REMOVED` au lieu d'émettre un appel réseau fictif.
|
Avant exécution, `ensure_runtime_supported()` est appliqué. Une méthode historique `Removed` retourne `ERROR_CODE_METHOD_REMOVED` au lieu d'émettre un appel réseau fictif.
|
||||||
|
|
||||||
## 5. Sélection et admission sans exécuter la requête
|
## 7. Sélection et admission sans exécuter la requête
|
||||||
|
|
||||||
Pour inspecter le routing :
|
Pour inspecter le routing :
|
||||||
|
|
||||||
@@ -154,13 +480,13 @@ Dans le même bloc, `acquire_for_method()` réserve réellement la capacité RPS
|
|||||||
|
|
||||||
`HttpRequestPermit` détient la capacité de concurrence jusqu'à sa destruction. Aucun verrou synchrone n'est conservé pendant l'attente réseau.
|
`HttpRequestPermit` détient la capacité de concurrence jusqu'à sa destruction. Aucun verrou synchrone n'est conservé pendant l'attente réseau.
|
||||||
|
|
||||||
## 6. Snapshots runtime
|
## 8. Snapshots runtime
|
||||||
|
|
||||||
`HttpTransportPool::snapshot()` fournit une vue sûre des endpoints/rôles : disponibilité, limites, requêtes en vol, cooldown restant et compteurs runtime.
|
`HttpTransportPool::snapshot()` fournit une vue sûre des endpoints/rôles : disponibilité, limites, requêtes en vol, cooldown restant et compteurs runtime.
|
||||||
|
|
||||||
Les URLs d'endpoint n'y apparaissent jamais.
|
Les URLs d'endpoint n'y apparaissent jamais.
|
||||||
|
|
||||||
## 7. Retry et write submissions
|
## 9. Retry et write submissions
|
||||||
|
|
||||||
La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`.
|
La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`.
|
||||||
|
|
||||||
@@ -168,7 +494,7 @@ Les reads/simulations classés `RetrySafe` peuvent être réessayés dans le bud
|
|||||||
|
|
||||||
Pour une opération `WriteSubmission / NeverAfterDispatch`, un timeout ou autre résultat ambigu après dispatch arrête la resoumission automatique. Le consumer métier ne doit pas contourner cette protection avec une boucle de retry externe aveugle.
|
Pour une opération `WriteSubmission / NeverAfterDispatch`, un timeout ou autre résultat ambigu après dispatch arrête la resoumission automatique. Le consumer métier ne doit pas contourner cette protection avec une boucle de retry externe aveugle.
|
||||||
|
|
||||||
## 8. Logging
|
## 10. Logging
|
||||||
|
|
||||||
Les événements Transport utilisent le target :
|
Les événements Transport utilisent le target :
|
||||||
|
|
||||||
@@ -180,9 +506,9 @@ Ne jamais journaliser l'URL complète, un token provider, un body massif, une tr
|
|||||||
|
|
||||||
La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement.
|
La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement.
|
||||||
|
|
||||||
## 9. Smokes Devnet opt-in
|
## 11. Smokes réseau opt-in
|
||||||
|
|
||||||
Le smoke **Transport pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif d'Accounts/Tokens/Cluster, trois reads Transactions, puis des reads Blocks/Economics de la release stable `0.2.4` :
|
Le smoke **Transport HTTP pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif d'Accounts/Tokens/Cluster, trois reads Transactions, puis des reads Blocks/Economics :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
|
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||||
@@ -190,7 +516,60 @@ cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignor
|
|||||||
|
|
||||||
Il appelle `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo`, `getVoteAccounts`, puis `getLatestBlockhash`, `isBlockhashValid`, `getTransactionCount`, `getBlockHeight`, `getInflationRate` et `getStakeMinimumDelegation`. La branche Token suit la forme Devnet documentée : owner Pubkey ordinaire de l'exemple officiel, selector `programId` avec l'ID canonique du programme SPL Token, puis config explicite `commitment: finalized` + `encoding: jsonParsed`. Une réponse vide reste acceptable. La branche Transaction reste read-only : elle ne déclenche ni airdrop ni soumission de transaction et ne remplace pas les fixtures déterministes couvrant les 11 wrappers.
|
Il appelle `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo`, `getVoteAccounts`, puis `getLatestBlockhash`, `isBlockhashValid`, `getTransactionCount`, `getBlockHeight`, `getInflationRate` et `getStakeMinimumDelegation`. La branche Token suit la forme Devnet documentée : owner Pubkey ordinaire de l'exemple officiel, selector `programId` avec l'ID canonique du programme SPL Token, puis config explicite `commitment: finalized` + `encoding: jsonParsed`. Une réponse vide reste acceptable. La branche Transaction reste read-only : elle ne déclenche ni airdrop ni soumission de transaction et ne remplace pas les fixtures déterministes couvrant les 11 wrappers.
|
||||||
|
|
||||||
Le smoke historique de **composition Config -> Transport** reste également disponible :
|
Le smoke **Transport WebSocket pur** utilise l'endpoint public Devnet standard avec des settings programmatiques, ouvre une session physique, crée une subscription stable `slotSubscribe`, attend une notification bornée, vérifie une valeur de slot non nulle, annule la subscription avec son handle puis ferme explicitement la session :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p ksp-onchain-transport-lib --test websocket_devnet_smoke -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Il n'utilise ni `blockSubscribe`, ni `slotsUpdatesSubscribe`, ni `voteSubscribe` : ces familles restent unstable et leur disponibilité dépend des capabilities du validator. Le smoke live n'est donc pas un gate de disponibilité de ces extensions.
|
||||||
|
|
||||||
|
Le smoke **Transport Yellowstone gRPC PublicNode** reste indépendant de Config mais nécessite un personal token opérateur. Il teste Mainnet et Testnet en ouvrant `Subscribe`, en demandant les updates `slots`, en attendant un `YellowstoneSubscribeUpdate::Slot` non nul puis en fermant la session de manière bornée.
|
||||||
|
|
||||||
|
Pour éviter de placer les secrets dans les arguments ou l'URL, le harness lit deux lignes sur stdin : Mainnet puis Testnet. Elles peuvent contenir la même valeur ; l'opérateur a validé un même personal token sur les deux réseaux.
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints validés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Mainnet https://solana-yellowstone-grpc.publicnode.com:443
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 n’est donc requis.
|
||||||
|
|
||||||
|
Le smoke de **composition Config -> Transport** reste également disponible :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||||
@@ -198,4 +577,32 @@ cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapt
|
|||||||
|
|
||||||
Il valide le profil committé `devnet_public` et les quatre canaris foundation. Il reste transitoirement hébergé dans Config : les futurs smokes cross-crates ne doivent pas faire de Config leur destination générale et devront migrer vers une surface d'intégration/orchestration dédiée lorsqu'elle existera.
|
Il valide le profil committé `devnet_public` et les quatre canaris foundation. Il reste transitoirement hébergé dans Config : les futurs smokes cross-crates ne doivent pas faire de Config leur destination générale et devront migrer vers une surface d'intégration/orchestration dédiée lorsqu'elle existera.
|
||||||
|
|
||||||
Les endpoints publics Solana sont rate-limités et non destinés à la production. Un échec réseau externe n'est pas assimilé automatiquement à une régression locale ; les fixtures HTTP locales restent les gates reproductibles.
|
### Smoke Helius live
|
||||||
|
|
||||||
|
Aucun nouveau test Helius live n’est committé en `0.2.8-pre.010`. La raison est architecturale : Transport ne peut pas lire `KSP_SECRET_HELIUS_API_KEY` ni dépendre de Config, et Config ne doit pas devenir la destination générale des futurs smokes `Config + autre crate`. Créer un smoke Helius supplémentaire dans l’une de ces deux crates contournerait donc une frontière déjà documentée.
|
||||||
|
|
||||||
|
Lorsque la surface KSP d’intégration/orchestration dédiée existera, le smoke live minimal recommandé sera :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config helius_devnet
|
||||||
|
-> endpoint helius_laserstream résolu avec KSP_SECRET_HELIUS_API_KEY
|
||||||
|
-> HeliusLaserStreamWsSession::connect
|
||||||
|
-> slotSubscribe
|
||||||
|
-> une slotNotification sous timeout
|
||||||
|
-> slotUnsubscribe
|
||||||
|
-> close
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce scénario utilise une méthode standard stable sur l’endpoint Helius et teste donc auth + façade provider + actor + unsubscribe sans dépendre d’une entitlement particulière de `transactionSubscribe`. Un smoke `transactionSubscribe` pourra être ajouté séparément comme opt-in provider-specific si l’environnement opérateur possède les droits nécessaires ; il ne doit pas devenir un gate réseau obligatoire de la release.
|
||||||
|
|
||||||
|
Les endpoints publics/provider sont des dépendances externes. Un rate-limit, refus d’auth, entitlement absente ou incident réseau n’est pas assimilé automatiquement à une régression locale ; les fixtures HTTP/WebSocket/gRPC locales et les gates déterministes restent autoritaires.
|
||||||
|
|
||||||
|
Pour auditer les dépendances, inspecter également le graphe effectif après résolution Cargo :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo tree -p ksp-onchain-transport-lib
|
||||||
|
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||||
|
cargo tree --duplicates
|
||||||
|
```
|
||||||
|
|
||||||
|
Le premier graphe doit conserver la frontière `Transport -> Core + Logging + crates techniques`; il ne doit introduire aucune dépendance Config, Wallet, Store, Program ou `tracing` directe. Les sorties `--duplicates` sont un diagnostic de résolution transitive : une duplication n'est pas supprimée aveuglément si elle est imposée par des dépendances upstream incompatibles.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/src/constants.rs
|
// file: crates/ksp-onchain-transport-lib/src/constants.rs
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
//! Transport-owned tracing constants.
|
//! Transport-owned tracing constants.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
||||||
// version: 3
|
// version: 7
|
||||||
|
|
||||||
/// Error code used when no logical endpoint can satisfy a request.
|
/// Error code used when no logical endpoint can satisfy a request.
|
||||||
pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
|
pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
|
||||||
|
/// Error code used when a bounded Yellowstone gRPC request/update queue is exhausted.
|
||||||
|
pub const ERROR_CODE_GRPC_BACKPRESSURE_OVERFLOW: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_backpressure_overflow");
|
||||||
|
/// Error code used when a Yellowstone gRPC channel cannot be prepared safely.
|
||||||
|
pub const ERROR_CODE_GRPC_CHANNEL_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_channel_failed");
|
||||||
|
/// Error code used when a Yellowstone gRPC subscribe session is no longer available to the caller.
|
||||||
|
pub const ERROR_CODE_GRPC_SESSION_CLOSED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_session_closed");
|
||||||
|
/// Error code used when a Yellowstone gRPC endpoint returns a remote gRPC status.
|
||||||
|
pub const ERROR_CODE_GRPC_STATUS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_status");
|
||||||
/// Error code used when an HTTP connection cannot be established.
|
/// Error code used when an HTTP connection cannot be established.
|
||||||
pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_connection_failed");
|
pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_connection_failed");
|
||||||
/// Error code used when an HTTP request fails after a connection exists.
|
/// Error code used when an HTTP request fails after a connection exists.
|
||||||
@@ -27,3 +35,11 @@ pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::Error
|
|||||||
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
|
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
|
||||||
/// Error code used when a transport deadline expires.
|
/// Error code used when a transport deadline expires.
|
||||||
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
||||||
|
/// Error code used when a bounded WebSocket runtime queue or pending-request capacity is exhausted.
|
||||||
|
pub const ERROR_CODE_WS_BACKPRESSURE_OVERFLOW: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_backpressure_overflow");
|
||||||
|
/// Error code used when a physical WebSocket connection or handshake fails.
|
||||||
|
pub const ERROR_CODE_WS_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_connection_failed");
|
||||||
|
/// Error code used when WebSocket wire data violates the KSP protocol contract.
|
||||||
|
pub const ERROR_CODE_WS_PROTOCOL_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_protocol_error");
|
||||||
|
/// Error code used when a WebSocket session is no longer available to a caller.
|
||||||
|
pub const ERROR_CODE_WS_SESSION_CLOSED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_session_closed");
|
||||||
|
|||||||
191
crates/ksp-onchain-transport-lib/src/grpc_channel.rs
Normal file
191
crates/ksp-onchain-transport-lib/src/grpc_channel.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/grpc_channel.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// Prepared or connected Yellowstone gRPC channel owned by KSP Transport.
|
||||||
|
///
|
||||||
|
/// The underlying Tonic channel, endpoint URL and request metadata remain private. Callers use the KSP-owned typed Yellowstone surfaces layered on this
|
||||||
|
/// physical channel instead of receiving a raw Tonic escape hatch.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct YellowstoneGrpcChannel {
|
||||||
|
endpoint_name: std::string::String,
|
||||||
|
provider: crate::YellowstoneGrpcProviderName,
|
||||||
|
cluster: crate::YellowstoneGrpcClusterName,
|
||||||
|
channel: tonic::transport::Channel,
|
||||||
|
metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>,
|
||||||
|
session: crate::YellowstoneGrpcSessionSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneGrpcChannel {
|
||||||
|
/// Prepares one lazy HTTP/2 channel without establishing a network connection.
|
||||||
|
///
|
||||||
|
/// HTTPS endpoints receive the KSP TLS configuration immediately, so invalid local TLS setup is rejected before a typed client is created. Tonic lazy
|
||||||
|
/// channels require an active Tokio runtime even though no socket is opened yet.
|
||||||
|
pub fn prepare(settings: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
if let std::result::Result::Err(error) = settings.validate() {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if !settings.enabled() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "disabled Yellowstone gRPC endpoint cannot prepare a channel")
|
||||||
|
.with_context("endpoint_name", settings.name()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if tokio::runtime::Handle::try_current().is_err() {
|
||||||
|
return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC channel preparation requires an active Tokio runtime"));
|
||||||
|
}
|
||||||
|
let endpoint = match build_tonic_endpoint(settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let channel = endpoint.connect_lazy();
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = settings.name(),
|
||||||
|
provider = settings.provider().as_str(),
|
||||||
|
cluster = settings.cluster().as_str(),
|
||||||
|
tls = settings.url().uses_tls(),
|
||||||
|
metadata_count = settings.metadata().len(),
|
||||||
|
"prepared lazy Yellowstone gRPC channel"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(Self::from_parts(settings, channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Establishes one Yellowstone gRPC HTTP/2 channel with bounded connect timeout and configured TLS roots.
|
||||||
|
pub async fn connect(settings: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
if let std::result::Result::Err(error) = settings.validate() {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if !settings.enabled() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "disabled Yellowstone gRPC endpoint cannot connect")
|
||||||
|
.with_context("endpoint_name", settings.name()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if tokio::runtime::Handle::try_current().is_err() {
|
||||||
|
return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC connection requires an active Tokio runtime"));
|
||||||
|
}
|
||||||
|
let endpoint = match build_tonic_endpoint(settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let channel = match endpoint.connect().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = settings.name(),
|
||||||
|
provider = settings.provider().as_str(),
|
||||||
|
cluster = settings.cluster().as_str(),
|
||||||
|
tls = settings.url().uses_tls(),
|
||||||
|
"Yellowstone gRPC channel connection failed"
|
||||||
|
);
|
||||||
|
return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC channel connection failed"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = settings.name(),
|
||||||
|
provider = settings.provider().as_str(),
|
||||||
|
cluster = settings.cluster().as_str(),
|
||||||
|
tls = settings.url().uses_tls(),
|
||||||
|
"connected Yellowstone gRPC channel"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(Self::from_parts(settings, channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe logical endpoint name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn endpoint_name(&self) -> &str {
|
||||||
|
return self.endpoint_name.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the open provider descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provider(&self) -> &crate::YellowstoneGrpcProviderName {
|
||||||
|
return &self.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the open cluster descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn cluster(&self) -> &crate::YellowstoneGrpcClusterName {
|
||||||
|
return &self.cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the standard Solana Yellowstone unary facade over this physical channel.
|
||||||
|
#[must_use]
|
||||||
|
pub fn standard_unary_client(&self) -> crate::SolanaYellowstoneGrpcUnaryClient {
|
||||||
|
return crate::SolanaYellowstoneGrpcUnaryClient::new(self.channel.clone(), self.metadata.clone(), self.session.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens one standard Solana Yellowstone bidirectional `Subscribe` session over this physical channel.
|
||||||
|
pub async fn open_standard_subscribe(
|
||||||
|
&self,
|
||||||
|
initial_request: crate::YellowstoneSubscribeRequest,
|
||||||
|
) -> ksp_core_lib::Result<crate::SolanaYellowstoneGrpcSubscribeSession> {
|
||||||
|
return crate::open_yellowstone_subscribe_session(
|
||||||
|
self.channel.clone(),
|
||||||
|
self.metadata.clone(),
|
||||||
|
self.session.clone(),
|
||||||
|
self.endpoint_name.clone(),
|
||||||
|
self.provider.clone(),
|
||||||
|
self.cluster.clone(),
|
||||||
|
initial_request,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_parts(settings: &crate::YellowstoneGrpcEndpointSettings, channel: tonic::transport::Channel) -> Self {
|
||||||
|
return Self {
|
||||||
|
endpoint_name: settings.name().to_owned(),
|
||||||
|
provider: settings.provider().clone(),
|
||||||
|
cluster: settings.cluster().clone(),
|
||||||
|
channel,
|
||||||
|
metadata: settings.metadata().to_vec(),
|
||||||
|
session: settings.session().clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for YellowstoneGrpcChannel {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("YellowstoneGrpcChannel")
|
||||||
|
.field("endpoint_name", &self.endpoint_name)
|
||||||
|
.field("provider", &self.provider)
|
||||||
|
.field("cluster", &self.cluster)
|
||||||
|
.field("metadata_count", &self.metadata.len())
|
||||||
|
.field("channel", &"<private>")
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tonic_endpoint(settings: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result<tonic::transport::Endpoint> {
|
||||||
|
let endpoint = match tonic::transport::Endpoint::from_shared(settings.url().as_str().to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC endpoint URI cannot be prepared by the HTTP/2 transport"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let endpoint = endpoint.connect_timeout(settings.session().connect_timeout()).buffer_size(settings.session().request_channel_capacity());
|
||||||
|
if settings.url().uses_tls() {
|
||||||
|
let tls = tonic::transport::ClientTlsConfig::new().with_webpki_roots().timeout(settings.session().connect_timeout());
|
||||||
|
return match endpoint.tls_config(tls) {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC TLS configuration failed before connection"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grpc_channel_error(settings: &crate::YellowstoneGrpcEndpointSettings, message: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, message)
|
||||||
|
.with_context("endpoint_name", settings.name())
|
||||||
|
.with_context("provider", settings.provider().as_str())
|
||||||
|
.with_context("cluster", settings.cluster().as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/grpc_channel.rs"]
|
||||||
|
mod tests;
|
||||||
666
crates/ksp-onchain-transport-lib/src/grpc_settings.rs
Normal file
666
crates/ksp-onchain-transport-lib/src/grpc_settings.rs
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/grpc_settings.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
const DEFAULT_GRPC_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
const DEFAULT_GRPC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
const DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
const DEFAULT_GRPC_MAX_OUTBOUND_MESSAGE_SIZE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
const DEFAULT_GRPC_RECONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
|
||||||
|
const DEFAULT_GRPC_RECONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
const DEFAULT_GRPC_RECONNECT_MAX_RETRIES: u32 = 5;
|
||||||
|
const DEFAULT_GRPC_REQUEST_CHANNEL_CAPACITY: usize = 128;
|
||||||
|
const DEFAULT_GRPC_UNARY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
const DEFAULT_GRPC_UPDATE_CHANNEL_CAPACITY: usize = 256;
|
||||||
|
const MAX_GRPC_CHANNEL_CAPACITY: usize = 65_536;
|
||||||
|
const MAX_GRPC_DESCRIPTOR_LENGTH_BYTES: usize = 128;
|
||||||
|
const MAX_GRPC_ENDPOINT_COUNT: usize = 128;
|
||||||
|
const MAX_GRPC_ENDPOINT_URL_LENGTH_BYTES: usize = 8 * 1024;
|
||||||
|
const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 512 * 1024 * 1024;
|
||||||
|
const MAX_GRPC_METADATA_ENTRY_COUNT: usize = 64;
|
||||||
|
const MAX_GRPC_METADATA_KEY_LENGTH_BYTES: usize = 128;
|
||||||
|
const MAX_GRPC_METADATA_VALUE_LENGTH_BYTES: usize = 8 * 1024;
|
||||||
|
const MAX_GRPC_RECONNECT_RETRIES: u32 = 100;
|
||||||
|
const MAX_GRPC_RUNTIME_DURATION: std::time::Duration = std::time::Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// Runtime Yellowstone gRPC endpoint URL owned by Transport.
|
||||||
|
///
|
||||||
|
/// The actual URL can contain provider credentials. Its [`std::fmt::Debug`] implementation is intentionally redacted.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneGrpcEndpointUrl {
|
||||||
|
value: std::string::String,
|
||||||
|
uses_tls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneGrpcEndpointUrl {
|
||||||
|
/// Parses and validates one Yellowstone gRPC endpoint URL.
|
||||||
|
pub fn parse(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating Yellowstone gRPC endpoint URL");
|
||||||
|
let value = value.into();
|
||||||
|
if value.len() > MAX_GRPC_ENDPOINT_URL_LENGTH_BYTES {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC endpoint URL exceeds the KSP length bound")
|
||||||
|
.with_context("field", "grpc_endpoints.url"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let parsed = match reqwest::Url::parse(value.as_str()) {
|
||||||
|
std::result::Result::Ok(parsed) => parsed,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "grpc_endpoints.url", "rejected invalid Yellowstone gRPC endpoint URL");
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC endpoint URL is invalid")
|
||||||
|
.with_context("field", "grpc_endpoints.url")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
field = "grpc_endpoints.url",
|
||||||
|
scheme = parsed.scheme(),
|
||||||
|
"rejected Yellowstone gRPC endpoint URL with unsupported scheme"
|
||||||
|
);
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC endpoint URL must use http or https")
|
||||||
|
.with_context("field", "grpc_endpoints.url")
|
||||||
|
.with_context("scheme", parsed.scheme()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if parsed.host_str().is_none() {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "grpc_endpoints.url", "rejected Yellowstone gRPC endpoint URL without host");
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC endpoint URL must contain a host")
|
||||||
|
.with_context("field", "grpc_endpoints.url"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, scheme = parsed.scheme(), "validated Yellowstone gRPC endpoint URL syntax");
|
||||||
|
return std::result::Result::Ok(Self { value, uses_tls: parsed.scheme() == "https" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sensitive runtime URL text.
|
||||||
|
///
|
||||||
|
/// Callers must not write this value to logs, generic diagnostics or snapshots.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this endpoint URL requires TLS.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn uses_tls(&self) -> bool {
|
||||||
|
return self.uses_tls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for YellowstoneGrpcEndpointUrl {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str("YellowstoneGrpcEndpointUrl(<redacted>)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One validated ASCII metadata entry attached to Yellowstone gRPC requests.
|
||||||
|
///
|
||||||
|
/// Metadata values are intentionally omitted from [`std::fmt::Debug`] for both public and secret entries. Secret entries are additionally marked sensitive on
|
||||||
|
/// the Tonic metadata value before transmission so the HTTP/2 stack avoids indexing them where supported. Binary `*-bin` metadata is not part of the
|
||||||
|
/// `0.2.9-pre.003` contract.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneGrpcMetadataEntry {
|
||||||
|
key: std::string::String,
|
||||||
|
value: std::string::String,
|
||||||
|
secret: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneGrpcMetadataEntry {
|
||||||
|
/// Creates one non-secret ASCII metadata entry.
|
||||||
|
pub fn public(key: impl std::convert::Into<std::string::String>, value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::new(key.into(), value.into(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates one secret ASCII metadata entry with redacted diagnostics.
|
||||||
|
pub fn secret(key: impl std::convert::Into<std::string::String>, value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::new(key.into(), value.into(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated metadata key.
|
||||||
|
#[must_use]
|
||||||
|
pub fn key(&self) -> &str {
|
||||||
|
return self.key.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the value must be treated as secret by Transport.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_secret(&self) -> bool {
|
||||||
|
return self.secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends this validated value to an internal Tonic metadata map while preserving its sensitivity marker.
|
||||||
|
pub(crate) fn append_to(&self, metadata: &mut tonic::metadata::MetadataMap) -> ksp_core_lib::Result<()> {
|
||||||
|
let key = match tonic::metadata::MetadataKey::<tonic::metadata::Ascii>::from_bytes(self.key.as_bytes()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata key is invalid")
|
||||||
|
.with_context("field", "grpc_endpoint.metadata.key"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut value = match tonic::metadata::AsciiMetadataValue::try_from(self.value.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_INVALID_SETTINGS, "Yellowstone gRPC metadata value is invalid")
|
||||||
|
.with_context("field", "grpc_endpoint.metadata.value")
|
||||||
|
.with_context("metadata_key", self.key.as_str()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
value.set_sensitive(self.secret);
|
||||||
|
metadata.append(key, value);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(key: std::string::String, value: std::string::String, secret: bool) -> ksp_core_lib::Result<Self> {
|
||||||
|
if key.is_empty()
|
||||||
|
|| key.len() > MAX_GRPC_METADATA_KEY_LENGTH_BYTES
|
||||||
|
|| key != key.to_ascii_lowercase()
|
||||||
|
|| key.starts_with("grpc-")
|
||||||
|
|| key.ends_with("-bin")
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata key violates the KSP ASCII metadata contract")
|
||||||
|
.with_context("field", "grpc_endpoint.metadata.key"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if tonic::metadata::MetadataKey::<tonic::metadata::Ascii>::from_bytes(key.as_bytes()).is_err() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata key is invalid")
|
||||||
|
.with_context("field", "grpc_endpoint.metadata.key"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if value.len() > MAX_GRPC_METADATA_VALUE_LENGTH_BYTES || tonic::metadata::AsciiMetadataValue::try_from(value.as_str()).is_err() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata value violates the KSP ASCII metadata contract")
|
||||||
|
.with_context("field", "grpc_endpoint.metadata.value")
|
||||||
|
.with_context("metadata_key", key.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { key, value, secret });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for YellowstoneGrpcMetadataEntry {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("YellowstoneGrpcMetadataEntry")
|
||||||
|
.field("key", &self.key)
|
||||||
|
.field("secret", &self.secret)
|
||||||
|
.field("value", &"<redacted>")
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open provider descriptor used by Yellowstone gRPC endpoint settings.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct YellowstoneGrpcProviderName {
|
||||||
|
value: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneGrpcProviderName {
|
||||||
|
/// Creates an open provider descriptor. Validation is performed by [`YellowstoneGrpcTransportSettings::validate`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||||
|
return Self { value: value.into() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider descriptor text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open cluster or network descriptor used by Yellowstone gRPC endpoint settings.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct YellowstoneGrpcClusterName {
|
||||||
|
value: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneGrpcClusterName {
|
||||||
|
/// Creates an open cluster descriptor. Validation is performed by [`YellowstoneGrpcTransportSettings::validate`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||||
|
return Self { value: value.into() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the cluster descriptor text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded reconnect settings owned by the Yellowstone gRPC runtime.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneGrpcReconnectSettings {
|
||||||
|
max_retries: u32,
|
||||||
|
initial_backoff: std::time::Duration,
|
||||||
|
max_backoff: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneGrpcReconnectSettings {
|
||||||
|
/// Creates bounded reconnect settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(max_retries: u32, initial_backoff: std::time::Duration, max_backoff: std::time::Duration) -> Self {
|
||||||
|
return Self { max_retries, initial_backoff, max_backoff };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of reconnect attempts allowed after one live channel or stream is lost.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_retries(&self) -> u32 {
|
||||||
|
return self.max_retries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the initial reconnect backoff.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn initial_backoff(&self) -> std::time::Duration {
|
||||||
|
return self.initial_backoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum reconnect backoff.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_backoff(&self) -> std::time::Duration {
|
||||||
|
return self.max_backoff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::default::Default for YellowstoneGrpcReconnectSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
return Self::new(DEFAULT_GRPC_RECONNECT_MAX_RETRIES, DEFAULT_GRPC_RECONNECT_INITIAL_BACKOFF, DEFAULT_GRPC_RECONNECT_MAX_BACKOFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime limits and lifecycle settings for one Yellowstone gRPC physical channel/session path.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneGrpcSessionSettings {
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
unary_timeout: std::time::Duration,
|
||||||
|
close_timeout: std::time::Duration,
|
||||||
|
reconnect: crate::YellowstoneGrpcReconnectSettings,
|
||||||
|
request_channel_capacity: usize,
|
||||||
|
update_channel_capacity: usize,
|
||||||
|
max_inbound_message_size_bytes: usize,
|
||||||
|
max_outbound_message_size_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneGrpcSessionSettings {
|
||||||
|
/// Creates complete runtime settings for one physical Yellowstone gRPC channel/session path.
|
||||||
|
#[must_use]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub const fn new(
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
unary_timeout: std::time::Duration,
|
||||||
|
close_timeout: std::time::Duration,
|
||||||
|
reconnect: crate::YellowstoneGrpcReconnectSettings,
|
||||||
|
request_channel_capacity: usize,
|
||||||
|
update_channel_capacity: usize,
|
||||||
|
max_inbound_message_size_bytes: usize,
|
||||||
|
max_outbound_message_size_bytes: usize,
|
||||||
|
) -> Self {
|
||||||
|
return Self {
|
||||||
|
connect_timeout,
|
||||||
|
unary_timeout,
|
||||||
|
close_timeout,
|
||||||
|
reconnect,
|
||||||
|
request_channel_capacity,
|
||||||
|
update_channel_capacity,
|
||||||
|
max_inbound_message_size_bytes,
|
||||||
|
max_outbound_message_size_bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the connection establishment timeout.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn connect_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.connect_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default unary request timeout.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn unary_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.unary_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bounded graceful-close timeout.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn close_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.close_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the reconnect policy.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn reconnect(&self) -> &crate::YellowstoneGrpcReconnectSettings {
|
||||||
|
return &self.reconnect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bounded outgoing Subscribe request channel capacity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn request_channel_capacity(&self) -> usize {
|
||||||
|
return self.request_channel_capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bounded incoming update delivery channel capacity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn update_channel_capacity(&self) -> usize {
|
||||||
|
return self.update_channel_capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum inbound protobuf message size accepted by KSP.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_inbound_message_size_bytes(&self) -> usize {
|
||||||
|
return self.max_inbound_message_size_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum outbound protobuf message size accepted by KSP.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_outbound_message_size_bytes(&self) -> usize {
|
||||||
|
return self.max_outbound_message_size_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates KSP-owned runtime bounds without reading Config or environment state.
|
||||||
|
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_duration(self.connect_timeout, "grpc_session.connect_timeout") {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_duration(self.unary_timeout, "grpc_session.unary_timeout") {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_duration(self.close_timeout, "grpc_session.close_timeout") {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if self.reconnect.max_retries() > MAX_GRPC_RECONNECT_RETRIES {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC reconnect retry count exceeds the KSP runtime bound", "grpc_session.reconnect.max_retries");
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_duration(self.reconnect.initial_backoff(), "grpc_session.reconnect.initial_backoff") {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_duration(self.reconnect.max_backoff(), "grpc_session.reconnect.max_backoff") {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if self.reconnect.initial_backoff() > self.reconnect.max_backoff() {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC reconnect initial backoff must not exceed maximum backoff", "grpc_session.reconnect");
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_capacity(self.request_channel_capacity, "grpc_session.request_channel_capacity") {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_capacity(self.update_channel_capacity, "grpc_session.update_channel_capacity") {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_message_size(self.max_inbound_message_size_bytes, "grpc_session.max_inbound_message_size_bytes")
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) =
|
||||||
|
validate_grpc_message_size(self.max_outbound_message_size_bytes, "grpc_session.max_outbound_message_size_bytes")
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
request_channel_capacity = self.request_channel_capacity,
|
||||||
|
update_channel_capacity = self.update_channel_capacity,
|
||||||
|
max_inbound_message_size_bytes = self.max_inbound_message_size_bytes,
|
||||||
|
max_outbound_message_size_bytes = self.max_outbound_message_size_bytes,
|
||||||
|
reconnect_max_retries = self.reconnect.max_retries(),
|
||||||
|
"validated Yellowstone gRPC session settings"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::default::Default for YellowstoneGrpcSessionSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
return Self::new(
|
||||||
|
DEFAULT_GRPC_CONNECT_TIMEOUT,
|
||||||
|
DEFAULT_GRPC_UNARY_TIMEOUT,
|
||||||
|
DEFAULT_GRPC_CLOSE_TIMEOUT,
|
||||||
|
crate::YellowstoneGrpcReconnectSettings::default(),
|
||||||
|
DEFAULT_GRPC_REQUEST_CHANNEL_CAPACITY,
|
||||||
|
DEFAULT_GRPC_UPDATE_CHANNEL_CAPACITY,
|
||||||
|
DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE_BYTES,
|
||||||
|
DEFAULT_GRPC_MAX_OUTBOUND_MESSAGE_SIZE_BYTES,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime settings for one named Yellowstone gRPC endpoint.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneGrpcEndpointSettings {
|
||||||
|
name: std::string::String,
|
||||||
|
enabled: bool,
|
||||||
|
provider: crate::YellowstoneGrpcProviderName,
|
||||||
|
cluster: crate::YellowstoneGrpcClusterName,
|
||||||
|
url: crate::YellowstoneGrpcEndpointUrl,
|
||||||
|
session: crate::YellowstoneGrpcSessionSettings,
|
||||||
|
metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneGrpcEndpointSettings {
|
||||||
|
/// Creates explicit settings for one logical Yellowstone gRPC endpoint.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
name: impl std::convert::Into<std::string::String>,
|
||||||
|
enabled: bool,
|
||||||
|
provider: crate::YellowstoneGrpcProviderName,
|
||||||
|
cluster: crate::YellowstoneGrpcClusterName,
|
||||||
|
url: crate::YellowstoneGrpcEndpointUrl,
|
||||||
|
session: crate::YellowstoneGrpcSessionSettings,
|
||||||
|
) -> Self {
|
||||||
|
return Self { name: name.into(), enabled, provider, cluster, url, session, metadata: std::vec::Vec::new() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical endpoint name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
return self.name.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this endpoint can be used to prepare a physical channel.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn enabled(&self) -> bool {
|
||||||
|
return self.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the open provider descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provider(&self) -> &crate::YellowstoneGrpcProviderName {
|
||||||
|
return &self.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the open cluster descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn cluster(&self) -> &crate::YellowstoneGrpcClusterName {
|
||||||
|
return &self.cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sensitive Yellowstone gRPC endpoint URL wrapper.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn url(&self) -> &crate::YellowstoneGrpcEndpointUrl {
|
||||||
|
return &self.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the effective runtime settings for the channel/session path.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn session(&self) -> &crate::YellowstoneGrpcSessionSettings {
|
||||||
|
return &self.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns metadata entries in declaration order without exposing their values.
|
||||||
|
#[must_use]
|
||||||
|
pub fn metadata(&self) -> &[crate::YellowstoneGrpcMetadataEntry] {
|
||||||
|
return self.metadata.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces request metadata after validating KSP bounds and ASCII metadata rules.
|
||||||
|
pub fn with_metadata(mut self, metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>) -> ksp_core_lib::Result<Self> {
|
||||||
|
if metadata.len() > MAX_GRPC_METADATA_ENTRY_COUNT {
|
||||||
|
return grpc_invalid_settings_value("Yellowstone gRPC metadata entry count exceeds the KSP bound", "grpc_endpoint.metadata");
|
||||||
|
}
|
||||||
|
self.metadata = metadata;
|
||||||
|
if let std::result::Result::Err(error) = self.validate() {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates this endpoint without performing network I/O.
|
||||||
|
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||||
|
return validate_grpc_endpoint(self, "grpc_endpoint");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete runtime settings consumed by the KSP Yellowstone gRPC transport engine.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneGrpcTransportSettings {
|
||||||
|
endpoints: std::vec::Vec<crate::YellowstoneGrpcEndpointSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneGrpcTransportSettings {
|
||||||
|
/// Creates complete Yellowstone gRPC runtime settings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(endpoints: std::vec::Vec<crate::YellowstoneGrpcEndpointSettings>) -> Self {
|
||||||
|
return Self { endpoints };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns configured Yellowstone gRPC endpoints in declaration order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn endpoints(&self) -> &[crate::YellowstoneGrpcEndpointSettings] {
|
||||||
|
return self.endpoints.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates structural runtime invariants without reading Config or environment state.
|
||||||
|
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, endpoint_count = self.endpoints.len(), "validating Yellowstone gRPC transport settings");
|
||||||
|
if self.endpoints.is_empty() {
|
||||||
|
return grpc_invalid_settings("at least one Yellowstone gRPC endpoint must be configured", "grpc_endpoints");
|
||||||
|
}
|
||||||
|
if self.endpoints.len() > MAX_GRPC_ENDPOINT_COUNT {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC endpoint count exceeds the KSP bound", "grpc_endpoints");
|
||||||
|
}
|
||||||
|
let mut enabled_endpoint_count = 0_usize;
|
||||||
|
for (endpoint_index, endpoint) in self.endpoints.iter().enumerate() {
|
||||||
|
let field_prefix = format!("grpc_endpoints[{endpoint_index}]");
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_endpoint(endpoint, field_prefix.as_str()) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if endpoint.enabled() {
|
||||||
|
enabled_endpoint_count += 1;
|
||||||
|
}
|
||||||
|
for previous in &self.endpoints[..endpoint_index] {
|
||||||
|
if previous.name() == endpoint.name() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC endpoint names must be unique")
|
||||||
|
.with_context("field", format!("grpc_endpoints[{endpoint_index}].name"))
|
||||||
|
.with_context("endpoint_name", endpoint.name()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if enabled_endpoint_count == 0 {
|
||||||
|
return grpc_invalid_settings("at least one Yellowstone gRPC endpoint must be enabled", "grpc_endpoints.enabled");
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_count = self.endpoints.len(),
|
||||||
|
enabled_endpoint_count,
|
||||||
|
"validated Yellowstone gRPC transport settings"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_grpc_endpoint(endpoint: &crate::YellowstoneGrpcEndpointSettings, field_prefix: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
let name_field = format!("{field_prefix}.name");
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_descriptor(endpoint.name(), name_field.as_str()) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let provider_field = format!("{field_prefix}.provider");
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_descriptor(endpoint.provider().as_str(), provider_field.as_str()) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let cluster_field = format!("{field_prefix}.cluster");
|
||||||
|
if let std::result::Result::Err(error) = validate_grpc_descriptor(endpoint.cluster().as_str(), cluster_field.as_str()) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = endpoint.session().validate() {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if endpoint.metadata().len() > MAX_GRPC_METADATA_ENTRY_COUNT {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC metadata entry count exceeds the KSP bound", "grpc_endpoint.metadata");
|
||||||
|
}
|
||||||
|
ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = endpoint.name(),
|
||||||
|
provider = endpoint.provider().as_str(),
|
||||||
|
cluster = endpoint.cluster().as_str(),
|
||||||
|
enabled = endpoint.enabled(),
|
||||||
|
"validated Yellowstone gRPC endpoint settings"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_grpc_descriptor(value: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC transport descriptor must not be empty", field);
|
||||||
|
}
|
||||||
|
if value.trim() != value {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC transport descriptor must not contain leading or trailing whitespace", field);
|
||||||
|
}
|
||||||
|
if value.len() > MAX_GRPC_DESCRIPTOR_LENGTH_BYTES {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC transport descriptor exceeds the KSP length bound", field);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_grpc_duration(value: std::time::Duration, field: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if value.is_zero() {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC runtime duration must be greater than zero", field);
|
||||||
|
}
|
||||||
|
if value > MAX_GRPC_RUNTIME_DURATION {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC runtime duration exceeds the KSP bound", field);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_grpc_capacity(value: usize, field: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if value == 0 {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC channel capacity must be greater than zero", field);
|
||||||
|
}
|
||||||
|
if value > MAX_GRPC_CHANNEL_CAPACITY {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC channel capacity exceeds the KSP bound", field);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_grpc_message_size(value: usize, field: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if value == 0 {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC message size must be greater than zero", field);
|
||||||
|
}
|
||||||
|
if value > MAX_GRPC_MESSAGE_SIZE_BYTES {
|
||||||
|
return grpc_invalid_settings("Yellowstone gRPC message size exceeds the KSP bound", field);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grpc_invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, reason = message, "rejected Yellowstone gRPC transport settings");
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grpc_invalid_settings_value<T>(message: &str, field: &str) -> ksp_core_lib::Result<T> {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, reason = message, "rejected Yellowstone gRPC transport settings");
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/grpc_settings.rs"]
|
||||||
|
mod tests;
|
||||||
1146
crates/ksp-onchain-transport-lib/src/grpc_stream.rs
Normal file
1146
crates/ksp-onchain-transport-lib/src/grpc_stream.rs
Normal file
File diff suppressed because it is too large
Load Diff
3740
crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs
Normal file
3740
crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs
Normal file
File diff suppressed because it is too large
Load Diff
398
crates/ksp-onchain-transport-lib/src/grpc_unary.rs
Normal file
398
crates/ksp-onchain-transport-lib/src/grpc_unary.rs
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/grpc_unary.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
const MAX_YELLOWSTONE_BLOCKHASH_LENGTH_BYTES: usize = 256;
|
||||||
|
const MAX_YELLOWSTONE_VERSION_LENGTH_BYTES: usize = 512;
|
||||||
|
const PATH_GET_BLOCK_HEIGHT: &str = "/geyser.Geyser/GetBlockHeight";
|
||||||
|
const PATH_GET_LATEST_BLOCKHASH: &str = "/geyser.Geyser/GetLatestBlockhash";
|
||||||
|
const PATH_GET_SLOT: &str = "/geyser.Geyser/GetSlot";
|
||||||
|
const PATH_GET_VERSION: &str = "/geyser.Geyser/GetVersion";
|
||||||
|
const PATH_IS_BLOCKHASH_VALID: &str = "/geyser.Geyser/IsBlockhashValid";
|
||||||
|
const PATH_PING: &str = "/geyser.Geyser/Ping";
|
||||||
|
const PATH_SUBSCRIBE_REPLAY_INFO: &str = "/geyser.Geyser/SubscribeReplayInfo";
|
||||||
|
|
||||||
|
/// Standard Solana Yellowstone unary client layered on one KSP-owned physical gRPC channel.
|
||||||
|
///
|
||||||
|
/// This N2 facade deliberately exposes no raw Tonic client and no upstream protobuf types. Streaming `Subscribe` is not part of `0.2.9-pre.003`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SolanaYellowstoneGrpcUnaryClient {
|
||||||
|
channel: tonic::transport::Channel,
|
||||||
|
metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>,
|
||||||
|
unary_timeout: std::time::Duration,
|
||||||
|
max_inbound_message_size_bytes: usize,
|
||||||
|
max_outbound_message_size_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SolanaYellowstoneGrpcUnaryClient {
|
||||||
|
/// Creates the internal unary facade state from a KSP-owned physical channel and validated endpoint settings.
|
||||||
|
pub(crate) fn new(
|
||||||
|
channel: tonic::transport::Channel,
|
||||||
|
metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>,
|
||||||
|
settings: crate::YellowstoneGrpcSessionSettings,
|
||||||
|
) -> Self {
|
||||||
|
return Self {
|
||||||
|
channel,
|
||||||
|
metadata,
|
||||||
|
unary_timeout: settings.unary_timeout(),
|
||||||
|
max_inbound_message_size_bytes: settings.max_inbound_message_size_bytes(),
|
||||||
|
max_outbound_message_size_bytes: settings.max_outbound_message_size_bytes(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first slot retained by the endpoint for replay when advertised.
|
||||||
|
pub async fn subscribe_replay_info(&self) -> ksp_core_lib::Result<crate::YellowstoneReplayInfo> {
|
||||||
|
let response: yellowstone_grpc_proto::geyser::SubscribeReplayInfoResponse =
|
||||||
|
match self.unary("SubscribeReplayInfo", PATH_SUBSCRIBE_REPLAY_INFO, yellowstone_grpc_proto::geyser::SubscribeReplayInfoRequest {}).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::YellowstoneReplayInfo::new(response.first_available));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the standard Yellowstone unary `Ping` RPC and verifies the echoed count.
|
||||||
|
pub async fn ping(&self, count: i32) -> ksp_core_lib::Result<crate::YellowstonePong> {
|
||||||
|
let response: yellowstone_grpc_proto::geyser::PongResponse =
|
||||||
|
match self.unary("Ping", PATH_PING, yellowstone_grpc_proto::geyser::PingRequest { count }).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if response.count != count {
|
||||||
|
return invalid_unary_response("Ping", "Yellowstone Ping response count does not match the request");
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(crate::YellowstonePong::new(response.count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the latest blockhash advertised by the Yellowstone endpoint at an optional commitment.
|
||||||
|
pub async fn get_latest_blockhash(
|
||||||
|
&self,
|
||||||
|
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||||
|
) -> ksp_core_lib::Result<crate::YellowstoneLatestBlockhash> {
|
||||||
|
let response: yellowstone_grpc_proto::geyser::GetLatestBlockhashResponse = match self
|
||||||
|
.unary(
|
||||||
|
"GetLatestBlockhash",
|
||||||
|
PATH_GET_LATEST_BLOCKHASH,
|
||||||
|
yellowstone_grpc_proto::geyser::GetLatestBlockhashRequest { commitment: commitment_to_wire(commitment) },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if response.blockhash.is_empty() || response.blockhash.len() > MAX_YELLOWSTONE_BLOCKHASH_LENGTH_BYTES {
|
||||||
|
return invalid_unary_response("GetLatestBlockhash", "Yellowstone latest blockhash response violates the KSP text bound");
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(crate::YellowstoneLatestBlockhash::new(response.slot, response.blockhash, response.last_valid_block_height));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current block height at an optional commitment.
|
||||||
|
pub async fn get_block_height(&self, commitment: std::option::Option<crate::SolanaCommitment>) -> ksp_core_lib::Result<crate::YellowstoneBlockHeight> {
|
||||||
|
let response: yellowstone_grpc_proto::geyser::GetBlockHeightResponse = match self
|
||||||
|
.unary(
|
||||||
|
"GetBlockHeight",
|
||||||
|
PATH_GET_BLOCK_HEIGHT,
|
||||||
|
yellowstone_grpc_proto::geyser::GetBlockHeightRequest { commitment: commitment_to_wire(commitment) },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::YellowstoneBlockHeight::new(response.block_height));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current slot at an optional commitment.
|
||||||
|
pub async fn get_slot(&self, commitment: std::option::Option<crate::SolanaCommitment>) -> ksp_core_lib::Result<crate::YellowstoneSlot> {
|
||||||
|
let response: yellowstone_grpc_proto::geyser::GetSlotResponse =
|
||||||
|
match self.unary("GetSlot", PATH_GET_SLOT, yellowstone_grpc_proto::geyser::GetSlotRequest { commitment: commitment_to_wire(commitment) }).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::YellowstoneSlot::new(response.slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks one opaque Solana blockhash at an optional commitment.
|
||||||
|
pub async fn is_blockhash_valid(
|
||||||
|
&self,
|
||||||
|
blockhash: impl std::convert::Into<std::string::String>,
|
||||||
|
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||||
|
) -> ksp_core_lib::Result<crate::YellowstoneBlockhashValidity> {
|
||||||
|
let blockhash = blockhash.into();
|
||||||
|
if blockhash.is_empty() || blockhash.len() > MAX_YELLOWSTONE_BLOCKHASH_LENGTH_BYTES || blockhash.trim() != blockhash {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "Yellowstone blockhash must be non-empty, bounded and untrimmed")
|
||||||
|
.with_context("grpc_operation", "IsBlockhashValid")
|
||||||
|
.with_context("field", "blockhash"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let response: yellowstone_grpc_proto::geyser::IsBlockhashValidResponse = match self
|
||||||
|
.unary(
|
||||||
|
"IsBlockhashValid",
|
||||||
|
PATH_IS_BLOCKHASH_VALID,
|
||||||
|
yellowstone_grpc_proto::geyser::IsBlockhashValidRequest { blockhash, commitment: commitment_to_wire(commitment) },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::YellowstoneBlockhashValidity::new(response.slot, response.valid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the endpoint's Yellowstone/validator version string after applying a deterministic KSP text bound.
|
||||||
|
pub async fn get_version(&self) -> ksp_core_lib::Result<crate::YellowstoneVersionInfo> {
|
||||||
|
let response: yellowstone_grpc_proto::geyser::GetVersionResponse =
|
||||||
|
match self.unary("GetVersion", PATH_GET_VERSION, yellowstone_grpc_proto::geyser::GetVersionRequest {}).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if response.version.is_empty() || response.version.len() > MAX_YELLOWSTONE_VERSION_LENGTH_BYTES {
|
||||||
|
return invalid_unary_response("GetVersion", "Yellowstone version response violates the KSP text bound");
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(crate::YellowstoneVersionInfo::new(response.version));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unary<Request, Response>(&self, operation: &'static str, path: &'static str, message: Request) -> ksp_core_lib::Result<Response>
|
||||||
|
where
|
||||||
|
Request: tonic_prost::prost::Message + Send + Sync + 'static,
|
||||||
|
Response: tonic_prost::prost::Message + std::default::Default + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let path = match path.parse::<http::uri::PathAndQuery>() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "internal Yellowstone gRPC method path is invalid")
|
||||||
|
.with_context("grpc_operation", operation),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut grpc = tonic::client::Grpc::new(self.channel.clone())
|
||||||
|
.max_decoding_message_size(self.max_inbound_message_size_bytes)
|
||||||
|
.max_encoding_message_size(self.max_outbound_message_size_bytes);
|
||||||
|
let future = async {
|
||||||
|
if grpc.ready().await.is_err() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "Yellowstone gRPC channel is not ready for unary dispatch")
|
||||||
|
.with_context("grpc_operation", operation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut request = tonic::Request::new(message);
|
||||||
|
for entry in &self.metadata {
|
||||||
|
if let std::result::Result::Err(error) = entry.append_to(request.metadata_mut()) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let response = match grpc.unary(request, path, tonic_prost::ProstCodec::<Request, Response>::default()).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(status) => return std::result::Result::Err(grpc_status_error(operation, status)),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(response.into_inner());
|
||||||
|
};
|
||||||
|
return match tokio::time::timeout(self.unary_timeout, future).await {
|
||||||
|
std::result::Result::Ok(result) => result,
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, "Yellowstone gRPC unary operation exceeded the KSP deadline")
|
||||||
|
.with_context("grpc_operation", operation),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SolanaYellowstoneGrpcUnaryClient {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("SolanaYellowstoneGrpcUnaryClient")
|
||||||
|
.field("metadata_count", &self.metadata.len())
|
||||||
|
.field("unary_timeout", &self.unary_timeout)
|
||||||
|
.field("max_inbound_message_size_bytes", &self.max_inbound_message_size_bytes)
|
||||||
|
.field("max_outbound_message_size_bytes", &self.max_outbound_message_size_bytes)
|
||||||
|
.field("channel", &"<private>")
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay availability advertised by `SubscribeReplayInfo`.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneReplayInfo {
|
||||||
|
first_available: std::option::Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneReplayInfo {
|
||||||
|
/// Creates a replay-info projection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(first_available: std::option::Option<u64>) -> Self {
|
||||||
|
return Self { first_available };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first replayable slot when the endpoint advertises one.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn first_available(&self) -> std::option::Option<u64> {
|
||||||
|
return self.first_available;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Echo returned by the Yellowstone unary `Ping` RPC.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstonePong {
|
||||||
|
count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstonePong {
|
||||||
|
/// Creates a ping response projection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(count: i32) -> Self {
|
||||||
|
return Self { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the echoed ping count.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn count(&self) -> i32 {
|
||||||
|
return self.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest blockhash response returned by Yellowstone.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneLatestBlockhash {
|
||||||
|
slot: u64,
|
||||||
|
blockhash: std::string::String,
|
||||||
|
last_valid_block_height: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneLatestBlockhash {
|
||||||
|
/// Creates a latest-blockhash projection.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(slot: u64, blockhash: std::string::String, last_valid_block_height: u64) -> Self {
|
||||||
|
return Self { slot, blockhash, last_valid_block_height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the response slot.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slot(&self) -> u64 {
|
||||||
|
return self.slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opaque Solana blockhash text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn blockhash(&self) -> &str {
|
||||||
|
return self.blockhash.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the last valid block height associated with the blockhash.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn last_valid_block_height(&self) -> u64 {
|
||||||
|
return self.last_valid_block_height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current block height returned by Yellowstone.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneBlockHeight {
|
||||||
|
block_height: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneBlockHeight {
|
||||||
|
/// Creates a block-height projection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(block_height: u64) -> Self {
|
||||||
|
return Self { block_height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the block height.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn block_height(&self) -> u64 {
|
||||||
|
return self.block_height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current slot returned by Yellowstone.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneSlot {
|
||||||
|
slot: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneSlot {
|
||||||
|
/// Creates a slot projection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(slot: u64) -> Self {
|
||||||
|
return Self { slot };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the slot.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slot(&self) -> u64 {
|
||||||
|
return self.slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of checking one blockhash with Yellowstone.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneBlockhashValidity {
|
||||||
|
slot: u64,
|
||||||
|
valid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneBlockhashValidity {
|
||||||
|
/// Creates a blockhash-validity projection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(slot: u64, valid: bool) -> Self {
|
||||||
|
return Self { slot, valid };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the response slot.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slot(&self) -> u64 {
|
||||||
|
return self.slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the blockhash is valid at the requested commitment.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn valid(&self) -> bool {
|
||||||
|
return self.valid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded Yellowstone endpoint version information.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct YellowstoneVersionInfo {
|
||||||
|
version: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YellowstoneVersionInfo {
|
||||||
|
/// Creates a version projection.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(version: std::string::String) -> Self {
|
||||||
|
return Self { version };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bounded endpoint version string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn version(&self) -> &str {
|
||||||
|
return self.version.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commitment_to_wire(commitment: std::option::Option<crate::SolanaCommitment>) -> std::option::Option<i32> {
|
||||||
|
return commitment.map(|value| {
|
||||||
|
return match value {
|
||||||
|
crate::SolanaCommitment::Processed => yellowstone_grpc_proto::geyser::CommitmentLevel::Processed as i32,
|
||||||
|
crate::SolanaCommitment::Confirmed => yellowstone_grpc_proto::geyser::CommitmentLevel::Confirmed as i32,
|
||||||
|
crate::SolanaCommitment::Finalized => yellowstone_grpc_proto::geyser::CommitmentLevel::Finalized as i32,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grpc_status_error(operation: &'static str, status: tonic::Status) -> ksp_core_lib::Error {
|
||||||
|
let code = status.code().to_string();
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, grpc_operation = operation, grpc_code = code.as_str(), "Yellowstone gRPC unary endpoint returned a status");
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_STATUS, "Yellowstone gRPC endpoint returned a gRPC status")
|
||||||
|
.with_context("grpc_operation", operation)
|
||||||
|
.with_context("grpc_code", code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_unary_response<T>(operation: &'static str, message: &str) -> ksp_core_lib::Result<T> {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("grpc_operation", operation));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/grpc_unary.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/src/client.rs
|
// file: crates/ksp-onchain-transport-lib/src/http_client.rs
|
||||||
// version: 7
|
// version: 8
|
||||||
|
|
||||||
/// Passive runtime availability reported for one logical HTTP endpoint or role.
|
/// Passive runtime availability reported for one logical HTTP endpoint or role.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
@@ -510,5 +510,5 @@ fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "../unit_tests/client.rs"]
|
#[path = "../unit_tests/http_client.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/src/executor.rs
|
// file: crates/ksp-onchain-transport-lib/src/http_executor.rs
|
||||||
// version: 3
|
// version: 4
|
||||||
|
|
||||||
const HTTP_BAD_GATEWAY: u16 = 502;
|
const HTTP_BAD_GATEWAY: u16 = 502;
|
||||||
const HTTP_GATEWAY_TIMEOUT: u16 = 504;
|
const HTTP_GATEWAY_TIMEOUT: u16 = 504;
|
||||||
@@ -228,5 +228,5 @@ fn http_status_error(method: &crate::HttpRpcMethodDescriptor, status: u16) -> ks
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "../unit_tests/executor.rs"]
|
#[path = "../unit_tests/http_executor.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user