85 Commits

Author SHA1 Message Date
db1f8d4a53 0.2.11-rel.001 2026-08-26 17:49:35 +02:00
9852613426 v0.2.11-pre.013 2026-08-26 17:41:00 +02:00
81eb295a09 v0.2.11-pre.012 2026-08-26 15:45:02 +02:00
b5a1293953 v0.2.11-pre.011-fix.001 2026-08-26 14:56:44 +02:00
251f9f9ad8 v0.2.11-pre.011 2026-08-26 14:37:59 +02:00
98708df6e0 v0.2.11-pre.010-fix.001 2026-08-26 12:34:48 +02:00
2db3c4be78 v0.2.11-pre.010 2026-08-26 12:06:51 +02:00
69294a153f v0.2.11-pre.009 2026-08-26 11:23:01 +02:00
2fdff17651 v0.2.11-pre.008-fix.001 2026-08-26 10:30:17 +02:00
87d7314bf4 v0.2.11-pre.008 2026-08-26 10:23:42 +02:00
5aeff5ca14 v0.2.11-pre.007 2026-08-26 10:06:21 +02:00
98093d859b v0.2.11-pre.006 2026-08-25 23:06:26 +02:00
4021d08541 v0.2.11-pre.005 2026-08-25 22:32:07 +02:00
5c97a772be v0.2.11-pre.004-fix.001 2026-08-25 21:58:05 +02:00
60afb51451 v0.2.11-pre.004 2026-08-25 21:47:47 +02:00
f4413ebbb0 v0.2.11-pre.003-fix.001 2026-08-25 21:24:30 +02:00
be2a06bc80 v0.2.11-pre.003 2026-08-25 21:00:19 +02:00
2dbddfe367 v0.2.11-pre.002-fix.003 2026-08-25 20:37:09 +02:00
126e8abfcd v0.2.11-pre.002-fix.002 2026-08-25 20:24:21 +02:00
3fd540a9db v0.2.11-pre.002-fix.001 2026-08-25 20:17:31 +02:00
4f92fb03f1 v0.2.11-pre.002 2026-08-25 19:45:06 +02:00
f42fa8f4f1 v0.2.11-pre.001-fix.001 2026-08-25 18:37:51 +02:00
a8e980b225 v0.2.11-pre.001 2026-08-25 17:50:08 +02:00
afaa9c0239 v0.2.10-rel.001 2026-08-25 16:40:46 +02:00
7d456b41dc v0.2.10-pre.005 2026-08-25 15:43:15 +02:00
0edeed1c48 v0.2.10-pre.004 2026-08-25 15:26:04 +02:00
be5e3464ee v0.2.10-pre.003 2026-08-25 15:18:28 +02:00
1967e845b0 v0.2.10-pre.002-fix.001 2026-08-25 14:17:40 +02:00
af807afff5 v0.2.10-pre.002 2026-08-25 10:29:16 +02:00
7f567bc1bb v0.2.10-pre.001 2026-08-25 10:14:37 +02:00
09aacc36ab v0.2.9-rel.001 2026-08-25 08:36:34 +02:00
15b129ad04 v0.2.9-pre.015 2026-08-25 08:35:16 +02:00
6ddf2b4995 v0.2.9-pre.014 2026-08-25 05:44:41 +02:00
dcabceb52f v0.2.9-pre.013-fix.004 2026-08-24 23:39:11 +02:00
51bcf870d3 v0.2.9-pre.013-fix.003 2026-08-24 23:13:49 +02:00
c6c5793c61 v0.2.9-pre.013-fix.002 2026-08-24 23:02:18 +02:00
d98ee736be v0.2.9-pre.013-fix.001 2026-08-24 22:48:33 +02:00
39dbd86f1e v0.2.9-pre.013 2026-08-24 21:13:38 +02:00
c938b2409e v0.2.9-pre.012-fix.001 2026-08-24 20:56:14 +02:00
5b30bb9948 v0.2.9-pre.012-fix.001 2026-08-24 20:56:04 +02:00
813a45385a v0.2.9-pre.012 2026-08-24 20:48:30 +02:00
21ed5f88da v0.2.9-pre.011 2026-08-24 19:53:45 +02:00
3198cc5666 v0.2.9-pre.010-fix.001 2026-08-24 18:53:10 +02:00
4568d94a62 v0.2.9-pre.010 2026-08-24 18:39:19 +02:00
d32d8de3a5 v0.2.9-pre.009-fix.001 2026-08-24 17:36:41 +02:00
a9fb6a7ac8 v0.2.9-pre.009 2026-08-24 17:26:27 +02:00
433e69272a v0.2.9-pre.008-fix.001 2026-08-24 17:10:34 +02:00
affa18a6e9 v0.2.9-pre.008 2026-08-24 16:16:48 +02:00
05aec88178 v0.2.9-pre.007 2026-08-24 14:10:57 +02:00
b32de48c0b v0.2.9-pre.006 2026-08-24 13:17:24 +02:00
15e3402e1c v0.2.9-pre.005-fix.001 2026-08-24 12:53:15 +02:00
dca0a4b809 v0.2.9-pre.005 2026-08-24 12:31:19 +02:00
20320dba7b v0.2.9-pre.004-fix.001 2026-08-24 12:09:48 +02:00
e4bbc78a41 v0.2.9-pre.004 2026-08-24 11:55:17 +02:00
feb9befb35 v0.2.9-pre.003-fix.001 2026-08-24 11:16:04 +02:00
a038194679 v0.2.9-pre.003 2026-08-24 11:10:59 +02:00
3172cda241 v0.2.9-pre.002-fix.002 2026-08-24 11:00:38 +02:00
61fba107ef v0.2.9-pre.002-fix.001 2026-08-24 10:05:05 +02:00
835de48cb7 v0.2.9-pre.002 2026-08-24 09:54:14 +02:00
21de590aea v0.2.9-pre.001-fix.002 2026-08-24 09:23:03 +02:00
3081bd1085 v0.2.9-pre.001-fix.001 2026-08-24 09:17:30 +02:00
bdcab1e1a7 v0.2.9-pre.001 2026-08-24 08:37:36 +02:00
d70c3a1672 v0.2.8-rel.001 2026-08-23 23:14:58 +02:00
7cdf5e80c9 v0.2.8-pre.011 2026-08-23 20:01:28 +02:00
d8bfd7cd2e v0.2.8-pre.010 2026-08-23 18:35:44 +02:00
9c0d4fc197 v0.2.8-pre.009 2026-08-23 18:16:03 +02:00
7c12ec886b v0.2.8-pre.008 2026-08-23 17:19:44 +02:00
7eb6dec809 v0.2.8-pre.007-fix.004 2026-08-23 16:54:00 +02:00
68f4384c5b v0.2.8-pre.007-fix.003 2026-08-23 16:44:31 +02:00
9cc140fb84 v0.2.8-pre.007-fix.002 2026-08-23 16:16:39 +02:00
3b64d1e0ec v0.2.8-pre.007-fix.001 2026-08-23 16:11:44 +02:00
56b9ce6abc v0.2.8-pre.007 2026-08-23 16:05:41 +02:00
1c8d69778b v0.2.8-pre.006 2026-08-23 15:41:54 +02:00
8e739b9e55 v0.2.8-pre.005-fix.002 2026-08-23 15:13:03 +02:00
53dbb5bccd v0.2.8-pre.005-fix.001 2026-08-23 15:02:41 +02:00
92224e5ac6 v0.2.8-pre.005 2026-08-23 14:49:56 +02:00
cbb4e7b0de v0.2.8-pre.004-fix.002 2026-08-23 14:27:25 +02:00
38fd62c256 v0.2.8-pre.004-fix.001 2026-08-23 14:20:24 +02:00
f2a3ec62aa v0.2.8-pre.004 2026-08-23 14:08:12 +02:00
b3363073c4 v0.2.8-pre.003 2026-08-23 13:52:57 +02:00
c94a54f3e3 v0.2.8-pre.002-fix.001 2026-08-23 13:22:19 +02:00
0871b85df9 v0.2.8-pre.002 2026-08-23 13:15:42 +02:00
315e7e67e5 v0.2.8-pre.001-fix.002 2026-08-23 12:53:13 +02:00
df95f2f558 v0.2.8-pre.001-fix.001 2026-08-23 12:46:17 +02:00
f0865d5137 v0.2.8-pre.001 2026-08-23 12:36:54 +02:00
248 changed files with 49890 additions and 497 deletions

View File

@@ -1,5 +1,5 @@
# file: .env.example
# version: 5
# version: 10
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
@@ -34,9 +34,41 @@ KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
# 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
# Optional complete private-provider WebSocket 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.
# KSP_SECRET_SOLANA_WS_URL=wss://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.
KSP_DESK_SPLASH_FADE_IN_MS=300

View File

@@ -1,10 +1,42 @@
<!-- file: CHANGELOG.md -->
<!-- version: 11 -->
<!-- version: 15 -->
# 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/`.
## 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 lextension 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 lactor 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 lenvironnement 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 dintégration/orchestration afin de préserver lownership Config du secret. Les graphes Cargo finaux nintroduisent 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 linventaire 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 laudit normatif courant et utilisent le warning KSP centralisé.

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 216
# version: 285
[workspace]
resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-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]
version = "0.2.7"
version = "0.2.11"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
@@ -15,31 +15,35 @@ publish = false
[workspace.dependencies]
argon2 = { version = "^0.5", default-features = false }
base64 = { version = "^0.23" }
chacha20poly1305 = { version = "^0.11", default-features = false }
chrono = { version = "^0.4", default-features = false }
directories = { version = "^6.0" }
ed25519-dalek = { version = "^3.0", default-features = false }
getrandom = { version = "^0.4", default-features = false }
base64 = { version = "^0.23" }
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_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-pubkey = { version = "^4.3", default-features = false }
tauri = { version = "^2.11" }
tauri-build = { version = "^2.6" }
tauri-plugin-dialog = { version = "^2.7" }
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 }
tempfile = { version = "^3.27" }
chrono = { version = "^0.4", default-features = false }
tauri = { version = "^2.11" }
tauri-build = { version = "^2.6" }
tauri-plugin-dialog = { version = "^2.7" }
tauri-plugin-tracing = { version = "^0.3" }
tonic = { version = "^0.14", default-features = false }
tonic-prost = { version = "^0.14", default-features = false }
ts-rs = { version = "^12.0" }
yellowstone-grpc-proto = { version = "^12.6", default-features = false }
zeroize = { version = "^1.9" }
[workspace.lints.rust]

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md -->
<!-- version: 82 -->
<!-- version: 89 -->
# Roadmap KSP
@@ -52,12 +52,23 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
- [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 laudit 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.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.
- [ ] `0.2.9` Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
- [ ] `0.2.10`Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
- [ ] `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é.
- [ ] `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.13` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur lactor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes dupdate, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
- [X] `0.2.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.
- [X] `0.2.11`Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique nest introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
- [ ] `0.2.12` — Introduire `ksp-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 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 dimplé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 dimplémentation.
### Règles Transport pour toute la série

View File

@@ -0,0 +1,43 @@
{
"format_version": 1,
"default_profile": "custom_free",
"profiles": [
{
"profile_id": "custom_free",
"market_price": {
"birdeye": {
"enabled": true,
"api_key": "${KSP_SECRET_BIRDEYE_API_KEY}"
},
"coinbase_exchange": {
"enabled": true
},
"coingecko": {
"enabled": true,
"access_mode": "demo",
"api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}"
},
"coinmarketcap": {
"enabled": true,
"access_mode": "basic",
"api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}"
},
"coinpaprika": {
"enabled": true
},
"dexscreener": {
"enabled": true,
"sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}"
},
"jupiter": {
"enabled": true,
"access_mode": "free",
"api_key": "${KSP_SECRET_JUPITER_API_KEY}"
},
"kraken": {
"enabled": true
}
}
}
]
}

View File

@@ -90,18 +90,59 @@
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
},
{
"name": "mainnet_private_ws",
"name": "mainnet_helius_ws",
"enabled": true,
"provider": "private-provider",
"provider": "helius",
"cluster": "mainnet-beta",
"kind": "solana_standard",
"url": "${KSP_SECRET_SOLANA_WS_URL:-wss://example.invalid}",
"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}"
}
]
}
]
}

View File

@@ -0,0 +1,366 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:ksp:schema:std.offchain_transport:v1",
"title": "KSP standard Off-chain Transport configuration",
"type": "object",
"additionalProperties": false,
"required": [
"format_version",
"default_profile",
"profiles"
],
"properties": {
"format_version": {
"const": 1
},
"default_profile": {
"$ref": "#/$defs/profileId"
},
"profiles": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/profile"
}
}
},
"$defs": {
"profileId": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]*$"
},
"enabledOnly": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"coingecko": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled",
"access_mode"
],
"properties": {
"enabled": {
"type": "boolean"
},
"access_mode": {
"enum": [
"demo",
"keyless"
]
},
"api_key": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"properties": {
"access_mode": {
"const": "keyless"
}
},
"required": [
"access_mode"
]
},
"then": {
"not": {
"required": [
"api_key"
]
}
}
},
{
"if": {
"properties": {
"enabled": {
"const": true
},
"access_mode": {
"const": "demo"
}
},
"required": [
"enabled",
"access_mode"
]
},
"then": {
"required": [
"api_key"
]
}
}
]
},
"coinmarketcap": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled",
"access_mode"
],
"properties": {
"enabled": {
"type": "boolean"
},
"access_mode": {
"enum": [
"basic",
"keyless"
]
},
"api_key": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"properties": {
"access_mode": {
"const": "keyless"
}
},
"required": [
"access_mode"
]
},
"then": {
"not": {
"required": [
"api_key"
]
}
}
},
{
"if": {
"properties": {
"enabled": {
"const": true
},
"access_mode": {
"const": "basic"
}
},
"required": [
"enabled",
"access_mode"
]
},
"then": {
"required": [
"api_key"
]
}
}
]
},
"jupiter": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled",
"access_mode"
],
"properties": {
"enabled": {
"type": "boolean"
},
"access_mode": {
"enum": [
"free",
"keyless"
]
},
"api_key": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"properties": {
"access_mode": {
"const": "keyless"
}
},
"required": [
"access_mode"
]
},
"then": {
"not": {
"required": [
"api_key"
]
}
}
},
{
"if": {
"properties": {
"enabled": {
"const": true
},
"access_mode": {
"const": "free"
}
},
"required": [
"enabled",
"access_mode"
]
},
"then": {
"required": [
"api_key"
]
}
}
]
},
"birdeye": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"api_key": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"properties": {
"enabled": {
"const": true
}
},
"required": [
"enabled"
]
},
"then": {
"required": [
"api_key"
]
}
}
]
},
"dexscreener": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"sol_usd_pair_address": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"properties": {
"enabled": {
"const": true
}
},
"required": [
"enabled"
]
},
"then": {
"required": [
"sol_usd_pair_address"
]
}
}
]
},
"marketPrice": {
"type": "object",
"additionalProperties": false,
"required": [
"birdeye",
"coinbase_exchange",
"coingecko",
"coinmarketcap",
"coinpaprika",
"dexscreener",
"jupiter",
"kraken"
],
"properties": {
"birdeye": {
"$ref": "#/$defs/birdeye"
},
"coinbase_exchange": {
"$ref": "#/$defs/enabledOnly"
},
"coingecko": {
"$ref": "#/$defs/coingecko"
},
"coinmarketcap": {
"$ref": "#/$defs/coinmarketcap"
},
"coinpaprika": {
"$ref": "#/$defs/enabledOnly"
},
"dexscreener": {
"$ref": "#/$defs/dexscreener"
},
"jupiter": {
"$ref": "#/$defs/jupiter"
},
"kraken": {
"$ref": "#/$defs/enabledOnly"
}
}
},
"profile": {
"type": "object",
"additionalProperties": false,
"required": [
"profile_id",
"market_price"
],
"properties": {
"profile_id": {
"$ref": "#/$defs/profileId"
},
"market_price": {
"$ref": "#/$defs/marketPrice"
}
}
}
}
}

View File

@@ -1,13 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:ksp:schema:std.transport:v2",
"title": "KSP standard HTTP + WebSocket Transport configuration",
"$id": "urn:ksp:schema:std.transport:v3",
"title": "KSP standard HTTP + WebSocket + Yellowstone gRPC Transport configuration",
"oneOf": [
{
"$ref": "#/$defs/documentV1"
},
{
"$ref": "#/$defs/documentV2"
},
{
"$ref": "#/$defs/documentV3"
}
],
"$defs": {
@@ -335,7 +338,8 @@
},
"kind": {
"enum": [
"solana_standard"
"solana_standard",
"helius_laserstream"
]
},
"url": {
@@ -454,6 +458,275 @@
}
}
}
},
"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"
}
}
}
}
}
}

View File

@@ -0,0 +1,75 @@
{
"format_version": 1,
"default_profile": "public_keyless",
"profiles": [
{
"profile_id": "public_keyless",
"market_price": {
"birdeye": {
"enabled": false
},
"coinbase_exchange": {
"enabled": true
},
"coingecko": {
"enabled": true,
"access_mode": "keyless"
},
"coinmarketcap": {
"enabled": true,
"access_mode": "keyless"
},
"coinpaprika": {
"enabled": true
},
"dexscreener": {
"enabled": false
},
"jupiter": {
"enabled": true,
"access_mode": "keyless"
},
"kraken": {
"enabled": true
}
}
},
{
"profile_id": "all_free",
"market_price": {
"birdeye": {
"enabled": true,
"api_key": "${KSP_SECRET_BIRDEYE_API_KEY}"
},
"coinbase_exchange": {
"enabled": true
},
"coingecko": {
"enabled": true,
"access_mode": "demo",
"api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}"
},
"coinmarketcap": {
"enabled": true,
"access_mode": "basic",
"api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}"
},
"coinpaprika": {
"enabled": true
},
"dexscreener": {
"enabled": true,
"sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}"
},
"jupiter": {
"enabled": true,
"access_mode": "free",
"api_key": "${KSP_SECRET_JUPITER_API_KEY}"
},
"kraken": {
"enabled": true
}
}
}
]
}

View File

@@ -1,5 +1,5 @@
{
"format_version": 2,
"format_version": 3,
"retry": {
"max_retries": 2,
"initial_backoff_ms": 100,
@@ -22,6 +22,20 @@
"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",
"profiles": [
{
@@ -65,6 +79,64 @@
}
]
},
{
"profile_id": "orbitflare_devnet",
"endpoints": [
{
"name": "solana_devnet_public",
"enabled": true,
"provider": "solana-public",
"cluster": "devnet",
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
"connect_timeout_ms": 5000,
"request_timeout_ms": 15000,
"max_idle_connections_per_host": 8,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": [
"*"
],
"priority": 100,
"limits": {
"requests_per_second": 5,
"burst_capacity": 10,
"max_concurrent_requests": 8,
"pause_after_rate_limit_ms": 1000
}
}
]
}
],
"ws_endpoints": [
{
"name": "solana_devnet_public_ws",
"enabled": true,
"provider": "solana-public",
"cluster": "devnet",
"kind": "solana_standard",
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
}
],
"grpc_endpoints": [
{
"name": "orbitflare_solana_devnet_yellowstone",
"enabled": true,
"provider": "orbitflare",
"cluster": "devnet",
"protocol": "solana_yellowstone",
"url": "http://devnet.rpc.orbitflare.com:10000",
"metadata": [],
"secret_metadata": [
{
"key": "x-token",
"value": "${KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN}"
}
]
}
]
},
{
"profile_id": "mainnet_public",
"endpoints": [
@@ -105,6 +177,120 @@
"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}"
}
]
}
]
}
]
}

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-app-config-desk/Cargo.toml
# version: 8
# version: 9
[package]
name = "ksp-app-config-desk"
@@ -22,6 +22,7 @@ path = "src/main.rs"
tauri-build.workspace = true
[dependencies]
chrono = { workspace = true, features = ["std", "now"] }
fs2.workspace = true
ksp-config-lib = { path = "../ksp-config-lib" }
ksp-core-lib = { path = "../ksp-core-lib" }
@@ -30,7 +31,6 @@ serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
tauri.workspace = true
tauri-plugin-tracing.workspace = true
chrono = { workspace = true, features = ["std", "now"] }
tokio = { workspace = true, features = ["time"] }
ts-rs.workspace = true

View File

@@ -55,10 +55,12 @@
"resources": {
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
"../../config/std.logging.json": "config/std.logging.json",
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
"../../config/std.transport.json": "config/std.transport.json",
"../../config/std.wallet.json": "config/std.wallet.json",
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
}

View File

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

View File

@@ -55,10 +55,12 @@
"resources": {
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
"../../config/std.logging.json": "config/std.logging.json",
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
"../../config/std.transport.json": "config/std.transport.json",
"../../config/std.wallet.json": "config/std.wallet.json",
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
// version: 22
// version: 23
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
@@ -472,7 +472,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
if let std::option::Option::Some(resources) = resources {
assert_eq!(resources.len(), 8);
assert_eq!(resources.len(), 10);
assert_eq!(
resources.get("../../config/composite.ksp-app-wallet-desk.json").and_then(serde_json::Value::as_str),
std::option::Option::Some("config/composite.ksp-app-wallet-desk.json"),
@@ -481,6 +481,14 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
resources.get("../../config/schemas/composite.schema.json").and_then(serde_json::Value::as_str),
std::option::Option::Some("config/schemas/composite.schema.json"),
);
assert_eq!(
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
std::option::Option::Some("config/std.offchain_transport.json"),
);
assert_eq!(
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
);
}
let main = read_text(root.join("frontend/main.html").as_path());
assert!(main.contains(packaged_version));

View File

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

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-config-lib/Cargo.toml
# version: 7
# version: 9
[package]
name = "ksp-config-lib"
@@ -9,12 +9,13 @@ repository.workspace = true
[dependencies]
directories.workspace = true
jsonschema.workspace = true
ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" }
ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
jsonschema.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

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

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/USAGE.md -->
<!-- version: 8 -->
<!-- version: 13 -->
# 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.
### 4.1 Construire le Transport HTTP + WebSocket 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
let transport = match engine.load_resolved_transport_config(std::option::Option::None, &environment) {
@@ -137,16 +137,30 @@ let transport = match engine.load_resolved_transport_config(std::option::Option:
let http_settings = transport.http_settings();
let ws_settings = transport.ws_settings();
let _ = (http_settings, ws_settings);
let grpc_settings = transport.grpc_settings();
let _ = (http_settings, ws_settings, grpc_settings);
```
`std.transport` V2 conserve `retry` et `profiles[].endpoints[]` pour HTTP, ajoute `ws_defaults` et `profiles[].ws_endpoints[]`, puis exige actuellement `kind = "solana_standard"`. Un `ws_endpoints[].session` optionnel surcharge seulement les paramètres génériques de `WsSessionSettings`.
`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.
Le même schema enregistré conserve la lecture stricte du V1 historique : dans ce cas `http_settings()` reste disponible et `ws_settings()` retourne `None`. Aucun `WsTransportSettings` vide n'est inventé pour simuler l'absence de WebSocket.
Un endpoint gRPC V3 sépare explicitement :
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. Les URLs HTTP et WebSocket 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.
```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_*
```
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.
`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
@@ -168,6 +182,38 @@ let _ = (root, profile_subdirectory, effective);
Les chemins Wallet refusent toute valeur `KSP_SECRET_*`. Les futurs `KSP_SECRET_WALLET_PASS_*` constituent un flux de secrets distinct et ne sont pas des champs de `std.wallet.json`.
### 4.3 Construire le service Off-chain Transport depuis Config
Le document `cfg.std.offchain_transport` possède actuellement le domaine `market_price`. Config résout les placeholders, vérifie la provenance des API keys et de la paire DexScreener, puis construit directement `MarketPriceService` :
```rust
let offchain = match engine.load_resolved_offchain_transport_config(
std::option::Option::None,
&environment,
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
for entry in offchain.service().registry().entries() {
println!(
"{} {:?}",
entry.descriptor().display_name(),
entry.state().availability(),
);
}
```
Le profil versionné `public_keyless` ne requiert aucun secret. Le profil `all_free` utilise les credentials `KSP_SECRET_*` et la paire `KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS` inventoriés dans `.env.example`. Une API key littérale ou issue d'une provenance non secrète est refusée par l'adapter effectif ; une paire DexScreener issue d'une provenance Secret est également refusée.
Config ne permet pas de fournir `base_url`, `endpoint_url`, `rate_limit` ou `requests_per` aux branches provider. Les origines et cadences sûres restent possédées par `ksp-offchain-transport-lib`. La direction de dépendance reste donc :
```text
ksp-config-lib -> ksp-offchain-transport-lib
```
Off-chain Transport ne lit ni `.env`, ni `KSP_*`, ni les documents Config. Une application telle que la future `ksp-app-solprices-desk` peut recevoir le service déjà composé puis utiliser uniquement `registry()`, `refresh`, `refresh_many` et `refresh_all`.
## 5. Profils et composites
Pour un document standard profilé :

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/lib.rs
// version: 17
// version: 19
#![warn(missing_docs)]
#![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
//! 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
//! provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
//! provenance. Standard Logging, on-chain Transport (HTTP/WebSocket/Yellowstone gRPC) and Wallet documents map explicitly to their runtime consumer
//! contracts, while the management surface provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic
//! JSON/`.env` persistence.
mod bootstrap;
mod composite;
@@ -20,6 +21,7 @@ mod environment;
mod error;
mod logging;
mod management;
mod offchain_transport;
mod packaging;
mod persistence;
mod profile;
@@ -126,6 +128,8 @@ pub use self::management::LoggingOutputFilterConfig;
pub use self::management::LoggingProfileConfig;
/// Typed source contract for one global Logging target override.
pub use self::management::LoggingTargetFilterConfig;
/// Effective standard Off-chain Transport configuration resolved from Config.
pub use self::offchain_transport::ResolvedOffchainTransportConfig;
/// Writable KSP runtime roots prepared from packaged Config resources.
pub use self::packaging::PackagedRuntimeLayout;
/// Prepares the shared writable KSP desktop runtime from immutable packaged resources.
@@ -154,9 +158,13 @@ pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
/// Default physical filename for the standard Logging JSON Schema document.
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
/// Default physical filename for the standard HTTP + WebSocket 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;
/// Default physical filename for the standard HTTP + WebSocket 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;
/// Default physical filename for the standard Wallet configuration document.
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;
/// Logical file identifier for the standard Logging JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
/// Logical file identifier for the standard HTTP + WebSocket 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;
/// Logical file identifier for the standard Wallet JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_WALLET;
/// Logical file identifier for the standard Logging configuration document.
pub use self::registry::FILE_ID_STD_LOGGING;
/// Logical file identifier for the standard HTTP + WebSocket 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;
/// Logical file identifier for the standard Wallet configuration document.
pub use self::registry::FILE_ID_STD_WALLET;
@@ -188,7 +200,7 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
pub use self::sensitivity::ResolvedConfigJson;
/// One resolved Config string preserving real/safe representations and provenance.
pub use self::sensitivity::ResolvedConfigText;
/// Effective standard Transport configuration mapped to HTTP and optional WebSocket runtime settings.
/// Effective standard Transport configuration mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime settings.
pub use self::transport::ResolvedTransportConfig;
/// Effective standard Wallet configuration resolved to validated filesystem roots.
pub use self::wallet::ResolvedWalletConfig;

View File

@@ -0,0 +1,520 @@
// file: crates/ksp-config-lib/src/offchain_transport.rs
// version: 2
//! Adapter from Config-owned Off-chain Transport documents to the provider-agnostic market-price runtime service.
/// Effective standard Off-chain Transport configuration resolved from Config.
pub struct ResolvedOffchainTransportConfig {
effective: crate::ResolvedConfigJson,
file_id: crate::ConfigFileId,
profile_id: String,
selection_source: crate::ConfigProfileSelectionSource,
service: ksp_offchain_transport_lib::MarketPriceService,
source_path: std::path::PathBuf,
}
impl crate::ResolvedOffchainTransportConfig {
/// Returns the detailed environment-resolved effective Config view with secret-safe diagnostics.
#[must_use]
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
return &self.effective;
}
/// Returns the logical Config document identifier used by this runtime configuration.
#[must_use]
pub const fn file_id(&self) -> &crate::ConfigFileId {
return &self.file_id;
}
/// Returns the selected standard Off-chain Transport profile identifier.
#[must_use]
pub fn profile_id(&self) -> &str {
return self.profile_id.as_str();
}
/// Returns the source that selected the standard Off-chain Transport profile.
#[must_use]
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
return self.selection_source;
}
/// Returns the provider-agnostic market-price runtime service.
#[must_use]
pub const fn service(&self) -> &ksp_offchain_transport_lib::MarketPriceService {
return &self.service;
}
/// Returns the physical source Config document path.
#[must_use]
pub fn source_path(&self) -> &std::path::Path {
return self.source_path.as_path();
}
}
impl std::fmt::Debug for crate::ResolvedOffchainTransportConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let registry = self.service.registry();
return formatter
.debug_struct("ResolvedOffchainTransportConfig")
.field("effective", &self.effective)
.field("file_id", &self.file_id)
.field("profile_id", &self.profile_id)
.field("provider_registry", &registry)
.field("selection_source", &self.selection_source)
.field("source_path", &self.source_path)
.finish();
}
}
impl crate::ConfigDocumentEngine {
/// Loads the standard Off-chain Transport document and maps one profile to the generic market-price runtime service.
pub fn load_resolved_offchain_transport_config(
&self,
requested_profile: std::option::Option<&str>,
environment: &crate::ConfigEnvironment,
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let profile = self.load_resolved_profile(&file_id, requested_profile);
let profile = match profile {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return resolve_offchain_transport_profile(&profile, environment);
}
/// Maps an already resolved standard Off-chain Transport profile to the runtime service while preserving composite selection provenance.
pub fn resolve_offchain_transport_config_profile(
&self,
profile: &crate::ResolvedConfigProfile,
environment: &crate::ConfigEnvironment,
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
if profile.file_id().as_str() != crate::FILE_ID_STD_OFFCHAIN_TRANSPORT {
return std::result::Result::Err(effective_error(profile, "resolved Config profile does not reference the standard Off-chain Transport document"));
}
let descriptor = self.registry().descriptor(profile.file_id());
if let std::result::Result::Err(error) = descriptor {
return std::result::Result::Err(error);
}
return resolve_offchain_transport_profile(profile, environment);
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveOffchainTransportSource {
format_version: u32,
market_price: EffectiveMarketPriceSource,
profile_id: String,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveMarketPriceSource {
birdeye: EffectiveBirdeyeSource,
coinbase_exchange: EffectiveEnabledSource,
coingecko: EffectiveCoinGeckoSource,
coinmarketcap: EffectiveCoinMarketCapSource,
coinpaprika: EffectiveEnabledSource,
dexscreener: EffectiveDexScreenerSource,
jupiter: EffectiveJupiterSource,
kraken: EffectiveEnabledSource,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveEnabledSource {
enabled: bool,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveBirdeyeSource {
api_key: std::option::Option<String>,
enabled: bool,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveCoinGeckoSource {
access_mode: ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode,
api_key: std::option::Option<String>,
enabled: bool,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveCoinMarketCapSource {
access_mode: ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode,
api_key: std::option::Option<String>,
enabled: bool,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveDexScreenerSource {
enabled: bool,
sol_usd_pair_address: std::option::Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveJupiterSource {
access_mode: ksp_offchain_transport_lib::MarketPriceJupiterAccessMode,
api_key: std::option::Option<String>,
enabled: bool,
}
fn resolve_offchain_transport_profile(
profile: &crate::ResolvedConfigProfile,
environment: &crate::ConfigEnvironment,
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
profile_id = profile.profile_id(),
"mapping standard Off-chain Transport Config profile"
);
let effective = profile.resolve_effective_environment_detailed(environment);
let effective = match effective {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let source = serde_json::from_value::<EffectiveOffchainTransportSource>(effective.value().clone());
let source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
effective_error(profile, "effective Off-chain Transport Config cannot be decoded into the runtime adapter contract").with_source(error),
);
},
};
if source.format_version != 1 {
return std::result::Result::Err(effective_error(profile, "effective Off-chain Transport format_version is unsupported"));
}
if source.profile_id != profile.profile_id() {
return std::result::Result::Err(effective_error(profile, "effective Off-chain Transport profile_id does not match the selected profile"));
}
let setups = map_market_price_setups(source.market_price, &effective, profile);
let setups = match setups {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let service = ksp_offchain_transport_lib::MarketPriceService::new(setups);
let service = match service {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(offchain_contract_error(
profile,
"effective market-price providers fail the Off-chain Transport runtime contract",
&error,
));
},
};
let provider_count = service.registry().entries().len();
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
profile_id = profile.profile_id(),
format_version = source.format_version,
provider_count,
"mapped standard Off-chain Transport Config to market-price service"
);
return std::result::Result::Ok(crate::ResolvedOffchainTransportConfig {
effective,
file_id: profile.file_id().clone(),
profile_id: profile.profile_id().to_owned(),
selection_source: profile.selection_source(),
service,
source_path: profile.path().to_path_buf(),
});
}
fn map_market_price_setups(
source: EffectiveMarketPriceSource,
effective: &crate::ResolvedConfigJson,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
let birdeye = map_birdeye(source.birdeye, effective, profile);
let birdeye = match birdeye {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinbase_exchange = ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(source.coinbase_exchange.enabled);
let coinbase_exchange = match coinbase_exchange {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "coinbase_exchange", &error)),
};
let coingecko = map_coingecko(source.coingecko, effective, profile);
let coingecko = match coingecko {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinmarketcap = map_coinmarketcap(source.coinmarketcap, effective, profile);
let coinmarketcap = match coinmarketcap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let coinpaprika = ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(source.coinpaprika.enabled);
let coinpaprika = match coinpaprika {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "coinpaprika", &error)),
};
let dexscreener = map_dexscreener(source.dexscreener, effective, profile);
let dexscreener = match dexscreener {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let jupiter = map_jupiter(source.jupiter, effective, profile);
let jupiter = match jupiter {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kraken = ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(source.kraken.enabled);
let kraken = match kraken {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "kraken", &error)),
};
return std::result::Result::Ok(std::vec![
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase_exchange),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
ksp_offchain_transport_lib::MarketPriceProviderSetup::DexScreener(dexscreener),
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
]);
}
fn map_birdeye(
source: EffectiveBirdeyeSource,
effective: &crate::ResolvedConfigJson,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceBirdeyeSettings> {
if source.api_key.is_some()
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/birdeye/api_key", profile, "birdeye")
{
return std::result::Result::Err(error);
}
let settings = ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(source.enabled, source.api_key);
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "birdeye", &error)),
};
}
fn map_coingecko(
source: EffectiveCoinGeckoSource,
effective: &crate::ResolvedConfigJson,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings> {
if source.api_key.is_some()
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/coingecko/api_key", profile, "coingecko")
{
return std::result::Result::Err(error);
}
let settings = match source.access_mode {
ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode::Demo => {
ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::demo(source.enabled, source.api_key)
},
ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode::Keyless => {
ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(source.enabled)
},
_ => {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport CoinGecko access mode is not supported by this Config adapter")
.with_context("provider", "coingecko")
.with_context("field", "access_mode"),
);
},
};
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "coingecko", &error)),
};
}
fn map_coinmarketcap(
source: EffectiveCoinMarketCapSource,
effective: &crate::ResolvedConfigJson,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings> {
if source.api_key.is_some()
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/coinmarketcap/api_key", profile, "coinmarketcap")
{
return std::result::Result::Err(error);
}
let settings = match source.access_mode {
ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode::Basic => {
ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::basic(source.enabled, source.api_key)
},
ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode::Keyless => {
ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(source.enabled)
},
_ => {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport CoinMarketCap access mode is not supported by this Config adapter")
.with_context("provider", "coinmarketcap")
.with_context("field", "access_mode"),
);
},
};
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "coinmarketcap", &error)),
};
}
fn map_dexscreener(
source: EffectiveDexScreenerSource,
effective: &crate::ResolvedConfigJson,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceDexScreenerSettings> {
if source.sol_usd_pair_address.is_some()
&& let std::result::Result::Err(error) =
validate_public_field_provenance(effective, "/market_price/dexscreener/sol_usd_pair_address", profile, "dexscreener")
{
return std::result::Result::Err(error);
}
let settings = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(source.enabled, source.sol_usd_pair_address);
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "dexscreener", &error)),
};
}
fn map_jupiter(
source: EffectiveJupiterSource,
effective: &crate::ResolvedConfigJson,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceJupiterSettings> {
if source.api_key.is_some()
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/jupiter/api_key", profile, "jupiter")
{
return std::result::Result::Err(error);
}
let settings = match source.access_mode {
ksp_offchain_transport_lib::MarketPriceJupiterAccessMode::Free => {
ksp_offchain_transport_lib::MarketPriceJupiterSettings::free(source.enabled, source.api_key)
},
ksp_offchain_transport_lib::MarketPriceJupiterAccessMode::Keyless => ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(source.enabled),
_ => {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport Jupiter access mode is not supported by this Config adapter")
.with_context("provider", "jupiter")
.with_context("field", "access_mode"),
);
},
};
return match settings {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "jupiter", &error)),
};
}
fn validate_secret_field_provenance(
effective: &crate::ResolvedConfigJson,
pointer: &str,
profile: &crate::ResolvedConfigProfile,
provider: &'static str,
) -> ksp_core_lib::Result<()> {
let provenance = match effective.provenance_at(pointer) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport secret provenance is unavailable")
.with_context("provider", provider)
.with_context("field", pointer),
);
},
};
let mut has_secret_environment = false;
for item in provenance {
let variable_name = match item.variable_name() {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
let sensitivity = match sensitivity {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if !sensitivity.is_secret() {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport credentials may reference only secret environment variables")
.with_context("provider", provider)
.with_context("field", pointer),
);
}
has_secret_environment = true;
}
if !has_secret_environment {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport credentials require secret environment provenance")
.with_context("provider", provider)
.with_context("field", pointer),
);
}
return std::result::Result::Ok(());
}
fn validate_public_field_provenance(
effective: &crate::ResolvedConfigJson,
pointer: &str,
profile: &crate::ResolvedConfigProfile,
provider: &'static str,
) -> ksp_core_lib::Result<()> {
let provenance = match effective.provenance_at(pointer) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
effective_error(profile, "Off-chain Transport public provenance is unavailable")
.with_context("provider", provider)
.with_context("field", pointer),
);
},
};
for item in provenance {
let variable_name = match item.variable_name() {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
let sensitivity = match sensitivity {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if sensitivity != crate::ConfigSensitivity::Public {
return std::result::Result::Err(
effective_error(profile, "public Off-chain Transport fields may reference only public environment variables")
.with_context("provider", provider)
.with_context("field", pointer),
);
}
}
return std::result::Result::Ok(());
}
fn provider_contract_error(profile: &crate::ResolvedConfigProfile, provider: &'static str, error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
return offchain_contract_error(profile, "effective market-price provider settings fail the Off-chain Transport runtime contract", error)
.with_context("provider", provider);
}
fn offchain_contract_error(profile: &crate::ResolvedConfigProfile, reason: &'static str, error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
return effective_error(profile, reason)
.with_context("offchain_transport_error_domain", error.code().domain())
.with_context("offchain_transport_error_code", error.code().code());
}
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
.with_context("file_id", profile.file_id().as_str())
.with_context("profile_id", profile.profile_id())
.with_context("reason", reason);
}
#[cfg(test)]
#[path = "../unit_tests/offchain_transport.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/registry.rs
// version: 9
// version: 11
/// Bootstrap argument used to replace a known Config filename mapping.
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";
/// Default physical filename for the standard Logging JSON Schema document.
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
/// Default physical filename for the standard HTTP + WebSocket 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";
/// Default physical filename for the standard HTTP + WebSocket 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";
/// Default physical filename for the standard Wallet configuration document.
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";
/// Logical file identifier for the standard Logging JSON Schema document.
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
/// Logical file identifier for the standard HTTP + WebSocket 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";
/// Logical file identifier for the standard Wallet JSON Schema document.
pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet";
/// Logical file identifier for the standard Logging configuration document.
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
/// Logical file identifier for the standard HTTP + WebSocket 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";
/// Logical file identifier for the standard Wallet configuration document.
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::Err(error) => return std::result::Result::Err(error),
};
let offchain_transport = ConfigFileDescriptor::new(
FILE_ID_STD_OFFCHAIN_TRANSPORT,
ConfigFileKind::Config,
DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME,
std::option::Option::Some(FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT),
);
let offchain_transport = match offchain_transport {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let offchain_transport_schema = ConfigFileDescriptor::new(
FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT,
ConfigFileKind::Schema,
DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME,
std::option::Option::None,
);
let offchain_transport_schema = match offchain_transport_schema {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let transport = ConfigFileDescriptor::new(
FILE_ID_STD_TRANSPORT,
ConfigFileKind::Config,
@@ -204,7 +232,18 @@ impl ConfigFileRegistry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::build_registry([composite, composite_schema, logging, logging_schema, transport, transport_schema, wallet, wallet_schema]);
return crate::build_registry([
composite,
composite_schema,
logging,
logging_schema,
offchain_transport,
offchain_transport_schema,
transport,
transport_schema,
wallet,
wallet_schema,
]);
}
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-config-lib/src/transport.rs
// version: 3
// version: 5
/// Effective standard on-chain Transport configuration resolved from Config and mapped to HTTP and optional WebSocket runtime contracts.
/// 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)]
pub struct ResolvedTransportConfig {
file_id: crate::ConfigFileId,
@@ -11,6 +11,7 @@ pub struct ResolvedTransportConfig {
effective: crate::ResolvedConfigJson,
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 {
@@ -61,7 +62,7 @@ impl ResolvedTransportConfig {
return &self.settings;
}
/// Returns validated WebSocket Transport settings when the selected document uses format V2.
/// 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]
@@ -69,19 +70,42 @@ impl ResolvedTransportConfig {
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.
#[must_use]
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
return self.settings;
}
/// Consumes this resolved Config and returns both HTTP and optional WebSocket runtime 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 {
@@ -94,15 +118,17 @@ impl std::fmt::Debug for ResolvedTransportConfig {
.field("selection_source", &self.selection_source)
.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();
}
}
impl crate::ConfigDocumentEngine {
/// Loads the standard Transport document, selects a profile, resolves environment placeholders and maps HTTP plus optional WebSocket 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
/// because Transport URL wrappers own runtime redaction. V1 documents remain HTTP-only; V2 documents require WebSocket defaults and endpoints.
/// because Transport URL wrappers own runtime redaction. V1 remains HTTP-only, V2 adds WebSocket and V3 can additionally map Yellowstone gRPC.
pub fn load_resolved_transport_config(
&self,
requested_profile: std::option::Option<&str>,
@@ -121,7 +147,8 @@ impl crate::ConfigDocumentEngine {
return resolve_transport_profile(&profile, environment);
}
/// Maps an already resolved standard Transport profile to HTTP plus optional WebSocket runtime adapters while preserving 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`.
pub fn resolve_transport_config_profile(
@@ -148,9 +175,13 @@ struct EffectiveTransportSource {
retry: EffectiveRetrySource,
#[serde(default)]
ws_defaults: std::option::Option<EffectiveWsSessionSource>,
#[serde(default)]
grpc_defaults: std::option::Option<EffectiveGrpcSessionSource>,
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)]
@@ -255,6 +286,72 @@ struct EffectiveWsReconnectOverrideSource {
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> {
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);
@@ -294,16 +391,26 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
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!(
target: crate::TRACING_TARGET,
profile_id = profile.profile_id(),
format_version,
http_endpoint_count = settings.endpoints().len(),
ws_endpoint_count,
grpc_endpoint_count,
"mapped standard Transport Config to runtime settings"
);
return std::result::Result::Ok(ResolvedTransportConfig {
@@ -314,6 +421,7 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
effective,
settings,
ws_settings,
grpc_settings,
});
}
@@ -328,18 +436,22 @@ fn map_optional_ws_settings(
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");
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 => {
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 requires ws_defaults")),
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 profile requires ws_endpoints")),
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 {
@@ -360,6 +472,283 @@ fn map_optional_ws_settings(
};
}
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(
sources: std::vec::Vec<EffectiveEndpointSource>,
profile: &crate::ResolvedConfigProfile,
@@ -443,6 +832,7 @@ fn map_ws_protocol_kind(
) -> 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)

View File

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

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/public_api.rs
// version: 22
// version: 24
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
//! Logging/Transport adapters and management contracts.
@@ -80,16 +80,18 @@ fn registry_descriptor_inventory_is_available_from_crate_root() {
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect();
assert_eq!(descriptors.len(), 8);
assert_eq!(descriptors.len(), 10);
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
let schema_file_id = descriptors[3].schema_file_id();
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
let schema_file_id = descriptors[4].schema_file_id();
assert!(schema_file_id.is_some(), "public Wallet descriptor should preserve schema association");
if let std::option::Option::Some(schema_file_id) = schema_file_id {
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
@@ -254,7 +256,9 @@ fn transport_adapter_contract_is_available_from_crate_root() {
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_SCHEMA_STD_TRANSPORT, "schema.std.transport");
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json");
@@ -274,6 +278,18 @@ fn wallet_adapter_contract_is_available_from_crate_root() {
assert_eq!(ksp_config_lib::DEFAULT_STD_WALLET_SCHEMA_FILENAME, "std.wallet.schema.json");
}
#[test]
fn offchain_transport_adapter_contract_is_available_from_crate_root() {
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_offchain_transport_config;
let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_offchain_transport_config_profile;
assert!(std::mem::size_of::<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]
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;

View File

@@ -0,0 +1,75 @@
{
"format_version": 1,
"default_profile": "public_keyless",
"profiles": [
{
"profile_id": "public_keyless",
"market_price": {
"birdeye": {
"enabled": false
},
"coinbase_exchange": {
"enabled": true
},
"coingecko": {
"enabled": true,
"access_mode": "keyless"
},
"coinmarketcap": {
"enabled": true,
"access_mode": "keyless"
},
"coinpaprika": {
"enabled": true
},
"dexscreener": {
"enabled": false
},
"jupiter": {
"enabled": true,
"access_mode": "keyless"
},
"kraken": {
"enabled": true
}
}
},
{
"profile_id": "all_free",
"market_price": {
"birdeye": {
"enabled": true,
"api_key": "${KSP_SECRET_BIRDEYE_API_KEY}"
},
"coinbase_exchange": {
"enabled": true
},
"coingecko": {
"enabled": true,
"access_mode": "demo",
"api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}"
},
"coinmarketcap": {
"enabled": true,
"access_mode": "basic",
"api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}"
},
"coinpaprika": {
"enabled": true
},
"dexscreener": {
"enabled": true,
"sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}"
},
"jupiter": {
"enabled": true,
"access_mode": "free",
"api_key": "${KSP_SECRET_JUPITER_API_KEY}"
},
"kraken": {
"enabled": true
}
}
}
]
}

View File

@@ -71,6 +71,55 @@
"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}"
}
]
}

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

View File

@@ -0,0 +1,284 @@
// file: crates/ksp-config-lib/unit_tests/offchain_transport.rs
// version: 2
const TEST_PAIR: &str = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE";
#[test]
fn committed_public_keyless_profile_maps_all_eight_providers_without_secret_environment() {
let engine = committed_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::None, &environment);
assert!(resolved.is_ok(), "committed public_keyless Off-chain Transport profile should map: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
assert_eq!(resolved.profile_id(), "public_keyless");
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
let registry = resolved.service().registry();
assert_eq!(registry.len(), 8);
let ids: std::vec::Vec<&str> = registry.entries().iter().map(|entry| return entry.descriptor().id().as_str()).collect();
assert_eq!(ids, ["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken"]);
assert_provider_availability(&registry, "birdeye", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
assert_provider_availability(&registry, "dexscreener", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
for provider_id in ["coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "jupiter", "kraken"] {
assert_provider_availability(&registry, provider_id, ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready);
}
let debug = format!("{resolved:?}");
assert!(!debug.contains("api_key"), "safe Debug should not expose credential field contents from the selected keyless profile");
}
}
#[test]
fn committed_all_free_profile_requires_config_owned_secrets_and_public_pair_provenance() {
let engine = committed_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let canaries = [
("KSP_SECRET_BIRDEYE_API_KEY", "birdeye-secret-canary"),
("KSP_SECRET_COINGECKO_DEMO_API_KEY", "coingecko-secret-canary"),
("KSP_SECRET_COINMARKETCAP_API_KEY", "coinmarketcap-secret-canary"),
("KSP_SECRET_JUPITER_API_KEY", "jupiter-secret-canary"),
("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS", TEST_PAIR),
];
let mut process = std::collections::BTreeMap::<String, String>::new();
for (name, value) in canaries {
process.insert(name.to_owned(), value.to_owned());
}
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("all_free"), &environment);
assert!(resolved.is_ok(), "committed all_free Off-chain Transport profile should map from Config-owned environment: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "all_free");
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
let registry = resolved.service().registry();
assert_eq!(registry.len(), 8);
for entry in registry.entries() {
assert_eq!(entry.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready);
}
assert!(resolved.effective().sensitivity().is_secret());
let safe = resolved.effective().safe_value().to_string();
for secret in ["birdeye-secret-canary", "coingecko-secret-canary", "coinmarketcap-secret-canary", "jupiter-secret-canary"] {
assert!(!safe.contains(secret), "safe effective Config must redact provider credential canary");
}
assert!(safe.contains(TEST_PAIR), "public DexScreener pair should remain visible in the safe effective Config");
let debug = format!("{resolved:?}");
for secret in ["birdeye-secret-canary", "coingecko-secret-canary", "coinmarketcap-secret-canary", "jupiter-secret-canary"] {
assert!(!debug.contains(secret), "ResolvedOffchainTransportConfig Debug must redact provider credential canary");
}
}
}
#[test]
fn literal_or_nonsecret_provider_credentials_are_rejected_by_effective_adapter() {
let fixture = tempfile::tempdir();
assert!(fixture.is_ok(), "temporary Config root should be creatable: {fixture:?}");
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let source = committed_document_value();
let mut source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
assert!(profiles.is_some(), "fixture should expose profiles");
if let std::option::Option::Some(profiles) = profiles {
let all_free = profiles
.iter_mut()
.find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("all_free"));
assert!(all_free.is_some(), "fixture should contain all_free profile");
if let std::option::Option::Some(all_free) = all_free {
all_free["market_price"]["birdeye"]["api_key"] = serde_json::Value::String("literal-secret".to_owned());
}
}
let engine = fixture_engine_with_document(fixture.path(), &source);
assert!(engine.is_ok(), "literal-secret fixture engine should be constructible: {engine:?}");
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let mut process = all_free_environment();
process.insert("KSP_SECRET_BIRDEYE_API_KEY".to_owned(), "unused-secret".to_owned());
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("all_free"), &environment);
assert!(resolved.is_err(), "literal provider credential must be rejected even though the JSON Schema accepts a non-empty string");
if let std::result::Result::Err(error) = resolved {
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
assert!(!format!("{error:?}").contains("literal-secret"));
}
}
#[test]
fn dexscreener_pair_environment_must_use_public_namespace_and_disabled_pair_may_be_absent() {
let fixture = tempfile::tempdir();
assert!(fixture.is_ok(), "temporary Config root should be creatable: {fixture:?}");
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let source = committed_document_value();
let mut source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
if let std::option::Option::Some(profiles) = profiles {
let all_free = profiles
.iter_mut()
.find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("all_free"));
if let std::option::Option::Some(all_free) = all_free {
all_free["market_price"]["dexscreener"]["sol_usd_pair_address"] = serde_json::Value::String("${KSP_SECRET_DEXSCREENER_PAIR}".to_owned());
}
}
let engine = fixture_engine_with_document(fixture.path(), &source);
assert!(engine.is_ok(), "secret-pair fixture engine should be constructible: {engine:?}");
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let mut process = all_free_environment();
process.remove("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS");
process.insert("KSP_SECRET_DEXSCREENER_PAIR".to_owned(), TEST_PAIR.to_owned());
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("all_free"), &environment);
assert!(resolved.is_err(), "DexScreener pair environment must not use secret provenance");
if let std::result::Result::Err(error) = resolved {
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
}
let disabled = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, std::option::Option::None);
assert!(disabled.is_ok(), "disabled DexScreener runtime settings should accept an absent pair after pre.009 capability reconciliation: {disabled:?}");
}
#[test]
fn provider_url_and_rate_limit_overrides_are_rejected_by_schema_before_runtime_mapping() {
for (provider, field, value) in [
("coingecko", "base_url", serde_json::Value::String("https://example.invalid".to_owned())),
("coinmarketcap", "rate_limit", serde_json::json!({"requests": 999999, "window_seconds": 1})),
] {
let fixture = tempfile::tempdir();
assert!(fixture.is_ok(), "temporary Config root should be creatable: {fixture:?}");
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let source = committed_document_value();
let mut source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
assert!(profiles.is_some(), "fixture should expose profiles");
if let std::option::Option::Some(profiles) = profiles {
let public_keyless = profiles
.iter_mut()
.find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("public_keyless"));
assert!(public_keyless.is_some(), "fixture should contain public_keyless profile");
if let std::option::Option::Some(public_keyless) = public_keyless {
public_keyless["market_price"][provider][field] = value;
}
}
let engine = fixture_engine_with_document(fixture.path(), &source);
assert!(engine.is_ok(), "override fixture engine should be constructible: {engine:?}");
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("public_keyless"), &environment);
assert!(resolved.is_err(), "provider URL/rate-limit override must be rejected before runtime mapping: provider={provider} field={field}");
if let std::result::Result::Err(error) = resolved {
assert_eq!(error.code(), crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED);
let debug = format!("{error:?}");
assert!(!debug.contains("https://example.invalid"));
}
}
}
fn assert_provider_availability(
registry: &ksp_offchain_transport_lib::MarketPriceProviderRegistry,
provider_id: &str,
expected: ksp_offchain_transport_lib::MarketPriceProviderAvailability,
) {
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id);
assert!(provider_id.is_ok(), "provider id fixture should be valid: {provider_id:?}");
if let std::result::Result::Ok(provider_id) = provider_id {
let state = registry.state(&provider_id);
assert!(state.is_some(), "provider should be present in Config-produced service registry");
if let std::option::Option::Some(state) = state {
assert_eq!(state.availability(), expected);
}
}
}
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
let workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = crate::ConfigFileRegistry::defaults();
let registry = match registry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
}
fn fixture_engine_with_document(root: &std::path::Path, document: &serde_json::Value) -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
let config_root = root.join("config");
let create = std::fs::create_dir_all(config_root.as_path());
if let std::result::Result::Err(error) = create {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Config root cannot be created").with_source(error),
);
}
let bytes = serde_json::to_vec_pretty(document);
let bytes = match bytes {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "test Config cannot be encoded").with_source(error),
);
},
};
let path = config_root.join(crate::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME);
if let std::result::Result::Err(error) = std::fs::write(path.as_path(), bytes) {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Config cannot be written").with_source(error));
}
let bootstrap = crate::ConfigBootstrapOptions::from_paths(config_root, workspace_root().join("config/schemas"));
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = crate::ConfigFileRegistry::defaults();
let registry = match registry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
}
fn committed_document_value() -> std::result::Result<serde_json::Value, serde_json::Error> {
return serde_json::from_str(include_str!("../../../config/std.offchain_transport.json"));
}
fn all_free_environment() -> std::collections::BTreeMap<String, String> {
let mut process = std::collections::BTreeMap::<String, String>::new();
process.insert("KSP_SECRET_BIRDEYE_API_KEY".to_owned(), "birdeye-test".to_owned());
process.insert("KSP_SECRET_COINGECKO_DEMO_API_KEY".to_owned(), "coingecko-test".to_owned());
process.insert("KSP_SECRET_COINMARKETCAP_API_KEY".to_owned(), "coinmarketcap-test".to_owned());
process.insert("KSP_SECRET_JUPITER_API_KEY".to_owned(), "jupiter-test".to_owned());
process.insert("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS".to_owned(), TEST_PAIR.to_owned());
return process;
}
fn workspace_root() -> std::path::PathBuf {
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/registry.rs
// version: 8
// version: 9
#[test]
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
@@ -7,21 +7,23 @@ fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect();
assert_eq!(descriptors.len(), 8);
assert_eq!(descriptors.len(), 10);
assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME));
assert_eq!(descriptors[0].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE));
assert_eq!(descriptors[1].file_id().as_str(), crate::FILE_ID_STD_LOGGING);
assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_WALLET);
assert_eq!(descriptors[3].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
assert_eq!(descriptors[3].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET));
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET);
assert!(descriptors[0..4].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config));
assert!(descriptors[4..8].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema));
assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_WALLET);
assert_eq!(descriptors[4].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
assert_eq!(descriptors[4].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET));
assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET);
assert!(descriptors[0..5].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config));
assert!(descriptors[5..10].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema));
}
}
@@ -85,6 +87,31 @@ fn defaults_register_logging_document_and_schema_with_distinct_roots() {
}
}
#[test]
fn defaults_register_offchain_transport_document_and_schema_with_distinct_roots() {
let registry = crate::ConfigFileRegistry::defaults();
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let config_id = crate::ConfigFileId::new(crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
assert!(config_id.is_ok(), "Off-chain Transport file_id should be valid: {config_id:?}");
assert!(schema_id.is_ok(), "Off-chain Transport schema file_id should be valid: {schema_id:?}");
if let (std::result::Result::Ok(config_id), std::result::Result::Ok(schema_id)) = (config_id, schema_id) {
let config = registry.descriptor(&config_id);
let schema = registry.descriptor(&schema_id);
assert!(config.is_ok(), "Off-chain Transport descriptor should exist: {config:?}");
assert!(schema.is_ok(), "Off-chain Transport schema descriptor should exist: {schema:?}");
if let (std::result::Result::Ok(config), std::result::Result::Ok(schema)) = (config, schema) {
assert_eq!(config.kind(), crate::ConfigFileKind::Config);
assert_eq!(config.filename(), std::path::Path::new(crate::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME));
assert_eq!(config.schema_file_id(), std::option::Option::Some(&schema_id));
assert_eq!(schema.kind(), crate::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(crate::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME));
}
}
}
}
#[test]
fn defaults_register_transport_document_and_schema_with_distinct_roots() {
let registry = crate::ConfigFileRegistry::defaults();

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/transport.rs
// version: 4
// version: 12
#[test]
fn fixture_transport_profile_maps_complete_runtime_contract() {
@@ -41,10 +41,11 @@ 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().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!(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(), 1);
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");
@@ -64,6 +65,13 @@ fn fixture_transport_profile_maps_complete_runtime_contract() {
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());
}
}
@@ -83,6 +91,7 @@ fn v1_transport_fixture_remains_backward_readable_and_http_only() {
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");
}
}
@@ -102,8 +111,9 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
assert_eq!(default.profile_id(), "devnet_public");
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!(default.grpc_settings().is_none(), "Devnet profile must not invent a Yellowstone gRPC endpoint");
let ws = default.ws_settings();
assert!(ws.is_some(), "committed V2 Devnet profile should expose WebSocket 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);
@@ -114,8 +124,9 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
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].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 V2 Mainnet profile should expose WebSocket 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");
}
@@ -123,7 +134,193 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
}
#[test]
fn committed_v2_websocket_endpoint_composes_with_public_session_constructor_without_polling() {
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,
@@ -131,11 +328,11 @@ fn committed_v2_websocket_endpoint_composes_with_public_session_constructor_with
};
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 V2 Transport profile should map: {resolved:?}");
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 V2 Transport profile should expose WebSocket settings");
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);
@@ -163,6 +360,7 @@ fn transport_profile_preserves_global_and_profile_origin() {
if let std::result::Result::Ok(profile) = profile {
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("ws_endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
assert_eq!(profile.origin("format_version"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
@@ -259,6 +457,78 @@ fn secret_websocket_url_is_runtime_available_but_safe_projection_is_redacted() {
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]
fn transport_secret_url_provenance_uses_process_and_process_beats_dotenv() {
let engine = fixture_engine();
@@ -342,6 +612,22 @@ fn v1_fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
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> {
let workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
// version: 4
// version: 7
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
@@ -62,11 +62,17 @@ fn transport_manifest_preserves_ksp_dependency_firewall() {
assert!(manifest.contains("ksp-core-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("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("tokio = { workspace = true, features = [\"net\", \"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]
@@ -86,7 +92,20 @@ fn transport_manifest_runtime_and_dev_dependency_names_are_exact() {
let dependency_names = manifest_dependency_names(dependencies);
assert_eq!(
dependency_names,
std::vec!["futures-util", "ksp-core-lib", "ksp-logging-lib", "reqwest", "serde", "serde_json", "tokio", "tokio-tungstenite"]
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");
@@ -98,7 +117,7 @@ fn transport_manifest_runtime_and_dev_dependency_names_are_exact() {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert_eq!(manifest_dependency_names(dev_dependencies), std::vec!["tokio"]);
assert_eq!(manifest_dependency_names(dev_dependencies), std::vec!["tokio", "tonic", "yellowstone-grpc-proto"]);
}
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-logging-lib/Cargo.toml
# version: 5
# version: 6
[package]
name = "ksp-logging-lib"
@@ -10,8 +10,8 @@ repository.workspace = true
[dependencies]
ksp-core-lib = { path = "../ksp-core-lib" }
tracing = { workspace = true, features = ["std"] }
tracing-subscriber = { workspace = true, features = ["fmt", "json", "ansi"] }
tracing-appender.workspace = true
tracing-subscriber = { workspace = true, features = ["fmt", "json", "ansi"] }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-onchain-transport-lib/Cargo.toml
# version: 5
# version: 9
[package]
name = "ksp-onchain-transport-lib"
@@ -8,17 +8,23 @@ edition.workspace = true
repository.workspace = true
[dependencies]
futures-util = { workspace = true, features = ["sink", "std"] }
http.workspace = true
ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" }
futures-util = { workspace = true, features = ["sink", "std"] }
reqwest = { workspace = true, features = ["rustls"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
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]
tokio = { workspace = true, features = ["net", "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]
workspace = true

View File

@@ -1,9 +1,9 @@
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
<!-- version: 19 -->
<!-- version: 24 -->
# `ksp-onchain-transport-lib`
`ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Elle fournit le transport HTTP JSON-RPC complet et le moteur WebSocket Solana standard ; les extensions provider-specific et gRPC sont ajoutées séparément lorsquune 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 lorsquune release les cible.
## Responsabilités
@@ -21,6 +21,8 @@ La crate possède :
- l'exécution générique des méthodes standard supportées ;
- 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 ;
- l'observabilité Transport via `ksp-logging-lib`.
@@ -37,6 +39,7 @@ ksp-config-lib
-> ksp-logging-lib
-> reqwest / tokio / serde
-> tokio-tungstenite / futures-util
-> tonic / tonic-prost / yellowstone-grpc-proto
```
La direction inverse est interdite :
@@ -199,6 +202,51 @@ Ces familles restent explicitement **unstable**. Le moteur commun `subscribe_typ
`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 laudit 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 sils existent sur la façade Solana standard. `transactionSubscribe` reste provider-specific et nest jamais ajouté à `SolanaStandardWsSession`.
Les endpoints Helius mainnet/devnet utilisent un `api-key` dans lURL. 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 nexposent pas le credential. Transport ne lit jamais lenvironnement et ne dépend jamais de Config.
Pour Helius, lactor envoie automatiquement un control frame WebSocket `Ping` toutes les 60 secondes sur une session active. Cette policy est provider-owned, non configurable et ne sapplique pas aux sessions `SolanaStandard`. Une perte physique suit le reconnect/resubscribe borné déjà décrit; aucun replay/lossless nest 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 nest 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 lobservabilité ; 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 nest committé ni placé dans lURL. Le même token a été validé par lopérateur sur les deux réseaux, même si Config conserve deux variables distinctes pour laisser cette policy flexible.
`0.2.10` ajoute le profil `orbitflare_devnet` sur le même moteur provider-neutral. Lendpoint validé est `http://devnet.rpc.orbitflare.com:10000`; la License Key `ORBIT-*` est fournie par Config via `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` et devient la metadata secrète `x-token`. Le smoke live dédié a validé `Subscribe` slots à commitment confirmed, un `Slot` non nul et un `SubscribeUpdate::Ping` standard. Cette preuve confirme quaucune façade OrbitFlare, aucun heartbeat provider et aucune modification N1/N2 ne sont nécessaires.
## Résilience
L'admission est calculée par couple endpoint/rôle. Le pool applique :
@@ -235,7 +283,7 @@ 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.
Trois smokes Devnet opt-in sont séparés par responsabilité :
Quatre smokes réseau opt-in sont séparés par responsabilité :
```text
Transport HTTP pur : settings programmatiques -> HttpTransportPool
@@ -252,13 +300,23 @@ Transport WebSocket pur : settings programmatiques -> WsSession
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
-> 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 HTTP Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
Le smoke WebSocket Transport cible uniquement la famille stable `slotSubscribe` sur l'endpoint public Devnet `wss://api.devnet.solana.com`. Il borne connexion, attente de notification, unsubscribe et fermeture ; il ne transforme aucune famille unstable en gate live.
Les trois tests sont `ignored` par défaut. Les deux smokes Transport appartiennent durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée. Un rate-limit, refus externe ou incident Devnet n'est pas assimilé automatiquement à une régression locale.
Les cinq tests sont `ignored` par défaut ; le test PublicNode Yellowstone contient deux cas live Mainnet/Testnet et le test OrbitFlare contient un cas Devnet. Les trois familles de smokes Transport appartiennent durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée. Un rate-limit, refus externe ou incident Devnet n'est pas assimilé automatiquement à une régression locale.
Aucun smoke Helius live supplémentaire nest committé en `0.2.8-pre.010`. Un tel test devrait à la fois obtenir `KSP_SECRET_HELIUS_API_KEY` via Config et exercer Transport ; lajouter dans Transport violerait lownership environnement/secret, tandis que lajouter dans Config étendrait lexception cross-crates que le projet veut au contraire résorber. La première surface KSP dintégration/orchestration dédiée devra héberger ce smoke. Le scénario live recommandé est alors `helius_devnet -> HeliusLaserStreamWsSession -> slotSubscribe -> notification -> unsubscribe -> close`; `transactionSubscribe` reste un smoke optionnel dépendant des droits provider et ne devient pas un gate stable de release.
## Documentation
@@ -270,4 +328,8 @@ Les trois tests sont `ignored` par défaut. Les deux smokes Transport appartienn
- [`../../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/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 + WebSocket V2, avec lecture backward V1 HTTP-only.
- [`../../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.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
<!-- version: 19 -->
<!-- version: 23 -->
# Utilisation de `ksp-onchain-transport-lib`
@@ -183,6 +183,46 @@ let mut blocks = match session
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 lendpoint WebSocket résolu puis ouvrir la façade Helius, sans reconstruire ni journaliser lURL contenant lAPI 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 lID 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 lenvironnement dans Transport, ne pas recopier lURL 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`.
@@ -217,7 +257,141 @@ Les limites de taille et de capacité sont des policies KSP configurables par `W
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. Appels typés
## 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;
```
LURL reste sensible : `Debug`, erreurs KSP et snapshots nen exposent pas la valeur. Les metadata publiques/secrètes se construisent avec `YellowstoneGrpcMetadataEntry`; Transport ne lit jamais lenvironnement. 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 laccesseur 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 nautorise pas Transport à introduire une API provider-specific sans divergence réelle.
### Profil OrbitFlare Devnet
Le profil committé `orbitflare_devnet` réutilise exactement le même accès Config -> Transport :
```rust
let resolved = match engine.load_resolved_transport_config(Some("orbitflare_devnet"), &environment) {
Ok(value) => value,
Err(error) => return Err(error),
};
let grpc_settings = match resolved.grpc_settings() {
Some(value) => value,
None => return Err(ksp_core_lib::Error::new(
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
"selected OrbitFlare profile has no Yellowstone gRPC endpoint",
)),
};
let endpoint = match grpc_settings.endpoints().iter().find(|candidate| candidate.enabled()) {
Some(value) => value,
None => return Err(ksp_core_lib::Error::new(
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
"selected OrbitFlare profile has no enabled Yellowstone gRPC endpoint",
)),
};
let channel = ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(endpoint).await;
```
Config résout `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` vers la metadata secrète `x-token`. Sa valeur effective est la License Key `ORBIT-*` du produit Solana ; `X-ORBIT-KEY` et le Bearer du Customer API ne doivent pas être utilisés pour Yellowstone. Transport ne lit jamais cette variable lui-même.
Lendpoint validé par `0.2.10` est `http://devnet.rpc.orbitflare.com:10000`. Il reste volontairement en `http` : KSP ne remplace pas le transport provider par `https` sans endpoint TLS explicitement fourni.
### Subscribe bidirectionnel
Une session standard part dune 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`.
@@ -277,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.
## 5. 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 :
@@ -291,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.
## 6. 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 :
@@ -306,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.
## 7. 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.
Les URLs d'endpoint n'y apparaissent jamais.
## 8. 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()`.
@@ -320,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.
## 9. Logging
## 10. Logging
Les événements Transport utilisent le target :
@@ -332,7 +506,7 @@ 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.
## 10. Smokes Devnet opt-in
## 11. Smokes réseau opt-in
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 :
@@ -350,6 +524,51 @@ cargo test -p ksp-onchain-transport-lib --test websocket_devnet_smoke -- --ignor
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 nest donc requis.
Le smoke de **composition Config -> Transport** reste également disponible :
```bash
@@ -358,7 +577,25 @@ 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.
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 et WebSocket locales restent les gates reproductibles.
### Smoke Helius live
Aucun nouveau test Helius live nest 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 lune de ces deux crates contournerait donc une frontière déjà documentée.
Lorsque la surface KSP dinté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 lendpoint Helius et teste donc auth + façade provider + actor + unsubscribe sans dépendre dune entitlement particulière de `transactionSubscribe`. Un smoke `transactionSubscribe` pourra être ajouté séparément comme opt-in provider-specific si lenvironnement 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 dauth, entitlement absente ou incident réseau nest 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 :

View File

@@ -1,8 +1,16 @@
// file: crates/ksp-onchain-transport-lib/src/error.rs
// version: 4
// version: 7
/// 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");
/// 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.
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.

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/client.rs
// version: 7
// file: crates/ksp-onchain-transport-lib/src/http_client.rs
// version: 8
/// Passive runtime availability reported for one logical HTTP endpoint or role.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -510,5 +510,5 @@ fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::
}
#[cfg(test)]
#[path = "../unit_tests/client.rs"]
#[path = "../unit_tests/http_client.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/executor.rs
// version: 3
// file: crates/ksp-onchain-transport-lib/src/http_executor.rs
// version: 4
const HTTP_BAD_GATEWAY: u16 = 502;
const HTTP_GATEWAY_TIMEOUT: u16 = 504;
@@ -228,5 +228,5 @@ fn http_status_error(method: &crate::HttpRpcMethodDescriptor, status: u16) -> ks
}
#[cfg(test)]
#[path = "../unit_tests/executor.rs"]
#[path = "../unit_tests/http_executor.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/pool.rs
// version: 7
// file: crates/ksp-onchain-transport-lib/src/http_pool.rs
// version: 8
/// Safe snapshot of the logical HTTP endpoint pool.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -616,5 +616,5 @@ fn request_timeout(role: &crate::HttpRoleName, request_kind: &crate::HttpRequest
}
#[cfg(test)]
#[path = "../unit_tests/pool.rs"]
#[path = "../unit_tests/http_pool.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/resilience.rs
// version: 3
// file: crates/ksp-onchain-transport-lib/src/http_resilience.rs
// version: 4
const DEFAULT_RATE_LIMIT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(1);
const MAX_PROVIDER_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
@@ -401,5 +401,5 @@ fn retry_backoff(settings: &crate::HttpRetrySettings, retry_number: u32) -> std:
}
#[cfg(test)]
#[path = "../unit_tests/resilience.rs"]
#[path = "../unit_tests/http_resilience.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/settings.rs
// version: 6
// file: crates/ksp-onchain-transport-lib/src/http_settings.rs
// version: 7
/// Runtime HTTP endpoint URL owned by Transport.
///
@@ -582,5 +582,5 @@ fn invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
}
#[cfg(test)]
#[path = "../unit_tests/settings.rs"]
#[path = "../unit_tests/http_settings.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 29
// version: 44
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -24,15 +24,41 @@
//! routing. `0.2.7-pre.007` adds finite reconnect, deterministic resubscribe and continuity-gap tracking. `0.2.7-pre.008` makes per-subscription notification
//! backpressure terminal and observable, preserves safe terminal error codes, performs best-effort remote cleanup and proves bounded capacity reuse.
//! `0.2.7-pre.009` opens the first stable typed WebSocket wrappers for account, program-account and transaction-log subscriptions without exposing a raw
//! provider-extension subscription API.
//! provider-extension subscription API. `0.2.8-pre.002` adds a Helius LaserStream WebSocket protocol discriminator and two typed protocol facades while
//! keeping the `WsSession` actor/socket implementation unique and the historical generic constructor standard-only.
//! `0.2.8-pre.003` initially exposed the six standard families unambiguously supported by the audited Helius pages; `0.2.8-pre.009` reconciles the current
//! Helius documentation and adds the now-documented unstable `slotsUpdatesSubscribe` pair while keeping explicitly unsupported block/vote pairs absent.
//! `0.2.8-pre.005` adds the typed Helius `transactionSubscribe` request contract and provider filter/options validation. `0.2.8-pre.006` integrates the live
//! transaction handle and typed `transactionNotification` union into the same actor-owned registry, remote-ID remap, unsubscribe-race handling and
//! per-subscription backpressure path.
//! `0.2.9-pre.002` opens the Yellowstone gRPC N1 engine foundation with Transport-owned redacted settings, bounded reconnect/channel/message policies, the
//! published Yellowstone protobuf dependency and a lazy Tonic HTTP/2 channel wrapper that exposes no raw Tonic or upstream protobuf types.
//! `0.2.9-pre.003` adds bounded TLS/WebPKI connection establishment, generic redacted ASCII request metadata and the seven standard Yellowstone unary RPCs
//! through KSP-owned DTOs.
//! `0.2.9-pre.004` materializes the provider-neutral standard `SubscribeRequest` foundation: all seven named filter maps, global filter-name bounds/uniqueness,
//! commitment, ordered account-data slices, ping and `from_slot`. Family-specific account/slot filters land in `pre.005`; transaction/block filters remain
//! staged for `pre.007008`.
//! `0.2.9-pre.006` normalizes the five unambiguously HTTP-owned private implementation modules with an `http_` prefix while preserving shared `rpc_*`,
//! JSON-RPC, error and constants modules.
//! `0.2.9-pre.007` completes the standard transaction/transaction-status filters and storage-wire projections; `pre.008` completes Blocks, block-meta and
//! entry projections. `0.2.9-pre.009` promotes those protobuf bridges into runtime and opens one KSP-owned bounded bidirectional `Subscribe` session with
//! request mutation, automatic server-Ping reply, observable Pong, normal server half-close, terminal backpressure and bounded graceful shutdown.
//! `0.2.9-pre.010` activates the bounded KSP-owned reconnect policy, deterministic replay from the latest accepted request and highest observed slot,
//! conservative ReplayInfo-based continuity-gap observability and bounded duplicate observation without claiming exactly-once or lossless delivery.
mod client;
mod constants;
mod error;
mod executor;
mod grpc_channel;
mod grpc_settings;
mod grpc_stream;
mod grpc_subscribe;
mod grpc_unary;
mod http_client;
mod http_executor;
mod http_pool;
mod http_resilience;
mod http_settings;
mod json_rpc;
mod pool;
mod resilience;
mod rpc_accounts;
mod rpc_blocks;
mod rpc_canary;
@@ -42,26 +68,27 @@ mod rpc_economics;
mod rpc_method;
mod rpc_tokens;
mod rpc_transactions;
mod settings;
mod ws_accounts;
mod ws_blocks;
mod ws_cluster;
mod ws_helius_transactions;
mod ws_lifecycle;
mod ws_protocol_session;
mod ws_session;
mod ws_settings;
mod ws_subscription;
mod ws_transactions;
/// Passive runtime availability reported for one logical HTTP endpoint.
pub use self::client::HttpEndpointAvailability;
/// Shareable logical HTTP endpoint client owned by KSP Transport.
pub use self::client::HttpEndpointClient;
/// Safe routing snapshot for one configured endpoint role.
pub use self::client::HttpEndpointRoleSnapshot;
/// Safe metadata snapshot for one logical HTTP endpoint.
pub use self::client::HttpEndpointSnapshot;
/// Error code used when no logical endpoint can satisfy a request.
pub use self::error::ERROR_CODE_ENDPOINT_SELECTION_FAILED;
/// Error code used when bounded Yellowstone gRPC runtime capacity is exhausted.
pub use self::error::ERROR_CODE_GRPC_BACKPRESSURE_OVERFLOW;
/// Error code used when a Yellowstone gRPC channel cannot be prepared safely.
pub use self::error::ERROR_CODE_GRPC_CHANNEL_FAILED;
/// Error code used when a Yellowstone gRPC subscribe session is no longer available.
pub use self::error::ERROR_CODE_GRPC_SESSION_CLOSED;
/// Error code used when a Yellowstone gRPC endpoint returns a remote status.
pub use self::error::ERROR_CODE_GRPC_STATUS;
/// Error code used when an HTTP connection cannot be established.
pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
/// Error code used when an HTTP request fails after connection establishment.
@@ -94,6 +121,192 @@ pub use self::error::ERROR_CODE_WS_CONNECTION_FAILED;
pub use self::error::ERROR_CODE_WS_PROTOCOL_ERROR;
/// Error code used when a WebSocket session is no longer available.
pub use self::error::ERROR_CODE_WS_SESSION_CLOSED;
/// Yellowstone gRPC channel owned by KSP Transport.
pub use self::grpc_channel::YellowstoneGrpcChannel;
/// Open cluster or network descriptor used by Yellowstone gRPC endpoint settings.
pub use self::grpc_settings::YellowstoneGrpcClusterName;
/// Runtime settings for one named Yellowstone gRPC endpoint.
pub use self::grpc_settings::YellowstoneGrpcEndpointSettings;
/// Runtime Yellowstone gRPC endpoint URL with redacted diagnostics.
pub use self::grpc_settings::YellowstoneGrpcEndpointUrl;
/// Validated public or secret ASCII metadata attached to Yellowstone gRPC requests.
pub use self::grpc_settings::YellowstoneGrpcMetadataEntry;
/// Open provider descriptor used by Yellowstone gRPC endpoint settings.
pub use self::grpc_settings::YellowstoneGrpcProviderName;
/// Bounded reconnect settings owned by the Yellowstone gRPC runtime.
pub use self::grpc_settings::YellowstoneGrpcReconnectSettings;
/// Runtime limits and lifecycle settings for one Yellowstone gRPC channel/session path.
pub use self::grpc_settings::YellowstoneGrpcSessionSettings;
/// Complete runtime settings consumed by the KSP Yellowstone gRPC transport engine.
pub use self::grpc_settings::YellowstoneGrpcTransportSettings;
/// Standard Yellowstone bidirectional Subscribe session.
pub use self::grpc_stream::SolanaYellowstoneGrpcSubscribeSession;
/// Safe Yellowstone reconnect/replay continuity snapshot.
pub use self::grpc_stream::YellowstoneGrpcSubscribeSnapshot;
/// Safe Yellowstone bidirectional Subscribe lifecycle state.
pub use self::grpc_stream::YellowstoneGrpcSubscribeState;
/// One validated standard Yellowstone account predicate.
pub use self::grpc_subscribe::YellowstoneAccountFilterPredicate;
/// Typed account payload carried by one standard Yellowstone account update.
pub use self::grpc_subscribe::YellowstoneAccountInfo;
/// Lamport comparison used by standard Yellowstone account filters.
pub use self::grpc_subscribe::YellowstoneAccountLamportsFilter;
/// Validated standard Yellowstone account memcmp predicate.
pub use self::grpc_subscribe::YellowstoneAccountMemcmp;
/// Encoding selected by one Yellowstone account memcmp predicate.
pub use self::grpc_subscribe::YellowstoneAccountMemcmpEncoding;
/// Standard Yellowstone account-update projection owned by KSP.
pub use self::grpc_subscribe::YellowstoneAccountUpdate;
/// One standard Yellowstone account-data slice.
pub use self::grpc_subscribe::YellowstoneAccountsDataSlice;
/// Metadata-only standard Yellowstone block update.
pub use self::grpc_subscribe::YellowstoneBlockMetaUpdate;
/// Rewards container carried by Yellowstone block and block-meta updates.
pub use self::grpc_subscribe::YellowstoneBlockRewards;
/// Full standard Yellowstone block update.
pub use self::grpc_subscribe::YellowstoneBlockUpdate;
/// One compiled instruction from the Yellowstone Solana-storage transaction wire.
pub use self::grpc_subscribe::YellowstoneCompiledInstruction;
/// Wire-preserving KSP representation of a standard Yellowstone Cuckoo filter.
pub use self::grpc_subscribe::YellowstoneCuckooFilter;
/// Hash algorithm carried by a standard Yellowstone Cuckoo filter.
pub use self::grpc_subscribe::YellowstoneCuckooHashAlgorithm;
/// One Yellowstone block-entry payload reused by block and standalone entry updates.
pub use self::grpc_subscribe::YellowstoneEntryInfo;
/// Standalone standard Yellowstone entry update.
pub use self::grpc_subscribe::YellowstoneEntryUpdate;
/// Fixed-width 32-byte hash from the Yellowstone Solana-storage transaction wire.
pub use self::grpc_subscribe::YellowstoneHashBytes;
/// One inner instruction from Yellowstone transaction status metadata.
pub use self::grpc_subscribe::YellowstoneInnerInstruction;
/// One indexed inner-instruction group from Yellowstone transaction status metadata.
pub use self::grpc_subscribe::YellowstoneInnerInstructions;
/// One address-table lookup from a Yellowstone transaction message.
pub use self::grpc_subscribe::YellowstoneMessageAddressTableLookup;
/// Return-data payload from Yellowstone transaction status metadata.
pub use self::grpc_subscribe::YellowstoneReturnData;
/// One reward entry from Yellowstone transaction status metadata.
pub use self::grpc_subscribe::YellowstoneReward;
/// Reward classification from the Yellowstone Solana-storage wire.
pub use self::grpc_subscribe::YellowstoneRewardType;
/// Current standard Yellowstone slot status.
pub use self::grpc_subscribe::YellowstoneSlotStatus;
/// Standard Yellowstone slot-update projection owned by KSP.
pub use self::grpc_subscribe::YellowstoneSlotUpdate;
/// Solana transaction body carried by Yellowstone storage protobuf messages.
pub use self::grpc_subscribe::YellowstoneStoredTransaction;
/// Complete account-family filter group for standard Yellowstone Subscribe.
pub use self::grpc_subscribe::YellowstoneSubscribeAccountFilter;
/// Complete block-family filter group for standard Yellowstone Subscribe.
pub use self::grpc_subscribe::YellowstoneSubscribeBlockFilter;
/// Empty filter marker activating the standard Yellowstone blocks-meta family.
pub use self::grpc_subscribe::YellowstoneSubscribeBlocksMetaFilter;
/// Empty filter marker activating the standard Yellowstone entry family.
pub use self::grpc_subscribe::YellowstoneSubscribeEntryFilter;
/// Validated globally unique logical filter name for standard Yellowstone Subscribe maps.
pub use self::grpc_subscribe::YellowstoneSubscribeFilterName;
/// Optional ping mutation carried by the standard Yellowstone Subscribe request.
pub use self::grpc_subscribe::YellowstoneSubscribePing;
/// Standard Yellowstone server Ping update.
pub use self::grpc_subscribe::YellowstoneSubscribePingUpdate;
/// Standard Yellowstone server Pong update.
pub use self::grpc_subscribe::YellowstoneSubscribePongUpdate;
/// Provider-neutral standard Yellowstone Subscribe request.
pub use self::grpc_subscribe::YellowstoneSubscribeRequest;
/// Complete slot-family filter group for standard Yellowstone Subscribe.
pub use self::grpc_subscribe::YellowstoneSubscribeSlotFilter;
/// Complete transaction-family filter shared by transactions and transaction-status maps.
pub use self::grpc_subscribe::YellowstoneSubscribeTransactionFilter;
/// Any standard Yellowstone Subscribe update.
pub use self::grpc_subscribe::YellowstoneSubscribeUpdate;
/// Optional token-account owner expansion for current Yellowstone transaction filters.
pub use self::grpc_subscribe::YellowstoneTokenAccountExpansion;
/// One pre/post token balance from Yellowstone transaction status metadata.
pub use self::grpc_subscribe::YellowstoneTokenBalance;
/// Optional Transaction V1 inline budget configuration from Yellowstone Solana-storage.
pub use self::grpc_subscribe::YellowstoneTransactionConfig;
/// Opaque runtime transaction error bytes from Yellowstone Solana-storage.
pub use self::grpc_subscribe::YellowstoneTransactionError;
/// Complete transaction info carried by Yellowstone transaction and block updates.
pub use self::grpc_subscribe::YellowstoneTransactionInfo;
/// Complete current Yellowstone transaction message.
pub use self::grpc_subscribe::YellowstoneTransactionMessage;
/// Solana transaction message header from Yellowstone Solana-storage.
pub use self::grpc_subscribe::YellowstoneTransactionMessageHeader;
/// Fixed-width transaction signature attached to Yellowstone updates.
pub use self::grpc_subscribe::YellowstoneTransactionSignature;
/// Validated base58 transaction-signature selector for Yellowstone transaction filters.
pub use self::grpc_subscribe::YellowstoneTransactionSignatureSelector;
/// Complete Yellowstone transaction status metadata.
pub use self::grpc_subscribe::YellowstoneTransactionStatusMeta;
/// Lightweight Yellowstone transaction-status update.
pub use self::grpc_subscribe::YellowstoneTransactionStatusUpdate;
/// Full Yellowstone transaction update.
pub use self::grpc_subscribe::YellowstoneTransactionUpdate;
/// UI token amount from Yellowstone transaction status metadata.
pub use self::grpc_subscribe::YellowstoneUiTokenAmount;
/// Timestamp attached to standard Yellowstone update envelopes.
pub use self::grpc_subscribe::YellowstoneUpdateTimestamp;
/// Standard Solana Yellowstone unary facade over one KSP-owned physical gRPC channel.
pub use self::grpc_unary::SolanaYellowstoneGrpcUnaryClient;
/// Block height returned by the standard Yellowstone unary surface.
pub use self::grpc_unary::YellowstoneBlockHeight;
/// Result of a standard Yellowstone blockhash-validity check.
pub use self::grpc_unary::YellowstoneBlockhashValidity;
/// Latest blockhash returned by the standard Yellowstone unary surface.
pub use self::grpc_unary::YellowstoneLatestBlockhash;
/// Echo returned by the standard Yellowstone unary Ping RPC.
pub use self::grpc_unary::YellowstonePong;
/// Replay availability advertised by the standard Yellowstone unary surface.
pub use self::grpc_unary::YellowstoneReplayInfo;
/// Current slot returned by the standard Yellowstone unary surface.
pub use self::grpc_unary::YellowstoneSlot;
/// Bounded endpoint version returned by the standard Yellowstone unary surface.
pub use self::grpc_unary::YellowstoneVersionInfo;
/// Passive runtime availability reported for one logical HTTP endpoint.
pub use self::http_client::HttpEndpointAvailability;
/// Shareable logical HTTP endpoint client owned by KSP Transport.
pub use self::http_client::HttpEndpointClient;
/// Safe routing snapshot for one configured endpoint role.
pub use self::http_client::HttpEndpointRoleSnapshot;
/// Safe metadata snapshot for one logical HTTP endpoint.
pub use self::http_client::HttpEndpointSnapshot;
/// Result of one logical endpoint selection.
pub use self::http_pool::HttpEndpointSelection;
/// Runtime admission permit for one HTTP request.
pub use self::http_pool::HttpRequestPermit;
/// Shareable logical HTTP endpoint pool with priority routing, admission limits and bounded deadlines.
pub use self::http_pool::HttpTransportPool;
/// Safe snapshot of the logical HTTP endpoint pool.
pub use self::http_pool::HttpTransportPoolSnapshot;
/// Dispatch knowledge used to prevent ambiguous automatic resubmission.
pub use self::http_resilience::HttpDispatchState;
/// Transport-level cause considered by the bounded retry policy.
pub use self::http_resilience::HttpRetryCause;
/// Result of evaluating one bounded transport retry opportunity.
pub use self::http_resilience::HttpRetryDecision;
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
pub use self::http_resilience::evaluate_transport_retry;
/// Open cluster or network descriptor used by HTTP endpoint settings.
pub use self::http_settings::HttpClusterName;
/// Runtime settings for one role declared by an HTTP endpoint.
pub use self::http_settings::HttpEndpointRoleSettings;
/// Runtime settings for one named Solana HTTP endpoint.
pub use self::http_settings::HttpEndpointSettings;
/// Runtime HTTP endpoint URL with redacted diagnostics.
pub use self::http_settings::HttpEndpointUrl;
/// Open provider descriptor used by HTTP endpoint settings.
pub use self::http_settings::HttpProviderName;
/// Open request-kind descriptor used by logical endpoint capabilities.
pub use self::http_settings::HttpRequestKind;
/// Bounded retry settings owned by the HTTP transport runtime.
pub use self::http_settings::HttpRetrySettings;
/// Local limits attached to one logical HTTP endpoint role.
pub use self::http_settings::HttpRoleLimits;
/// Open logical endpoint role descriptor.
pub use self::http_settings::HttpRoleName;
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
pub use self::http_settings::HttpTransportSettings;
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
pub use self::json_rpc::JsonRpcErrorObject;
/// Validated JSON-RPC 2.0 error response.
@@ -108,22 +321,6 @@ pub use self::json_rpc::JsonRpcSuccessResponse;
pub use self::json_rpc::parse_json_rpc_response_text;
/// Validates a decoded JSON value as one JSON-RPC HTTP response.
pub use self::json_rpc::parse_json_rpc_response_value;
/// Result of one logical endpoint selection.
pub use self::pool::HttpEndpointSelection;
/// Runtime admission permit for one HTTP request.
pub use self::pool::HttpRequestPermit;
/// Shareable logical HTTP endpoint pool with priority routing, admission limits and bounded deadlines.
pub use self::pool::HttpTransportPool;
/// Safe snapshot of the logical HTTP endpoint pool.
pub use self::pool::HttpTransportPoolSnapshot;
/// Dispatch knowledge used to prevent ambiguous automatic resubmission.
pub use self::resilience::HttpDispatchState;
/// Transport-level cause considered by the bounded retry policy.
pub use self::resilience::HttpRetryCause;
/// Result of evaluating one bounded transport retry opportunity.
pub use self::resilience::HttpRetryDecision;
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
pub use self::resilience::evaluate_transport_retry;
/// Typed transport-level Solana account without Program/SPL decoding.
pub use self::rpc_accounts::SolanaAccount;
/// Address and lamport balance returned by `getLargestAccounts`.
@@ -296,26 +493,6 @@ pub use self::rpc_transactions::SolanaTransactionEncoding;
pub use self::rpc_transactions::SolanaTransactionVersion;
/// Three-state wire field used when Solana distinguishes omission from an explicit JSON `null`.
pub use self::rpc_transactions::SolanaWireField;
/// Open cluster or network descriptor used by HTTP endpoint settings.
pub use self::settings::HttpClusterName;
/// Runtime settings for one role declared by an HTTP endpoint.
pub use self::settings::HttpEndpointRoleSettings;
/// Runtime settings for one named Solana HTTP endpoint.
pub use self::settings::HttpEndpointSettings;
/// Runtime HTTP endpoint URL with redacted diagnostics.
pub use self::settings::HttpEndpointUrl;
/// Open provider descriptor used by HTTP endpoint settings.
pub use self::settings::HttpProviderName;
/// Open request-kind descriptor used by logical endpoint capabilities.
pub use self::settings::HttpRequestKind;
/// Bounded retry settings owned by the HTTP transport runtime.
pub use self::settings::HttpRetrySettings;
/// Local limits attached to one logical HTTP endpoint role.
pub use self::settings::HttpRoleLimits;
/// Open logical endpoint role descriptor.
pub use self::settings::HttpRoleName;
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
pub use self::settings::HttpTransportSettings;
/// Configuration accepted by the standard Solana `accountSubscribe` WebSocket method.
pub use self::ws_accounts::SolanaAccountSubscribeConfig;
/// One `programNotification` payload preserving contextual and non-contextual upstream forms.
@@ -336,6 +513,22 @@ pub use self::ws_cluster::SolanaSlotUpdate;
pub use self::ws_cluster::SolanaSlotUpdateStats;
/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`.
pub use self::ws_cluster::SolanaVoteNotification;
/// Full/accounts-mode notification delivered by Helius `transactionSubscribe`.
pub use self::ws_helius_transactions::HeliusFullTransactionNotification;
/// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`.
pub use self::ws_helius_transactions::HeliusTokenAccountsFilter;
/// Typed Helius `transactionNotification` payload union.
pub use self::ws_helius_transactions::HeliusTransactionNotification;
/// Signatures-mode notification delivered by Helius `transactionSubscribe`.
pub use self::ws_helius_transactions::HeliusTransactionSignatureNotification;
/// Transaction encoding accepted by Helius `transactionSubscribe`.
pub use self::ws_helius_transactions::HeliusTransactionSubscribeEncoding;
/// Helius-specific filter object accepted as the first `transactionSubscribe` parameter.
pub use self::ws_helius_transactions::HeliusTransactionSubscribeFilter;
/// Optional Helius `transactionSubscribe` result-shaping configuration.
pub use self::ws_helius_transactions::HeliusTransactionSubscribeOptions;
/// Complete typed request contract for Helius `transactionSubscribe` before actor registration.
pub use self::ws_helius_transactions::HeliusTransactionSubscribeRequest;
/// Stable local identity assigned to one physical WebSocket session.
pub use self::ws_lifecycle::WsSessionId;
/// Safe runtime snapshot for one physical WebSocket session.
@@ -344,13 +537,17 @@ pub use self::ws_lifecycle::WsSessionSnapshot;
pub use self::ws_lifecycle::WsSessionState;
/// Stable local identity assigned to one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionId;
/// Standard Solana subscription family represented by one logical WebSocket subscription.
/// WebSocket subscription family represented by one logical subscription.
pub use self::ws_lifecycle::WsSubscriptionKind;
/// Safe lifecycle projection for one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionSnapshot;
/// Observable lifecycle state of one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionState;
/// Shareable handle for one explicitly created physical WebSocket session.
/// Typed facade for one Helius LaserStream WebSocket physical session.
pub use self::ws_protocol_session::HeliusLaserStreamWsSession;
/// Typed facade for one standard Solana WebSocket physical session.
pub use self::ws_protocol_session::SolanaStandardWsSession;
/// Shareable compatibility handle for one explicitly created standard Solana physical WebSocket session.
pub use self::ws_session::WsSession;
/// Open cluster or network descriptor used by WebSocket endpoint settings.
pub use self::ws_settings::WsClusterName;
@@ -370,7 +567,7 @@ pub use self::ws_settings::WsResubscribePolicy;
pub use self::ws_settings::WsSessionSettings;
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
pub use self::ws_settings::WsTransportSettings;
/// Typed handle for one logical Solana WebSocket subscription.
/// Typed handle for one logical WebSocket subscription.
pub use self::ws_subscription::WsSubscription;
/// Typed value carried by a contextual Solana `logsNotification`.
pub use self::ws_transactions::SolanaLogsNotification;
@@ -383,18 +580,24 @@ pub use self::ws_transactions::SolanaSignatureSubscribeConfig;
/// Owning tracing target for events emitted by the on-chain transport crate.
pub(crate) use self::constants::TRACING_TARGET;
/// Internal Yellowstone Subscribe session opener used by the physical channel.
pub(crate) use self::grpc_stream::open_yellowstone_subscribe_session;
/// Internal Yellowstone Subscribe request wire conversion shared with the stream engine.
pub(crate) use self::grpc_subscribe::yellowstone_subscribe_request_to_wire;
/// Internal Yellowstone Subscribe update decoder shared with the stream engine.
pub(crate) use self::grpc_subscribe::yellowstone_subscribe_update_from_wire;
/// Crate-internal `HttpConcurrencyPermit` state shared across the owning crate.
pub(crate) use self::resilience::HttpConcurrencyPermit;
pub(crate) use self::http_resilience::HttpConcurrencyPermit;
/// Crate-internal `HttpRoleRuntime` state shared across the owning crate.
pub(crate) use self::resilience::HttpRoleRuntime;
pub(crate) use self::http_resilience::HttpRoleRuntime;
/// Crate-internal `RoleAdmissionAttempt` variants used by the owning crate.
pub(crate) use self::resilience::RoleAdmissionAttempt;
pub(crate) use self::http_resilience::RoleAdmissionAttempt;
/// Validates endpoint settings.
pub(crate) use self::http_settings::validate_endpoint_settings;
/// Decodes one private serde wire type into the shared Transport error domain for typed RPC adapters.
pub(crate) use self::rpc_common::decode_wire_json;
/// Parses a base58 public key without echoing its wire value into diagnostics for typed RPC adapters.
pub(crate) use self::rpc_common::parse_wire_pubkey;
/// Validates endpoint settings.
pub(crate) use self::settings::validate_endpoint_settings;
/// Crate-internal command surface shared by the physical session and typed subscription handle.
pub(crate) use self::ws_session::WsSessionCommand;
/// Crate-internal notification dispatch result.

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
// version: 6
// version: 7
/// Commitment level accepted by typed Solana HTTP and WebSocket adapters.
/// Commitment level accepted by typed Solana HTTP, WebSocket and Yellowstone gRPC adapters.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaCommitment {
/// Query the most recent processed bank.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_accounts.rs
// version: 2
// version: 3
const MAX_PROGRAM_SUBSCRIBE_FILTERS: usize = 4;
const MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES: usize = 128;
@@ -277,6 +277,46 @@ fn validate_program_subscribe_filters(filters: &[crate::SolanaProgramAccountFilt
return std::result::Result::Ok(());
}
impl crate::SolanaStandardWsSession {
/// Subscribes to changes for one Solana account through standard `accountSubscribe`.
pub async fn account_subscribe(
&self,
account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
return self.physical_session().account_subscribe(account, config).await;
}
/// Subscribes to account changes owned by one Solana program through standard `programSubscribe`.
pub async fn program_subscribe(
&self,
program_id: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
return self.physical_session().program_subscribe(program_id, config).await;
}
}
impl crate::HeliusLaserStreamWsSession {
/// Subscribes to account changes through the standard `accountSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn account_subscribe(
&self,
account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
return self.physical_session().account_subscribe(account, config).await;
}
/// Subscribes to program-owned account changes through the standard `programSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn program_subscribe(
&self,
program_id: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
return self.physical_session().program_subscribe(program_id, config).await;
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_accounts.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_blocks.rs
// version: 1
// version: 2
/// Filter accepted by unstable Solana `blockSubscribe`.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -210,6 +210,17 @@ struct WireRpcResponse {
value: WireBlockNotification,
}
impl crate::SolanaStandardWsSession {
/// Subscribes to unstable standard Solana block notifications through `blockSubscribe`.
pub async fn block_subscribe(
&self,
filter: &crate::SolanaBlockSubscribeFilter,
config: std::option::Option<&crate::SolanaBlockSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaBlockNotification>>> {
return self.physical_session().block_subscribe(filter, config).await;
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_blocks.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs
// version: 3
// version: 5
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -401,6 +401,46 @@ fn decode_vote_notification(method: &str, value: serde_json::Value) -> ksp_core_
});
}
impl crate::SolanaStandardWsSession {
/// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`.
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
return self.physical_session().slot_subscribe().await;
}
/// Subscribes to standard Solana root-slot notifications through `rootSubscribe`.
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
return self.physical_session().root_subscribe().await;
}
/// Subscribes to unstable standard Solana slot-lifecycle notifications through `slotsUpdatesSubscribe`.
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
return self.physical_session().slots_updates_subscribe().await;
}
/// Subscribes to unstable pre-consensus gossip vote notifications through `voteSubscribe`.
pub async fn vote_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaVoteNotification>> {
return self.physical_session().vote_subscribe().await;
}
}
impl crate::HeliusLaserStreamWsSession {
/// Subscribes to slot-processing notifications through the standard `slotSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
return self.physical_session().slot_subscribe().await;
}
/// Subscribes to root-slot notifications through the standard `rootSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
return self.physical_session().root_subscribe().await;
}
/// Subscribes to unstable slot-lifecycle notifications through the standard `slotsUpdatesSubscribe` wire currently documented by Helius LaserStream
/// WebSocket.
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
return self.physical_session().slots_updates_subscribe().await;
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_cluster.rs"]
mod tests;

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