Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db1f8d4a53 | |||
| 9852613426 | |||
| 81eb295a09 | |||
| b5a1293953 | |||
| 251f9f9ad8 | |||
| 98708df6e0 | |||
| 2db3c4be78 | |||
| 69294a153f | |||
| 2fdff17651 | |||
| 87d7314bf4 | |||
| 5aeff5ca14 | |||
| 98093d859b | |||
| 4021d08541 | |||
| 5c97a772be | |||
| 60afb51451 | |||
| f4413ebbb0 | |||
| be2a06bc80 | |||
| 2dbddfe367 | |||
| 126e8abfcd | |||
| 3fd540a9db | |||
| 4f92fb03f1 | |||
| f42fa8f4f1 | |||
| a8e980b225 | |||
| afaa9c0239 | |||
| 7d456b41dc | |||
| 0edeed1c48 | |||
| be5e3464ee | |||
| 1967e845b0 | |||
| af807afff5 | |||
| 7f567bc1bb |
26
.env.example
26
.env.example
@@ -1,5 +1,5 @@
|
|||||||
# file: .env.example
|
# file: .env.example
|
||||||
# version: 8
|
# version: 10
|
||||||
|
|
||||||
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
||||||
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
||||||
@@ -42,10 +42,34 @@ KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
|
|||||||
# Keep the real value only in the process environment or local .env; do not assume that a Mainnet token is authorized on Testnet.
|
# Keep the real value only in the process environment or local .env; do not assume that a Mainnet token is authorized on Testnet.
|
||||||
# KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN=replace-me
|
# KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN=replace-me
|
||||||
|
|
||||||
|
# OrbitFlare Solana Free License Key used as secret x-token metadata by the committed Devnet Yellowstone gRPC profile.
|
||||||
|
# Keep the real ORBIT-* value only in the process environment or local .env; do not substitute the Customer API X-ORBIT-KEY.
|
||||||
|
# KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN=replace-me
|
||||||
|
|
||||||
# Helius API key used by the LaserStream WebSocket endpoint in config/examples/std.transport.example.json.
|
# Helius API key used by the LaserStream WebSocket endpoint in config/examples/std.transport.example.json.
|
||||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
# KSP_SECRET_HELIUS_API_KEY=replace-me
|
# KSP_SECRET_HELIUS_API_KEY=replace-me
|
||||||
|
|
||||||
|
# Birdeye Standard API key used by the optional `all_free` profile in config/std.offchain_transport.json.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_BIRDEYE_API_KEY=replace-me
|
||||||
|
|
||||||
|
# CoinGecko Demo API key used by the optional `all_free` Off-chain Transport profile.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_COINGECKO_DEMO_API_KEY=replace-me
|
||||||
|
|
||||||
|
# CoinMarketCap Basic API key used by the optional `all_free` Off-chain Transport profile.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_COINMARKETCAP_API_KEY=replace-me
|
||||||
|
|
||||||
|
# Public Solana pair address used by DexScreener for explicit SOL/USD pair lookup in the optional `all_free` profile.
|
||||||
|
# This value is intentionally public and must identify the exact pair chosen by the operator.
|
||||||
|
# KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS=replace-with-solana-pair-pubkey
|
||||||
|
|
||||||
|
# Jupiter Free API key used by the optional `all_free` Off-chain Transport profile.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_JUPITER_API_KEY=replace-me
|
||||||
|
|
||||||
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
||||||
KSP_DESK_SPLASH_FADE_IN_MS=300
|
KSP_DESK_SPLASH_FADE_IN_MS=300
|
||||||
|
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,8 +1,26 @@
|
|||||||
<!-- file: CHANGELOG.md -->
|
<!-- file: CHANGELOG.md -->
|
||||||
<!-- version: 13 -->
|
<!-- version: 15 -->
|
||||||
|
|
||||||
# Changelog KSP
|
# Changelog KSP
|
||||||
|
|
||||||
|
## 0.2.11 — Off-chain price transport — 2026-08-26
|
||||||
|
|
||||||
|
`0.2.11` crée `ksp-offchain-transport-lib` avec une première surface volontairement bornée à **SOL/USD** et huit adapters REST V1 : CoinGecko, CoinMarketCap, CoinPaprika, Kraken, Coinbase Exchange, Jupiter Price V3, Birdeye et DexScreener. Les adapters utilisent `reqwest` directement, sans SDK provider, avec origines HTTPS possédées par la crate, DTOs wire privés et prix normalisés par `MarketPriceDecimal` sans passage canonique par `f64`. `MarketPriceObservation` conserve paire, sémantique, provenance et timestamps réellement disponibles afin de ne pas confondre prix agrégé, dernier trade d'exchange, heuristique Solana, spot provider et prix DEX pair. DexScreener exige une paire Solana explicite et ne réalise aucune discovery ou sélection automatique de pool.
|
||||||
|
|
||||||
|
La crate possède désormais le registry provider-neutral, les descriptors/capabilities, les limites et quotas informatifs, les états `MarketPriceProviderAvailability`, le cooldown et le service générique `MarketPriceService`. Les consumers disposent de `registry`, `refresh`, `refresh_many` et `refresh_all`; le batch V1 reste séquentiel et déterministe, ne dort jamais pour attendre un rate limit et n'introduit ni fallback, ni consensus, ni moyenne KSP. Les erreurs provider sont normalisées en états génériques et les credentials/endpoints/raw bodies restent absents des projections et diagnostics publics.
|
||||||
|
|
||||||
|
Config ajoute `std.offchain_transport` V1, son schema, les profils `public_keyless` et `all_free`, ainsi que `ResolvedOffchainTransportConfig` qui construit le service sans dépendance inverse. Les credentials CoinGecko/CoinMarketCap/Jupiter/Birdeye restent sous `KSP_SECRET_*`; la paire DexScreener utilise une provenance `KSP_PUBLIC_*`. Les deux applications desktop existantes embarquent les nouvelles resources Config sans encore consommer fonctionnellement le prix. Les canaris de sécurité verrouillent notamment l'absence d'URL/rate-limit provider configurable au-dessus de Config, de lecture directe d'environnement dans Off-chain Transport et de fuite de clés par `Debug`.
|
||||||
|
|
||||||
|
Le gate technique final a passé les audits Rust/Markdown, `cargo check`, Clippy, les tests ciblés Config/Off-chain, le workspace complet et les graphes Cargo. Le premier smoke live keyless a isolé une erreur de query CoinMarketCap (`ids=5426` au lieu de `id=5426`); `pre.011-fix.001` la corrige pour les modes Keyless et Basic, puis le re-smoke passe **7/7** providers keyless. Le smoke keyed Birdeye/CoinGecko Demo/CoinMarketCap Basic/Jupiter Free reste explicitement `SKIP opérateur`. `prompts/017-V0_2_12_START_PROMPT.md` ouvre ensuite `0.2.12 — SOL Prices Desk + intégration prix Wallet Desk` uniquement depuis le tag stable `v0.2.11`, avec audit/sizing desktop strict avant toute implémentation UI lourde.
|
||||||
|
|
||||||
|
## 0.2.10 — OrbitFlare Yellowstone gRPC — 2026-08-25
|
||||||
|
|
||||||
|
`0.2.10` ajoute OrbitFlare comme provider Yellowstone gRPC sur le moteur N1 et le standard N2 acquis dans `0.2.9`, sans modifier le moteur gRPC ni créer de façade provider inutile. L'audit et le smoke live ont établi le contrat réel du service Solana Free Devnet : endpoint `http://devnet.rpc.orbitflare.com:10000`, authentification par metadata gRPC secrète `x-token` portant la License Key OrbitFlare, et émission du Ping Yellowstone standard. Config Transport V3 représente ce provider avec `provider = orbitflare`, `cluster = devnet`, `protocol = solana_yellowstone` et `secret_metadata`, tandis que `.env.example` inventorie `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` sans valeur réelle.
|
||||||
|
|
||||||
|
La première tentative live sans metadata a volontairement échoué avec `Unauthenticated`, ce qui a conduit à `pre.002-fix.001` après audit des sources OrbitFlare actuelles. Le smoke corrigé reçoit la License Key uniquement par stdin opérateur, ouvre `Subscribe`, observe un Slot non nul et un `SubscribeUpdate::Ping`, puis ferme la session proprement. Le même smoke est repassé au gate technique final `pre.003`. La clôture conserve donc N1/N2 inchangés, n'ajoute ni heartbeat OrbitFlare, ni SDK provider, ni Config V4, et confirme que la réponse automatique au Ping standard existante suffit.
|
||||||
|
|
||||||
|
Le gate final conserve l'ensemble du workspace vert : audits Rust/Markdown, `cargo check`, Clippy, tests Config et Transport, `cargo test --workspace`, canaris de dépendances et graphes Cargo. Helius LaserStream gRPC, audité comme largement wire-compatible Yellowstone mais non raisonnablement live-testable sans abonnement payant, est reporté dans les TODO provider sans numéro de release. La séquence active passe donc à `0.2.11 — off-chain price transport`; `prompts/016-V0_2_11_START_PROMPT.md` l'ouvre exclusivement depuis le tag stable `v0.2.10` avec audit actuel des sources de prix, de leurs sémantiques et de leur testabilité avant toute implémentation lourde.
|
||||||
|
|
||||||
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
|
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
|
||||||
|
|
||||||
## 0.2.9 — Yellowstone gRPC standard/provider-neutral — 2026-08-24
|
## 0.2.9 — Yellowstone gRPC standard/provider-neutral — 2026-08-24
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 261
|
# version: 285
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.9"
|
version = "0.2.11"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
|
|||||||
14
ROADMAP.md
14
ROADMAP.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 86 -->
|
<!-- version: 89 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -54,15 +54,15 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
- [X] `0.2.7` — WebSocket Solana standard stable : 9 familles subscribe/unsubscribe typées (18/18 opérations), sessions physiques multiples explicites, subscriptions logiques typées, lifecycle/reconnect/resubscribe/backpressure/shutdown bornés, Config V2, non-régression HTTP 52+14, compliance finale, smoke WebSocket Devnet et audit de dépendances validés ; publication `rel.001` et prompt `0.2.8` prêts.
|
- [X] `0.2.7` — WebSocket Solana standard stable : 9 familles subscribe/unsubscribe typées (18/18 opérations), sessions physiques multiples explicites, subscriptions logiques typées, lifecycle/reconnect/resubscribe/backpressure/shutdown bornés, Config V2, non-régression HTTP 52+14, compliance finale, smoke WebSocket Devnet et audit de dépendances validés ; publication `rel.001` et prompt `0.2.8` prêts.
|
||||||
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur l’actor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
|
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur l’actor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
|
||||||
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes d’update, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
|
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes d’update, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
|
||||||
- [ ] `0.2.10` — Ajouter l’intégration Yellowstone gRPC OrbitFlare sur le moteur/standard acquis, avec audit explicite des auth/capabilities/restrictions/extensions et Devnet comme cible gratuite prioritaire.
|
- [X] `0.2.10` — OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
|
||||||
- [ ] `0.2.11` — Ajouter l’intégration Helius LaserStream gRPC sur le moteur Yellowstone partagé, avec audit de compatibilité/replay/auth/extensions, sans confondre cette surface avec LaserStream WebSocket.
|
- [X] `0.2.11` — Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique n’est introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
|
||||||
- [ ] `0.2.12` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
- [ ] `0.2.12` — Introduire `ksp-app-solprices-desk` comme HID provider-agnostic consommant uniquement `ksp-offchain-transport-lib`, avec tableau prix/provider et refresh individuel/multiple ; puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation.
|
||||||
- [ ] `0.2.13` — Introduire une petite application desk de visualisation/validation des prix offchain, puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation possédée par le composant spécialisé.
|
- [ ] `0.2.13` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
||||||
- [ ] `0.2.14` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
- [ ] `0.2.14` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
||||||
- [ ] `0.2.15` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
|
||||||
|
|
||||||
### TODO/IDEAS — providers Yellowstone non planifiés
|
### TODO/IDEAS — providers Yellowstone non planifiés
|
||||||
|
|
||||||
|
- [ ] **TODO** — Helius LaserStream gRPC : réauditer lorsque l'accès live gRPC est raisonnablement disponible ; conserver N1/N2 Yellowstone inchangés, vérifier auth/endpoints/Subscribe/Ping/replay/from_slot/erreurs provider et traiter les preprocessed transactions comme extension Helius séparée.
|
||||||
- [ ] **TODO** — eRPC : réauditer accès, auth/IP policy, capabilities et produits complémentaires avant toute décision d’implémentation.
|
- [ ] **TODO** — eRPC : réauditer accès, auth/IP policy, capabilities et produits complémentaires avant toute décision d’implémentation.
|
||||||
- [ ] **TODO** — Triton : réauditer la frontière Yellowstone upstream / extensions Triton, notamment Deshred et futures extensions.
|
- [ ] **TODO** — Triton : réauditer la frontière Yellowstone upstream / extensions Triton, notamment Deshred et futures extensions.
|
||||||
- [ ] **TODO** — Alchemy : réauditer auth, replay, limites et capabilities Yellowstone avant toute intégration.
|
- [ ] **TODO** — Alchemy : réauditer auth, replay, limites et capabilities Yellowstone avant toute intégration.
|
||||||
|
|||||||
43
config/examples/std.offchain_transport.example.json
Normal file
43
config/examples/std.offchain_transport.example.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "custom_free",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "custom_free",
|
||||||
|
"market_price": {
|
||||||
|
"birdeye": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "${KSP_SECRET_BIRDEYE_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinbase_exchange": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "demo",
|
||||||
|
"api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "basic",
|
||||||
|
"api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinpaprika": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"enabled": true,
|
||||||
|
"sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}"
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "free",
|
||||||
|
"api_key": "${KSP_SECRET_JUPITER_API_KEY}"
|
||||||
|
},
|
||||||
|
"kraken": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
366
config/schemas/std.offchain_transport.schema.json
Normal file
366
config/schemas/std.offchain_transport.schema.json
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "urn:ksp:schema:std.offchain_transport:v1",
|
||||||
|
"title": "KSP standard Off-chain Transport configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"profileId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||||
|
},
|
||||||
|
"enabledOnly": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"enum": [
|
||||||
|
"demo",
|
||||||
|
"keyless"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"access_mode": {
|
||||||
|
"const": "keyless"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"const": "demo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"enum": [
|
||||||
|
"basic",
|
||||||
|
"keyless"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"access_mode": {
|
||||||
|
"const": "keyless"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"const": "basic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"enum": [
|
||||||
|
"free",
|
||||||
|
"keyless"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"access_mode": {
|
||||||
|
"const": "keyless"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"const": "free"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"birdeye": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sol_usd_pair_address": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"sol_usd_pair_address"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"marketPrice": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"birdeye",
|
||||||
|
"coinbase_exchange",
|
||||||
|
"coingecko",
|
||||||
|
"coinmarketcap",
|
||||||
|
"coinpaprika",
|
||||||
|
"dexscreener",
|
||||||
|
"jupiter",
|
||||||
|
"kraken"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"birdeye": {
|
||||||
|
"$ref": "#/$defs/birdeye"
|
||||||
|
},
|
||||||
|
"coinbase_exchange": {
|
||||||
|
"$ref": "#/$defs/enabledOnly"
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"$ref": "#/$defs/coingecko"
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"$ref": "#/$defs/coinmarketcap"
|
||||||
|
},
|
||||||
|
"coinpaprika": {
|
||||||
|
"$ref": "#/$defs/enabledOnly"
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"$ref": "#/$defs/dexscreener"
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"$ref": "#/$defs/jupiter"
|
||||||
|
},
|
||||||
|
"kraken": {
|
||||||
|
"$ref": "#/$defs/enabledOnly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"market_price"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"market_price": {
|
||||||
|
"$ref": "#/$defs/marketPrice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
config/std.offchain_transport.json
Normal file
75
config/std.offchain_transport.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "public_keyless",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "public_keyless",
|
||||||
|
"market_price": {
|
||||||
|
"birdeye": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"coinbase_exchange": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "keyless"
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "keyless"
|
||||||
|
},
|
||||||
|
"coinpaprika": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "keyless"
|
||||||
|
},
|
||||||
|
"kraken": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "all_free",
|
||||||
|
"market_price": {
|
||||||
|
"birdeye": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "${KSP_SECRET_BIRDEYE_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinbase_exchange": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "demo",
|
||||||
|
"api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "basic",
|
||||||
|
"api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinpaprika": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"enabled": true,
|
||||||
|
"sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}"
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "free",
|
||||||
|
"api_key": "${KSP_SECRET_JUPITER_API_KEY}"
|
||||||
|
},
|
||||||
|
"kraken": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -79,6 +79,64 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "orbitflare_devnet",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_devnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "devnet",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_devnet_public_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "devnet",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grpc_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "orbitflare_solana_devnet_yellowstone",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "orbitflare",
|
||||||
|
"cluster": "devnet",
|
||||||
|
"protocol": "solana_yellowstone",
|
||||||
|
"url": "http://devnet.rpc.orbitflare.com:10000",
|
||||||
|
"metadata": [],
|
||||||
|
"secret_metadata": [
|
||||||
|
{
|
||||||
|
"key": "x-token",
|
||||||
|
"value": "${KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"profile_id": "mainnet_public",
|
"profile_id": "mainnet_public",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
|||||||
@@ -55,10 +55,12 @@
|
|||||||
"resources": {
|
"resources": {
|
||||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
"../../config/std.logging.json": "config/std.logging.json",
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
"../../config/std.transport.json": "config/std.transport.json",
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
"../../config/std.wallet.json": "config/std.wallet.json",
|
||||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||||
// version: 7
|
// version: 8
|
||||||
|
|
||||||
//! Desktop build/shell contract audits for Config Desk.
|
//! Desktop build/shell contract audits for Config Desk.
|
||||||
|
|
||||||
@@ -150,8 +150,16 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writab
|
|||||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||||
assert!(resources.is_some(), "packaged Config resources map must exist");
|
assert!(resources.is_some(), "packaged Config resources map must exist");
|
||||||
if let std::option::Option::Some(resources) = resources {
|
if let std::option::Option::Some(resources) = resources {
|
||||||
assert_eq!(resources.len(), 8);
|
assert_eq!(resources.len(), 10);
|
||||||
assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json"));
|
assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json"));
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/std.offchain_transport.json"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/schemas/std.wallet.schema.json"),
|
std::option::Option::Some("config/schemas/std.wallet.schema.json"),
|
||||||
|
|||||||
@@ -55,10 +55,12 @@
|
|||||||
"resources": {
|
"resources": {
|
||||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
"../../config/std.logging.json": "config/std.logging.json",
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
"../../config/std.transport.json": "config/std.transport.json",
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
"../../config/std.wallet.json": "config/std.wallet.json",
|
||||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
||||||
// version: 22
|
// version: 23
|
||||||
|
|
||||||
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
|
|||||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||||
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
|
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
|
||||||
if let std::option::Option::Some(resources) = resources {
|
if let std::option::Option::Some(resources) = resources {
|
||||||
assert_eq!(resources.len(), 8);
|
assert_eq!(resources.len(), 10);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/composite.ksp-app-wallet-desk.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/composite.ksp-app-wallet-desk.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/composite.ksp-app-wallet-desk.json"),
|
std::option::Option::Some("config/composite.ksp-app-wallet-desk.json"),
|
||||||
@@ -481,6 +481,14 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
|
|||||||
resources.get("../../config/schemas/composite.schema.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/schemas/composite.schema.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/schemas/composite.schema.json"),
|
std::option::Option::Some("config/schemas/composite.schema.json"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/std.offchain_transport.json"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let main = read_text(root.join("frontend/main.html").as_path());
|
let main = read_text(root.join("frontend/main.html").as_path());
|
||||||
assert!(main.contains(packaged_version));
|
assert!(main.contains(packaged_version));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-wallet-desk/tests/release_compliance.rs
|
// file: crates/ksp-app-wallet-desk/tests/release_compliance.rs
|
||||||
// version: 5
|
// version: 6
|
||||||
|
|
||||||
//! Release-wide deterministic compliance canaries for Wallet Desk.
|
//! Release-wide deterministic compliance canaries for Wallet Desk.
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ fn packaged_resources_include_only_registered_config_sources_and_schemas() {
|
|||||||
std::option::Option::Some(value) => value,
|
std::option::Option::Some(value) => value,
|
||||||
std::option::Option::None => return,
|
std::option::Option::None => return,
|
||||||
};
|
};
|
||||||
assert_eq!(resources.len(), 8);
|
assert_eq!(resources.len(), 10);
|
||||||
for (source, destination) in resources {
|
for (source, destination) in resources {
|
||||||
let destination = destination.as_str();
|
let destination = destination.as_str();
|
||||||
assert!(destination.is_some(), "resource destination must be textual");
|
assert!(destination.is_some(), "resource destination must be textual");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-config-lib/Cargo.toml
|
# file: crates/ksp-config-lib/Cargo.toml
|
||||||
# version: 8
|
# version: 9
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-config-lib"
|
name = "ksp-config-lib"
|
||||||
@@ -12,6 +12,7 @@ directories.workspace = true
|
|||||||
jsonschema.workspace = true
|
jsonschema.workspace = true
|
||||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }
|
||||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-config-lib/README.md -->
|
<!-- file: crates/ksp-config-lib/README.md -->
|
||||||
<!-- version: 9 -->
|
<!-- version: 10 -->
|
||||||
|
|
||||||
# ksp-config-lib
|
# ksp-config-lib
|
||||||
|
|
||||||
@@ -23,7 +23,8 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
|||||||
- la classification `Public`, `Internal`, `Secret` ;
|
- la classification `Public`, `Internal`, `Secret` ;
|
||||||
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
|
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
|
||||||
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
||||||
- l'adapter du document Transport V1/V2 vers `HttpTransportSettings` et, en V2, `WsTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
- l'adapter du document Transport V1/V2/V3 vers `HttpTransportSettings`, `WsTransportSettings` et, en V3, `YellowstoneGrpcTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
||||||
|
- l'adapter de `cfg.std.offchain_transport` vers `ksp_offchain_transport_lib::MarketPriceService`, avec contrôle de provenance des credentials/public fields et sans rendre les limites provider configurables ;
|
||||||
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
|
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
|
||||||
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
|
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
|
||||||
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
|
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
|
||||||
@@ -35,10 +36,12 @@ Le registre par défaut connaît :
|
|||||||
```text
|
```text
|
||||||
cfg.composite.ksp-app-wallet-desk -> config/composite.ksp-app-wallet-desk.json
|
cfg.composite.ksp-app-wallet-desk -> config/composite.ksp-app-wallet-desk.json
|
||||||
cfg.std.logging -> config/std.logging.json
|
cfg.std.logging -> config/std.logging.json
|
||||||
|
cfg.std.offchain_transport -> config/std.offchain_transport.json
|
||||||
cfg.std.transport -> config/std.transport.json
|
cfg.std.transport -> config/std.transport.json
|
||||||
cfg.std.wallet -> config/std.wallet.json
|
cfg.std.wallet -> config/std.wallet.json
|
||||||
schema.composite -> config/schemas/composite.schema.json
|
schema.composite -> config/schemas/composite.schema.json
|
||||||
schema.std.logging -> config/schemas/std.logging.schema.json
|
schema.std.logging -> config/schemas/std.logging.schema.json
|
||||||
|
schema.std.offchain_transport -> config/schemas/std.offchain_transport.schema.json
|
||||||
schema.std.transport -> config/schemas/std.transport.schema.json
|
schema.std.transport -> config/schemas/std.transport.schema.json
|
||||||
schema.std.wallet -> config/schemas/std.wallet.schema.json
|
schema.std.wallet -> config/schemas/std.wallet.schema.json
|
||||||
```
|
```
|
||||||
@@ -65,11 +68,11 @@ Les autres crates et applications KSP ne doivent pas :
|
|||||||
- parser ou écrire directement `.env` ;
|
- parser ou écrire directement `.env` ;
|
||||||
- ouvrir directement les documents Config connus par leur filename physique ;
|
- ouvrir directement les documents Config connus par leur filename physique ;
|
||||||
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
|
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
|
||||||
- reconstruire elles-mêmes la configuration Logging, Transport ou Wallet depuis le JSON.
|
- reconstruire elles-mêmes la configuration Logging, On-chain Transport, Off-chain Transport ou Wallet depuis le JSON.
|
||||||
|
|
||||||
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, et de `ksp-onchain-transport-lib` pour construire le contrat runtime Transport dans la direction Config -> Transport. Le document Wallet reste un contrat de chemins/profils Config et n’introduit aucune dépendance Config -> `ksp-wallet-lib`.
|
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, de `ksp-onchain-transport-lib` pour construire le contrat runtime On-chain Transport et de `ksp-offchain-transport-lib` pour construire le service market-price dans la direction Config -> Transport. Le document Wallet reste un contrat de chemins/profils Config et n’introduit aucune dépendance Config -> `ksp-wallet-lib`.
|
||||||
|
|
||||||
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib` et `ksp-onchain-transport-lib` ne dépendent pas de Config.
|
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib`, `ksp-onchain-transport-lib` et `ksp-offchain-transport-lib` ne dépendent pas de Config.
|
||||||
|
|
||||||
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
|
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ Un secret reste accessible au runtime ou au management lorsqu'un consumer autori
|
|||||||
|
|
||||||
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
|
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
|
||||||
|
|
||||||
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport accepte les valeurs secrètes pour les URLs HTTP/WebSocket et, en V3, pour `grpc_endpoints[].secret_metadata[]` : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. Les metadata gRPC publiques et secrètes sont séparées et leur provenance Config est contrôlée avant mapping. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
|
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport accepte les valeurs secrètes pour les URLs HTTP/WebSocket et, en V3, pour `grpc_endpoints[].secret_metadata[]` : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. Les metadata gRPC publiques et secrètes sont séparées et leur provenance Config est contrôlée avant mapping. `std.offchain_transport` exige une provenance `Secret` pour les API keys effectives et une provenance `Public` pour la paire DexScreener lorsqu'elle vient de l'environnement ; il ne permet ni URL provider arbitraire ni override de rate limit. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configur
|
|||||||
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
|
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
|
||||||
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
|
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
|
||||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport V3 HTTP + WebSocket + Yellowstone gRPC, avec lecture backward des V1/V2 ;
|
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport V3 HTTP + WebSocket + Yellowstone gRPC, avec lecture backward des V1/V2 ;
|
||||||
|
- [`../../config/std.offchain_transport.json`](../../config/std.offchain_transport.json) — document standard Off-chain Transport V1, actuellement limité au domaine `market_price` SOL/USD ;
|
||||||
- [`../../config/std.wallet.json`](../../config/std.wallet.json) — racine Wallet globale et sous-répertoire optionnel par profil ;
|
- [`../../config/std.wallet.json`](../../config/std.wallet.json) — racine Wallet globale et sous-répertoire optionnel par profil ;
|
||||||
- [`../../config/composite.ksp-app-wallet-desk.json`](../../config/composite.ksp-app-wallet-desk.json) — composition Logging/Transport/Wallet de Wallet Desk ;
|
- [`../../config/composite.ksp-app-wallet-desk.json`](../../config/composite.ksp-app-wallet-desk.json) — composition Logging/Transport/Wallet de Wallet Desk ;
|
||||||
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||||
<!-- version: 12 -->
|
<!-- version: 13 -->
|
||||||
|
|
||||||
# Utilisation de ksp-config-lib
|
# Utilisation de ksp-config-lib
|
||||||
|
|
||||||
@@ -182,6 +182,38 @@ let _ = (root, profile_subdirectory, effective);
|
|||||||
|
|
||||||
Les chemins Wallet refusent toute valeur `KSP_SECRET_*`. Les futurs `KSP_SECRET_WALLET_PASS_*` constituent un flux de secrets distinct et ne sont pas des champs de `std.wallet.json`.
|
Les chemins Wallet refusent toute valeur `KSP_SECRET_*`. Les futurs `KSP_SECRET_WALLET_PASS_*` constituent un flux de secrets distinct et ne sont pas des champs de `std.wallet.json`.
|
||||||
|
|
||||||
|
### 4.3 Construire le service Off-chain Transport depuis Config
|
||||||
|
|
||||||
|
Le document `cfg.std.offchain_transport` possède actuellement le domaine `market_price`. Config résout les placeholders, vérifie la provenance des API keys et de la paire DexScreener, puis construit directement `MarketPriceService` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let offchain = match engine.load_resolved_offchain_transport_config(
|
||||||
|
std::option::Option::None,
|
||||||
|
&environment,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in offchain.service().registry().entries() {
|
||||||
|
println!(
|
||||||
|
"{} {:?}",
|
||||||
|
entry.descriptor().display_name(),
|
||||||
|
entry.state().availability(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le profil versionné `public_keyless` ne requiert aucun secret. Le profil `all_free` utilise les credentials `KSP_SECRET_*` et la paire `KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS` inventoriés dans `.env.example`. Une API key littérale ou issue d'une provenance non secrète est refusée par l'adapter effectif ; une paire DexScreener issue d'une provenance Secret est également refusée.
|
||||||
|
|
||||||
|
Config ne permet pas de fournir `base_url`, `endpoint_url`, `rate_limit` ou `requests_per` aux branches provider. Les origines et cadences sûres restent possédées par `ksp-offchain-transport-lib`. La direction de dépendance reste donc :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-offchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Off-chain Transport ne lit ni `.env`, ni `KSP_*`, ni les documents Config. Une application telle que la future `ksp-app-solprices-desk` peut recevoir le service déjà composé puis utiliser uniquement `registry()`, `refresh`, `refresh_many` et `refresh_all`.
|
||||||
|
|
||||||
## 5. Profils et composites
|
## 5. Profils et composites
|
||||||
|
|
||||||
Pour un document standard profilé :
|
Pour un document standard profilé :
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/lib.rs
|
// file: crates/ksp-config-lib/src/lib.rs
|
||||||
// version: 18
|
// version: 19
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
@@ -21,6 +21,7 @@ mod environment;
|
|||||||
mod error;
|
mod error;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod management;
|
mod management;
|
||||||
|
mod offchain_transport;
|
||||||
mod packaging;
|
mod packaging;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod profile;
|
mod profile;
|
||||||
@@ -127,6 +128,8 @@ pub use self::management::LoggingOutputFilterConfig;
|
|||||||
pub use self::management::LoggingProfileConfig;
|
pub use self::management::LoggingProfileConfig;
|
||||||
/// Typed source contract for one global Logging target override.
|
/// Typed source contract for one global Logging target override.
|
||||||
pub use self::management::LoggingTargetFilterConfig;
|
pub use self::management::LoggingTargetFilterConfig;
|
||||||
|
/// Effective standard Off-chain Transport configuration resolved from Config.
|
||||||
|
pub use self::offchain_transport::ResolvedOffchainTransportConfig;
|
||||||
/// Writable KSP runtime roots prepared from packaged Config resources.
|
/// Writable KSP runtime roots prepared from packaged Config resources.
|
||||||
pub use self::packaging::PackagedRuntimeLayout;
|
pub use self::packaging::PackagedRuntimeLayout;
|
||||||
/// Prepares the shared writable KSP desktop runtime from immutable packaged resources.
|
/// Prepares the shared writable KSP desktop runtime from immutable packaged resources.
|
||||||
@@ -155,6 +158,10 @@ pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
|
|||||||
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
|
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
|
||||||
/// Default physical filename for the standard Logging JSON Schema document.
|
/// Default physical filename for the standard Logging JSON Schema document.
|
||||||
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
||||||
|
/// Default physical filename for the standard Off-chain Transport configuration document.
|
||||||
|
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME;
|
||||||
|
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
||||||
|
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME;
|
||||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
||||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
@@ -169,12 +176,16 @@ pub use self::registry::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
|
|||||||
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
||||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||||
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
||||||
|
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
||||||
|
pub use self::registry::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT;
|
||||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
||||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||||
pub use self::registry::FILE_ID_SCHEMA_STD_WALLET;
|
pub use self::registry::FILE_ID_SCHEMA_STD_WALLET;
|
||||||
/// Logical file identifier for the standard Logging configuration document.
|
/// Logical file identifier for the standard Logging configuration document.
|
||||||
pub use self::registry::FILE_ID_STD_LOGGING;
|
pub use self::registry::FILE_ID_STD_LOGGING;
|
||||||
|
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||||
|
pub use self::registry::FILE_ID_STD_OFFCHAIN_TRANSPORT;
|
||||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
||||||
/// Logical file identifier for the standard Wallet configuration document.
|
/// Logical file identifier for the standard Wallet configuration document.
|
||||||
|
|||||||
520
crates/ksp-config-lib/src/offchain_transport.rs
Normal file
520
crates/ksp-config-lib/src/offchain_transport.rs
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/offchain_transport.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Adapter from Config-owned Off-chain Transport documents to the provider-agnostic market-price runtime service.
|
||||||
|
|
||||||
|
/// Effective standard Off-chain Transport configuration resolved from Config.
|
||||||
|
pub struct ResolvedOffchainTransportConfig {
|
||||||
|
effective: crate::ResolvedConfigJson,
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
profile_id: String,
|
||||||
|
selection_source: crate::ConfigProfileSelectionSource,
|
||||||
|
service: ksp_offchain_transport_lib::MarketPriceService,
|
||||||
|
source_path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ResolvedOffchainTransportConfig {
|
||||||
|
/// Returns the detailed environment-resolved effective Config view with secret-safe diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||||
|
return &self.effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical Config document identifier used by this runtime configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected standard Off-chain Transport profile identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the source that selected the standard Off-chain Transport profile.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||||
|
return self.selection_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-agnostic market-price runtime service.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn service(&self) -> &ksp_offchain_transport_lib::MarketPriceService {
|
||||||
|
return &self.service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the physical source Config document path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn source_path(&self) -> &std::path::Path {
|
||||||
|
return self.source_path.as_path();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for crate::ResolvedOffchainTransportConfig {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let registry = self.service.registry();
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ResolvedOffchainTransportConfig")
|
||||||
|
.field("effective", &self.effective)
|
||||||
|
.field("file_id", &self.file_id)
|
||||||
|
.field("profile_id", &self.profile_id)
|
||||||
|
.field("provider_registry", ®istry)
|
||||||
|
.field("selection_source", &self.selection_source)
|
||||||
|
.field("source_path", &self.source_path)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads the standard Off-chain Transport document and maps one profile to the generic market-price runtime service.
|
||||||
|
pub fn load_resolved_offchain_transport_config(
|
||||||
|
&self,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
environment: &crate::ConfigEnvironment,
|
||||||
|
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let profile = self.load_resolved_profile(&file_id, requested_profile);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return resolve_offchain_transport_profile(&profile, environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps an already resolved standard Off-chain Transport profile to the runtime service while preserving composite selection provenance.
|
||||||
|
pub fn resolve_offchain_transport_config_profile(
|
||||||
|
&self,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
environment: &crate::ConfigEnvironment,
|
||||||
|
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
|
||||||
|
if profile.file_id().as_str() != crate::FILE_ID_STD_OFFCHAIN_TRANSPORT {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "resolved Config profile does not reference the standard Off-chain Transport document"));
|
||||||
|
}
|
||||||
|
let descriptor = self.registry().descriptor(profile.file_id());
|
||||||
|
if let std::result::Result::Err(error) = descriptor {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return resolve_offchain_transport_profile(profile, environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveOffchainTransportSource {
|
||||||
|
format_version: u32,
|
||||||
|
market_price: EffectiveMarketPriceSource,
|
||||||
|
profile_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveMarketPriceSource {
|
||||||
|
birdeye: EffectiveBirdeyeSource,
|
||||||
|
coinbase_exchange: EffectiveEnabledSource,
|
||||||
|
coingecko: EffectiveCoinGeckoSource,
|
||||||
|
coinmarketcap: EffectiveCoinMarketCapSource,
|
||||||
|
coinpaprika: EffectiveEnabledSource,
|
||||||
|
dexscreener: EffectiveDexScreenerSource,
|
||||||
|
jupiter: EffectiveJupiterSource,
|
||||||
|
kraken: EffectiveEnabledSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveEnabledSource {
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveBirdeyeSource {
|
||||||
|
api_key: std::option::Option<String>,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveCoinGeckoSource {
|
||||||
|
access_mode: ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode,
|
||||||
|
api_key: std::option::Option<String>,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveCoinMarketCapSource {
|
||||||
|
access_mode: ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode,
|
||||||
|
api_key: std::option::Option<String>,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveDexScreenerSource {
|
||||||
|
enabled: bool,
|
||||||
|
sol_usd_pair_address: std::option::Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveJupiterSource {
|
||||||
|
access_mode: ksp_offchain_transport_lib::MarketPriceJupiterAccessMode,
|
||||||
|
api_key: std::option::Option<String>,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_offchain_transport_profile(
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
environment: &crate::ConfigEnvironment,
|
||||||
|
) -> ksp_core_lib::Result<crate::ResolvedOffchainTransportConfig> {
|
||||||
|
ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
profile_id = profile.profile_id(),
|
||||||
|
"mapping standard Off-chain Transport Config profile"
|
||||||
|
);
|
||||||
|
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let source = serde_json::from_value::<EffectiveOffchainTransportSource>(effective.value().clone());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective Off-chain Transport Config cannot be decoded into the runtime adapter contract").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if source.format_version != 1 {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Off-chain Transport format_version is unsupported"));
|
||||||
|
}
|
||||||
|
if source.profile_id != profile.profile_id() {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Off-chain Transport profile_id does not match the selected profile"));
|
||||||
|
}
|
||||||
|
let setups = map_market_price_setups(source.market_price, &effective, profile);
|
||||||
|
let setups = match setups {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let service = ksp_offchain_transport_lib::MarketPriceService::new(setups);
|
||||||
|
let service = match service {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(offchain_contract_error(
|
||||||
|
profile,
|
||||||
|
"effective market-price providers fail the Off-chain Transport runtime contract",
|
||||||
|
&error,
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let provider_count = service.registry().entries().len();
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
profile_id = profile.profile_id(),
|
||||||
|
format_version = source.format_version,
|
||||||
|
provider_count,
|
||||||
|
"mapped standard Off-chain Transport Config to market-price service"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(crate::ResolvedOffchainTransportConfig {
|
||||||
|
effective,
|
||||||
|
file_id: profile.file_id().clone(),
|
||||||
|
profile_id: profile.profile_id().to_owned(),
|
||||||
|
selection_source: profile.selection_source(),
|
||||||
|
service,
|
||||||
|
source_path: profile.path().to_path_buf(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_market_price_setups(
|
||||||
|
source: EffectiveMarketPriceSource,
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
|
||||||
|
let birdeye = map_birdeye(source.birdeye, effective, profile);
|
||||||
|
let birdeye = match birdeye {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinbase_exchange = ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(source.coinbase_exchange.enabled);
|
||||||
|
let coinbase_exchange = match coinbase_exchange {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "coinbase_exchange", &error)),
|
||||||
|
};
|
||||||
|
let coingecko = map_coingecko(source.coingecko, effective, profile);
|
||||||
|
let coingecko = match coingecko {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinmarketcap = map_coinmarketcap(source.coinmarketcap, effective, profile);
|
||||||
|
let coinmarketcap = match coinmarketcap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinpaprika = ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(source.coinpaprika.enabled);
|
||||||
|
let coinpaprika = match coinpaprika {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "coinpaprika", &error)),
|
||||||
|
};
|
||||||
|
let dexscreener = map_dexscreener(source.dexscreener, effective, profile);
|
||||||
|
let dexscreener = match dexscreener {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let jupiter = map_jupiter(source.jupiter, effective, profile);
|
||||||
|
let jupiter = match jupiter {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kraken = ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(source.kraken.enabled);
|
||||||
|
let kraken = match kraken {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(provider_contract_error(profile, "kraken", &error)),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(std::vec![
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase_exchange),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::DexScreener(dexscreener),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_birdeye(
|
||||||
|
source: EffectiveBirdeyeSource,
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceBirdeyeSettings> {
|
||||||
|
if source.api_key.is_some()
|
||||||
|
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/birdeye/api_key", profile, "birdeye")
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let settings = ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(source.enabled, source.api_key);
|
||||||
|
return match settings {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "birdeye", &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_coingecko(
|
||||||
|
source: EffectiveCoinGeckoSource,
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings> {
|
||||||
|
if source.api_key.is_some()
|
||||||
|
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/coingecko/api_key", profile, "coingecko")
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let settings = match source.access_mode {
|
||||||
|
ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode::Demo => {
|
||||||
|
ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::demo(source.enabled, source.api_key)
|
||||||
|
},
|
||||||
|
ksp_offchain_transport_lib::MarketPriceCoinGeckoAccessMode::Keyless => {
|
||||||
|
ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(source.enabled)
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "Off-chain Transport CoinGecko access mode is not supported by this Config adapter")
|
||||||
|
.with_context("provider", "coingecko")
|
||||||
|
.with_context("field", "access_mode"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match settings {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "coingecko", &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_coinmarketcap(
|
||||||
|
source: EffectiveCoinMarketCapSource,
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings> {
|
||||||
|
if source.api_key.is_some()
|
||||||
|
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/coinmarketcap/api_key", profile, "coinmarketcap")
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let settings = match source.access_mode {
|
||||||
|
ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode::Basic => {
|
||||||
|
ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::basic(source.enabled, source.api_key)
|
||||||
|
},
|
||||||
|
ksp_offchain_transport_lib::MarketPriceCoinMarketCapAccessMode::Keyless => {
|
||||||
|
ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(source.enabled)
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "Off-chain Transport CoinMarketCap access mode is not supported by this Config adapter")
|
||||||
|
.with_context("provider", "coinmarketcap")
|
||||||
|
.with_context("field", "access_mode"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match settings {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "coinmarketcap", &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_dexscreener(
|
||||||
|
source: EffectiveDexScreenerSource,
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceDexScreenerSettings> {
|
||||||
|
if source.sol_usd_pair_address.is_some()
|
||||||
|
&& let std::result::Result::Err(error) =
|
||||||
|
validate_public_field_provenance(effective, "/market_price/dexscreener/sol_usd_pair_address", profile, "dexscreener")
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let settings = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(source.enabled, source.sol_usd_pair_address);
|
||||||
|
return match settings {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "dexscreener", &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_jupiter(
|
||||||
|
source: EffectiveJupiterSource,
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<ksp_offchain_transport_lib::MarketPriceJupiterSettings> {
|
||||||
|
if source.api_key.is_some()
|
||||||
|
&& let std::result::Result::Err(error) = validate_secret_field_provenance(effective, "/market_price/jupiter/api_key", profile, "jupiter")
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let settings = match source.access_mode {
|
||||||
|
ksp_offchain_transport_lib::MarketPriceJupiterAccessMode::Free => {
|
||||||
|
ksp_offchain_transport_lib::MarketPriceJupiterSettings::free(source.enabled, source.api_key)
|
||||||
|
},
|
||||||
|
ksp_offchain_transport_lib::MarketPriceJupiterAccessMode::Keyless => ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(source.enabled),
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "Off-chain Transport Jupiter access mode is not supported by this Config adapter")
|
||||||
|
.with_context("provider", "jupiter")
|
||||||
|
.with_context("field", "access_mode"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match settings {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(provider_contract_error(profile, "jupiter", &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_secret_field_provenance(
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
pointer: &str,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
provider: &'static str,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
let provenance = match effective.provenance_at(pointer) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "Off-chain Transport secret provenance is unavailable")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("field", pointer),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut has_secret_environment = false;
|
||||||
|
for item in provenance {
|
||||||
|
let variable_name = match item.variable_name() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||||
|
let sensitivity = match sensitivity {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !sensitivity.is_secret() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "Off-chain Transport credentials may reference only secret environment variables")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("field", pointer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
has_secret_environment = true;
|
||||||
|
}
|
||||||
|
if !has_secret_environment {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "Off-chain Transport credentials require secret environment provenance")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("field", pointer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_public_field_provenance(
|
||||||
|
effective: &crate::ResolvedConfigJson,
|
||||||
|
pointer: &str,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
provider: &'static str,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
let provenance = match effective.provenance_at(pointer) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "Off-chain Transport public provenance is unavailable")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("field", pointer),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for item in provenance {
|
||||||
|
let variable_name = match item.variable_name() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||||
|
let sensitivity = match sensitivity {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if sensitivity != crate::ConfigSensitivity::Public {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "public Off-chain Transport fields may reference only public environment variables")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("field", pointer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_contract_error(profile: &crate::ResolvedConfigProfile, provider: &'static str, error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||||
|
return offchain_contract_error(profile, "effective market-price provider settings fail the Off-chain Transport runtime contract", error)
|
||||||
|
.with_context("provider", provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offchain_contract_error(profile: &crate::ResolvedConfigProfile, reason: &'static str, error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||||
|
return effective_error(profile, reason)
|
||||||
|
.with_context("offchain_transport_error_domain", error.code().domain())
|
||||||
|
.with_context("offchain_transport_error_code", error.code().code());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
|
||||||
|
.with_context("file_id", profile.file_id().as_str())
|
||||||
|
.with_context("profile_id", profile.profile_id())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/offchain_transport.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/registry.rs
|
// file: crates/ksp-config-lib/src/registry.rs
|
||||||
// version: 10
|
// version: 11
|
||||||
|
|
||||||
/// Bootstrap argument used to replace a known Config filename mapping.
|
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||||
pub const ARG_FILE_MAP: &str = "--filemap";
|
pub const ARG_FILE_MAP: &str = "--filemap";
|
||||||
@@ -11,6 +11,10 @@ pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
|||||||
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
||||||
/// Default physical filename for the standard Logging JSON Schema document.
|
/// Default physical filename for the standard Logging JSON Schema document.
|
||||||
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
||||||
|
/// Default physical filename for the standard Off-chain Transport configuration document.
|
||||||
|
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME: &str = "std.offchain_transport.json";
|
||||||
|
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
||||||
|
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME: &str = "std.offchain_transport.schema.json";
|
||||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
||||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
@@ -25,12 +29,16 @@ pub const FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK: &str = "cfg.composite.ksp-app-w
|
|||||||
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
||||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||||
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
||||||
|
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
||||||
|
pub const FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT: &str = "schema.std.offchain_transport";
|
||||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
||||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||||
pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet";
|
pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet";
|
||||||
/// Logical file identifier for the standard Logging configuration document.
|
/// Logical file identifier for the standard Logging configuration document.
|
||||||
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
||||||
|
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||||
|
pub const FILE_ID_STD_OFFCHAIN_TRANSPORT: &str = "cfg.std.offchain_transport";
|
||||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
||||||
/// Logical file identifier for the standard Wallet configuration document.
|
/// Logical file identifier for the standard Wallet configuration document.
|
||||||
@@ -172,6 +180,26 @@ impl ConfigFileRegistry {
|
|||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
};
|
};
|
||||||
|
let offchain_transport = ConfigFileDescriptor::new(
|
||||||
|
FILE_ID_STD_OFFCHAIN_TRANSPORT,
|
||||||
|
ConfigFileKind::Config,
|
||||||
|
DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME,
|
||||||
|
std::option::Option::Some(FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT),
|
||||||
|
);
|
||||||
|
let offchain_transport = match offchain_transport {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let offchain_transport_schema = ConfigFileDescriptor::new(
|
||||||
|
FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT,
|
||||||
|
ConfigFileKind::Schema,
|
||||||
|
DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let offchain_transport_schema = match offchain_transport_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
let transport = ConfigFileDescriptor::new(
|
let transport = ConfigFileDescriptor::new(
|
||||||
FILE_ID_STD_TRANSPORT,
|
FILE_ID_STD_TRANSPORT,
|
||||||
ConfigFileKind::Config,
|
ConfigFileKind::Config,
|
||||||
@@ -204,7 +232,18 @@ impl ConfigFileRegistry {
|
|||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
};
|
};
|
||||||
return crate::build_registry([composite, composite_schema, logging, logging_schema, transport, transport_schema, wallet, wallet_schema]);
|
return crate::build_registry([
|
||||||
|
composite,
|
||||||
|
composite_schema,
|
||||||
|
logging,
|
||||||
|
logging_schema,
|
||||||
|
offchain_transport,
|
||||||
|
offchain_transport_schema,
|
||||||
|
transport,
|
||||||
|
transport_schema,
|
||||||
|
wallet,
|
||||||
|
wallet_schema,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
|
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/tests/ownership.rs
|
// file: crates/ksp-config-lib/tests/ownership.rs
|
||||||
// version: 6
|
// version: 7
|
||||||
|
|
||||||
//! Workspace ownership audits for KSP application configuration boundaries.
|
//! Workspace ownership audits for KSP application configuration boundaries.
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ fn has_preceding_explanatory_comment(source: &str, assignment_line_index: usize)
|
|||||||
#[test]
|
#[test]
|
||||||
fn foundational_dependency_direction_does_not_point_back_to_config() {
|
fn foundational_dependency_direction_does_not_point_back_to_config() {
|
||||||
let root = workspace_root();
|
let root = workspace_root();
|
||||||
for crate_name in ["ksp-core-lib", "ksp-logging-lib"] {
|
for crate_name in ["ksp-core-lib", "ksp-logging-lib", "ksp-offchain-transport-lib"] {
|
||||||
let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml");
|
let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml");
|
||||||
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
||||||
assert!(manifest.is_ok(), "unable to read {}", manifest_path.display());
|
assert!(manifest.is_ok(), "unable to read {}", manifest_path.display());
|
||||||
@@ -266,6 +266,8 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
|
|||||||
"\"composite.schema.json\"",
|
"\"composite.schema.json\"",
|
||||||
"\"std.logging.json\"",
|
"\"std.logging.json\"",
|
||||||
"\"std.logging.schema.json\"",
|
"\"std.logging.schema.json\"",
|
||||||
|
"\"std.offchain_transport.json\"",
|
||||||
|
"\"std.offchain_transport.schema.json\"",
|
||||||
"\"std.transport.json\"",
|
"\"std.transport.json\"",
|
||||||
"\"std.transport.schema.json\"",
|
"\"std.transport.schema.json\"",
|
||||||
"\"std.wallet.json\"",
|
"\"std.wallet.json\"",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/tests/public_api.rs
|
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||||
// version: 23
|
// version: 24
|
||||||
|
|
||||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
|
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
|
||||||
//! Logging/Transport adapters and management contracts.
|
//! Logging/Transport adapters and management contracts.
|
||||||
@@ -80,16 +80,18 @@ fn registry_descriptor_inventory_is_available_from_crate_root() {
|
|||||||
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
if let std::result::Result::Ok(registry) = registry {
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect();
|
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect();
|
||||||
assert_eq!(descriptors.len(), 8);
|
assert_eq!(descriptors.len(), 10);
|
||||||
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
|
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
|
||||||
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
|
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
||||||
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
|
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
||||||
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
|
||||||
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
||||||
let schema_file_id = descriptors[3].schema_file_id();
|
assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||||
|
assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
||||||
|
let schema_file_id = descriptors[4].schema_file_id();
|
||||||
assert!(schema_file_id.is_some(), "public Wallet descriptor should preserve schema association");
|
assert!(schema_file_id.is_some(), "public Wallet descriptor should preserve schema association");
|
||||||
if let std::option::Option::Some(schema_file_id) = schema_file_id {
|
if let std::option::Option::Some(schema_file_id) = schema_file_id {
|
||||||
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
||||||
@@ -276,6 +278,18 @@ fn wallet_adapter_contract_is_available_from_crate_root() {
|
|||||||
assert_eq!(ksp_config_lib::DEFAULT_STD_WALLET_SCHEMA_FILENAME, "std.wallet.schema.json");
|
assert_eq!(ksp_config_lib::DEFAULT_STD_WALLET_SCHEMA_FILENAME, "std.wallet.schema.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offchain_transport_adapter_contract_is_available_from_crate_root() {
|
||||||
|
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_offchain_transport_config;
|
||||||
|
let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_offchain_transport_config_profile;
|
||||||
|
assert!(std::mem::size_of::<ksp_config_lib::ResolvedOffchainTransportConfig>() > 0);
|
||||||
|
let _service = ksp_config_lib::ResolvedOffchainTransportConfig::service;
|
||||||
|
assert_eq!(ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT, "cfg.std.offchain_transport");
|
||||||
|
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT, "schema.std.offchain_transport");
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME, "std.offchain_transport.json");
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME, "std.offchain_transport.schema.json");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn packaged_runtime_layout_contract_is_available_from_crate_root() {
|
fn packaged_runtime_layout_contract_is_available_from_crate_root() {
|
||||||
let prepare: fn(&std::path::Path) -> ksp_core_lib::Result<ksp_config_lib::PackagedRuntimeLayout> = ksp_config_lib::prepare_packaged_runtime;
|
let prepare: fn(&std::path::Path) -> ksp_core_lib::Result<ksp_config_lib::PackagedRuntimeLayout> = ksp_config_lib::prepare_packaged_runtime;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
284
crates/ksp-config-lib/unit_tests/offchain_transport.rs
Normal file
284
crates/ksp-config-lib/unit_tests/offchain_transport.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/offchain_transport.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
const TEST_PAIR: &str = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_public_keyless_profile_maps_all_eight_providers_without_secret_environment() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "committed public_keyless Off-chain Transport profile should map: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
||||||
|
assert_eq!(resolved.profile_id(), "public_keyless");
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
let registry = resolved.service().registry();
|
||||||
|
assert_eq!(registry.len(), 8);
|
||||||
|
let ids: std::vec::Vec<&str> = registry.entries().iter().map(|entry| return entry.descriptor().id().as_str()).collect();
|
||||||
|
assert_eq!(ids, ["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken"]);
|
||||||
|
assert_provider_availability(®istry, "birdeye", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
|
||||||
|
assert_provider_availability(®istry, "dexscreener", ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
|
||||||
|
for provider_id in ["coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "jupiter", "kraken"] {
|
||||||
|
assert_provider_availability(®istry, provider_id, ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready);
|
||||||
|
}
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains("api_key"), "safe Debug should not expose credential field contents from the selected keyless profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_all_free_profile_requires_config_owned_secrets_and_public_pair_provenance() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canaries = [
|
||||||
|
("KSP_SECRET_BIRDEYE_API_KEY", "birdeye-secret-canary"),
|
||||||
|
("KSP_SECRET_COINGECKO_DEMO_API_KEY", "coingecko-secret-canary"),
|
||||||
|
("KSP_SECRET_COINMARKETCAP_API_KEY", "coinmarketcap-secret-canary"),
|
||||||
|
("KSP_SECRET_JUPITER_API_KEY", "jupiter-secret-canary"),
|
||||||
|
("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS", TEST_PAIR),
|
||||||
|
];
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
for (name, value) in canaries {
|
||||||
|
process.insert(name.to_owned(), value.to_owned());
|
||||||
|
}
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("all_free"), &environment);
|
||||||
|
assert!(resolved.is_ok(), "committed all_free Off-chain Transport profile should map from Config-owned environment: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.profile_id(), "all_free");
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||||
|
let registry = resolved.service().registry();
|
||||||
|
assert_eq!(registry.len(), 8);
|
||||||
|
for entry in registry.entries() {
|
||||||
|
assert_eq!(entry.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready);
|
||||||
|
}
|
||||||
|
assert!(resolved.effective().sensitivity().is_secret());
|
||||||
|
let safe = resolved.effective().safe_value().to_string();
|
||||||
|
for secret in ["birdeye-secret-canary", "coingecko-secret-canary", "coinmarketcap-secret-canary", "jupiter-secret-canary"] {
|
||||||
|
assert!(!safe.contains(secret), "safe effective Config must redact provider credential canary");
|
||||||
|
}
|
||||||
|
assert!(safe.contains(TEST_PAIR), "public DexScreener pair should remain visible in the safe effective Config");
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
for secret in ["birdeye-secret-canary", "coingecko-secret-canary", "coinmarketcap-secret-canary", "jupiter-secret-canary"] {
|
||||||
|
assert!(!debug.contains(secret), "ResolvedOffchainTransportConfig Debug must redact provider credential canary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn literal_or_nonsecret_provider_credentials_are_rejected_by_effective_adapter() {
|
||||||
|
let fixture = tempfile::tempdir();
|
||||||
|
assert!(fixture.is_ok(), "temporary Config root should be creatable: {fixture:?}");
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let source = committed_document_value();
|
||||||
|
let mut source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
|
||||||
|
assert!(profiles.is_some(), "fixture should expose profiles");
|
||||||
|
if let std::option::Option::Some(profiles) = profiles {
|
||||||
|
let all_free = profiles
|
||||||
|
.iter_mut()
|
||||||
|
.find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("all_free"));
|
||||||
|
assert!(all_free.is_some(), "fixture should contain all_free profile");
|
||||||
|
if let std::option::Option::Some(all_free) = all_free {
|
||||||
|
all_free["market_price"]["birdeye"]["api_key"] = serde_json::Value::String("literal-secret".to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let engine = fixture_engine_with_document(fixture.path(), &source);
|
||||||
|
assert!(engine.is_ok(), "literal-secret fixture engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut process = all_free_environment();
|
||||||
|
process.insert("KSP_SECRET_BIRDEYE_API_KEY".to_owned(), "unused-secret".to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("all_free"), &environment);
|
||||||
|
assert!(resolved.is_err(), "literal provider credential must be rejected even though the JSON Schema accepts a non-empty string");
|
||||||
|
if let std::result::Result::Err(error) = resolved {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
assert!(!format!("{error:?}").contains("literal-secret"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dexscreener_pair_environment_must_use_public_namespace_and_disabled_pair_may_be_absent() {
|
||||||
|
let fixture = tempfile::tempdir();
|
||||||
|
assert!(fixture.is_ok(), "temporary Config root should be creatable: {fixture:?}");
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let source = committed_document_value();
|
||||||
|
let mut source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
|
||||||
|
if let std::option::Option::Some(profiles) = profiles {
|
||||||
|
let all_free = profiles
|
||||||
|
.iter_mut()
|
||||||
|
.find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("all_free"));
|
||||||
|
if let std::option::Option::Some(all_free) = all_free {
|
||||||
|
all_free["market_price"]["dexscreener"]["sol_usd_pair_address"] = serde_json::Value::String("${KSP_SECRET_DEXSCREENER_PAIR}".to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let engine = fixture_engine_with_document(fixture.path(), &source);
|
||||||
|
assert!(engine.is_ok(), "secret-pair fixture engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut process = all_free_environment();
|
||||||
|
process.remove("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS");
|
||||||
|
process.insert("KSP_SECRET_DEXSCREENER_PAIR".to_owned(), TEST_PAIR.to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("all_free"), &environment);
|
||||||
|
assert!(resolved.is_err(), "DexScreener pair environment must not use secret provenance");
|
||||||
|
if let std::result::Result::Err(error) = resolved {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
}
|
||||||
|
let disabled = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, std::option::Option::None);
|
||||||
|
assert!(disabled.is_ok(), "disabled DexScreener runtime settings should accept an absent pair after pre.009 capability reconciliation: {disabled:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_url_and_rate_limit_overrides_are_rejected_by_schema_before_runtime_mapping() {
|
||||||
|
for (provider, field, value) in [
|
||||||
|
("coingecko", "base_url", serde_json::Value::String("https://example.invalid".to_owned())),
|
||||||
|
("coinmarketcap", "rate_limit", serde_json::json!({"requests": 999999, "window_seconds": 1})),
|
||||||
|
] {
|
||||||
|
let fixture = tempfile::tempdir();
|
||||||
|
assert!(fixture.is_ok(), "temporary Config root should be creatable: {fixture:?}");
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let source = committed_document_value();
|
||||||
|
let mut source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
|
||||||
|
assert!(profiles.is_some(), "fixture should expose profiles");
|
||||||
|
if let std::option::Option::Some(profiles) = profiles {
|
||||||
|
let public_keyless = profiles
|
||||||
|
.iter_mut()
|
||||||
|
.find(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some("public_keyless"));
|
||||||
|
assert!(public_keyless.is_some(), "fixture should contain public_keyless profile");
|
||||||
|
if let std::option::Option::Some(public_keyless) = public_keyless {
|
||||||
|
public_keyless["market_price"][provider][field] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let engine = fixture_engine_with_document(fixture.path(), &source);
|
||||||
|
assert!(engine.is_ok(), "override fixture engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_offchain_transport_config(std::option::Option::Some("public_keyless"), &environment);
|
||||||
|
assert!(resolved.is_err(), "provider URL/rate-limit override must be rejected before runtime mapping: provider={provider} field={field}");
|
||||||
|
if let std::result::Result::Err(error) = resolved {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED);
|
||||||
|
let debug = format!("{error:?}");
|
||||||
|
assert!(!debug.contains("https://example.invalid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_provider_availability(
|
||||||
|
registry: &ksp_offchain_transport_lib::MarketPriceProviderRegistry,
|
||||||
|
provider_id: &str,
|
||||||
|
expected: ksp_offchain_transport_lib::MarketPriceProviderAvailability,
|
||||||
|
) {
|
||||||
|
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id);
|
||||||
|
assert!(provider_id.is_ok(), "provider id fixture should be valid: {provider_id:?}");
|
||||||
|
if let std::result::Result::Ok(provider_id) = provider_id {
|
||||||
|
let state = registry.state(&provider_id);
|
||||||
|
assert!(state.is_some(), "provider should be present in Config-produced service registry");
|
||||||
|
if let std::option::Option::Some(state) = state {
|
||||||
|
assert_eq!(state.availability(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_engine_with_document(root: &std::path::Path, document: &serde_json::Value) -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let config_root = root.join("config");
|
||||||
|
let create = std::fs::create_dir_all(config_root.as_path());
|
||||||
|
if let std::result::Result::Err(error) = create {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Config root cannot be created").with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let bytes = serde_json::to_vec_pretty(document);
|
||||||
|
let bytes = match bytes {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "test Config cannot be encoded").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let path = config_root.join(crate::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME);
|
||||||
|
if let std::result::Result::Err(error) = std::fs::write(path.as_path(), bytes) {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Config cannot be written").with_source(error));
|
||||||
|
}
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(config_root, workspace_root().join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_document_value() -> std::result::Result<serde_json::Value, serde_json::Error> {
|
||||||
|
return serde_json::from_str(include_str!("../../../config/std.offchain_transport.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_free_environment() -> std::collections::BTreeMap<String, String> {
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_BIRDEYE_API_KEY".to_owned(), "birdeye-test".to_owned());
|
||||||
|
process.insert("KSP_SECRET_COINGECKO_DEMO_API_KEY".to_owned(), "coingecko-test".to_owned());
|
||||||
|
process.insert("KSP_SECRET_COINMARKETCAP_API_KEY".to_owned(), "coinmarketcap-test".to_owned());
|
||||||
|
process.insert("KSP_SECRET_JUPITER_API_KEY".to_owned(), "jupiter-test".to_owned());
|
||||||
|
process.insert("KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS".to_owned(), TEST_PAIR.to_owned());
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
||||||
// version: 8
|
// version: 9
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
|
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
|
||||||
@@ -7,21 +7,23 @@ fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
|
|||||||
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
if let std::result::Result::Ok(registry) = registry {
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect();
|
let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect();
|
||||||
assert_eq!(descriptors.len(), 8);
|
assert_eq!(descriptors.len(), 10);
|
||||||
assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
|
assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
|
||||||
assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME));
|
assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME));
|
||||||
assert_eq!(descriptors[0].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE));
|
assert_eq!(descriptors[0].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE));
|
||||||
assert_eq!(descriptors[1].file_id().as_str(), crate::FILE_ID_STD_LOGGING);
|
assert_eq!(descriptors[1].file_id().as_str(), crate::FILE_ID_STD_LOGGING);
|
||||||
assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
|
assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
||||||
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_WALLET);
|
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
|
||||||
assert_eq!(descriptors[3].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
|
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_WALLET);
|
||||||
assert_eq!(descriptors[3].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET));
|
assert_eq!(descriptors[4].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
|
||||||
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
|
assert_eq!(descriptors[4].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET));
|
||||||
assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
|
assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
|
assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET);
|
assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
||||||
assert!(descriptors[0..4].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config));
|
assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||||
assert!(descriptors[4..8].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema));
|
assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET);
|
||||||
|
assert!(descriptors[0..5].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config));
|
||||||
|
assert!(descriptors[5..10].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +87,31 @@ fn defaults_register_logging_document_and_schema_with_distinct_roots() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_register_offchain_transport_document_and_schema_with_distinct_roots() {
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
|
let config_id = crate::ConfigFileId::new(crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
||||||
|
let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
||||||
|
assert!(config_id.is_ok(), "Off-chain Transport file_id should be valid: {config_id:?}");
|
||||||
|
assert!(schema_id.is_ok(), "Off-chain Transport schema file_id should be valid: {schema_id:?}");
|
||||||
|
if let (std::result::Result::Ok(config_id), std::result::Result::Ok(schema_id)) = (config_id, schema_id) {
|
||||||
|
let config = registry.descriptor(&config_id);
|
||||||
|
let schema = registry.descriptor(&schema_id);
|
||||||
|
assert!(config.is_ok(), "Off-chain Transport descriptor should exist: {config:?}");
|
||||||
|
assert!(schema.is_ok(), "Off-chain Transport schema descriptor should exist: {schema:?}");
|
||||||
|
if let (std::result::Result::Ok(config), std::result::Result::Ok(schema)) = (config, schema) {
|
||||||
|
assert_eq!(config.kind(), crate::ConfigFileKind::Config);
|
||||||
|
assert_eq!(config.filename(), std::path::Path::new(crate::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME));
|
||||||
|
assert_eq!(config.schema_file_id(), std::option::Option::Some(&schema_id));
|
||||||
|
assert_eq!(schema.kind(), crate::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(schema.filename(), std::path::Path::new(crate::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_register_transport_document_and_schema_with_distinct_roots() {
|
fn defaults_register_transport_document_and_schema_with_distinct_roots() {
|
||||||
let registry = crate::ConfigFileRegistry::defaults();
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/unit_tests/transport.rs
|
// file: crates/ksp-config-lib/unit_tests/transport.rs
|
||||||
// version: 10
|
// version: 12
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fixture_transport_profile_maps_complete_runtime_contract() {
|
fn fixture_transport_profile_maps_complete_runtime_contract() {
|
||||||
@@ -133,6 +133,52 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_v3_orbitflare_devnet_profile_maps_provider_neutral_yellowstone_grpc_with_license_x_token() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "ORBITFLARE-DEVNET-LICENSE-X-TOKEN-CANARY";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN".to_owned(), canary.to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_transport_config(std::option::Option::Some("orbitflare_devnet"), &environment);
|
||||||
|
assert!(resolved.is_ok(), "committed OrbitFlare Devnet Transport profile should map from its License Key x-token: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.profile_id(), "orbitflare_devnet");
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||||
|
assert_eq!(resolved.settings().endpoints()[0].cluster().as_str(), "devnet");
|
||||||
|
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), "https://api.devnet.solana.com");
|
||||||
|
let ws = resolved.ws_settings();
|
||||||
|
assert!(ws.is_some(), "OrbitFlare Devnet profile should retain the standard Solana Devnet WebSocket companion endpoint");
|
||||||
|
if let std::option::Option::Some(ws) = ws {
|
||||||
|
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.devnet.solana.com");
|
||||||
|
assert_eq!(ws.endpoints()[0].protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
|
||||||
|
}
|
||||||
|
let grpc = resolved.grpc_settings();
|
||||||
|
assert!(grpc.is_some(), "OrbitFlare Devnet profile should expose one provider-neutral Yellowstone gRPC endpoint");
|
||||||
|
if let std::option::Option::Some(grpc) = grpc {
|
||||||
|
assert_eq!(grpc.endpoints().len(), 1);
|
||||||
|
let endpoint = &grpc.endpoints()[0];
|
||||||
|
assert_eq!(endpoint.name(), "orbitflare_solana_devnet_yellowstone");
|
||||||
|
assert_eq!(endpoint.provider().as_str(), "orbitflare");
|
||||||
|
assert_eq!(endpoint.cluster().as_str(), "devnet");
|
||||||
|
assert_eq!(endpoint.url().as_str(), "http://devnet.rpc.orbitflare.com:10000");
|
||||||
|
assert!(!endpoint.url().uses_tls(), "OrbitFlare shared Devnet URL must remain explicitly HTTP/2 plaintext");
|
||||||
|
assert_eq!(endpoint.metadata().len(), 1);
|
||||||
|
assert_eq!(endpoint.metadata()[0].key(), "x-token");
|
||||||
|
assert!(endpoint.metadata()[0].is_secret());
|
||||||
|
assert!(grpc.validate().is_ok(), "Config-produced OrbitFlare Yellowstone gRPC settings should satisfy Transport validation");
|
||||||
|
let debug = format!("{grpc:?}");
|
||||||
|
assert!(!debug.contains("orbitflare.com"), "Transport settings Debug must not expose OrbitFlare gRPC endpoint URLs");
|
||||||
|
assert!(!debug.contains(canary), "OrbitFlare License Key x-token must stay redacted from Transport settings Debug");
|
||||||
|
assert!(!debug.contains("X-ORBIT-KEY"), "OrbitFlare Customer API credentials must never enter Yellowstone Transport settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn committed_v3_publicnode_profiles_map_provider_neutral_yellowstone_grpc_with_network_scoped_secret_x_tokens() {
|
fn committed_v3_publicnode_profiles_map_provider_neutral_yellowstone_grpc_with_network_scoped_secret_x_tokens() {
|
||||||
let engine = committed_engine();
|
let engine = committed_engine();
|
||||||
|
|||||||
22
crates/ksp-offchain-transport-lib/Cargo.toml
Normal file
22
crates/ksp-offchain-transport-lib/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# file: crates/ksp-offchain-transport-lib/Cargo.toml
|
||||||
|
# version: 4
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-offchain-transport-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { workspace = true, features = ["std"] }
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
reqwest = { workspace = true, features = ["rustls"] }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true, features = ["raw_value"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "time"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
146
crates/ksp-offchain-transport-lib/README.md
Normal file
146
crates/ksp-offchain-transport-lib/README.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<!-- file: crates/ksp-offchain-transport-lib/README.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# ksp-offchain-transport-lib
|
||||||
|
|
||||||
|
`ksp-offchain-transport-lib` est le propriétaire des transports et adaptations de données **off-chain** utilisés par KSP. La release `0.2.11` matérialise sa première famille fonctionnelle, `market_price`, limitée à des observations SOL/USD multi-provider.
|
||||||
|
|
||||||
|
La crate n'est pas une crate « prix uniquement ». Les responsabilités durables sont séparées par famille :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http_* mécanique HTTP partagée réellement commune
|
||||||
|
market_price_* prix de marché / spot normalisés
|
||||||
|
swap_quote_* future famille de quotes montant/route
|
||||||
|
<future_capability>_* ajoutée uniquement lorsqu'un scope réel l'exige
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contrat `market_price` V1
|
||||||
|
|
||||||
|
La façade publique fournit :
|
||||||
|
|
||||||
|
- `MarketPriceDecimal`, représentation décimale exacte positive sans vérité canonique `f64` ;
|
||||||
|
- `MarketPriceObservation`, avec paire, prix, sémantique, timestamps KSP/provider et provenance sûre ;
|
||||||
|
- `MarketPriceProviderDescriptor` et `MarketPriceProviderState`, pour décrire capacités et availability sans logique provider côté consumer ;
|
||||||
|
- `MarketPriceProviderRegistry`, inventaire déterministe des providers configurés ;
|
||||||
|
- `MarketPriceService`, façade provider-agnostic pour `refresh`, `refresh_many` et `refresh_all` ;
|
||||||
|
- `MarketPriceProviderSetup`, frontière de composition initiale provider-specific qui ne doit pas devenir la surface runtime de la HID.
|
||||||
|
|
||||||
|
La paire publique V1 est exclusivement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
SOL/USD
|
||||||
|
```
|
||||||
|
|
||||||
|
Les enums publiques susceptibles d'évoluer sont `#[non_exhaustive]`. Un consumer externe doit donc conserver une branche future-safe et ne pas supposer que les paires, sémantiques, états ou providers resteront définitivement fermés à ceux de `0.2.11`.
|
||||||
|
|
||||||
|
## Providers V1
|
||||||
|
|
||||||
|
L'inventaire fonctionnel comporte exactement huit adapters :
|
||||||
|
|
||||||
|
```text
|
||||||
|
birdeye
|
||||||
|
coinbase_exchange
|
||||||
|
coingecko
|
||||||
|
coinmarketcap
|
||||||
|
coinpaprika
|
||||||
|
dexscreener
|
||||||
|
jupiter
|
||||||
|
kraken
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils ne prétendent pas produire la même vérité de marché. `MarketPriceSemantics` conserve notamment la différence entre agrégateur, dernier trade d'exchange, heuristique Solana, spot Solana et paire DEX.
|
||||||
|
|
||||||
|
Les origines HTTPS, chemins, headers d'authentification, identités d'asset et limites provider restent possédés par les adapters. V1 n'expose aucune URL provider arbitraire ni aucun SDK fournisseur.
|
||||||
|
|
||||||
|
DexScreener reste un cas volontairement strict : une paire Solana explicite est fournie à la composition lorsqu'il est activé ; l'adapter appelle uniquement la paire configurée et ne découvre, ne classe ni n'agrège automatiquement des pools.
|
||||||
|
|
||||||
|
## HTTP et résilience
|
||||||
|
|
||||||
|
Les primitives `http_*` sont crate-private. Elles appliquent notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
reqwest uniquement
|
||||||
|
HTTPS provider fixe
|
||||||
|
redirects désactivés
|
||||||
|
Referer automatique désactivé
|
||||||
|
proxy système implicite désactivé
|
||||||
|
retries reqwest implicites désactivés
|
||||||
|
connect/request timeouts bornés
|
||||||
|
body borné pendant la lecture
|
||||||
|
JSON validé avant mapping typed
|
||||||
|
URL retirée des erreurs reqwest
|
||||||
|
aucun body distant brut dans KspError
|
||||||
|
429 et Retry-After classés
|
||||||
|
```
|
||||||
|
|
||||||
|
Le rate limiting est provider-owned et non bloquant. Un provider non éligible est projeté en availability/cooldown ; le service ne dort pas pour attendre sa prochaine fenêtre.
|
||||||
|
|
||||||
|
## Refresh individuel et multiple
|
||||||
|
|
||||||
|
`MarketPriceService` est la surface runtime générique.
|
||||||
|
|
||||||
|
La V1 garde un comportement multiple **séquentiel et déterministe** :
|
||||||
|
|
||||||
|
```text
|
||||||
|
refresh(provider_id) un provider opaque
|
||||||
|
refresh_many(provider_ids) ordre demandé conservé
|
||||||
|
refresh_all() ordre stable du registry
|
||||||
|
```
|
||||||
|
|
||||||
|
Un provider en cooldown ou en erreur n'empêche pas la projection des autres outcomes. Le service ne fait aucun fallback, aucun consensus et aucune agrégation de prix entre providers.
|
||||||
|
|
||||||
|
Le séquentiel de V1 est un contrat volontaire de simplicité et de déterminisme, pas une obligation architecturale éternelle. Une évolution vers une orchestration concurrente demanderait un contrat explicite sur l'ordre, les limites et les effets observables.
|
||||||
|
|
||||||
|
## Config et secrets
|
||||||
|
|
||||||
|
Cette crate **ne lit jamais** directement `KSP_*`, `KSPB_*`, `.env` ou les documents Config.
|
||||||
|
|
||||||
|
La direction autorisée est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib
|
||||||
|
-> ksp-offchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
`ksp-config-lib` résout les credentials, vérifie leur provenance et construit `MarketPriceProviderSetup` / `MarketPriceService`. La dépendance inverse est interdite.
|
||||||
|
|
||||||
|
Les API keys ne sont pas exposées par les projections publiques usuelles et leurs `Debug` sont redacted. Les erreurs/logs n'embarquent ni credential, ni URL sensible, ni payload distant brut.
|
||||||
|
|
||||||
|
## Numeric safety et provenance
|
||||||
|
|
||||||
|
`MarketPriceDecimal` accepte les formes décimales/scientifiques bornées nécessaires aux wire providers, puis normalise vers un coefficient `u128` et une scale limitée. Sont rejetés notamment : zéro pour une observation réussie, négatifs, valeurs non numériques, overflow, scale excessive et exposants pathologiques.
|
||||||
|
|
||||||
|
Les timestamps provider ne sont présents que lorsqu'un provider fournit réellement une information temporelle correspondant au prix. Un block id, une date de création d'asset ou une donnée de récence non temporelle n'est jamais convertie en faux timestamp.
|
||||||
|
|
||||||
|
La provenance textuelle est bornée et contrôlée afin de rester sûre pour les projections/logs.
|
||||||
|
|
||||||
|
## Hors scope de `0.2.11`
|
||||||
|
|
||||||
|
```text
|
||||||
|
SOL/EUR
|
||||||
|
fallback automatique
|
||||||
|
consensus ou moyenne multi-provider
|
||||||
|
découverte automatique de pool DexScreener
|
||||||
|
scheduler périodique
|
||||||
|
historique persistant
|
||||||
|
swap routing / Jupiter quote
|
||||||
|
soumission ou signature de transaction Solana
|
||||||
|
SDK provider
|
||||||
|
URL provider configurable
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`USAGE.md`](USAGE.md) — construction programmatique et utilisation de la façade générique ;
|
||||||
|
- [`../../docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md`](../../docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md) — plan de release ;
|
||||||
|
- [`../../docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md`](../../docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md) — matrice de validation.
|
||||||
|
|
||||||
|
## Validation de release `0.2.11`
|
||||||
|
|
||||||
|
La candidate finale a été validée sur les huit adapters par tests déterministes. Le smoke live keyless de clôture a réellement rafraîchi les sept modes ne nécessitant aucun credential : Coinbase Exchange, CoinGecko, CoinMarketCap, CoinPaprika, DexScreener, Jupiter et Kraken. Un premier passage a révélé le paramètre CoinMarketCap V2 erroné `ids`; `pre.011-fix.001` l'a corrigé en `id=5426`, puis le re-smoke a passé les sept providers.
|
||||||
|
|
||||||
|
Birdeye n'expose pas de mode keyless V1. Le smoke keyed commun Birdeye/CoinGecko Demo/CoinMarketCap Basic/Jupiter Free reste opt-in et peut être `SKIP opérateur` lorsque les quatre credentials gratuits ne sont pas disponibles. KSP ne transforme jamais cette absence de credentials en preuve live fictive.
|
||||||
|
|
||||||
|
Les offres gratuites, quotas et conditions d'usage appartiennent aux providers et peuvent évoluer indépendamment du contrat KSP. Les descriptors représentent le snapshot audité pour `0.2.11`; un changement commercial ou de quota futur peut rendre un provider indisponible sans modifier la façade provider-neutral.
|
||||||
|
|
||||||
|
Ce README est la documentation durable de la surface `0.2.11`. Les détails de construction et les commandes de smoke sont conservés dans [`USAGE.md`](USAGE.md), tandis que le plan et la matrice de validation enregistrent les décisions et preuves de release.
|
||||||
181
crates/ksp-offchain-transport-lib/USAGE.md
Normal file
181
crates/ksp-offchain-transport-lib/USAGE.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<!-- file: crates/ksp-offchain-transport-lib/USAGE.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# Usage de ksp-offchain-transport-lib
|
||||||
|
|
||||||
|
Cette page documente la surface stable préparée pour `0.2.11`. Les credentials et documents runtime doivent normalement être résolus par `ksp-config-lib`; les constructions directes ci-dessous servent surtout aux tests, outils bas niveau et compositions programmatiques maîtrisées.
|
||||||
|
|
||||||
|
## 1. Construire un service programmatique
|
||||||
|
|
||||||
|
Un service reçoit une liste de `MarketPriceProviderSetup`. Le setup est provider-specific uniquement au moment de la composition :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let coingecko = ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(true)?;
|
||||||
|
let kraken = ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(true)?;
|
||||||
|
let service = ksp_offchain_transport_lib::MarketPriceService::new(std::vec![
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
|
||||||
|
])?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Une application normale n'a pas à reproduire le mapping des huit providers. `ksp-config-lib::ResolvedOffchainTransportConfig` construit cette composition depuis `cfg.std.offchain_transport`.
|
||||||
|
|
||||||
|
## 2. Découvrir le registry générique
|
||||||
|
|
||||||
|
Après construction, le consumer travaille sur le registry sans matcher les variants provider :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
for entry in service.registry().entries() {
|
||||||
|
let descriptor = entry.descriptor();
|
||||||
|
let state = entry.state();
|
||||||
|
println!(
|
||||||
|
"{} {:?} {:?}",
|
||||||
|
descriptor.display_name(),
|
||||||
|
descriptor.semantics(),
|
||||||
|
state.availability(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `provider_id` est opaque. Il sert d'identité stable pour rappeler le service, pas de signal autorisant le consumer à reconstruire un endpoint ou une règle provider.
|
||||||
|
|
||||||
|
## 3. Rafraîchir un provider
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("coingecko")?;
|
||||||
|
let outcome = service.refresh(&provider_id).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
L'outcome expose génériquement l'observation éventuelle et l'état provider. Une erreur provider normalisée n'oblige pas le consumer à parser CoinGecko, Kraken ou Jupiter.
|
||||||
|
|
||||||
|
Avant un refresh, l'état peut être consulté via le registry. `MarketPriceProviderAvailability::retry_at()` expose le prochain instant connu lorsqu'il existe réellement.
|
||||||
|
|
||||||
|
## 4. Rafraîchir plusieurs providers
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let ids = service
|
||||||
|
.registry()
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|entry| return entry.descriptor().id().clone())
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let outcomes = service.refresh_many(ids.as_slice()).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
La V1 exécute ce batch **séquentiellement**, dans l'ordre demandé. Elle valide les IDs avant le premier dispatch, rejette les doublons, ne dort pas pour un cooldown et produit un outcome générique par provider lorsque l'opération est valide.
|
||||||
|
|
||||||
|
`refresh_all()` applique le même contrat dans l'ordre stable du registry :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let outcomes = service.refresh_all().await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Il n'existe pas de fallback, consensus ou moyenne implicite. Une application voulant comparer les observations doit conserver leurs sémantiques et provenances ; elle ne doit pas présenter leurs différences comme une erreur de KSP.
|
||||||
|
|
||||||
|
## 5. Construire depuis Config
|
||||||
|
|
||||||
|
La voie runtime normale est :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resolved = engine.load_resolved_offchain_transport_config(
|
||||||
|
std::option::Option::None,
|
||||||
|
&environment,
|
||||||
|
)?;
|
||||||
|
let service = resolved.service();
|
||||||
|
let registry = service.registry();
|
||||||
|
```
|
||||||
|
|
||||||
|
Le profil `public_keyless` du document standard peut être résolu sans credentials. Le profil `all_free` attend les secrets/public fields inventoriés dans `.env.example` et validés par `ksp-config-lib`.
|
||||||
|
|
||||||
|
Off-chain Transport ne lit pas l'environnement lui-même. Ne passez pas un credential via une URL, une query arbitraire ou une surface UI libre pour contourner Config.
|
||||||
|
|
||||||
|
## 6. DexScreener
|
||||||
|
|
||||||
|
DexScreener doit recevoir la paire SOL/USD Solana explicitement approuvée par la composition lorsqu'il est activé :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let pair = ksp_core_lib::Pubkey::parse("<PAIR_ADDRESS_APPROUVEE>")?;
|
||||||
|
let settings = ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(
|
||||||
|
true,
|
||||||
|
std::option::Option::Some(pair),
|
||||||
|
)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
La paire n'est pas un secret. Aucun helper V1 ne découvre automatiquement une autre pool, ne trie par liquidité ou ne remplace la paire configurée.
|
||||||
|
|
||||||
|
## 7. Exactitude numérique
|
||||||
|
|
||||||
|
Ne convertissez pas l'observation canonique en `f64` pour la stocker ou la comparer comme vérité KSP. `MarketPriceDecimal` conserve une forme décimale exacte et sérialise une représentation canonique.
|
||||||
|
|
||||||
|
Pour l'affichage, un consumer peut utiliser sa représentation textuelle publique. Toute conversion approximative éventuelle appartient à une couche de présentation qui accepte explicitement cette perte ; elle ne doit pas remplacer le type canonique dans le transport.
|
||||||
|
|
||||||
|
## 8. Forward compatibility
|
||||||
|
|
||||||
|
Les enums publiques susceptibles d'évoluer sont `#[non_exhaustive]`. Hors de la crate, les matches doivent donc prévoir un fallback :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match entry.state().availability() {
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready => {},
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled => {},
|
||||||
|
_ => {},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La branche `_` est intentionnelle : de nouveaux providers, états, sémantiques ou paires pourront être ajoutés sans imposer une rupture source aux consumers bien écrits.
|
||||||
|
|
||||||
|
## 9. Diagnostics sûrs
|
||||||
|
|
||||||
|
Les diagnostics applicatifs peuvent journaliser :
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider_id validé
|
||||||
|
code d'erreur KSP
|
||||||
|
classe d'availability
|
||||||
|
retry_at borné lorsqu'il existe
|
||||||
|
durée/opération générique
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils ne doivent pas journaliser :
|
||||||
|
|
||||||
|
```text
|
||||||
|
API key
|
||||||
|
URL complète sensible
|
||||||
|
header provider secret
|
||||||
|
body distant brut
|
||||||
|
payload de Config secret
|
||||||
|
```
|
||||||
|
|
||||||
|
`ksp-offchain-transport-lib` utilise `ksp-logging-lib` et son `TRACING_TARGET` propriétaire ; une application ne doit pas ajouter un bypass direct `tracing` pour obtenir les payloads rejetés.
|
||||||
|
|
||||||
|
## 10. Smokes live de release
|
||||||
|
|
||||||
|
Le smoke keyless final ne requiert aucun secret et couvre les sept modes V1 concernés :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p ksp-offchain-transport-lib \
|
||||||
|
--test market_price_live_smoke \
|
||||||
|
keyless_market_price_providers_live_smoke \
|
||||||
|
-- --ignored --exact --nocapture --test-threads=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate `0.2.11-pre.011-fix.001` a passé ce smoke en `7/7`. Le test ne compare jamais les providers à un prix exact commun : il valide l'identité, la paire SOL/USD, la sémantique, un prix canonique positif et la cohérence des timestamps disponibles.
|
||||||
|
|
||||||
|
Le smoke keyed reste volontairement distinct. Il lit quatre clés sur `stdin`, dans l'ordre Birdeye, CoinGecko Demo, CoinMarketCap Basic et Jupiter Free :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '%s\n%s\n%s\n%s\n' \
|
||||||
|
"$KSP_SECRET_BIRDEYE_API_KEY" \
|
||||||
|
"$KSP_SECRET_COINGECKO_DEMO_API_KEY" \
|
||||||
|
"$KSP_SECRET_COINMARKETCAP_API_KEY" \
|
||||||
|
"$KSP_SECRET_JUPITER_API_KEY" \
|
||||||
|
| cargo test -p ksp-offchain-transport-lib \
|
||||||
|
--test market_price_live_smoke \
|
||||||
|
keyed_market_price_providers_live_smoke \
|
||||||
|
-- --ignored --exact --nocapture --test-threads=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce second smoke peut être omis si l'opérateur ne possède pas les quatre credentials ; l'absence de credentials doit alors rester explicitement `SKIP opérateur`.
|
||||||
|
|
||||||
|
## 11. Statut documentaire
|
||||||
|
|
||||||
|
Ce document est la référence d'usage durable de la surface `market_price` livrée par `0.2.11`. Il ne promet ni prix identique entre providers, ni disponibilité permanente de leurs plans gratuits, ni compatibilité avec une surface provider qui changerait après la release.
|
||||||
7
crates/ksp-offchain-transport-lib/src/constants.rs
Normal file
7
crates/ksp-offchain-transport-lib/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/constants.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Off-chain Transport-owned tracing constants.
|
||||||
|
|
||||||
|
/// Owning tracing target for events emitted by the off-chain transport crate.
|
||||||
|
pub(crate) const TRACING_TARGET: &str = "ksp-offchain-transport-lib";
|
||||||
57
crates/ksp-offchain-transport-lib/src/error.rs
Normal file
57
crates/ksp-offchain-transport-lib/src/error.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
// version: 9
|
||||||
|
|
||||||
|
/// Stable off-chain transport error for HTTP 401/403 access denial.
|
||||||
|
pub const ERROR_CODE_HTTP_ACCESS_DENIED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_access_denied");
|
||||||
|
/// Stable off-chain transport error when local provider admission defers a request.
|
||||||
|
pub const ERROR_CODE_HTTP_ADMISSION_DEFERRED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_admission_deferred");
|
||||||
|
/// Stable off-chain transport error when the hardened reqwest client cannot be initialized.
|
||||||
|
pub const ERROR_CODE_HTTP_CLIENT_BUILD_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_client_build_failed");
|
||||||
|
/// Stable off-chain transport error for a connection failure without exposing the provider URL.
|
||||||
|
pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_connection_failed");
|
||||||
|
/// Stable off-chain transport error when a successful HTTP response is not syntactically valid JSON.
|
||||||
|
pub const ERROR_CODE_HTTP_INVALID_JSON: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_invalid_json");
|
||||||
|
/// Stable off-chain transport error for HTTP 429 rate limiting.
|
||||||
|
pub const ERROR_CODE_HTTP_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_rate_limited");
|
||||||
|
/// Stable off-chain transport error for an invalid local request-admission/rate-limit policy.
|
||||||
|
pub const ERROR_CODE_HTTP_RATE_LIMIT_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_rate_limit_invalid");
|
||||||
|
/// Stable off-chain transport error for a generic unsuccessful or transport-level HTTP request.
|
||||||
|
pub const ERROR_CODE_HTTP_REQUEST_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_request_failed");
|
||||||
|
/// Stable off-chain transport error for an invalid crate-owned HTTP request definition.
|
||||||
|
pub const ERROR_CODE_HTTP_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_request_invalid");
|
||||||
|
/// Stable off-chain transport error when a response exceeds the defensive body limit.
|
||||||
|
pub const ERROR_CODE_HTTP_RESPONSE_TOO_LARGE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_response_too_large");
|
||||||
|
/// Stable off-chain transport error for an invalid crate-wide HTTP runtime configuration.
|
||||||
|
pub const ERROR_CODE_HTTP_SETTINGS_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_settings_invalid");
|
||||||
|
/// Stable off-chain transport error for transient HTTP status failures such as 408 or 5xx.
|
||||||
|
pub const ERROR_CODE_HTTP_TEMPORARY_FAILURE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_temporary_failure");
|
||||||
|
/// Stable off-chain transport error for an end-to-end HTTP timeout.
|
||||||
|
pub const ERROR_CODE_HTTP_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_timeout");
|
||||||
|
/// Stable off-chain transport error for an invalid exact market-price decimal.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_decimal_invalid");
|
||||||
|
/// Stable off-chain transport error for an invalid normalized market-price observation.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_observation_invalid");
|
||||||
|
/// Stable off-chain transport error for an invalid market-price provider descriptor.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_descriptor_invalid");
|
||||||
|
/// Stable off-chain transport error when a disabled market-price provider is invoked directly.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_disabled");
|
||||||
|
/// Stable off-chain transport error for an invalid market-price provider identifier.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_id_invalid");
|
||||||
|
/// Stable off-chain transport error when a requested provider is absent from the configured service.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_not_found");
|
||||||
|
/// Stable off-chain transport error when a provider response violates its adapter contract.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_response_invalid");
|
||||||
|
/// Stable off-chain transport error for invalid common market-price provider settings.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_provider_settings_invalid");
|
||||||
|
/// Stable off-chain transport error for an invalid generic market-price refresh request.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_REFRESH_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_refresh_invalid");
|
||||||
|
/// Stable off-chain transport error for an invalid market-price provider registry.
|
||||||
|
pub const ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_registry_invalid");
|
||||||
209
crates/ksp-offchain-transport-lib/src/http_admission.rs
Normal file
209
crates/ksp-offchain-transport-lib/src/http_admission.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/http_admission.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! Provider-neutral local request admission and rate-limit cooldown primitives.
|
||||||
|
|
||||||
|
/// Maximum provider-directed cooldown accepted from `Retry-After`.
|
||||||
|
pub(crate) const HTTP_MAX_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(3_600);
|
||||||
|
|
||||||
|
const DEFAULT_RATE_LIMIT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
const MAX_RATE_LIMIT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3_600);
|
||||||
|
|
||||||
|
/// Crate-internal local request-admission policy.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum HttpAdmissionPolicy {
|
||||||
|
/// No stable local cadence is known; only provider-driven cooldown is enforced.
|
||||||
|
Dynamic,
|
||||||
|
/// Enforce a smooth token bucket for a documented request budget and window.
|
||||||
|
Fixed { requests: u32, window: std::time::Duration, burst: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::HttpAdmissionPolicy {
|
||||||
|
/// Creates a validated fixed-window admission policy.
|
||||||
|
pub(crate) fn fixed(requests: u32, window: std::time::Duration, burst: std::option::Option<u32>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let burst = match burst {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => 1,
|
||||||
|
};
|
||||||
|
if requests == 0 || window.is_zero() || window > MAX_RATE_LIMIT_WINDOW || burst == 0 {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "HTTP request-admission policy is invalid")
|
||||||
|
.with_context("field", "rate_limit"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self::Fixed { requests, window, burst });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of one non-blocking local admission attempt.
|
||||||
|
pub(crate) enum HttpAdmissionDecision {
|
||||||
|
/// The request may dispatch now and one local token has been consumed when applicable.
|
||||||
|
Ready,
|
||||||
|
/// The request must be deferred for at least this duration.
|
||||||
|
Deferred(std::time::Duration),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared non-blocking limiter used by provider adapters and later refresh orchestration.
|
||||||
|
pub(crate) struct HttpAdmissionController {
|
||||||
|
cooldown_until: std::sync::Mutex<std::option::Option<std::time::Instant>>,
|
||||||
|
fallback_cooldown: std::time::Duration,
|
||||||
|
token_bucket: std::sync::Mutex<std::option::Option<HttpTokenBucketState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::HttpAdmissionController {
|
||||||
|
/// Creates one limiter from a provider-owned local admission policy.
|
||||||
|
pub(crate) fn new(policy: crate::HttpAdmissionPolicy, fallback_cooldown: std::option::Option<std::time::Duration>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let fallback_cooldown = match fallback_cooldown {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => DEFAULT_RATE_LIMIT_COOLDOWN,
|
||||||
|
};
|
||||||
|
if fallback_cooldown.is_zero() || fallback_cooldown > crate::HTTP_MAX_RETRY_AFTER {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "HTTP fallback cooldown is outside the supported bounds")
|
||||||
|
.with_context("field", "fallback_cooldown"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let token_bucket = match policy {
|
||||||
|
crate::HttpAdmissionPolicy::Fixed { requests, window, burst } => {
|
||||||
|
std::option::Option::Some(HttpTokenBucketState::new(requests, window, burst, std::time::Instant::now()))
|
||||||
|
},
|
||||||
|
crate::HttpAdmissionPolicy::Dynamic => std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
cooldown_until: std::sync::Mutex::new(std::option::Option::None),
|
||||||
|
fallback_cooldown,
|
||||||
|
token_bucket: std::sync::Mutex::new(token_bucket),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to admit one request immediately without sleeping.
|
||||||
|
pub(crate) fn try_admit(&self) -> crate::HttpAdmissionDecision {
|
||||||
|
return self.try_admit_at(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a provider 429 and extends cooldown using a bounded `Retry-After` value when present.
|
||||||
|
pub(crate) fn record_rate_limited(&self, provider_retry_after: std::option::Option<std::time::Duration>) -> std::time::Duration {
|
||||||
|
let provider_delay = match provider_retry_after {
|
||||||
|
std::option::Option::Some(value) => std::cmp::min(value, crate::HTTP_MAX_RETRY_AFTER),
|
||||||
|
std::option::Option::None => std::time::Duration::ZERO,
|
||||||
|
};
|
||||||
|
let effective = std::cmp::max(self.fallback_cooldown, provider_delay);
|
||||||
|
self.record_cooldown_until(std::time::Instant::now(), effective);
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
cooldown_ms = duration_millis_u64(effective),
|
||||||
|
provider_retry_after_present = provider_retry_after.is_some(),
|
||||||
|
"recorded off-chain HTTP provider cooldown"
|
||||||
|
);
|
||||||
|
return effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remaining provider cooldown for deterministic limiter tests.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
|
||||||
|
return self.cooldown_remaining_at(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_admit_at(&self, now: std::time::Instant) -> crate::HttpAdmissionDecision {
|
||||||
|
if let std::option::Option::Some(remaining) = self.cooldown_remaining_at(now) {
|
||||||
|
return crate::HttpAdmissionDecision::Deferred(remaining);
|
||||||
|
}
|
||||||
|
let lock_result = self.token_bucket.lock();
|
||||||
|
let mut token_bucket = match lock_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
let state = match token_bucket.as_mut() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return crate::HttpAdmissionDecision::Ready,
|
||||||
|
};
|
||||||
|
return match state.try_consume_at(now) {
|
||||||
|
std::option::Option::Some(delay) => crate::HttpAdmissionDecision::Deferred(delay),
|
||||||
|
std::option::Option::None => crate::HttpAdmissionDecision::Ready,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cooldown_remaining_at(&self, now: std::time::Instant) -> std::option::Option<std::time::Duration> {
|
||||||
|
let lock_result = self.cooldown_until.lock();
|
||||||
|
let mut cooldown_until = match lock_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
let deadline = match *cooldown_until {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
if deadline <= now {
|
||||||
|
*cooldown_until = std::option::Option::None;
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return std::option::Option::Some(deadline.duration_since(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_cooldown_until(&self, now: std::time::Instant, delay: std::time::Duration) {
|
||||||
|
let candidate = match now.checked_add(delay) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => now,
|
||||||
|
};
|
||||||
|
let lock_result = self.cooldown_until.lock();
|
||||||
|
let mut cooldown_until = match lock_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
let replace = match *cooldown_until {
|
||||||
|
std::option::Option::Some(current) => candidate > current,
|
||||||
|
std::option::Option::None => true,
|
||||||
|
};
|
||||||
|
if replace {
|
||||||
|
*cooldown_until = std::option::Option::Some(candidate);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct HttpTokenBucketState {
|
||||||
|
available_tokens: f64,
|
||||||
|
burst: u32,
|
||||||
|
last_refill: std::time::Instant,
|
||||||
|
refill_per_second: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpTokenBucketState {
|
||||||
|
fn new(requests: u32, window: std::time::Duration, burst: u32, now: std::time::Instant) -> Self {
|
||||||
|
let refill_per_second = f64::from(requests) / window.as_secs_f64();
|
||||||
|
return Self { available_tokens: f64::from(burst), burst, last_refill: now, refill_per_second };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_consume_at(&mut self, now: std::time::Instant) -> std::option::Option<std::time::Duration> {
|
||||||
|
self.refill_at(now);
|
||||||
|
if self.available_tokens >= 1.0 {
|
||||||
|
self.available_tokens -= 1.0;
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
let missing = 1.0 - self.available_tokens;
|
||||||
|
let wait_seconds = missing / self.refill_per_second;
|
||||||
|
return std::option::Option::Some(std::time::Duration::from_secs_f64(wait_seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refill_at(&mut self, now: std::time::Instant) {
|
||||||
|
if now <= self.last_refill {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
|
||||||
|
self.available_tokens = (self.available_tokens + elapsed * self.refill_per_second).min(f64::from(self.burst));
|
||||||
|
self.last_refill = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
|
||||||
|
return match u64::try_from(duration.as_millis()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => u64::MAX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/http_admission.rs"]
|
||||||
|
mod tests;
|
||||||
364
crates/ksp-offchain-transport-lib/src/http_client.rs
Normal file
364
crates/ksp-offchain-transport-lib/src/http_client.rs
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/http_client.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Crate-wide hardened REST client used internally by off-chain capability adapters.
|
||||||
|
|
||||||
|
/// Bounded successful JSON document returned by the crate-internal REST client.
|
||||||
|
pub(crate) struct HttpJsonDocument {
|
||||||
|
bytes: std::vec::Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::HttpJsonDocument {
|
||||||
|
/// Returns the validated raw JSON bytes for provider-specific typed deserialization.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn as_bytes(&self) -> &[u8] {
|
||||||
|
return self.bytes.as_slice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for crate::HttpJsonDocument {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.debug_struct("HttpJsonDocument").field("byte_len", &self.bytes.len()).finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crate-internal fixed-origin GET request.
|
||||||
|
///
|
||||||
|
/// URLs and headers are deliberately absent from [`std::fmt::Debug`] because future provider adapters can attach credentials to headers.
|
||||||
|
pub(crate) struct HttpGetRequest {
|
||||||
|
headers: reqwest::header::HeaderMap,
|
||||||
|
url: reqwest::Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::HttpGetRequest {
|
||||||
|
/// Creates one HTTPS GET request from a crate-owned official provider URL.
|
||||||
|
pub(crate) fn new_https(url: &'static str) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::parse(url, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends one validated non-secret path segment to a crate-owned official provider base URL.
|
||||||
|
pub(crate) fn append_path_segment(&mut self, value: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
let segments_result = self.url.path_segments_mut();
|
||||||
|
let mut segments = match segments_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(()) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot accept a path segment")
|
||||||
|
.with_context("field", "provider_url_path"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
segments.push(value);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends one non-secret query pair using URL encoding.
|
||||||
|
pub(crate) fn append_query_pair(&mut self, name: &'static str, value: &str) {
|
||||||
|
self.url.query_pairs_mut().append_pair(name, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds one sensitive header without exposing its value through this type's debug representation.
|
||||||
|
pub(crate) fn insert_sensitive_header(&mut self, name: &'static str, value: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
let name_result = reqwest::header::HeaderName::from_bytes(name.as_bytes());
|
||||||
|
let name = match name_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header name is invalid")
|
||||||
|
.with_context("field", "header_name")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let value_result = reqwest::header::HeaderValue::from_bytes(value.as_bytes());
|
||||||
|
let mut value = match value_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header value is invalid")
|
||||||
|
.with_context("field", "header_value")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
value.set_sensitive(true);
|
||||||
|
self.headers.insert(name, value);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether a named header is present without exposing its value in tests.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn has_header_for_test(&self, name: &'static str) -> bool {
|
||||||
|
return self.headers.contains_key(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a plain-HTTP request for loopback-only deterministic unit tests.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn new_test_http(url: &str) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::parse(url, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the constructed URL only to deterministic in-crate tests; production diagnostics remain redacted.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn url_for_test(&self) -> &reqwest::Url {
|
||||||
|
return &self.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(url: &str, allow_http_for_tests: bool) -> ksp_core_lib::Result<Self> {
|
||||||
|
let parsed_result = reqwest::Url::parse(url);
|
||||||
|
let parsed = match parsed_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL is invalid")
|
||||||
|
.with_context("field", "provider_url")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let scheme_allowed = parsed.scheme() == "https" || (allow_http_for_tests && parsed.scheme() == "http");
|
||||||
|
if !scheme_allowed || parsed.host_str().is_none() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL must use an allowed scheme and host")
|
||||||
|
.with_context("field", "provider_url"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot embed credentials")
|
||||||
|
.with_context("field", "provider_url"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { headers: reqwest::header::HeaderMap::new(), url: parsed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for crate::HttpGetRequest {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str("HttpGetRequest(<redacted>)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shareable hardened REST client owned by Off-chain Transport.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct HttpRestClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
settings: crate::HttpClientSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::HttpRestClient {
|
||||||
|
/// Builds one hardened client with redirects, system proxies and reqwest automatic retries disabled.
|
||||||
|
pub(crate) fn new(settings: crate::HttpClientSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let client_result = reqwest::Client::builder()
|
||||||
|
.connect_timeout(settings.connect_timeout())
|
||||||
|
.timeout(settings.request_timeout())
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.referer(false)
|
||||||
|
.retry(reqwest::retry::never())
|
||||||
|
.no_proxy()
|
||||||
|
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build();
|
||||||
|
let client = match client_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED, "Off-chain HTTP client could not be initialized")
|
||||||
|
.with_source(error.without_url()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
connect_timeout_ms = duration_millis_u64(settings.connect_timeout()),
|
||||||
|
request_timeout_ms = duration_millis_u64(settings.request_timeout()),
|
||||||
|
max_response_body_bytes = settings.max_response_body_bytes(),
|
||||||
|
"created hardened off-chain HTTP REST client"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(Self { client, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes one GET request and returns only a bounded syntactically valid JSON document.
|
||||||
|
pub(crate) async fn get_json(
|
||||||
|
&self,
|
||||||
|
provider: &'static str,
|
||||||
|
operation: &'static str,
|
||||||
|
request: crate::HttpGetRequest,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
|
||||||
|
let send_result = self.client.get(request.url).headers(request.headers).send().await;
|
||||||
|
let mut response = match send_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)),
|
||||||
|
};
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let retry_after = parse_retry_after(response.headers());
|
||||||
|
if !(200..300).contains(&status) {
|
||||||
|
return classify_http_status(provider, operation, status, retry_after);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(content_length) = response.content_length()
|
||||||
|
&& content_length > usize_to_u64(self.settings.max_response_body_bytes())
|
||||||
|
{
|
||||||
|
return response_too_large(provider, operation, self.settings.max_response_body_bytes());
|
||||||
|
}
|
||||||
|
let mut body = std::vec::Vec::new();
|
||||||
|
loop {
|
||||||
|
let chunk_result = response.chunk().await;
|
||||||
|
let chunk = match chunk_result {
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||||
|
std::result::Result::Ok(std::option::Option::None) => break,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)),
|
||||||
|
};
|
||||||
|
let next_len = body.len().saturating_add(chunk.len());
|
||||||
|
if next_len > self.settings.max_response_body_bytes() {
|
||||||
|
return response_too_large(provider, operation, self.settings.max_response_body_bytes());
|
||||||
|
}
|
||||||
|
body.extend_from_slice(chunk.as_ref());
|
||||||
|
}
|
||||||
|
let json_validation = serde_json::from_slice::<serde::de::IgnoredAny>(body.as_slice());
|
||||||
|
if let std::result::Result::Err(error) = json_validation {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_INVALID_JSON, "Off-chain provider returned invalid JSON")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("operation", operation)
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
provider = provider,
|
||||||
|
operation = operation,
|
||||||
|
http_status = status,
|
||||||
|
response_body_bytes = body.len(),
|
||||||
|
"completed off-chain HTTP REST request"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(crate::HttpJsonDocument { bytes: body });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_http_status(
|
||||||
|
provider: &'static str,
|
||||||
|
operation: &'static str,
|
||||||
|
status: u16,
|
||||||
|
retry_after: std::option::Option<std::time::Duration>,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
|
||||||
|
if status == 401 || status == 403 {
|
||||||
|
return std::result::Result::Err(http_status_error(
|
||||||
|
crate::ERROR_CODE_HTTP_ACCESS_DENIED,
|
||||||
|
"Off-chain provider denied HTTP access",
|
||||||
|
provider,
|
||||||
|
operation,
|
||||||
|
status,
|
||||||
|
std::option::Option::None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if status == 429 {
|
||||||
|
return std::result::Result::Err(http_status_error(
|
||||||
|
crate::ERROR_CODE_HTTP_RATE_LIMITED,
|
||||||
|
"Off-chain provider rate-limited the request",
|
||||||
|
provider,
|
||||||
|
operation,
|
||||||
|
status,
|
||||||
|
retry_after,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if status == 408 || (500..600).contains(&status) {
|
||||||
|
return std::result::Result::Err(http_status_error(
|
||||||
|
crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE,
|
||||||
|
"Off-chain provider returned a temporary HTTP failure",
|
||||||
|
provider,
|
||||||
|
operation,
|
||||||
|
status,
|
||||||
|
retry_after,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(http_status_error(
|
||||||
|
crate::ERROR_CODE_HTTP_REQUEST_FAILED,
|
||||||
|
"Off-chain provider returned an unsuccessful HTTP status",
|
||||||
|
provider,
|
||||||
|
operation,
|
||||||
|
status,
|
||||||
|
std::option::Option::None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_status_error(
|
||||||
|
code: ksp_core_lib::ErrorCode,
|
||||||
|
message: &'static str,
|
||||||
|
provider: &'static str,
|
||||||
|
operation: &'static str,
|
||||||
|
status: u16,
|
||||||
|
retry_after: std::option::Option<std::time::Duration>,
|
||||||
|
) -> ksp_core_lib::Error {
|
||||||
|
let mut error = ksp_core_lib::Error::new(code, message)
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("operation", operation)
|
||||||
|
.with_context("http_status", status.to_string());
|
||||||
|
if let std::option::Option::Some(value) = retry_after {
|
||||||
|
error = error.with_context("retry_after_seconds", value.as_secs().to_string());
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_reqwest_error(provider: &'static str, operation: &'static str, error: reqwest::Error) -> ksp_core_lib::Error {
|
||||||
|
let code = if error.is_timeout() {
|
||||||
|
crate::ERROR_CODE_HTTP_TIMEOUT
|
||||||
|
} else if error.is_connect() {
|
||||||
|
crate::ERROR_CODE_HTTP_CONNECTION_FAILED
|
||||||
|
} else {
|
||||||
|
crate::ERROR_CODE_HTTP_REQUEST_FAILED
|
||||||
|
};
|
||||||
|
let message = if code == crate::ERROR_CODE_HTTP_TIMEOUT {
|
||||||
|
"Off-chain HTTP request timed out"
|
||||||
|
} else if code == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
|
||||||
|
"Off-chain HTTP connection failed"
|
||||||
|
} else {
|
||||||
|
"Off-chain HTTP request failed"
|
||||||
|
};
|
||||||
|
return ksp_core_lib::Error::new(code, message)
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("operation", operation)
|
||||||
|
.with_source(error.without_url());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> std::option::Option<std::time::Duration> {
|
||||||
|
let value = match headers.get(reqwest::header::RETRY_AFTER) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let text = match value.to_str() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let seconds = match text.parse::<u64>() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(std::cmp::min(std::time::Duration::from_secs(seconds), crate::HTTP_MAX_RETRY_AFTER));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_too_large(provider: &'static str, operation: &'static str, limit: usize) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, "Off-chain provider response exceeded the configured body limit")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("operation", operation)
|
||||||
|
.with_context("max_response_body_bytes", limit.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
|
||||||
|
return match u64::try_from(duration.as_millis()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => u64::MAX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usize_to_u64(value: usize) -> u64 {
|
||||||
|
return match u64::try_from(value) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => u64::MAX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/http_client.rs"]
|
||||||
|
mod tests;
|
||||||
84
crates/ksp-offchain-transport-lib/src/http_settings.rs
Normal file
84
crates/ksp-offchain-transport-lib/src/http_settings.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/http_settings.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! Crate-wide bounded HTTP runtime settings shared by off-chain capability families.
|
||||||
|
|
||||||
|
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 1_048_576;
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
#[cfg(test)]
|
||||||
|
const MAX_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
#[cfg(test)]
|
||||||
|
const MAX_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
#[cfg(test)]
|
||||||
|
const MAX_RESPONSE_BODY_BYTES: usize = 4_194_304;
|
||||||
|
|
||||||
|
/// Crate-internal HTTP client settings with defensive hard bounds.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) struct HttpClientSettings {
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
max_response_body_bytes: usize,
|
||||||
|
request_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::HttpClientSettings {
|
||||||
|
/// Creates one validated HTTP settings value for deterministic settings tests until runtime configuration consumes this constructor.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn new(
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
request_timeout: std::time::Duration,
|
||||||
|
max_response_body_bytes: usize,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
if connect_timeout.is_zero() || connect_timeout > MAX_CONNECT_TIMEOUT {
|
||||||
|
return invalid_http_settings("HTTP connect timeout is outside the supported bounds", "connect_timeout");
|
||||||
|
}
|
||||||
|
if request_timeout.is_zero() || request_timeout > MAX_REQUEST_TIMEOUT {
|
||||||
|
return invalid_http_settings("HTTP request timeout is outside the supported bounds", "request_timeout");
|
||||||
|
}
|
||||||
|
if connect_timeout > request_timeout {
|
||||||
|
return invalid_http_settings("HTTP connect timeout cannot exceed the total request timeout", "connect_timeout");
|
||||||
|
}
|
||||||
|
if max_response_body_bytes == 0 || max_response_body_bytes > MAX_RESPONSE_BODY_BYTES {
|
||||||
|
return invalid_http_settings("HTTP response-body limit is outside the supported bounds", "max_response_body_bytes");
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { connect_timeout, max_response_body_bytes, request_timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bounded connect timeout.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn connect_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.connect_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum decoded response body accepted before JSON parsing.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn max_response_body_bytes(&self) -> usize {
|
||||||
|
return self.max_response_body_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the end-to-end request timeout.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn request_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.request_timeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::default::Default for crate::HttpClientSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
return Self {
|
||||||
|
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
|
||||||
|
max_response_body_bytes: DEFAULT_MAX_RESPONSE_BODY_BYTES,
|
||||||
|
request_timeout: DEFAULT_REQUEST_TIMEOUT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn invalid_http_settings(message: &str, field: &'static str) -> ksp_core_lib::Result<crate::HttpClientSettings> {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, "rejected invalid off-chain HTTP client settings");
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_SETTINGS_INVALID, message).with_context("field", field));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/http_settings.rs"]
|
||||||
|
mod tests;
|
||||||
218
crates/ksp-offchain-transport-lib/src/lib.rs
Normal file
218
crates/ksp-offchain-transport-lib/src/lib.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
// version: 12
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! KSP-owned off-chain transport foundation.
|
||||||
|
//!
|
||||||
|
//! `0.2.11` delivers the complete SOL/USD V1 surface over eight providers. The crate owns exact decimal normalization, provider adapters, hardened
|
||||||
|
//! HTTP, non-blocking rate-limit/cooldown handling, provider-neutral registry/availability and individual/multiple/all refresh operations. Provider-specific
|
||||||
|
//! setup remains confined to composition while runtime consumers can operate on opaque provider identifiers, registry projections and normalized outcomes.
|
||||||
|
//! Config integration is implemented externally by `ksp-config-lib`; this crate remains independent from Config and never reads KSP environment variables
|
||||||
|
//! directly.
|
||||||
|
|
||||||
|
mod constants;
|
||||||
|
mod error;
|
||||||
|
mod http_admission;
|
||||||
|
mod http_client;
|
||||||
|
mod http_settings;
|
||||||
|
mod market_price_adapter;
|
||||||
|
mod market_price_api_key;
|
||||||
|
mod market_price_birdeye;
|
||||||
|
mod market_price_coinbase_exchange;
|
||||||
|
mod market_price_coingecko;
|
||||||
|
mod market_price_coinmarketcap;
|
||||||
|
mod market_price_coinpaprika;
|
||||||
|
mod market_price_decimal;
|
||||||
|
mod market_price_dexscreener;
|
||||||
|
mod market_price_jupiter;
|
||||||
|
mod market_price_kraken;
|
||||||
|
mod market_price_observation;
|
||||||
|
mod market_price_provider;
|
||||||
|
mod market_price_registry;
|
||||||
|
mod market_price_service;
|
||||||
|
mod market_price_settings;
|
||||||
|
|
||||||
|
/// Stable error code for HTTP access denial.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_ACCESS_DENIED;
|
||||||
|
/// Stable error code for local HTTP request deferral.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_ADMISSION_DEFERRED;
|
||||||
|
/// Stable error code for hardened HTTP client initialization failure.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED;
|
||||||
|
/// Stable error code for an off-chain HTTP connection failure.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
|
||||||
|
/// Stable error code for a syntactically invalid JSON response.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_INVALID_JSON;
|
||||||
|
/// Stable error code for an invalid local HTTP rate-limit policy.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_RATE_LIMIT_INVALID;
|
||||||
|
/// Stable error code for HTTP 429 rate limiting.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_RATE_LIMITED;
|
||||||
|
/// Stable error code for a generic unsuccessful HTTP request.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED;
|
||||||
|
/// Stable error code for an invalid crate-owned HTTP request definition.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_REQUEST_INVALID;
|
||||||
|
/// Stable error code for an oversized HTTP response body.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE;
|
||||||
|
/// Stable error code for invalid crate-wide HTTP runtime settings.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_SETTINGS_INVALID;
|
||||||
|
/// Stable error code for temporary HTTP provider failures.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_TEMPORARY_FAILURE;
|
||||||
|
/// Stable error code for end-to-end HTTP timeout.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_TIMEOUT;
|
||||||
|
/// Stable error code for an invalid exact decimal price.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID;
|
||||||
|
/// Stable error code for an invalid provider-neutral observation.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID;
|
||||||
|
/// Stable error code for an invalid provider descriptor.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID;
|
||||||
|
/// Stable error code returned when a disabled provider is invoked directly.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED;
|
||||||
|
/// Stable error code for an invalid provider identifier.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID;
|
||||||
|
/// Stable error code when a generic refresh targets an unconfigured provider.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND;
|
||||||
|
/// Stable error code for a provider response that violates its adapter contract.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID;
|
||||||
|
/// Stable error code for invalid common provider settings.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID;
|
||||||
|
/// Stable error code for an invalid generic refresh request.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID;
|
||||||
|
/// Stable error code for an invalid provider registry.
|
||||||
|
pub use self::error::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID;
|
||||||
|
/// Birdeye Standard SOL/USD provider adapter.
|
||||||
|
pub use self::market_price_birdeye::MarketPriceBirdeyeProvider;
|
||||||
|
/// Birdeye Standard runtime settings.
|
||||||
|
pub use self::market_price_birdeye::MarketPriceBirdeyeSettings;
|
||||||
|
/// Coinbase Exchange SOL/USD provider adapter.
|
||||||
|
pub use self::market_price_coinbase_exchange::MarketPriceCoinbaseExchangeProvider;
|
||||||
|
/// Coinbase Exchange runtime settings.
|
||||||
|
pub use self::market_price_coinbase_exchange::MarketPriceCoinbaseExchangeSettings;
|
||||||
|
/// CoinGecko V1 access mode.
|
||||||
|
pub use self::market_price_coingecko::MarketPriceCoinGeckoAccessMode;
|
||||||
|
/// CoinGecko SOL/USD provider adapter.
|
||||||
|
pub use self::market_price_coingecko::MarketPriceCoinGeckoProvider;
|
||||||
|
/// CoinGecko runtime settings.
|
||||||
|
pub use self::market_price_coingecko::MarketPriceCoinGeckoSettings;
|
||||||
|
/// CoinMarketCap V1 access mode.
|
||||||
|
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapAccessMode;
|
||||||
|
/// CoinMarketCap SOL/USD provider adapter.
|
||||||
|
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapProvider;
|
||||||
|
/// CoinMarketCap runtime settings.
|
||||||
|
pub use self::market_price_coinmarketcap::MarketPriceCoinMarketCapSettings;
|
||||||
|
/// CoinPaprika SOL/USD provider adapter.
|
||||||
|
pub use self::market_price_coinpaprika::MarketPriceCoinPaprikaProvider;
|
||||||
|
/// CoinPaprika runtime settings.
|
||||||
|
pub use self::market_price_coinpaprika::MarketPriceCoinPaprikaSettings;
|
||||||
|
/// Maximum accepted byte length for one textual decimal input.
|
||||||
|
pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES;
|
||||||
|
/// Maximum decimal scale retained by the canonical SOL/USD price representation.
|
||||||
|
pub use self::market_price_decimal::MARKET_PRICE_DECIMAL_MAX_SCALE;
|
||||||
|
/// Exact positive decimal value used by the public market-price contract.
|
||||||
|
pub use self::market_price_decimal::MarketPriceDecimal;
|
||||||
|
/// DexScreener SOL/USD provider adapter bound to one configured Solana pair.
|
||||||
|
pub use self::market_price_dexscreener::MarketPriceDexScreenerProvider;
|
||||||
|
/// DexScreener runtime settings carrying the explicit Solana pair address.
|
||||||
|
pub use self::market_price_dexscreener::MarketPriceDexScreenerSettings;
|
||||||
|
/// Jupiter Developer Platform access mode.
|
||||||
|
pub use self::market_price_jupiter::MarketPriceJupiterAccessMode;
|
||||||
|
/// Jupiter Price V3 SOL/USD provider adapter.
|
||||||
|
pub use self::market_price_jupiter::MarketPriceJupiterProvider;
|
||||||
|
/// Jupiter Price V3 runtime settings.
|
||||||
|
pub use self::market_price_jupiter::MarketPriceJupiterSettings;
|
||||||
|
/// Kraken Spot SOL/USD provider adapter.
|
||||||
|
pub use self::market_price_kraken::MarketPriceKrakenProvider;
|
||||||
|
/// Kraken Spot runtime settings.
|
||||||
|
pub use self::market_price_kraken::MarketPriceKrakenSettings;
|
||||||
|
/// Maximum safe provenance length attached to one normalized observation.
|
||||||
|
pub use self::market_price_observation::MARKET_PRICE_PROVENANCE_MAX_BYTES;
|
||||||
|
/// Public V1 SOL/USD observation normalized by Off-chain Transport.
|
||||||
|
pub use self::market_price_observation::MarketPriceObservation;
|
||||||
|
/// Safe bounded provenance supplied by one provider adapter.
|
||||||
|
pub use self::market_price_observation::MarketPriceProvenance;
|
||||||
|
/// Millisecond UTC timestamp used for request, receipt, provider and cooldown projections.
|
||||||
|
pub use self::market_price_observation::MarketPriceTimestamp;
|
||||||
|
/// Maximum provider display-name length accepted by descriptors.
|
||||||
|
pub use self::market_price_provider::MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES;
|
||||||
|
/// Maximum opaque provider identifier length accepted by the public contract.
|
||||||
|
pub use self::market_price_provider::MARKET_PRICE_PROVIDER_ID_MAX_BYTES;
|
||||||
|
/// Only price pair exposed by the `0.2.11` V1 public contract.
|
||||||
|
pub use self::market_price_provider::MarketPricePair;
|
||||||
|
/// Generic authentication capability exposed by a configured provider descriptor.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderAuthMode;
|
||||||
|
/// Generic runtime availability state exposed without provider-specific error parsing.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderAvailability;
|
||||||
|
/// Provider capability and presentation descriptor consumed by provider-agnostic callers.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderDescriptor;
|
||||||
|
/// Opaque validated provider identifier owned by Off-chain Transport.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderId;
|
||||||
|
/// Long-term provider quota descriptor that is informational rather than an authoritative local counter.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderLongTermQuota;
|
||||||
|
/// Period used by a documented long-term provider quota.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderQuotaPeriod;
|
||||||
|
/// Unit used by a documented long-term provider quota.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderQuotaUnit;
|
||||||
|
/// Generic provider request-limit capability.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderRateLimit;
|
||||||
|
/// Shape of one generic provider request-limit capability.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderRateLimitKind;
|
||||||
|
/// Scope to which a provider documents one request limit.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderRateLimitScope;
|
||||||
|
/// Informational cost of one normalized SOL/USD request in a provider-defined quota unit.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderRequestCost;
|
||||||
|
/// Current provider-neutral runtime state projection.
|
||||||
|
pub use self::market_price_provider::MarketPriceProviderState;
|
||||||
|
/// Market-price semantics retained so consumers never assume all providers report equivalent market values.
|
||||||
|
pub use self::market_price_provider::MarketPriceSemantics;
|
||||||
|
/// Deterministically ordered registry of configured market-price providers.
|
||||||
|
pub use self::market_price_registry::MarketPriceProviderRegistry;
|
||||||
|
/// Immutable descriptor-plus-state entry exposed by the configured provider registry.
|
||||||
|
pub use self::market_price_registry::MarketPriceProviderRegistryEntry;
|
||||||
|
/// Provider-specific setup accepted once by the generic market-price service.
|
||||||
|
pub use self::market_price_service::MarketPriceProviderSetup;
|
||||||
|
/// Generic result of one market-price refresh.
|
||||||
|
pub use self::market_price_service::MarketPriceRefreshOutcome;
|
||||||
|
/// Provider-agnostic market-price refresh service.
|
||||||
|
pub use self::market_price_service::MarketPriceService;
|
||||||
|
/// Common provider settings shared by provider-specific runtime settings.
|
||||||
|
pub use self::market_price_settings::MarketPriceProviderCommonSettings;
|
||||||
|
|
||||||
|
/// Owning tracing target for events emitted by Off-chain Transport.
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET;
|
||||||
|
/// Maximum provider-directed cooldown accepted from a server `Retry-After` value.
|
||||||
|
pub(crate) use self::http_admission::HTTP_MAX_RETRY_AFTER;
|
||||||
|
/// Crate-internal non-blocking request-admission controller.
|
||||||
|
pub(crate) use self::http_admission::HttpAdmissionController;
|
||||||
|
/// Crate-internal result of one immediate request-admission attempt.
|
||||||
|
pub(crate) use self::http_admission::HttpAdmissionDecision;
|
||||||
|
/// Crate-internal provider-neutral local request-admission policy.
|
||||||
|
pub(crate) use self::http_admission::HttpAdmissionPolicy;
|
||||||
|
/// Crate-internal fixed-origin GET request with redacted diagnostics.
|
||||||
|
pub(crate) use self::http_client::HttpGetRequest;
|
||||||
|
/// Crate-internal bounded syntactically valid JSON response document.
|
||||||
|
pub(crate) use self::http_client::HttpJsonDocument;
|
||||||
|
/// Crate-internal hardened REST client shared by capability adapters.
|
||||||
|
pub(crate) use self::http_client::HttpRestClient;
|
||||||
|
/// Crate-internal bounded HTTP runtime settings.
|
||||||
|
pub(crate) use self::http_settings::HttpClientSettings;
|
||||||
|
/// Applies one market-price request admission decision.
|
||||||
|
pub(crate) use self::market_price_adapter::admit_request;
|
||||||
|
/// Captures the current market-price wall-clock timestamp.
|
||||||
|
pub(crate) use self::market_price_adapter::current_timestamp;
|
||||||
|
/// Executes one market-price HTTP GET with rate-limit feedback.
|
||||||
|
pub(crate) use self::market_price_adapter::get_json;
|
||||||
|
/// Builds one safe invalid-provider-response error.
|
||||||
|
pub(crate) use self::market_price_adapter::invalid_provider_response;
|
||||||
|
/// Builds one safe invalid-provider-response error with parser source.
|
||||||
|
pub(crate) use self::market_price_adapter::invalid_provider_response_with_source;
|
||||||
|
/// Parses one RFC 3339 provider market-price timestamp.
|
||||||
|
pub(crate) use self::market_price_adapter::market_price_timestamp_from_rfc3339;
|
||||||
|
/// Converts whole Unix seconds into a market-price timestamp.
|
||||||
|
pub(crate) use self::market_price_adapter::market_price_timestamp_from_unix_seconds;
|
||||||
|
/// Builds the disabled-provider error used by direct adapters.
|
||||||
|
pub(crate) use self::market_price_adapter::provider_disabled_error;
|
||||||
|
/// Builds common HTTP runtime primitives for one market-price provider.
|
||||||
|
pub(crate) use self::market_price_adapter::provider_http_runtime;
|
||||||
|
/// Crate-internal redacted holder for provider API keys.
|
||||||
|
pub(crate) use self::market_price_api_key::MarketPriceApiKey;
|
||||||
158
crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
Normal file
158
crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Shared market-price adapter mechanics layered over crate-wide HTTP primitives.
|
||||||
|
|
||||||
|
/// Applies one non-blocking market-price admission decision and maps deferral to a stable KSP error.
|
||||||
|
pub(crate) fn admit_request(provider: &'static str, admission: &crate::HttpAdmissionController) -> ksp_core_lib::Result<()> {
|
||||||
|
return match admission.try_admit() {
|
||||||
|
crate::HttpAdmissionDecision::Ready => std::result::Result::Ok(()),
|
||||||
|
crate::HttpAdmissionDecision::Deferred(delay) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_ADMISSION_DEFERRED, "Off-chain provider request is locally deferred")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("retry_after_millis", duration_millis_u64(delay).to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures the current UTC wall clock as a bounded market-price timestamp.
|
||||||
|
pub(crate) fn current_timestamp() -> ksp_core_lib::Result<crate::MarketPriceTimestamp> {
|
||||||
|
let duration = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID, "System clock cannot produce a market-price timestamp")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let millis = match u64::try_from(duration.as_millis()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID, "System clock exceeds market-price timestamp bounds")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::MarketPriceTimestamp::from_unix_millis(millis));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes one provider GET and feeds any HTTP 429 cooldown back into the provider admission controller.
|
||||||
|
pub(crate) async fn get_json(
|
||||||
|
http: &crate::HttpRestClient,
|
||||||
|
admission: &crate::HttpAdmissionController,
|
||||||
|
provider: &'static str,
|
||||||
|
request: crate::HttpGetRequest,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
|
||||||
|
let result = http.get_json(provider, "sol_usd", request).await;
|
||||||
|
if let std::result::Result::Err(error) = &result
|
||||||
|
&& error.code() == crate::ERROR_CODE_HTTP_RATE_LIMITED
|
||||||
|
{
|
||||||
|
let retry_after = retry_after_from_error(error);
|
||||||
|
admission.record_rate_limited(retry_after);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one safe provider-response contract error without copying remote payload data.
|
||||||
|
pub(crate) fn invalid_provider_response(provider: &'static str, field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, provider = provider, field = field, "rejected invalid market-price provider response");
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID, "Off-chain provider returned an invalid market-price response")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one safe provider-response contract error and attaches a parser source that contains no remote payload copy.
|
||||||
|
pub(crate) fn invalid_provider_response_with_source<E>(provider: &'static str, field: &'static str, source: E) -> ksp_core_lib::Error
|
||||||
|
where
|
||||||
|
E: std::error::Error + std::marker::Send + std::marker::Sync + 'static,
|
||||||
|
{
|
||||||
|
return invalid_provider_response(provider, field).with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts one provider RFC 3339 timestamp into the public millisecond timestamp contract.
|
||||||
|
pub(crate) fn market_price_timestamp_from_rfc3339(source: &str) -> std::option::Option<crate::MarketPriceTimestamp> {
|
||||||
|
let parsed = match chrono::DateTime::parse_from_rfc3339(source) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let millis = parsed.timestamp_millis();
|
||||||
|
if millis < 0 {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return match u64::try_from(millis) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(crate::MarketPriceTimestamp::from_unix_millis(value)),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts whole Unix seconds into the public millisecond timestamp contract with overflow checking.
|
||||||
|
pub(crate) fn market_price_timestamp_from_unix_seconds(seconds: u64) -> std::option::Option<crate::MarketPriceTimestamp> {
|
||||||
|
return seconds.checked_mul(1_000).map(crate::MarketPriceTimestamp::from_unix_millis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the stable error returned when a disabled provider is invoked directly.
|
||||||
|
pub(crate) fn provider_disabled_error(provider: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED, "Off-chain market-price provider is disabled")
|
||||||
|
.with_context("provider", provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds hardened HTTP and admission runtime primitives from one provider-neutral rate-limit descriptor.
|
||||||
|
pub(crate) fn provider_http_runtime(
|
||||||
|
rate_limit: crate::MarketPriceProviderRateLimit,
|
||||||
|
) -> ksp_core_lib::Result<(crate::HttpRestClient, crate::HttpAdmissionController)> {
|
||||||
|
let policy = match rate_limit.kind() {
|
||||||
|
crate::MarketPriceProviderRateLimitKind::Dynamic => crate::HttpAdmissionPolicy::Dynamic,
|
||||||
|
crate::MarketPriceProviderRateLimitKind::Fixed => {
|
||||||
|
let requests = match rate_limit.requests() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(invalid_rate_limit_bridge()),
|
||||||
|
};
|
||||||
|
let window_seconds = match rate_limit.window_seconds() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(invalid_rate_limit_bridge()),
|
||||||
|
};
|
||||||
|
match crate::HttpAdmissionPolicy::fixed(requests, std::time::Duration::from_secs(u64::from(window_seconds)), rate_limit.burst()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let admission = match crate::HttpAdmissionController::new(policy, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let http = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok((http, admission));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
|
||||||
|
return match u64::try_from(duration.as_millis()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => u64::MAX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_rate_limit_bridge() -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "Market-price rate-limit descriptor cannot map to HTTP admission policy");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_after_from_error(error: &ksp_core_lib::Error) -> std::option::Option<std::time::Duration> {
|
||||||
|
for context in error.context() {
|
||||||
|
if context.key() == "retry_after_seconds" {
|
||||||
|
let seconds = match context.value().parse::<u64>() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(std::time::Duration::from_secs(seconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_adapter.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_api_key.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Secret API-key holder shared by keyed market-price adapters.
|
||||||
|
|
||||||
|
const MARKET_PRICE_API_KEY_MAX_BYTES: usize = 512;
|
||||||
|
|
||||||
|
/// Redacted bounded API-key holder used by keyed market-price adapters.
|
||||||
|
pub(crate) struct MarketPriceApiKey(std::boxed::Box<str>);
|
||||||
|
|
||||||
|
impl crate::MarketPriceApiKey {
|
||||||
|
/// Creates one validated API-key holder without logging or exposing the credential.
|
||||||
|
pub(crate) fn new(provider: &'static str, value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let value = value.into();
|
||||||
|
if value.is_empty() || value.len() > MARKET_PRICE_API_KEY_MAX_BYTES || value.trim() != value || value.chars().any(char::is_control) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "Market-price provider API key is invalid")
|
||||||
|
.with_context("provider", provider)
|
||||||
|
.with_context("field", "api_key"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self(value.into_boxed_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the credential only to the provider request builder that owns the corresponding secret header.
|
||||||
|
pub(crate) fn as_str(&self) -> &str {
|
||||||
|
return self.0.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for crate::MarketPriceApiKey {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str("MarketPriceApiKey(<redacted>)");
|
||||||
|
}
|
||||||
|
}
|
||||||
238
crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
Normal file
238
crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Birdeye SOL/USD market-price adapter using the official Price Single REST endpoint.
|
||||||
|
|
||||||
|
const BIRDEYE_API_KEY_HEADER: &str = "x-api-key";
|
||||||
|
const BIRDEYE_CHAIN_HEADER: &str = "x-chain";
|
||||||
|
const BIRDEYE_PRICE_URL: &str = "https://public-api.birdeye.so/defi/price";
|
||||||
|
const BIRDEYE_PROVIDER_ID: &str = "birdeye";
|
||||||
|
const BIRDEYE_SOL_MINT: &str = "So11111111111111111111111111111111111111112";
|
||||||
|
|
||||||
|
/// Runtime settings for the Birdeye Standard market-price adapter.
|
||||||
|
pub struct MarketPriceBirdeyeSettings {
|
||||||
|
api_key: std::option::Option<crate::MarketPriceApiKey>,
|
||||||
|
common: crate::MarketPriceProviderCommonSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceBirdeyeSettings {
|
||||||
|
/// Creates Birdeye settings. An API key is mandatory while the provider is enabled.
|
||||||
|
pub fn new(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(BIRDEYE_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let api_key = match api_key {
|
||||||
|
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(BIRDEYE_PROVIDER_ID, value) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
},
|
||||||
|
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
|
||||||
|
return std::result::Result::Ok(Self { api_key, common });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns common provider identity and enablement settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
||||||
|
return &self.common;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> {
|
||||||
|
return self.api_key.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for crate::MarketPriceBirdeyeSettings {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("MarketPriceBirdeyeSettings")
|
||||||
|
.field("api_key_present", &self.api_key.is_some())
|
||||||
|
.field("common", &self.common)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Birdeye SOL/USD provider adapter backed by the Standard Price Single endpoint.
|
||||||
|
pub struct MarketPriceBirdeyeProvider {
|
||||||
|
admission: crate::HttpAdmissionController,
|
||||||
|
descriptor: crate::MarketPriceProviderDescriptor,
|
||||||
|
http: crate::HttpRestClient,
|
||||||
|
settings: crate::MarketPriceBirdeyeSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceBirdeyeProvider {
|
||||||
|
/// Builds one Birdeye provider from validated runtime settings.
|
||||||
|
pub fn new(settings: crate::MarketPriceBirdeyeSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-neutral Birdeye capability descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return &self.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated Birdeye settings without exposing credential material.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &crate::MarketPriceBirdeyeSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one normalized SOL/USD observation from Birdeye Price Single.
|
||||||
|
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
if !self.settings.common().enabled() {
|
||||||
|
return std::result::Result::Err(crate::provider_disabled_error(BIRDEYE_PROVIDER_ID));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = crate::admit_request(BIRDEYE_PROVIDER_ID, &self.admission) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let request_started_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match build_request(&self.settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = match crate::get_json(&self.http, &self.admission, BIRDEYE_PROVIDER_ID, request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let received_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request(settings: &crate::MarketPriceBirdeyeSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
||||||
|
let mut request = match crate::HttpGetRequest::new_https(BIRDEYE_PRICE_URL) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
request.append_query_pair("address", BIRDEYE_SOL_MINT);
|
||||||
|
if let std::result::Result::Err(error) = request.insert_sensitive_header(BIRDEYE_CHAIN_HEADER, "solana") {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let api_key = match settings.api_key() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(provider_settings_error("api_key")),
|
||||||
|
};
|
||||||
|
if let std::result::Result::Err(error) = request.insert_sensitive_header(BIRDEYE_API_KEY_HEADER, api_key.as_str()) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||||
|
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let quota = match crate::MarketPriceProviderLongTermQuota::new(
|
||||||
|
30_000,
|
||||||
|
crate::MarketPriceProviderQuotaPeriod::Month,
|
||||||
|
crate::MarketPriceProviderQuotaUnit::ComputeUnits,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request_cost = match crate::MarketPriceProviderRequestCost::new(3, crate::MarketPriceProviderQuotaUnit::ComputeUnits) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let descriptor = match crate::MarketPriceProviderDescriptor::new(
|
||||||
|
provider_id,
|
||||||
|
"Birdeye",
|
||||||
|
crate::MarketPriceSemantics::SolanaSpot,
|
||||||
|
crate::MarketPriceProviderAuthMode::RequiredApiKey,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::Some(quota),
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return descriptor.with_sol_usd_request_cost(request_cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_response(
|
||||||
|
bytes: &[u8],
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
let wire = match serde_json::from_slice::<BirdeyeWireResponse>(bytes) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(BIRDEYE_PROVIDER_ID, "response", error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if !wire.success {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "success"));
|
||||||
|
}
|
||||||
|
let data = match wire.data {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "data")),
|
||||||
|
};
|
||||||
|
let price_raw = match data.value.as_deref() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "data.value")),
|
||||||
|
};
|
||||||
|
let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider_timestamp = match data.update_unix_time.and_then(crate::market_price_timestamp_from_unix_seconds) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(BIRDEYE_PROVIDER_ID, "data.updateUnixTime")),
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new("birdeye:solana:wsol:value") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::SolanaSpot,
|
||||||
|
request_started_at,
|
||||||
|
received_at,
|
||||||
|
std::option::Option::Some(provider_timestamp),
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "Birdeye market-price settings are invalid")
|
||||||
|
.with_context("provider", BIRDEYE_PROVIDER_ID)
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct BirdeyeWireData {
|
||||||
|
#[serde(rename = "updateUnixTime")]
|
||||||
|
update_unix_time: std::option::Option<u64>,
|
||||||
|
value: std::option::Option<std::boxed::Box<serde_json::value::RawValue>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct BirdeyeWireResponse {
|
||||||
|
data: std::option::Option<BirdeyeWireData>,
|
||||||
|
success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_birdeye.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Coinbase Exchange SOL/USD market-price adapter using the public product ticker directly through `reqwest`.
|
||||||
|
|
||||||
|
const COINBASE_EXCHANGE_PROVIDER_ID: &str = "coinbase_exchange";
|
||||||
|
const COINBASE_EXCHANGE_SOL_USD_TICKER_URL: &str = "https://api.exchange.coinbase.com/products/SOL-USD/ticker";
|
||||||
|
|
||||||
|
/// Runtime settings for the keyless Coinbase Exchange market-price adapter.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MarketPriceCoinbaseExchangeSettings {
|
||||||
|
common: crate::MarketPriceProviderCommonSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceCoinbaseExchangeSettings {
|
||||||
|
/// Creates Coinbase Exchange settings for the public keyless REST surface.
|
||||||
|
pub fn new(enabled: bool) -> ksp_core_lib::Result<Self> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(COINBASE_EXCHANGE_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { common: crate::MarketPriceProviderCommonSettings::new(provider_id, enabled) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns common provider identity and enablement settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
||||||
|
return &self.common;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coinbase Exchange SOL/USD provider adapter.
|
||||||
|
pub struct MarketPriceCoinbaseExchangeProvider {
|
||||||
|
admission: crate::HttpAdmissionController,
|
||||||
|
descriptor: crate::MarketPriceProviderDescriptor,
|
||||||
|
http: crate::HttpRestClient,
|
||||||
|
settings: crate::MarketPriceCoinbaseExchangeSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceCoinbaseExchangeProvider {
|
||||||
|
/// Builds one Coinbase Exchange provider from validated runtime settings.
|
||||||
|
pub fn new(settings: crate::MarketPriceCoinbaseExchangeSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-neutral Coinbase Exchange capability descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return &self.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated Coinbase Exchange runtime settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &crate::MarketPriceCoinbaseExchangeSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one normalized SOL/USD last-trade observation from Coinbase Exchange.
|
||||||
|
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
if !self.settings.common().enabled() {
|
||||||
|
return std::result::Result::Err(crate::provider_disabled_error(COINBASE_EXCHANGE_PROVIDER_ID));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = crate::admit_request(COINBASE_EXCHANGE_PROVIDER_ID, &self.admission) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let request_started_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match build_request() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = match crate::get_json(&self.http, &self.admission, COINBASE_EXCHANGE_PROVIDER_ID, request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let received_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request() -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
||||||
|
return crate::HttpGetRequest::new_https(COINBASE_EXCHANGE_SOL_USD_TICKER_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||||
|
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(10, 1, std::option::Option::Some(15), crate::MarketPriceProviderRateLimitScope::Ip) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceProviderDescriptor::new(
|
||||||
|
provider_id,
|
||||||
|
"Coinbase Exchange",
|
||||||
|
crate::MarketPriceSemantics::ExchangeLastTrade,
|
||||||
|
crate::MarketPriceProviderAuthMode::None,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::None,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_response(
|
||||||
|
bytes: &[u8],
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
let wire = match serde_json::from_slice::<CoinbaseExchangeWireTicker>(bytes) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINBASE_EXCHANGE_PROVIDER_ID, "response", error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let price = match crate::MarketPriceDecimal::parse_json_raw(wire.price.as_ref()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider_timestamp = match crate::market_price_timestamp_from_rfc3339(wire.time.as_str()) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINBASE_EXCHANGE_PROVIDER_ID, "time"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new("coinbase_exchange:SOL-USD:last_trade") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::ExchangeLastTrade,
|
||||||
|
request_started_at,
|
||||||
|
received_at,
|
||||||
|
std::option::Option::Some(provider_timestamp),
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinbaseExchangeWireTicker {
|
||||||
|
price: std::boxed::Box<serde_json::value::RawValue>,
|
||||||
|
time: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_coinbase_exchange.rs"]
|
||||||
|
mod tests;
|
||||||
264
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
Normal file
264
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! CoinGecko SOL/USD market-price adapter using the official REST API directly through `reqwest`.
|
||||||
|
|
||||||
|
const COINGECKO_DEMO_API_KEY_HEADER: &str = "x-cg-demo-api-key";
|
||||||
|
const COINGECKO_PROVIDER_ID: &str = "coingecko";
|
||||||
|
const COINGECKO_SIMPLE_PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price";
|
||||||
|
|
||||||
|
/// CoinGecko V1 access mode supported by Off-chain Transport.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceCoinGeckoAccessMode {
|
||||||
|
/// Free Demo plan with a provider-issued API key and published allowance.
|
||||||
|
Demo,
|
||||||
|
/// Shared keyless public API with dynamic IP-based throttling.
|
||||||
|
Keyless,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime settings for the CoinGecko market-price adapter.
|
||||||
|
pub struct MarketPriceCoinGeckoSettings {
|
||||||
|
access_mode: crate::MarketPriceCoinGeckoAccessMode,
|
||||||
|
api_key: std::option::Option<crate::MarketPriceApiKey>,
|
||||||
|
common: crate::MarketPriceProviderCommonSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceCoinGeckoSettings {
|
||||||
|
/// Creates keyless CoinGecko settings without accepting a credential.
|
||||||
|
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::new(enabled, crate::MarketPriceCoinGeckoAccessMode::Keyless, std::option::Option::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates Demo CoinGecko settings. An API key is mandatory while the provider is enabled.
|
||||||
|
pub fn demo(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::new(enabled, crate::MarketPriceCoinGeckoAccessMode::Demo, api_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured CoinGecko access mode.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn access_mode(&self) -> crate::MarketPriceCoinGeckoAccessMode {
|
||||||
|
return self.access_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns common provider identity and enablement settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
||||||
|
return &self.common;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(enabled: bool, access_mode: crate::MarketPriceCoinGeckoAccessMode, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(COINGECKO_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let api_key = match access_mode {
|
||||||
|
crate::MarketPriceCoinGeckoAccessMode::Keyless => {
|
||||||
|
if api_key.is_some() {
|
||||||
|
return std::result::Result::Err(provider_settings_error("api_key"));
|
||||||
|
}
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
crate::MarketPriceCoinGeckoAccessMode::Demo => match api_key {
|
||||||
|
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(COINGECKO_PROVIDER_ID, value) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
},
|
||||||
|
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
|
||||||
|
return std::result::Result::Ok(Self { access_mode, api_key, common });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> {
|
||||||
|
return self.api_key.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for crate::MarketPriceCoinGeckoSettings {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("MarketPriceCoinGeckoSettings")
|
||||||
|
.field("access_mode", &self.access_mode)
|
||||||
|
.field("api_key_present", &self.api_key.is_some())
|
||||||
|
.field("common", &self.common)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoinGecko SOL/USD provider adapter.
|
||||||
|
pub struct MarketPriceCoinGeckoProvider {
|
||||||
|
admission: crate::HttpAdmissionController,
|
||||||
|
descriptor: crate::MarketPriceProviderDescriptor,
|
||||||
|
http: crate::HttpRestClient,
|
||||||
|
settings: crate::MarketPriceCoinGeckoSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceCoinGeckoProvider {
|
||||||
|
/// Builds one CoinGecko provider from validated runtime settings.
|
||||||
|
pub fn new(settings: crate::MarketPriceCoinGeckoSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let descriptor = match descriptor_for(settings.access_mode(), settings.common().provider_id().clone()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-neutral CoinGecko capability descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return &self.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated CoinGecko runtime settings without exposing credential material.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &crate::MarketPriceCoinGeckoSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one normalized SOL/USD observation from CoinGecko.
|
||||||
|
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
if !self.settings.common().enabled() {
|
||||||
|
return std::result::Result::Err(crate::provider_disabled_error(COINGECKO_PROVIDER_ID));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = crate::admit_request(COINGECKO_PROVIDER_ID, &self.admission) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let request_started_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match build_request(&self.settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = match crate::get_json(&self.http, &self.admission, COINGECKO_PROVIDER_ID, request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let received_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request(settings: &crate::MarketPriceCoinGeckoSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
||||||
|
let mut request = match crate::HttpGetRequest::new_https(COINGECKO_SIMPLE_PRICE_URL) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
request.append_query_pair("ids", "solana");
|
||||||
|
request.append_query_pair("vs_currencies", "usd");
|
||||||
|
request.append_query_pair("include_last_updated_at", "true");
|
||||||
|
if let std::option::Option::Some(api_key) = settings.api_key()
|
||||||
|
&& let std::result::Result::Err(error) = request.insert_sensitive_header(COINGECKO_DEMO_API_KEY_HEADER, api_key.as_str())
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_for(
|
||||||
|
access_mode: crate::MarketPriceCoinGeckoAccessMode,
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||||
|
let (auth_mode, rate_limit, long_term_quota) = match access_mode {
|
||||||
|
crate::MarketPriceCoinGeckoAccessMode::Keyless => (
|
||||||
|
crate::MarketPriceProviderAuthMode::None,
|
||||||
|
crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip),
|
||||||
|
std::option::Option::None,
|
||||||
|
),
|
||||||
|
crate::MarketPriceCoinGeckoAccessMode::Demo => {
|
||||||
|
let rate_limit =
|
||||||
|
match crate::MarketPriceProviderRateLimit::fixed(100, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let quota = match crate::MarketPriceProviderLongTermQuota::new(
|
||||||
|
10_000,
|
||||||
|
crate::MarketPriceProviderQuotaPeriod::Month,
|
||||||
|
crate::MarketPriceProviderQuotaUnit::Credits,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
(crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit, std::option::Option::Some(quota))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::MarketPriceProviderDescriptor::new(
|
||||||
|
provider_id,
|
||||||
|
"CoinGecko",
|
||||||
|
crate::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
auth_mode,
|
||||||
|
rate_limit,
|
||||||
|
long_term_quota,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_response(
|
||||||
|
bytes: &[u8],
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
let wire = match serde_json::from_slice::<CoinGeckoWireResponse>(bytes) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINGECKO_PROVIDER_ID, "response", error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let price = match crate::MarketPriceDecimal::parse_json_raw(wire.solana.usd.as_ref()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider_timestamp = match crate::market_price_timestamp_from_unix_seconds(wire.solana.last_updated_at) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINGECKO_PROVIDER_ID, "last_updated_at"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new("coingecko:solana:usd") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
request_started_at,
|
||||||
|
received_at,
|
||||||
|
std::option::Option::Some(provider_timestamp),
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "CoinGecko market-price settings are invalid")
|
||||||
|
.with_context("provider", COINGECKO_PROVIDER_ID)
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinGeckoWireResponse {
|
||||||
|
solana: CoinGeckoWireSolana,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinGeckoWireSolana {
|
||||||
|
last_updated_at: u64,
|
||||||
|
usd: std::boxed::Box<serde_json::value::RawValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_coingecko.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! CoinMarketCap SOL/USD market-price adapter using the current Simple Price V2 REST surface.
|
||||||
|
|
||||||
|
const COINMARKETCAP_API_KEY_HEADER: &str = "x-cmc_pro_api_key";
|
||||||
|
const COINMARKETCAP_BASIC_URL: &str = "https://pro-api.coinmarketcap.com/v2/simple/price";
|
||||||
|
const COINMARKETCAP_KEYLESS_URL: &str = "https://pro-api.coinmarketcap.com/public-api/v2/simple/price";
|
||||||
|
const COINMARKETCAP_PROVIDER_ID: &str = "coinmarketcap";
|
||||||
|
const COINMARKETCAP_SOL_ID: u64 = 5_426;
|
||||||
|
|
||||||
|
/// CoinMarketCap V1 access mode supported by Off-chain Transport.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceCoinMarketCapAccessMode {
|
||||||
|
/// Free authenticated Basic plan with a provider-issued API key.
|
||||||
|
Basic,
|
||||||
|
/// Keyless public API intended for evaluation and low-volume use.
|
||||||
|
Keyless,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime settings for the CoinMarketCap market-price adapter.
|
||||||
|
pub struct MarketPriceCoinMarketCapSettings {
|
||||||
|
access_mode: crate::MarketPriceCoinMarketCapAccessMode,
|
||||||
|
api_key: std::option::Option<crate::MarketPriceApiKey>,
|
||||||
|
common: crate::MarketPriceProviderCommonSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceCoinMarketCapSettings {
|
||||||
|
/// Creates keyless CoinMarketCap settings without accepting a credential.
|
||||||
|
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::new(enabled, crate::MarketPriceCoinMarketCapAccessMode::Keyless, std::option::Option::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates Basic CoinMarketCap settings. An API key is mandatory while the provider is enabled.
|
||||||
|
pub fn basic(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::new(enabled, crate::MarketPriceCoinMarketCapAccessMode::Basic, api_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured CoinMarketCap access mode.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn access_mode(&self) -> crate::MarketPriceCoinMarketCapAccessMode {
|
||||||
|
return self.access_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns common provider identity and enablement settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
||||||
|
return &self.common;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
enabled: bool,
|
||||||
|
access_mode: crate::MarketPriceCoinMarketCapAccessMode,
|
||||||
|
api_key: std::option::Option<std::string::String>,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(COINMARKETCAP_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let api_key = match access_mode {
|
||||||
|
crate::MarketPriceCoinMarketCapAccessMode::Keyless => {
|
||||||
|
if api_key.is_some() {
|
||||||
|
return std::result::Result::Err(provider_settings_error("api_key"));
|
||||||
|
}
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
crate::MarketPriceCoinMarketCapAccessMode::Basic => match api_key {
|
||||||
|
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(COINMARKETCAP_PROVIDER_ID, value) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
},
|
||||||
|
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
|
||||||
|
return std::result::Result::Ok(Self { access_mode, api_key, common });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> {
|
||||||
|
return self.api_key.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for crate::MarketPriceCoinMarketCapSettings {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("MarketPriceCoinMarketCapSettings")
|
||||||
|
.field("access_mode", &self.access_mode)
|
||||||
|
.field("api_key_present", &self.api_key.is_some())
|
||||||
|
.field("common", &self.common)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoinMarketCap SOL/USD provider adapter.
|
||||||
|
pub struct MarketPriceCoinMarketCapProvider {
|
||||||
|
admission: crate::HttpAdmissionController,
|
||||||
|
descriptor: crate::MarketPriceProviderDescriptor,
|
||||||
|
http: crate::HttpRestClient,
|
||||||
|
settings: crate::MarketPriceCoinMarketCapSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceCoinMarketCapProvider {
|
||||||
|
/// Builds one CoinMarketCap provider from validated runtime settings.
|
||||||
|
pub fn new(settings: crate::MarketPriceCoinMarketCapSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let descriptor = match descriptor_for(settings.access_mode(), settings.common().provider_id().clone()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-neutral CoinMarketCap capability descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return &self.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated CoinMarketCap runtime settings without exposing credential material.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &crate::MarketPriceCoinMarketCapSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one normalized SOL/USD observation from CoinMarketCap.
|
||||||
|
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
if !self.settings.common().enabled() {
|
||||||
|
return std::result::Result::Err(crate::provider_disabled_error(COINMARKETCAP_PROVIDER_ID));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = crate::admit_request(COINMARKETCAP_PROVIDER_ID, &self.admission) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let request_started_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match build_request(&self.settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = match crate::get_json(&self.http, &self.admission, COINMARKETCAP_PROVIDER_ID, request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let received_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request(settings: &crate::MarketPriceCoinMarketCapSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
||||||
|
let url = match settings.access_mode() {
|
||||||
|
crate::MarketPriceCoinMarketCapAccessMode::Basic => COINMARKETCAP_BASIC_URL,
|
||||||
|
crate::MarketPriceCoinMarketCapAccessMode::Keyless => COINMARKETCAP_KEYLESS_URL,
|
||||||
|
};
|
||||||
|
let mut request = match crate::HttpGetRequest::new_https(url) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
request.append_query_pair("id", "5426");
|
||||||
|
request.append_query_pair("convert", "USD");
|
||||||
|
request.append_query_pair("include_last_updated", "true");
|
||||||
|
if let std::option::Option::Some(api_key) = settings.api_key()
|
||||||
|
&& let std::result::Result::Err(error) = request.insert_sensitive_header(COINMARKETCAP_API_KEY_HEADER, api_key.as_str())
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_for(
|
||||||
|
access_mode: crate::MarketPriceCoinMarketCapAccessMode,
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||||
|
let (auth_mode, rate_limit, long_term_quota) = match access_mode {
|
||||||
|
crate::MarketPriceCoinMarketCapAccessMode::Keyless => (
|
||||||
|
crate::MarketPriceProviderAuthMode::None,
|
||||||
|
crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip),
|
||||||
|
std::option::Option::None,
|
||||||
|
),
|
||||||
|
crate::MarketPriceCoinMarketCapAccessMode::Basic => {
|
||||||
|
let rate_limit =
|
||||||
|
match crate::MarketPriceProviderRateLimit::fixed(50, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let quota = match crate::MarketPriceProviderLongTermQuota::new(
|
||||||
|
15_000,
|
||||||
|
crate::MarketPriceProviderQuotaPeriod::Month,
|
||||||
|
crate::MarketPriceProviderQuotaUnit::Credits,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
(crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit, std::option::Option::Some(quota))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::MarketPriceProviderDescriptor::new(
|
||||||
|
provider_id,
|
||||||
|
"CoinMarketCap",
|
||||||
|
crate::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
auth_mode,
|
||||||
|
rate_limit,
|
||||||
|
long_term_quota,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_response(
|
||||||
|
bytes: &[u8],
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
let wire = match serde_json::from_slice::<CoinMarketCapWireResponse>(bytes) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINMARKETCAP_PROVIDER_ID, "response", error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if !raw_status_is_zero(wire.status.error_code.as_ref()) {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "status.error_code"));
|
||||||
|
}
|
||||||
|
if wire.data.len() != 1 {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data"));
|
||||||
|
}
|
||||||
|
let item = &wire.data[0];
|
||||||
|
if item.id != COINMARKETCAP_SOL_ID || item.symbol != "SOL" {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.identity"));
|
||||||
|
}
|
||||||
|
let mut usd_quote = std::option::Option::None;
|
||||||
|
for quote in &item.quotes {
|
||||||
|
if quote.symbol == "USD" {
|
||||||
|
if usd_quote.is_some() {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes"));
|
||||||
|
}
|
||||||
|
usd_quote = std::option::Option::Some(quote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let quote = match usd_quote {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes.USD"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let price = match crate::MarketPriceDecimal::parse_json_raw(quote.price.as_ref()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider_timestamp = match crate::market_price_timestamp_from_rfc3339(quote.last_updated.as_str()) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINMARKETCAP_PROVIDER_ID, "data.quotes.last_updated"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new("coinmarketcap:5426:usd:v2") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
request_started_at,
|
||||||
|
received_at,
|
||||||
|
std::option::Option::Some(provider_timestamp),
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "CoinMarketCap market-price settings are invalid")
|
||||||
|
.with_context("provider", COINMARKETCAP_PROVIDER_ID)
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_status_is_zero(raw: &serde_json::value::RawValue) -> bool {
|
||||||
|
return raw.get() == "0" || raw.get() == "\"0\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinMarketCapWireQuote {
|
||||||
|
last_updated: std::string::String,
|
||||||
|
price: std::boxed::Box<serde_json::value::RawValue>,
|
||||||
|
symbol: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinMarketCapWireItem {
|
||||||
|
id: u64,
|
||||||
|
quotes: std::vec::Vec<CoinMarketCapWireQuote>,
|
||||||
|
symbol: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinMarketCapWireResponse {
|
||||||
|
data: std::vec::Vec<CoinMarketCapWireItem>,
|
||||||
|
status: CoinMarketCapWireStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinMarketCapWireStatus {
|
||||||
|
error_code: std::boxed::Box<serde_json::value::RawValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_coinmarketcap.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_coinpaprika.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! CoinPaprika SOL/USD market-price adapter using the free official REST API directly through `reqwest`.
|
||||||
|
|
||||||
|
const COINPAPRIKA_PROVIDER_ID: &str = "coinpaprika";
|
||||||
|
const COINPAPRIKA_SOL_ID: &str = "sol-solana";
|
||||||
|
const COINPAPRIKA_SOL_TICKER_URL: &str = "https://api.coinpaprika.com/v1/tickers/sol-solana";
|
||||||
|
|
||||||
|
/// Runtime settings for the keyless CoinPaprika market-price adapter.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MarketPriceCoinPaprikaSettings {
|
||||||
|
common: crate::MarketPriceProviderCommonSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceCoinPaprikaSettings {
|
||||||
|
/// Creates CoinPaprika settings for the free keyless REST surface.
|
||||||
|
pub fn new(enabled: bool) -> ksp_core_lib::Result<Self> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(COINPAPRIKA_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { common: crate::MarketPriceProviderCommonSettings::new(provider_id, enabled) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns common provider identity and enablement settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
||||||
|
return &self.common;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoinPaprika SOL/USD provider adapter.
|
||||||
|
pub struct MarketPriceCoinPaprikaProvider {
|
||||||
|
admission: crate::HttpAdmissionController,
|
||||||
|
descriptor: crate::MarketPriceProviderDescriptor,
|
||||||
|
http: crate::HttpRestClient,
|
||||||
|
settings: crate::MarketPriceCoinPaprikaSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceCoinPaprikaProvider {
|
||||||
|
/// Builds one CoinPaprika provider from validated runtime settings.
|
||||||
|
pub fn new(settings: crate::MarketPriceCoinPaprikaSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-neutral CoinPaprika capability descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return &self.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated CoinPaprika runtime settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &crate::MarketPriceCoinPaprikaSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one normalized SOL/USD observation from CoinPaprika.
|
||||||
|
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
if !self.settings.common().enabled() {
|
||||||
|
return std::result::Result::Err(crate::provider_disabled_error(COINPAPRIKA_PROVIDER_ID));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = crate::admit_request(COINPAPRIKA_PROVIDER_ID, &self.admission) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let request_started_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match build_request() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = match crate::get_json(&self.http, &self.admission, COINPAPRIKA_PROVIDER_ID, request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let received_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request() -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
||||||
|
let mut request = match crate::HttpGetRequest::new_https(COINPAPRIKA_SOL_TICKER_URL) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
request.append_query_pair("quotes", "USD");
|
||||||
|
return std::result::Result::Ok(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||||
|
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(10, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let quota =
|
||||||
|
match crate::MarketPriceProviderLongTermQuota::new(20_000, crate::MarketPriceProviderQuotaPeriod::Month, crate::MarketPriceProviderQuotaUnit::Requests)
|
||||||
|
{
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceProviderDescriptor::new(
|
||||||
|
provider_id,
|
||||||
|
"CoinPaprika",
|
||||||
|
crate::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
crate::MarketPriceProviderAuthMode::None,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::Some(quota),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_response(
|
||||||
|
bytes: &[u8],
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
let wire = match serde_json::from_slice::<CoinPaprikaWireResponse>(bytes) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(COINPAPRIKA_PROVIDER_ID, "response", error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if wire.id != COINPAPRIKA_SOL_ID || wire.symbol != "SOL" {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINPAPRIKA_PROVIDER_ID, "identity"));
|
||||||
|
}
|
||||||
|
let price = match crate::MarketPriceDecimal::parse_json_raw(wire.quotes.usd.price.as_ref()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider_timestamp = match crate::market_price_timestamp_from_rfc3339(wire.last_updated.as_str()) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(COINPAPRIKA_PROVIDER_ID, "last_updated"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new("coinpaprika:sol-solana:usd") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
request_started_at,
|
||||||
|
received_at,
|
||||||
|
std::option::Option::Some(provider_timestamp),
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinPaprikaWireQuote {
|
||||||
|
price: std::boxed::Box<serde_json::value::RawValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinPaprikaWireQuotes {
|
||||||
|
#[serde(rename = "USD")]
|
||||||
|
usd: CoinPaprikaWireQuote,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CoinPaprikaWireResponse {
|
||||||
|
id: std::string::String,
|
||||||
|
last_updated: std::string::String,
|
||||||
|
quotes: CoinPaprikaWireQuotes,
|
||||||
|
symbol: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_coinpaprika.rs"]
|
||||||
|
mod tests;
|
||||||
238
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
Normal file
238
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// Maximum accepted UTF-8 byte length for one textual decimal input.
|
||||||
|
pub const MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES: usize = 96;
|
||||||
|
/// Maximum scale retained by the canonical exact decimal representation.
|
||||||
|
pub const MARKET_PRICE_DECIMAL_MAX_SCALE: u8 = 18;
|
||||||
|
|
||||||
|
/// Exact positive decimal value used for one successful V1 SOL/USD observation.
|
||||||
|
///
|
||||||
|
/// The value is represented as a positive `u128` coefficient plus a bounded decimal scale. Trailing fractional zeroes are removed during construction, and
|
||||||
|
/// Serde serialization always emits the canonical decimal string instead of an IEEE-754 number.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct MarketPriceDecimal {
|
||||||
|
coefficient: u128,
|
||||||
|
scale: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPriceDecimal {
|
||||||
|
/// Parses a positive decimal or bounded scientific-notation value without converting through `f64`.
|
||||||
|
pub fn parse(source: &str) -> ksp_core_lib::Result<Self> {
|
||||||
|
if source.is_empty() || source.len() > crate::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES || source.trim() != source {
|
||||||
|
return std::result::Result::Err(invalid_decimal_error());
|
||||||
|
}
|
||||||
|
let (significand, exponent) = match split_exponent(source) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(()) => return std::result::Result::Err(invalid_decimal_error()),
|
||||||
|
};
|
||||||
|
let (digits, fractional_digits) = match significand_digits(significand) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(()) => return std::result::Result::Err(invalid_decimal_error()),
|
||||||
|
};
|
||||||
|
let mut coefficient = match digits.parse::<u128>() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
|
||||||
|
};
|
||||||
|
if coefficient == 0 {
|
||||||
|
return std::result::Result::Err(invalid_decimal_error());
|
||||||
|
}
|
||||||
|
let mut effective_scale = i32::from(fractional_digits) - exponent;
|
||||||
|
if effective_scale < 0 {
|
||||||
|
let multiplication_power = match u32::try_from(-effective_scale) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
|
||||||
|
};
|
||||||
|
coefficient = match checked_multiply_power_of_ten(coefficient, multiplication_power) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(invalid_decimal_error()),
|
||||||
|
};
|
||||||
|
effective_scale = 0;
|
||||||
|
}
|
||||||
|
if effective_scale > i32::from(crate::MARKET_PRICE_DECIMAL_MAX_SCALE) {
|
||||||
|
return std::result::Result::Err(invalid_decimal_error());
|
||||||
|
}
|
||||||
|
let mut scale = match u8::try_from(effective_scale) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
|
||||||
|
};
|
||||||
|
while scale > 0 && coefficient % 10 == 0 {
|
||||||
|
coefficient /= 10;
|
||||||
|
scale -= 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { coefficient, scale });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses one provider JSON number or string while preserving the original numeric lexeme.
|
||||||
|
pub(crate) fn parse_json_raw(raw: &serde_json::value::RawValue) -> ksp_core_lib::Result<Self> {
|
||||||
|
let source = raw.get();
|
||||||
|
if source.starts_with('"') {
|
||||||
|
let decoded = match serde_json::from_str::<std::string::String>(source) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceDecimal::parse(decoded.as_str());
|
||||||
|
}
|
||||||
|
return crate::MarketPriceDecimal::parse(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the normalized integer coefficient.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn coefficient(&self) -> u128 {
|
||||||
|
return self.coefficient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the normalized decimal scale.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn scale(&self) -> u8 {
|
||||||
|
return self.scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical non-scientific decimal representation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_canonical_string(&self) -> std::string::String {
|
||||||
|
let digits = self.coefficient.to_string();
|
||||||
|
if self.scale == 0 {
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
let scale = usize::from(self.scale);
|
||||||
|
if digits.len() > scale {
|
||||||
|
let split = digits.len() - scale;
|
||||||
|
return std::format!("{}.{}", &digits[..split], &digits[split..]);
|
||||||
|
}
|
||||||
|
let zero_count = scale - digits.len();
|
||||||
|
return std::format!("0.{}{}", "0".repeat(zero_count), digits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MarketPriceDecimal {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str(self.to_canonical_string().as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for MarketPriceDecimal {
|
||||||
|
type Err = ksp_core_lib::Error;
|
||||||
|
|
||||||
|
fn from_str(source: &str) -> std::result::Result<Self, Self::Err> {
|
||||||
|
return crate::MarketPriceDecimal::parse(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl serde::Serialize for MarketPriceDecimal {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
return serializer.serialize_str(self.to_canonical_string().as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> serde::Deserialize<'de> for MarketPriceDecimal {
|
||||||
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
return deserializer.deserialize_str(MarketPriceDecimalVisitor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MarketPriceDecimalVisitor;
|
||||||
|
|
||||||
|
impl<'de> serde::de::Visitor<'de> for MarketPriceDecimalVisitor {
|
||||||
|
type Value = crate::MarketPriceDecimal;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str("a canonicalizable positive decimal string");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
return match crate::MarketPriceDecimal::parse(value) {
|
||||||
|
std::result::Result::Ok(decimal) => std::result::Result::Ok(decimal),
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(E::custom("invalid KSP market-price decimal")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checked_multiply_power_of_ten(mut value: u128, exponent: u32) -> std::option::Option<u128> {
|
||||||
|
let mut remaining = exponent;
|
||||||
|
while remaining > 0 {
|
||||||
|
value = match value.checked_mul(10) {
|
||||||
|
std::option::Option::Some(next) => next,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
return std::option::Option::Some(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_decimal_error() -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "invalid exact market-price decimal");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn significand_digits(significand: &str) -> std::result::Result<(std::string::String, u8), ()> {
|
||||||
|
if significand.is_empty() || significand.starts_with('-') || significand.starts_with('+') {
|
||||||
|
return std::result::Result::Err(());
|
||||||
|
}
|
||||||
|
let mut digits = std::string::String::with_capacity(significand.len());
|
||||||
|
let mut fractional_digits: usize = 0;
|
||||||
|
let mut decimal_seen = false;
|
||||||
|
let mut digit_seen = false;
|
||||||
|
for character in significand.chars() {
|
||||||
|
if character.is_ascii_digit() {
|
||||||
|
digits.push(character);
|
||||||
|
digit_seen = true;
|
||||||
|
if decimal_seen {
|
||||||
|
fractional_digits += 1;
|
||||||
|
}
|
||||||
|
} else if character == '.' && !decimal_seen {
|
||||||
|
decimal_seen = true;
|
||||||
|
} else {
|
||||||
|
return std::result::Result::Err(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !digit_seen || significand.ends_with('.') || significand.starts_with('.') {
|
||||||
|
return std::result::Result::Err(());
|
||||||
|
}
|
||||||
|
let fractional_digits = match u8::try_from(fractional_digits) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(()),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok((digits, fractional_digits));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_exponent(source: &str) -> std::result::Result<(&str, i32), ()> {
|
||||||
|
let mut separator_index = std::option::Option::None;
|
||||||
|
for (index, character) in source.char_indices() {
|
||||||
|
if character == 'e' || character == 'E' {
|
||||||
|
if separator_index.is_some() {
|
||||||
|
return std::result::Result::Err(());
|
||||||
|
}
|
||||||
|
separator_index = std::option::Option::Some(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let index = match separator_index {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok((source, 0)),
|
||||||
|
};
|
||||||
|
let significand = &source[..index];
|
||||||
|
let exponent_source = &source[index + 1..];
|
||||||
|
if exponent_source.is_empty() || exponent_source.len() > 4 {
|
||||||
|
return std::result::Result::Err(());
|
||||||
|
}
|
||||||
|
let exponent = match exponent_source.parse::<i32>() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(()),
|
||||||
|
};
|
||||||
|
if !(-128..=128).contains(&exponent) {
|
||||||
|
return std::result::Result::Err(());
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((significand, exponent));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_decimal.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! DexScreener SOL/USD adapter bound to one explicitly configured Solana pair address.
|
||||||
|
|
||||||
|
const DEXSCREENER_PAIR_BASE_URL: &str = "https://api.dexscreener.com/latest/dex/pairs/solana";
|
||||||
|
const DEXSCREENER_PROVIDER_ID: &str = "dexscreener";
|
||||||
|
const DEXSCREENER_SOL_MINT: &str = "So11111111111111111111111111111111111111112";
|
||||||
|
|
||||||
|
/// Runtime settings for the keyless DexScreener market-price adapter.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MarketPriceDexScreenerSettings {
|
||||||
|
common: crate::MarketPriceProviderCommonSettings,
|
||||||
|
pair_address: std::option::Option<ksp_core_lib::Pubkey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceDexScreenerSettings {
|
||||||
|
/// Creates DexScreener settings for one optional explicit Solana pair address.
|
||||||
|
///
|
||||||
|
/// An enabled provider requires a valid pair address. A disabled provider may omit it so Config does not need to fabricate provider data merely to keep the
|
||||||
|
/// provider visible in the generic registry.
|
||||||
|
pub fn new(enabled: bool, pair_address: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(DEXSCREENER_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let pair_address = match pair_address {
|
||||||
|
std::option::Option::Some(value) => match value.parse::<ksp_core_lib::Pubkey>() {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(provider_settings_error("pair_address")),
|
||||||
|
},
|
||||||
|
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("pair_address")),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
|
||||||
|
return std::result::Result::Ok(Self { common, pair_address });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns common provider identity and enablement settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
||||||
|
return &self.common;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the explicit Solana pair address when one is configured.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pair_address(&self) -> std::option::Option<&ksp_core_lib::Pubkey> {
|
||||||
|
return self.pair_address.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DexScreener SOL/USD provider adapter for one configured pair.
|
||||||
|
pub struct MarketPriceDexScreenerProvider {
|
||||||
|
admission: crate::HttpAdmissionController,
|
||||||
|
descriptor: crate::MarketPriceProviderDescriptor,
|
||||||
|
http: crate::HttpRestClient,
|
||||||
|
settings: crate::MarketPriceDexScreenerSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceDexScreenerProvider {
|
||||||
|
/// Builds one DexScreener provider from validated runtime settings.
|
||||||
|
pub fn new(settings: crate::MarketPriceDexScreenerSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-neutral DexScreener capability descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return &self.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated DexScreener settings including the safe configured pair address.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &crate::MarketPriceDexScreenerSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one normalized SOL/USD observation from the configured DexScreener pair.
|
||||||
|
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
if !self.settings.common().enabled() {
|
||||||
|
return std::result::Result::Err(crate::provider_disabled_error(DEXSCREENER_PROVIDER_ID));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = crate::admit_request(DEXSCREENER_PROVIDER_ID, &self.admission) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let pair_address = match self.settings.pair_address() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(provider_settings_error("pair_address")),
|
||||||
|
};
|
||||||
|
let request_started_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match build_request(pair_address) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = match crate::get_json(&self.http, &self.admission, DEXSCREENER_PROVIDER_ID, request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let received_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), pair_address, request_started_at, received_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request(pair_address: &ksp_core_lib::Pubkey) -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
||||||
|
let mut request = match crate::HttpGetRequest::new_https(DEXSCREENER_PAIR_BASE_URL) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let pair_address = pair_address.to_string();
|
||||||
|
if let std::result::Result::Err(error) = request.append_path_segment(pair_address.as_str()) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||||
|
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(300, 60, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified)
|
||||||
|
{
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceProviderDescriptor::new(
|
||||||
|
provider_id,
|
||||||
|
"DexScreener",
|
||||||
|
crate::MarketPriceSemantics::DexPairUsd,
|
||||||
|
crate::MarketPriceProviderAuthMode::None,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::None,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_response(
|
||||||
|
bytes: &[u8],
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
configured_pair_address: &ksp_core_lib::Pubkey,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
let wire = match serde_json::from_slice::<DexScreenerWireResponse>(bytes) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(DEXSCREENER_PROVIDER_ID, "response", error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let pairs = match wire.pairs {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs")),
|
||||||
|
};
|
||||||
|
if pairs.len() != 1 {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs"));
|
||||||
|
}
|
||||||
|
let pair = &pairs[0];
|
||||||
|
if pair.chain_id != "solana" {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.chainId"));
|
||||||
|
}
|
||||||
|
let response_pair_address = match pair.pair_address.parse::<ksp_core_lib::Pubkey>() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.pairAddress")),
|
||||||
|
};
|
||||||
|
if response_pair_address != *configured_pair_address {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.pairAddress"));
|
||||||
|
}
|
||||||
|
let base_token = match &pair.base_token {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.baseToken")),
|
||||||
|
};
|
||||||
|
if base_token.address != DEXSCREENER_SOL_MINT {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.baseToken.address"));
|
||||||
|
}
|
||||||
|
let price_raw = match pair.price_usd.as_deref() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(DEXSCREENER_PROVIDER_ID, "pairs.priceUsd")),
|
||||||
|
};
|
||||||
|
let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new(std::format!("dexscreener:solana:{configured_pair_address}:priceUsd")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::DexPairUsd,
|
||||||
|
request_started_at,
|
||||||
|
received_at,
|
||||||
|
std::option::Option::None,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "DexScreener market-price settings are invalid")
|
||||||
|
.with_context("provider", DEXSCREENER_PROVIDER_ID)
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct DexScreenerWirePair {
|
||||||
|
#[serde(rename = "baseToken")]
|
||||||
|
base_token: std::option::Option<DexScreenerWireToken>,
|
||||||
|
#[serde(rename = "chainId")]
|
||||||
|
chain_id: std::string::String,
|
||||||
|
#[serde(rename = "pairAddress")]
|
||||||
|
pair_address: std::string::String,
|
||||||
|
#[serde(rename = "priceUsd")]
|
||||||
|
price_usd: std::option::Option<std::boxed::Box<serde_json::value::RawValue>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct DexScreenerWireResponse {
|
||||||
|
pairs: std::option::Option<std::vec::Vec<DexScreenerWirePair>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct DexScreenerWireToken {
|
||||||
|
address: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_dexscreener.rs"]
|
||||||
|
mod tests;
|
||||||
269
crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs
Normal file
269
crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Jupiter Price V3 SOL/USD adapter using the current Developer Platform REST surface.
|
||||||
|
|
||||||
|
const JUPITER_API_KEY_HEADER: &str = "x-api-key";
|
||||||
|
const JUPITER_PRICE_V3_URL: &str = "https://api.jup.ag/price/v3";
|
||||||
|
const JUPITER_PROVIDER_ID: &str = "jupiter";
|
||||||
|
const JUPITER_SOL_DECIMALS: u8 = 9;
|
||||||
|
const JUPITER_SOL_MINT: &str = "So11111111111111111111111111111111111111112";
|
||||||
|
|
||||||
|
/// Jupiter V1 access mode supported by Off-chain Transport.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceJupiterAccessMode {
|
||||||
|
/// Free Developer Platform plan using a provider-issued API key.
|
||||||
|
Free,
|
||||||
|
/// Keyless Developer Platform access with the lower documented request cadence.
|
||||||
|
Keyless,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime settings for the Jupiter Price V3 market-price adapter.
|
||||||
|
pub struct MarketPriceJupiterSettings {
|
||||||
|
access_mode: crate::MarketPriceJupiterAccessMode,
|
||||||
|
api_key: std::option::Option<crate::MarketPriceApiKey>,
|
||||||
|
common: crate::MarketPriceProviderCommonSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceJupiterSettings {
|
||||||
|
/// Creates keyless Jupiter settings without accepting a credential.
|
||||||
|
pub fn keyless(enabled: bool) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::new(enabled, crate::MarketPriceJupiterAccessMode::Keyless, std::option::Option::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates Free-plan Jupiter settings. An API key is mandatory while the provider is enabled.
|
||||||
|
pub fn free(enabled: bool, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::new(enabled, crate::MarketPriceJupiterAccessMode::Free, api_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured Jupiter access mode.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn access_mode(&self) -> crate::MarketPriceJupiterAccessMode {
|
||||||
|
return self.access_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns common provider identity and enablement settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
||||||
|
return &self.common;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(enabled: bool, access_mode: crate::MarketPriceJupiterAccessMode, api_key: std::option::Option<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(JUPITER_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let api_key = match access_mode {
|
||||||
|
crate::MarketPriceJupiterAccessMode::Keyless => {
|
||||||
|
if api_key.is_some() {
|
||||||
|
return std::result::Result::Err(provider_settings_error("api_key"));
|
||||||
|
}
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
crate::MarketPriceJupiterAccessMode::Free => match api_key {
|
||||||
|
std::option::Option::Some(value) => match crate::MarketPriceApiKey::new(JUPITER_PROVIDER_ID, value) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
},
|
||||||
|
std::option::Option::None if enabled => return std::result::Result::Err(provider_settings_error("api_key")),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let common = crate::MarketPriceProviderCommonSettings::new(provider_id, enabled);
|
||||||
|
return std::result::Result::Ok(Self { access_mode, api_key, common });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_key(&self) -> std::option::Option<&crate::MarketPriceApiKey> {
|
||||||
|
return self.api_key.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for crate::MarketPriceJupiterSettings {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("MarketPriceJupiterSettings")
|
||||||
|
.field("access_mode", &self.access_mode)
|
||||||
|
.field("api_key_present", &self.api_key.is_some())
|
||||||
|
.field("common", &self.common)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jupiter Price V3 SOL/USD provider adapter.
|
||||||
|
pub struct MarketPriceJupiterProvider {
|
||||||
|
admission: crate::HttpAdmissionController,
|
||||||
|
descriptor: crate::MarketPriceProviderDescriptor,
|
||||||
|
http: crate::HttpRestClient,
|
||||||
|
settings: crate::MarketPriceJupiterSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceJupiterProvider {
|
||||||
|
/// Builds one Jupiter Price V3 provider from validated runtime settings.
|
||||||
|
pub fn new(settings: crate::MarketPriceJupiterSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let descriptor = match descriptor_for(settings.access_mode(), settings.common().provider_id().clone()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-neutral Jupiter capability descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return &self.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated Jupiter runtime settings without exposing credential material.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &crate::MarketPriceJupiterSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one normalized SOL/USD heuristic observation from Jupiter Price V3.
|
||||||
|
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
if !self.settings.common().enabled() {
|
||||||
|
return std::result::Result::Err(crate::provider_disabled_error(JUPITER_PROVIDER_ID));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = crate::admit_request(JUPITER_PROVIDER_ID, &self.admission) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let request_started_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match build_request(&self.settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = match crate::get_json(&self.http, &self.admission, JUPITER_PROVIDER_ID, request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let received_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request(settings: &crate::MarketPriceJupiterSettings) -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
||||||
|
let mut request = match crate::HttpGetRequest::new_https(JUPITER_PRICE_V3_URL) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
request.append_query_pair("ids", JUPITER_SOL_MINT);
|
||||||
|
if let std::option::Option::Some(api_key) = settings.api_key()
|
||||||
|
&& let std::result::Result::Err(error) = request.insert_sensitive_header(JUPITER_API_KEY_HEADER, api_key.as_str())
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_for(
|
||||||
|
access_mode: crate::MarketPriceJupiterAccessMode,
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||||
|
let (auth_mode, rate_limit) = match access_mode {
|
||||||
|
crate::MarketPriceJupiterAccessMode::Keyless => {
|
||||||
|
let rate_limit =
|
||||||
|
match crate::MarketPriceProviderRateLimit::fixed(1, 2, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
(crate::MarketPriceProviderAuthMode::None, rate_limit)
|
||||||
|
},
|
||||||
|
crate::MarketPriceJupiterAccessMode::Free => {
|
||||||
|
let rate_limit =
|
||||||
|
match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
(crate::MarketPriceProviderAuthMode::RequiredApiKey, rate_limit)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::MarketPriceProviderDescriptor::new(
|
||||||
|
provider_id,
|
||||||
|
"Jupiter Price V3",
|
||||||
|
crate::MarketPriceSemantics::SolanaHeuristic,
|
||||||
|
auth_mode,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::None,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_response(
|
||||||
|
bytes: &[u8],
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
let wire = match serde_json::from_slice::<std::collections::BTreeMap<std::string::String, JupiterWirePrice>>(bytes) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(JUPITER_PROVIDER_ID, "response", error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if wire.len() != 1 {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "response.identity"));
|
||||||
|
}
|
||||||
|
let item = match wire.get(JUPITER_SOL_MINT) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "response.identity")),
|
||||||
|
};
|
||||||
|
if item.decimals != std::option::Option::Some(JUPITER_SOL_DECIMALS) {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "decimals"));
|
||||||
|
}
|
||||||
|
let price_raw = match item.usd_price.as_deref() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "usdPrice")),
|
||||||
|
};
|
||||||
|
let price = match crate::MarketPriceDecimal::parse_json_raw(price_raw) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let block_id = match item.block_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(JUPITER_PROVIDER_ID, "blockId")),
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new(std::format!("jupiter:price_v3:{JUPITER_SOL_MINT}:block:{block_id}")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::SolanaHeuristic,
|
||||||
|
request_started_at,
|
||||||
|
received_at,
|
||||||
|
std::option::Option::None,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_settings_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "Jupiter market-price settings are invalid")
|
||||||
|
.with_context("provider", JUPITER_PROVIDER_ID)
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct JupiterWirePrice {
|
||||||
|
#[serde(rename = "blockId")]
|
||||||
|
block_id: std::option::Option<u64>,
|
||||||
|
decimals: std::option::Option<u8>,
|
||||||
|
#[serde(rename = "usdPrice")]
|
||||||
|
usd_price: std::option::Option<std::boxed::Box<serde_json::value::RawValue>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_jupiter.rs"]
|
||||||
|
mod tests;
|
||||||
179
crates/ksp-offchain-transport-lib/src/market_price_kraken.rs
Normal file
179
crates/ksp-offchain-transport-lib/src/market_price_kraken.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_kraken.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Kraken Spot SOL/USD market-price adapter using the public REST ticker directly through `reqwest`.
|
||||||
|
|
||||||
|
const KRAKEN_PROVIDER_ID: &str = "kraken";
|
||||||
|
const KRAKEN_SOL_USD_PAIR: &str = "SOLUSD";
|
||||||
|
const KRAKEN_TICKER_URL: &str = "https://api.kraken.com/0/public/Ticker";
|
||||||
|
|
||||||
|
/// Runtime settings for the keyless Kraken Spot market-price adapter.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MarketPriceKrakenSettings {
|
||||||
|
common: crate::MarketPriceProviderCommonSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceKrakenSettings {
|
||||||
|
/// Creates Kraken Spot settings for the public keyless REST surface.
|
||||||
|
pub fn new(enabled: bool) -> ksp_core_lib::Result<Self> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(KRAKEN_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { common: crate::MarketPriceProviderCommonSettings::new(provider_id, enabled) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns common provider identity and enablement settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn common(&self) -> &crate::MarketPriceProviderCommonSettings {
|
||||||
|
return &self.common;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kraken Spot SOL/USD provider adapter.
|
||||||
|
pub struct MarketPriceKrakenProvider {
|
||||||
|
admission: crate::HttpAdmissionController,
|
||||||
|
descriptor: crate::MarketPriceProviderDescriptor,
|
||||||
|
http: crate::HttpRestClient,
|
||||||
|
settings: crate::MarketPriceKrakenSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceKrakenProvider {
|
||||||
|
/// Builds one Kraken Spot provider from validated runtime settings.
|
||||||
|
pub fn new(settings: crate::MarketPriceKrakenSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let descriptor = match descriptor_for(settings.common().provider_id().clone()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime = match crate::provider_http_runtime(descriptor.rate_limit()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { admission: runtime.1, descriptor, http: runtime.0, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-neutral Kraken Spot capability descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return &self.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated Kraken Spot runtime settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &crate::MarketPriceKrakenSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one normalized SOL/USD last-trade observation from Kraken Spot.
|
||||||
|
pub async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
if !self.settings.common().enabled() {
|
||||||
|
return std::result::Result::Err(crate::provider_disabled_error(KRAKEN_PROVIDER_ID));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = crate::admit_request(KRAKEN_PROVIDER_ID, &self.admission) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let request_started_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match build_request() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = match crate::get_json(&self.http, &self.admission, KRAKEN_PROVIDER_ID, request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let received_at = match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return parse_response(document.as_bytes(), self.settings.common().provider_id().clone(), request_started_at, received_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request() -> ksp_core_lib::Result<crate::HttpGetRequest> {
|
||||||
|
let mut request = match crate::HttpGetRequest::new_https(KRAKEN_TICKER_URL) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
request.append_query_pair("pair", KRAKEN_SOL_USD_PAIR);
|
||||||
|
return std::result::Result::Ok(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_for(provider_id: crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||||
|
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceProviderDescriptor::new(
|
||||||
|
provider_id,
|
||||||
|
"Kraken",
|
||||||
|
crate::MarketPriceSemantics::ExchangeLastTrade,
|
||||||
|
crate::MarketPriceProviderAuthMode::None,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::None,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_response(
|
||||||
|
bytes: &[u8],
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
let wire = match serde_json::from_slice::<KrakenWireResponse>(bytes) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response_with_source(KRAKEN_PROVIDER_ID, "response", error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if !wire.error.is_empty() {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "error"));
|
||||||
|
}
|
||||||
|
if wire.result.len() != 1 {
|
||||||
|
return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "result"));
|
||||||
|
}
|
||||||
|
let ticker = match wire.result.get(KRAKEN_SOL_USD_PAIR) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "identity")),
|
||||||
|
};
|
||||||
|
let last_trade = match ticker.last_trade.first() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response(KRAKEN_PROVIDER_ID, "last_trade")),
|
||||||
|
};
|
||||||
|
let price = match crate::MarketPriceDecimal::parse_json_raw(last_trade.as_ref()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new("kraken:SOLUSD:last_trade") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::ExchangeLastTrade,
|
||||||
|
request_started_at,
|
||||||
|
received_at,
|
||||||
|
std::option::Option::None,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct KrakenWireResponse {
|
||||||
|
error: std::vec::Vec<std::string::String>,
|
||||||
|
result: std::collections::BTreeMap<std::string::String, KrakenWireTicker>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct KrakenWireTicker {
|
||||||
|
#[serde(rename = "c")]
|
||||||
|
last_trade: std::vec::Vec<std::boxed::Box<serde_json::value::RawValue>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_kraken.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_observation.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
/// Maximum UTF-8 byte length accepted for safe provider provenance.
|
||||||
|
pub const MARKET_PRICE_PROVENANCE_MAX_BYTES: usize = 256;
|
||||||
|
|
||||||
|
/// Millisecond UTC timestamp used by provider-neutral public projections.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct MarketPriceTimestamp {
|
||||||
|
unix_millis: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPriceTimestamp {
|
||||||
|
/// Creates a UTC timestamp from whole milliseconds since Unix epoch.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn from_unix_millis(unix_millis: u64) -> Self {
|
||||||
|
return Self { unix_millis };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whole milliseconds since Unix epoch.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn unix_millis(&self) -> u64 {
|
||||||
|
return self.unix_millis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe bounded provenance supplied by one provider adapter.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(try_from = "std::string::String", into = "std::string::String")]
|
||||||
|
pub struct MarketPriceProvenance(std::string::String);
|
||||||
|
|
||||||
|
impl MarketPriceProvenance {
|
||||||
|
/// Creates bounded non-empty provenance without accepting control characters.
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let value = value.into();
|
||||||
|
if !valid_provenance(value.as_str()) {
|
||||||
|
return std::result::Result::Err(observation_error());
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe provider provenance.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.0.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::TryFrom<std::string::String> for MarketPriceProvenance {
|
||||||
|
type Error = ksp_core_lib::Error;
|
||||||
|
|
||||||
|
fn try_from(value: std::string::String) -> std::result::Result<Self, Self::Error> {
|
||||||
|
return crate::MarketPriceProvenance::new(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::From<MarketPriceProvenance> for std::string::String {
|
||||||
|
fn from(value: MarketPriceProvenance) -> Self {
|
||||||
|
return value.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public V1 SOL/USD observation normalized by Off-chain Transport.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceObservation {
|
||||||
|
pair: crate::MarketPricePair,
|
||||||
|
price: crate::MarketPriceDecimal,
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
provider_timestamp: std::option::Option<crate::MarketPriceTimestamp>,
|
||||||
|
provenance: crate::MarketPriceProvenance,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
semantics: crate::MarketPriceSemantics,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPriceObservation {
|
||||||
|
/// Creates one successful normalized SOL/USD observation.
|
||||||
|
pub fn new(
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
price: crate::MarketPriceDecimal,
|
||||||
|
semantics: crate::MarketPriceSemantics,
|
||||||
|
request_started_at: crate::MarketPriceTimestamp,
|
||||||
|
received_at: crate::MarketPriceTimestamp,
|
||||||
|
provider_timestamp: std::option::Option<crate::MarketPriceTimestamp>,
|
||||||
|
provenance: crate::MarketPriceProvenance,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
if received_at < request_started_at {
|
||||||
|
return std::result::Result::Err(observation_error());
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
pair: crate::MarketPricePair::SolUsd,
|
||||||
|
price,
|
||||||
|
provider_id,
|
||||||
|
provider_timestamp,
|
||||||
|
provenance,
|
||||||
|
received_at,
|
||||||
|
request_started_at,
|
||||||
|
semantics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the only V1 pair represented by this observation.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pair(&self) -> crate::MarketPricePair {
|
||||||
|
return self.pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact positive SOL/USD price.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn price(&self) -> crate::MarketPriceDecimal {
|
||||||
|
return self.price;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opaque provider identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
|
||||||
|
return &self.provider_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a provider timestamp only when the provider adapter has a real price-time field.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provider_timestamp(&self) -> std::option::Option<crate::MarketPriceTimestamp> {
|
||||||
|
return self.provider_timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns safe provider provenance.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provenance(&self) -> &crate::MarketPriceProvenance {
|
||||||
|
return &self.provenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the KSP wall-clock receipt timestamp.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn received_at(&self) -> crate::MarketPriceTimestamp {
|
||||||
|
return self.received_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the KSP wall-clock request-start timestamp.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn request_started_at(&self) -> crate::MarketPriceTimestamp {
|
||||||
|
return self.request_started_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-specific semantic class retained by the normalized observation.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn semantics(&self) -> crate::MarketPriceSemantics {
|
||||||
|
return self.semantics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observation_error() -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID, "invalid off-chain market-price observation");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_provenance(value: &str) -> bool {
|
||||||
|
if value.is_empty() || value.len() > crate::MARKET_PRICE_PROVENANCE_MAX_BYTES || value.trim() != value {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for character in value.chars() {
|
||||||
|
if character.is_control() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_observation.rs"]
|
||||||
|
mod tests;
|
||||||
514
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
Normal file
514
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_provider.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
/// Maximum UTF-8 byte length of one provider display name.
|
||||||
|
pub const MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES: usize = 96;
|
||||||
|
/// Maximum byte length of one opaque provider identifier.
|
||||||
|
pub const MARKET_PRICE_PROVIDER_ID_MAX_BYTES: usize = 64;
|
||||||
|
|
||||||
|
/// Only price pair exposed by the `0.2.11` V1 public contract.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPricePair {
|
||||||
|
/// Native SOL quoted directly in US dollars according to one provider's documented semantics.
|
||||||
|
SolUsd,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPricePair {
|
||||||
|
/// Returns the stable human-readable pair code.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(&self) -> &'static str {
|
||||||
|
return match self {
|
||||||
|
Self::SolUsd => "SOL/USD",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Market-price semantics retained so normalized observations do not imply cross-provider equivalence.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceSemantics {
|
||||||
|
/// Aggregated market price produced by a multi-market data provider.
|
||||||
|
AggregatedMarket,
|
||||||
|
/// USD price associated with one explicitly configured DEX pair.
|
||||||
|
DexPairUsd,
|
||||||
|
/// Last-trade price reported by one centralized exchange market.
|
||||||
|
ExchangeLastTrade,
|
||||||
|
/// Heuristic USD price derived from Solana swap/liquidity activity.
|
||||||
|
SolanaHeuristic,
|
||||||
|
/// Direct Solana-oriented spot price supplied by an on-chain market data provider.
|
||||||
|
SolanaSpot,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic authentication capability exposed by one configured provider.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceProviderAuthMode {
|
||||||
|
/// No credential is required for the configured access mode.
|
||||||
|
None,
|
||||||
|
/// Provider accepts an API key but also supports an unauthenticated mode selected by configuration.
|
||||||
|
OptionalApiKey,
|
||||||
|
/// An API key is required for the configured access mode.
|
||||||
|
RequiredApiKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scope to which a provider documents a request limit.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceProviderRateLimitScope {
|
||||||
|
/// Limit is associated with the configured account or API key.
|
||||||
|
Account,
|
||||||
|
/// Limit is associated with the source IP address.
|
||||||
|
Ip,
|
||||||
|
/// Limit is associated with an organization or project wider than one key.
|
||||||
|
Organization,
|
||||||
|
/// Provider documentation does not expose a stronger stable scope.
|
||||||
|
Unspecified,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shape of one generic provider request-limit capability.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceProviderRateLimitKind {
|
||||||
|
/// Dynamic or server-driven limit that cannot be represented as one safe fixed local cadence.
|
||||||
|
Dynamic,
|
||||||
|
/// Locally enforceable fixed request budget over a documented window.
|
||||||
|
Fixed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic provider request-limit capability with validated fixed-limit values.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceProviderRateLimit {
|
||||||
|
burst: std::option::Option<u32>,
|
||||||
|
kind: crate::MarketPriceProviderRateLimitKind,
|
||||||
|
requests: std::option::Option<u32>,
|
||||||
|
scope: crate::MarketPriceProviderRateLimitScope,
|
||||||
|
window_seconds: std::option::Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPriceProviderRateLimit {
|
||||||
|
/// Creates a validated fixed request limit.
|
||||||
|
pub fn fixed(
|
||||||
|
requests: u32,
|
||||||
|
window_seconds: u32,
|
||||||
|
burst: std::option::Option<u32>,
|
||||||
|
scope: crate::MarketPriceProviderRateLimitScope,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
if requests == 0 || window_seconds == 0 || burst == std::option::Option::Some(0) {
|
||||||
|
return std::result::Result::Err(provider_descriptor_error());
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
burst,
|
||||||
|
kind: crate::MarketPriceProviderRateLimitKind::Fixed,
|
||||||
|
requests: std::option::Option::Some(requests),
|
||||||
|
scope,
|
||||||
|
window_seconds: std::option::Option::Some(window_seconds),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a dynamic/server-driven request-limit descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn dynamic(scope: crate::MarketPriceProviderRateLimitScope) -> Self {
|
||||||
|
return Self {
|
||||||
|
burst: std::option::Option::None,
|
||||||
|
kind: crate::MarketPriceProviderRateLimitKind::Dynamic,
|
||||||
|
requests: std::option::Option::None,
|
||||||
|
scope,
|
||||||
|
window_seconds: std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns optional documented burst capacity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn burst(&self) -> std::option::Option<u32> {
|
||||||
|
return self.burst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the limit is fixed or dynamic/server-driven.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn kind(&self) -> crate::MarketPriceProviderRateLimitKind {
|
||||||
|
return self.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the request budget for a fixed limit, or `None` for a dynamic limit.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn requests(&self) -> std::option::Option<u32> {
|
||||||
|
return self.requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the documented limit scope.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn scope(&self) -> crate::MarketPriceProviderRateLimitScope {
|
||||||
|
return self.scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the fixed window duration in seconds, or `None` for a dynamic limit.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn window_seconds(&self) -> std::option::Option<u32> {
|
||||||
|
return self.window_seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Period used by one documented long-term provider quota.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceProviderQuotaPeriod {
|
||||||
|
/// Quota resets on a provider-defined daily period.
|
||||||
|
Day,
|
||||||
|
/// Quota resets on a provider-defined monthly period.
|
||||||
|
Month,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unit used by one documented long-term provider quota.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceProviderQuotaUnit {
|
||||||
|
/// Provider-defined compute units consumed by API operations.
|
||||||
|
ComputeUnits,
|
||||||
|
/// Provider-specific credits, not assumed to equal HTTP requests.
|
||||||
|
Credits,
|
||||||
|
/// HTTP/API requests.
|
||||||
|
Requests,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Long-term provider quota descriptor exposed as non-authoritative capability metadata.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceProviderLongTermQuota {
|
||||||
|
amount: u64,
|
||||||
|
period: crate::MarketPriceProviderQuotaPeriod,
|
||||||
|
unit: crate::MarketPriceProviderQuotaUnit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPriceProviderLongTermQuota {
|
||||||
|
/// Creates a non-zero documented quota descriptor.
|
||||||
|
pub fn new(amount: u64, period: crate::MarketPriceProviderQuotaPeriod, unit: crate::MarketPriceProviderQuotaUnit) -> ksp_core_lib::Result<Self> {
|
||||||
|
if amount == 0 {
|
||||||
|
return std::result::Result::Err(provider_descriptor_error());
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { amount, period, unit });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the documented amount without treating it as a local remaining counter.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn amount(&self) -> u64 {
|
||||||
|
return self.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-defined quota period.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn period(&self) -> crate::MarketPriceProviderQuotaPeriod {
|
||||||
|
return self.period;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the documented quota unit.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn unit(&self) -> crate::MarketPriceProviderQuotaUnit {
|
||||||
|
return self.unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Informational cost of one normalized SOL/USD request in a provider-defined quota unit.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceProviderRequestCost {
|
||||||
|
amount: u64,
|
||||||
|
unit: crate::MarketPriceProviderQuotaUnit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceProviderRequestCost {
|
||||||
|
/// Creates a non-zero informational request-cost descriptor.
|
||||||
|
pub fn new(amount: u64, unit: crate::MarketPriceProviderQuotaUnit) -> ksp_core_lib::Result<Self> {
|
||||||
|
if amount == 0 {
|
||||||
|
return std::result::Result::Err(provider_descriptor_error());
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { amount, unit });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the documented amount consumed by one SOL/USD request.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn amount(&self) -> u64 {
|
||||||
|
return self.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-defined quota unit used by this request cost.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn unit(&self) -> crate::MarketPriceProviderQuotaUnit {
|
||||||
|
return self.unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque validated provider identifier owned by Off-chain Transport.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(try_from = "std::string::String", into = "std::string::String")]
|
||||||
|
pub struct MarketPriceProviderId(std::string::String);
|
||||||
|
|
||||||
|
impl MarketPriceProviderId {
|
||||||
|
/// Creates one bounded stable provider identifier.
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating market-price provider identifier");
|
||||||
|
let value = value.into();
|
||||||
|
if !valid_provider_id(value.as_str()) {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "provider_id", "rejected invalid market-price provider identifier");
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID,
|
||||||
|
"invalid off-chain market-price provider identifier",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opaque identifier as a stable string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.0.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MarketPriceProviderId {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str(self.0.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::TryFrom<std::string::String> for MarketPriceProviderId {
|
||||||
|
type Error = ksp_core_lib::Error;
|
||||||
|
|
||||||
|
fn try_from(value: std::string::String) -> std::result::Result<Self, Self::Error> {
|
||||||
|
return crate::MarketPriceProviderId::new(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::From<MarketPriceProviderId> for std::string::String {
|
||||||
|
fn from(value: MarketPriceProviderId) -> Self {
|
||||||
|
return value.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider capability and presentation descriptor consumed by provider-agnostic callers.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceProviderDescriptor {
|
||||||
|
auth_mode: crate::MarketPriceProviderAuthMode,
|
||||||
|
display_name: std::string::String,
|
||||||
|
id: crate::MarketPriceProviderId,
|
||||||
|
long_term_quota: std::option::Option<crate::MarketPriceProviderLongTermQuota>,
|
||||||
|
rate_limit: crate::MarketPriceProviderRateLimit,
|
||||||
|
semantics: crate::MarketPriceSemantics,
|
||||||
|
sol_usd_request_cost: std::option::Option<crate::MarketPriceProviderRequestCost>,
|
||||||
|
supports_sol_usd: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPriceProviderDescriptor {
|
||||||
|
/// Creates a validated provider-neutral descriptor.
|
||||||
|
pub fn new(
|
||||||
|
id: crate::MarketPriceProviderId,
|
||||||
|
display_name: impl std::convert::Into<std::string::String>,
|
||||||
|
semantics: crate::MarketPriceSemantics,
|
||||||
|
auth_mode: crate::MarketPriceProviderAuthMode,
|
||||||
|
rate_limit: crate::MarketPriceProviderRateLimit,
|
||||||
|
long_term_quota: std::option::Option<crate::MarketPriceProviderLongTermQuota>,
|
||||||
|
supports_sol_usd: bool,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating market-price provider descriptor");
|
||||||
|
let display_name = display_name.into();
|
||||||
|
if !valid_display_name(display_name.as_str()) {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "display_name", "rejected invalid market-price provider descriptor");
|
||||||
|
return std::result::Result::Err(provider_descriptor_error());
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
auth_mode,
|
||||||
|
display_name,
|
||||||
|
id,
|
||||||
|
long_term_quota,
|
||||||
|
rate_limit,
|
||||||
|
semantics,
|
||||||
|
sol_usd_request_cost: std::option::Option::None,
|
||||||
|
supports_sol_usd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches informational provider cost metadata for one normalized SOL/USD request.
|
||||||
|
pub fn with_sol_usd_request_cost(mut self, request_cost: crate::MarketPriceProviderRequestCost) -> ksp_core_lib::Result<Self> {
|
||||||
|
if let std::option::Option::Some(quota) = self.long_term_quota
|
||||||
|
&& quota.unit() != request_cost.unit()
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(provider_descriptor_error());
|
||||||
|
}
|
||||||
|
self.sol_usd_request_cost = std::option::Option::Some(request_cost);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured authentication capability.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn auth_mode(&self) -> crate::MarketPriceProviderAuthMode {
|
||||||
|
return self.auth_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe display name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
return self.display_name.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opaque provider identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> &crate::MarketPriceProviderId {
|
||||||
|
return &self.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns optional long-term quota metadata without exposing a local remaining counter.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn long_term_quota(&self) -> std::option::Option<crate::MarketPriceProviderLongTermQuota> {
|
||||||
|
return self.long_term_quota;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured request-limit capability.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn rate_limit(&self) -> crate::MarketPriceProviderRateLimit {
|
||||||
|
return self.rate_limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the documented price semantics.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn semantics(&self) -> crate::MarketPriceSemantics {
|
||||||
|
return self.semantics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns optional informational provider cost for one normalized SOL/USD request.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sol_usd_request_cost(&self) -> std::option::Option<crate::MarketPriceProviderRequestCost> {
|
||||||
|
return self.sol_usd_request_cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether this descriptor can serve the V1 SOL/USD pair.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn supports_sol_usd(&self) -> bool {
|
||||||
|
return self.supports_sol_usd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic runtime availability state exposed without provider-specific error parsing.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(tag = "state", rename_all = "snake_case")]
|
||||||
|
pub enum MarketPriceProviderAvailability {
|
||||||
|
/// Required authentication material is unavailable or rejected.
|
||||||
|
AuthenticationUnavailable,
|
||||||
|
/// Provider is locally cooling down until the supplied timestamp.
|
||||||
|
CoolingDown {
|
||||||
|
/// Earliest known wall-clock timestamp at which a new attempt may be admitted.
|
||||||
|
retry_at: crate::MarketPriceTimestamp,
|
||||||
|
},
|
||||||
|
/// Provider is disabled by runtime configuration.
|
||||||
|
Disabled,
|
||||||
|
/// Runtime settings do not satisfy the provider adapter contract.
|
||||||
|
Misconfigured,
|
||||||
|
/// Provider-reported quota prevents current use.
|
||||||
|
QuotaUnavailable,
|
||||||
|
/// Provider is eligible for a new request.
|
||||||
|
Ready,
|
||||||
|
/// Transport/provider failure is transient; retry time is present only when actually known.
|
||||||
|
TemporarilyUnavailable {
|
||||||
|
/// Optional next retry timestamp derived from safe runtime/provider information.
|
||||||
|
retry_at: std::option::Option<crate::MarketPriceTimestamp>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceProviderAvailability {
|
||||||
|
/// Reports whether a generic refresh may be attempted immediately.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_refresh_eligible(&self) -> bool {
|
||||||
|
return matches!(self, Self::Ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether a refresh may be attempted at the supplied wall-clock timestamp.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_refresh_eligible_at(&self, now: crate::MarketPriceTimestamp) -> bool {
|
||||||
|
return match self {
|
||||||
|
Self::Ready => true,
|
||||||
|
Self::CoolingDown { retry_at } => *retry_at <= now,
|
||||||
|
Self::TemporarilyUnavailable { retry_at: std::option::Option::Some(retry_at) } => *retry_at <= now,
|
||||||
|
Self::TemporarilyUnavailable { retry_at: std::option::Option::None } => true,
|
||||||
|
Self::AuthenticationUnavailable | Self::Disabled | Self::Misconfigured | Self::QuotaUnavailable => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the known next retry timestamp for cooling-down or temporary states.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn retry_at(&self) -> std::option::Option<crate::MarketPriceTimestamp> {
|
||||||
|
return match self {
|
||||||
|
Self::CoolingDown { retry_at } => std::option::Option::Some(*retry_at),
|
||||||
|
Self::TemporarilyUnavailable { retry_at } => *retry_at,
|
||||||
|
Self::AuthenticationUnavailable | Self::Disabled | Self::Misconfigured | Self::QuotaUnavailable | Self::Ready => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current provider-neutral runtime state projection.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceProviderState {
|
||||||
|
availability: crate::MarketPriceProviderAvailability,
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPriceProviderState {
|
||||||
|
/// Creates one generic state projection for a configured provider.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(provider_id: crate::MarketPriceProviderId, availability: crate::MarketPriceProviderAvailability) -> Self {
|
||||||
|
return Self { availability, provider_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the generic availability classification.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn availability(&self) -> crate::MarketPriceProviderAvailability {
|
||||||
|
return self.availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opaque provider identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
|
||||||
|
return &self.provider_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the internal availability projection while preserving provider identity.
|
||||||
|
pub(crate) fn set_availability(&mut self, availability: crate::MarketPriceProviderAvailability) {
|
||||||
|
self.availability = availability;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_descriptor_error() -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID, "invalid off-chain market-price provider descriptor");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_display_name(value: &str) -> bool {
|
||||||
|
if value.is_empty() || value.len() > crate::MARKET_PRICE_PROVIDER_DISPLAY_NAME_MAX_BYTES || value.trim() != value {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for character in value.chars() {
|
||||||
|
if character.is_control() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_provider_id(value: &str) -> bool {
|
||||||
|
if value.is_empty() || value.len() > crate::MARKET_PRICE_PROVIDER_ID_MAX_BYTES {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for byte in value.bytes() {
|
||||||
|
if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_provider.rs"]
|
||||||
|
mod tests;
|
||||||
123
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
Normal file
123
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_registry.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Provider-neutral registry projection for configured market-price providers.
|
||||||
|
|
||||||
|
const MARKET_PRICE_PROVIDER_REGISTRY_MAX_ENTRIES: usize = 64;
|
||||||
|
|
||||||
|
/// Immutable provider descriptor plus its current generic runtime state.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceProviderRegistryEntry {
|
||||||
|
descriptor: crate::MarketPriceProviderDescriptor,
|
||||||
|
state: crate::MarketPriceProviderState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceProviderRegistryEntry {
|
||||||
|
/// Returns the provider capability descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return &self.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider-neutral runtime state.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn state(&self) -> &crate::MarketPriceProviderState {
|
||||||
|
return &self.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces this entry's generic availability projection for the owning runtime service.
|
||||||
|
pub(crate) fn set_availability(&mut self, availability: crate::MarketPriceProviderAvailability) {
|
||||||
|
self.state.set_availability(availability);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates one generic registry entry while keeping descriptor and state identity synchronized.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(descriptor: crate::MarketPriceProviderDescriptor, availability: crate::MarketPriceProviderAvailability) -> Self {
|
||||||
|
let state = crate::MarketPriceProviderState::new(descriptor.id().clone(), availability);
|
||||||
|
return Self { descriptor, state };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministically ordered registry of configured market-price providers.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceProviderRegistry {
|
||||||
|
entries: std::vec::Vec<crate::MarketPriceProviderRegistryEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceProviderRegistry {
|
||||||
|
/// Returns a provider descriptor by opaque identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn descriptor(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<&crate::MarketPriceProviderDescriptor> {
|
||||||
|
return self.entry(provider_id).map(crate::MarketPriceProviderRegistryEntry::descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all configured provider entries in stable provider-id order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn entries(&self) -> &[crate::MarketPriceProviderRegistryEntry] {
|
||||||
|
return self.entries.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one configured registry entry by opaque identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn entry(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<&crate::MarketPriceProviderRegistryEntry> {
|
||||||
|
let index = self.entries.binary_search_by(|entry| return entry.descriptor().id().cmp(provider_id));
|
||||||
|
return match index {
|
||||||
|
std::result::Result::Ok(value) => self.entries.get(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether no provider is currently registered.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
return self.entries.is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured provider count.
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
return self.entries.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one provider-neutral runtime state by opaque identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn state(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<&crate::MarketPriceProviderState> {
|
||||||
|
return self.entry(provider_id).map(crate::MarketPriceProviderRegistryEntry::state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates one existing provider availability and reports whether the provider was present.
|
||||||
|
pub(crate) fn set_availability(&mut self, provider_id: &crate::MarketPriceProviderId, availability: crate::MarketPriceProviderAvailability) -> bool {
|
||||||
|
let index = self.entries.binary_search_by(|entry| return entry.descriptor().id().cmp(provider_id));
|
||||||
|
let entry = match index {
|
||||||
|
std::result::Result::Ok(value) => self.entries.get_mut(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
if let std::option::Option::Some(entry) = entry {
|
||||||
|
entry.set_availability(availability);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one bounded deterministic registry and rejects duplicate provider identifiers.
|
||||||
|
pub fn new(mut entries: std::vec::Vec<crate::MarketPriceProviderRegistryEntry>) -> ksp_core_lib::Result<Self> {
|
||||||
|
if entries.len() > MARKET_PRICE_PROVIDER_REGISTRY_MAX_ENTRIES {
|
||||||
|
return std::result::Result::Err(registry_error("entry_count"));
|
||||||
|
}
|
||||||
|
entries.sort_by(|left, right| return left.descriptor().id().cmp(right.descriptor().id()));
|
||||||
|
for index in 1..entries.len() {
|
||||||
|
if entries[index - 1].descriptor().id() == entries[index].descriptor().id() {
|
||||||
|
return std::result::Result::Err(registry_error("provider_id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { entries });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID, "Market-price provider registry is invalid").with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_registry.rs"]
|
||||||
|
mod tests;
|
||||||
387
crates/ksp-offchain-transport-lib/src/market_price_service.rs
Normal file
387
crates/ksp-offchain-transport-lib/src/market_price_service.rs
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_service.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! Generic market-price refresh service owning provider dispatch and availability transitions.
|
||||||
|
|
||||||
|
const MARKET_PRICE_RATE_LIMIT_FALLBACK_MILLIS: u64 = 1_000;
|
||||||
|
const MARKET_PRICE_REFRESH_MAX_PROVIDERS: usize = 64;
|
||||||
|
|
||||||
|
/// Provider-specific runtime setup consumed once by [`crate::MarketPriceService`].
|
||||||
|
///
|
||||||
|
/// This enum is intended for composition layers such as Config. Runtime consumers use the generic service methods and never need to branch on provider kinds.
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum MarketPriceProviderSetup {
|
||||||
|
/// Birdeye Standard setup.
|
||||||
|
Birdeye(crate::MarketPriceBirdeyeSettings),
|
||||||
|
/// Coinbase Exchange setup.
|
||||||
|
CoinbaseExchange(crate::MarketPriceCoinbaseExchangeSettings),
|
||||||
|
/// CoinGecko setup.
|
||||||
|
CoinGecko(crate::MarketPriceCoinGeckoSettings),
|
||||||
|
/// CoinMarketCap setup.
|
||||||
|
CoinMarketCap(crate::MarketPriceCoinMarketCapSettings),
|
||||||
|
/// CoinPaprika setup.
|
||||||
|
CoinPaprika(crate::MarketPriceCoinPaprikaSettings),
|
||||||
|
/// DexScreener setup bound to one explicit Solana pair.
|
||||||
|
DexScreener(crate::MarketPriceDexScreenerSettings),
|
||||||
|
/// Jupiter Price V3 setup.
|
||||||
|
Jupiter(crate::MarketPriceJupiterSettings),
|
||||||
|
/// Kraken Spot setup.
|
||||||
|
Kraken(crate::MarketPriceKrakenSettings),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic result of one explicit market-price refresh attempt or eligibility projection.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceRefreshOutcome {
|
||||||
|
observation: std::option::Option<crate::MarketPriceObservation>,
|
||||||
|
state: crate::MarketPriceProviderState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceRefreshOutcome {
|
||||||
|
/// Returns the successful normalized observation when this refresh produced one.
|
||||||
|
#[must_use]
|
||||||
|
pub fn observation(&self) -> std::option::Option<&crate::MarketPriceObservation> {
|
||||||
|
return self.observation.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider identifier without exposing provider-specific runtime types.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
|
||||||
|
return self.state.provider_id();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether this refresh produced a new observation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn refreshed(&self) -> bool {
|
||||||
|
return self.observation.is_some();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the resulting provider-neutral runtime state.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn state(&self) -> &crate::MarketPriceProviderState {
|
||||||
|
return &self.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_state(state: crate::MarketPriceProviderState) -> Self {
|
||||||
|
return Self { observation: std::option::Option::None, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_observation(observation: crate::MarketPriceObservation, state: crate::MarketPriceProviderState) -> Self {
|
||||||
|
return Self { observation: std::option::Option::Some(observation), state };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-agnostic SOL/USD refresh service owning all configured provider adapters.
|
||||||
|
pub struct MarketPriceService {
|
||||||
|
providers: std::vec::Vec<MarketPriceProviderRuntime>,
|
||||||
|
registry: std::sync::Mutex<crate::MarketPriceProviderRegistry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceService {
|
||||||
|
/// Builds the service from provider-specific setup supplied by a composition layer.
|
||||||
|
///
|
||||||
|
/// Provider identifiers must be unique. Initial registry state is `Ready` for enabled providers and `Disabled` for disabled providers.
|
||||||
|
pub fn new(setups: std::vec::Vec<crate::MarketPriceProviderSetup>) -> ksp_core_lib::Result<Self> {
|
||||||
|
if setups.len() > MARKET_PRICE_REFRESH_MAX_PROVIDERS {
|
||||||
|
return std::result::Result::Err(refresh_error("provider_count"));
|
||||||
|
}
|
||||||
|
let mut providers = std::vec::Vec::with_capacity(setups.len());
|
||||||
|
let mut entries = std::vec::Vec::with_capacity(setups.len());
|
||||||
|
for setup in setups {
|
||||||
|
let runtime = match MarketPriceProviderRuntime::new(setup) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let availability = if runtime.enabled() {
|
||||||
|
crate::MarketPriceProviderAvailability::Ready
|
||||||
|
} else {
|
||||||
|
crate::MarketPriceProviderAvailability::Disabled
|
||||||
|
};
|
||||||
|
entries.push(crate::MarketPriceProviderRegistryEntry::new(runtime.descriptor().clone(), availability));
|
||||||
|
providers.push(runtime);
|
||||||
|
}
|
||||||
|
let registry = match crate::MarketPriceProviderRegistry::new(entries) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
providers.sort_by(|left, right| return left.provider_id().cmp(right.provider_id()));
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
provider_count = providers.len(),
|
||||||
|
"created generic market-price refresh service"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(Self { providers, registry: std::sync::Mutex::new(registry) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a detached provider-neutral registry snapshot suitable for HID projection.
|
||||||
|
#[must_use]
|
||||||
|
pub fn registry(&self) -> crate::MarketPriceProviderRegistry {
|
||||||
|
let guard = lock_registry(&self.registry);
|
||||||
|
return guard.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes one provider by generic provider identifier.
|
||||||
|
///
|
||||||
|
/// Non-eligible states are returned without network dispatch. Provider transport/application failures are normalized into availability and returned as an
|
||||||
|
/// outcome rather than forcing consumers to parse provider-specific errors.
|
||||||
|
pub async fn refresh(&self, provider_id: &crate::MarketPriceProviderId) -> ksp_core_lib::Result<crate::MarketPriceRefreshOutcome> {
|
||||||
|
let index = match self.provider_index(provider_id) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(provider_not_found_error(provider_id)),
|
||||||
|
};
|
||||||
|
let now = current_timestamp_or_zero();
|
||||||
|
let state = match self.state_snapshot(provider_id) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(provider_not_found_error(provider_id)),
|
||||||
|
};
|
||||||
|
if !state.availability().is_refresh_eligible_at(now) {
|
||||||
|
return std::result::Result::Ok(crate::MarketPriceRefreshOutcome::from_state(state));
|
||||||
|
}
|
||||||
|
let result = self.providers[index].fetch_sol_usd().await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(observation) => {
|
||||||
|
let state = self.update_availability(provider_id, crate::MarketPriceProviderAvailability::Ready);
|
||||||
|
std::result::Result::Ok(crate::MarketPriceRefreshOutcome::from_observation(observation, state))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let availability = availability_from_error(&error, self.providers[index].descriptor().auth_mode());
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
provider_id = provider_id.as_str(),
|
||||||
|
error_domain = error.code().domain(),
|
||||||
|
error_code = error.code().code(),
|
||||||
|
"classified market-price refresh failure"
|
||||||
|
);
|
||||||
|
let state = self.update_availability(provider_id, availability);
|
||||||
|
std::result::Result::Ok(crate::MarketPriceRefreshOutcome::from_state(state))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes a caller-selected provider set in deterministic request order without fallback or consensus.
|
||||||
|
///
|
||||||
|
/// The service never sleeps to wait for a local rate limit. Duplicate or unknown identifiers are rejected before any provider request is attempted.
|
||||||
|
pub async fn refresh_many(&self, provider_ids: &[crate::MarketPriceProviderId]) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceRefreshOutcome>> {
|
||||||
|
if provider_ids.len() > MARKET_PRICE_REFRESH_MAX_PROVIDERS {
|
||||||
|
return std::result::Result::Err(refresh_error("provider_count"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = validate_requested_provider_ids(self, provider_ids) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let mut outcomes = std::vec::Vec::with_capacity(provider_ids.len());
|
||||||
|
for provider_id in provider_ids {
|
||||||
|
let outcome = match self.refresh(provider_id).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
outcomes.push(outcome);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(outcomes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes every currently eligible configured provider in stable provider-id order.
|
||||||
|
///
|
||||||
|
/// Disabled, authentication-unavailable, misconfigured, quota-unavailable and not-yet-expired cooldown states are projected without network dispatch.
|
||||||
|
pub async fn refresh_all(&self) -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceRefreshOutcome>> {
|
||||||
|
let provider_ids = self.providers.iter().map(|provider| return provider.provider_id().clone()).collect::<std::vec::Vec<_>>();
|
||||||
|
return self.refresh_many(provider_ids.as_slice()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_index(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<usize> {
|
||||||
|
let result = self.providers.binary_search_by(|provider| return provider.provider_id().cmp(provider_id));
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_snapshot(&self, provider_id: &crate::MarketPriceProviderId) -> std::option::Option<crate::MarketPriceProviderState> {
|
||||||
|
let guard = lock_registry(&self.registry);
|
||||||
|
return guard.state(provider_id).cloned();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_availability(
|
||||||
|
&self,
|
||||||
|
provider_id: &crate::MarketPriceProviderId,
|
||||||
|
availability: crate::MarketPriceProviderAvailability,
|
||||||
|
) -> crate::MarketPriceProviderState {
|
||||||
|
let mut guard = lock_registry(&self.registry);
|
||||||
|
if guard.set_availability(provider_id, availability)
|
||||||
|
&& let std::option::Option::Some(state) = guard.state(provider_id)
|
||||||
|
{
|
||||||
|
return state.clone();
|
||||||
|
}
|
||||||
|
return crate::MarketPriceProviderState::new(provider_id.clone(), availability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for crate::MarketPriceService {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.debug_struct("MarketPriceService").field("registry", &self.registry()).finish_non_exhaustive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MarketPriceProviderRuntime {
|
||||||
|
Birdeye(crate::MarketPriceBirdeyeProvider),
|
||||||
|
CoinbaseExchange(crate::MarketPriceCoinbaseExchangeProvider),
|
||||||
|
CoinGecko(crate::MarketPriceCoinGeckoProvider),
|
||||||
|
CoinMarketCap(crate::MarketPriceCoinMarketCapProvider),
|
||||||
|
CoinPaprika(crate::MarketPriceCoinPaprikaProvider),
|
||||||
|
DexScreener(crate::MarketPriceDexScreenerProvider),
|
||||||
|
Jupiter(crate::MarketPriceJupiterProvider),
|
||||||
|
Kraken(crate::MarketPriceKrakenProvider),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPriceProviderRuntime {
|
||||||
|
fn new(setup: crate::MarketPriceProviderSetup) -> ksp_core_lib::Result<Self> {
|
||||||
|
return match setup {
|
||||||
|
crate::MarketPriceProviderSetup::Birdeye(settings) => crate::MarketPriceBirdeyeProvider::new(settings).map(Self::Birdeye),
|
||||||
|
crate::MarketPriceProviderSetup::CoinbaseExchange(settings) => {
|
||||||
|
crate::MarketPriceCoinbaseExchangeProvider::new(settings).map(Self::CoinbaseExchange)
|
||||||
|
},
|
||||||
|
crate::MarketPriceProviderSetup::CoinGecko(settings) => crate::MarketPriceCoinGeckoProvider::new(settings).map(Self::CoinGecko),
|
||||||
|
crate::MarketPriceProviderSetup::CoinMarketCap(settings) => crate::MarketPriceCoinMarketCapProvider::new(settings).map(Self::CoinMarketCap),
|
||||||
|
crate::MarketPriceProviderSetup::CoinPaprika(settings) => crate::MarketPriceCoinPaprikaProvider::new(settings).map(Self::CoinPaprika),
|
||||||
|
crate::MarketPriceProviderSetup::DexScreener(settings) => crate::MarketPriceDexScreenerProvider::new(settings).map(Self::DexScreener),
|
||||||
|
crate::MarketPriceProviderSetup::Jupiter(settings) => crate::MarketPriceJupiterProvider::new(settings).map(Self::Jupiter),
|
||||||
|
crate::MarketPriceProviderSetup::Kraken(settings) => crate::MarketPriceKrakenProvider::new(settings).map(Self::Kraken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor(&self) -> &crate::MarketPriceProviderDescriptor {
|
||||||
|
return match self {
|
||||||
|
Self::Birdeye(provider) => provider.descriptor(),
|
||||||
|
Self::CoinbaseExchange(provider) => provider.descriptor(),
|
||||||
|
Self::CoinGecko(provider) => provider.descriptor(),
|
||||||
|
Self::CoinMarketCap(provider) => provider.descriptor(),
|
||||||
|
Self::CoinPaprika(provider) => provider.descriptor(),
|
||||||
|
Self::DexScreener(provider) => provider.descriptor(),
|
||||||
|
Self::Jupiter(provider) => provider.descriptor(),
|
||||||
|
Self::Kraken(provider) => provider.descriptor(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enabled(&self) -> bool {
|
||||||
|
return match self {
|
||||||
|
Self::Birdeye(provider) => provider.settings().common().enabled(),
|
||||||
|
Self::CoinbaseExchange(provider) => provider.settings().common().enabled(),
|
||||||
|
Self::CoinGecko(provider) => provider.settings().common().enabled(),
|
||||||
|
Self::CoinMarketCap(provider) => provider.settings().common().enabled(),
|
||||||
|
Self::CoinPaprika(provider) => provider.settings().common().enabled(),
|
||||||
|
Self::DexScreener(provider) => provider.settings().common().enabled(),
|
||||||
|
Self::Jupiter(provider) => provider.settings().common().enabled(),
|
||||||
|
Self::Kraken(provider) => provider.settings().common().enabled(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_sol_usd(&self) -> ksp_core_lib::Result<crate::MarketPriceObservation> {
|
||||||
|
return match self {
|
||||||
|
Self::Birdeye(provider) => provider.fetch_sol_usd().await,
|
||||||
|
Self::CoinbaseExchange(provider) => provider.fetch_sol_usd().await,
|
||||||
|
Self::CoinGecko(provider) => provider.fetch_sol_usd().await,
|
||||||
|
Self::CoinMarketCap(provider) => provider.fetch_sol_usd().await,
|
||||||
|
Self::CoinPaprika(provider) => provider.fetch_sol_usd().await,
|
||||||
|
Self::DexScreener(provider) => provider.fetch_sol_usd().await,
|
||||||
|
Self::Jupiter(provider) => provider.fetch_sol_usd().await,
|
||||||
|
Self::Kraken(provider) => provider.fetch_sol_usd().await,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_id(&self) -> &crate::MarketPriceProviderId {
|
||||||
|
return self.descriptor().id();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn availability_from_error(error: &ksp_core_lib::Error, auth_mode: crate::MarketPriceProviderAuthMode) -> crate::MarketPriceProviderAvailability {
|
||||||
|
let code = error.code();
|
||||||
|
if code == crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED {
|
||||||
|
return crate::MarketPriceProviderAvailability::Disabled;
|
||||||
|
}
|
||||||
|
if code == crate::ERROR_CODE_HTTP_ACCESS_DENIED {
|
||||||
|
return match auth_mode {
|
||||||
|
crate::MarketPriceProviderAuthMode::OptionalApiKey | crate::MarketPriceProviderAuthMode::RequiredApiKey => {
|
||||||
|
crate::MarketPriceProviderAvailability::AuthenticationUnavailable
|
||||||
|
},
|
||||||
|
crate::MarketPriceProviderAuthMode::None => crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if code == crate::ERROR_CODE_HTTP_ADMISSION_DEFERRED {
|
||||||
|
let delay = context_u64(error, "retry_after_millis").unwrap_or(MARKET_PRICE_RATE_LIMIT_FALLBACK_MILLIS);
|
||||||
|
return crate::MarketPriceProviderAvailability::CoolingDown { retry_at: timestamp_after_millis(delay) };
|
||||||
|
}
|
||||||
|
if code == crate::ERROR_CODE_HTTP_RATE_LIMITED {
|
||||||
|
let delay = context_u64(error, "retry_after_seconds")
|
||||||
|
.and_then(|seconds| return seconds.checked_mul(1_000))
|
||||||
|
.unwrap_or(MARKET_PRICE_RATE_LIMIT_FALLBACK_MILLIS);
|
||||||
|
return crate::MarketPriceProviderAvailability::CoolingDown { retry_at: timestamp_after_millis(delay) };
|
||||||
|
}
|
||||||
|
if code == crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE {
|
||||||
|
let retry_at = context_u64(error, "retry_after_seconds").and_then(|seconds| return seconds.checked_mul(1_000)).map(timestamp_after_millis);
|
||||||
|
return crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at };
|
||||||
|
}
|
||||||
|
if code == crate::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED
|
||||||
|
|| code == crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID
|
||||||
|
|| code == crate::ERROR_CODE_HTTP_REQUEST_INVALID
|
||||||
|
|| code == crate::ERROR_CODE_HTTP_SETTINGS_INVALID
|
||||||
|
|| code == crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID
|
||||||
|
{
|
||||||
|
return crate::MarketPriceProviderAvailability::Misconfigured;
|
||||||
|
}
|
||||||
|
return crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn context_u64(error: &ksp_core_lib::Error, key: &'static str) -> std::option::Option<u64> {
|
||||||
|
for context in error.context() {
|
||||||
|
if context.key() == key {
|
||||||
|
return match context.value().parse::<u64>() {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_timestamp_or_zero() -> crate::MarketPriceTimestamp {
|
||||||
|
return match crate::current_timestamp() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => crate::MarketPriceTimestamp::from_unix_millis(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_registry(registry: &std::sync::Mutex<crate::MarketPriceProviderRegistry>) -> std::sync::MutexGuard<'_, crate::MarketPriceProviderRegistry> {
|
||||||
|
return match registry.lock() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_not_found_error(provider_id: &crate::MarketPriceProviderId) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND, "Market-price provider is not configured")
|
||||||
|
.with_context("provider_id", provider_id.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID, "Market-price refresh request is invalid").with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp_after_millis(delay_millis: u64) -> crate::MarketPriceTimestamp {
|
||||||
|
let now = current_timestamp_or_zero().unix_millis();
|
||||||
|
return crate::MarketPriceTimestamp::from_unix_millis(now.saturating_add(delay_millis));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_requested_provider_ids(service: &crate::MarketPriceService, provider_ids: &[crate::MarketPriceProviderId]) -> ksp_core_lib::Result<()> {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for provider_id in provider_ids {
|
||||||
|
if service.provider_index(provider_id).is_none() {
|
||||||
|
return std::result::Result::Err(provider_not_found_error(provider_id));
|
||||||
|
}
|
||||||
|
if !seen.insert(provider_id) {
|
||||||
|
return std::result::Result::Err(refresh_error("provider_id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_service.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/src/market_price_settings.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
/// Common provider settings embedded by future provider-specific runtime settings.
|
||||||
|
///
|
||||||
|
/// Provider-specific credentials, pair selectors and access modes intentionally do not live here because providers without those capabilities must not be
|
||||||
|
/// forced into artificial fields.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MarketPriceProviderCommonSettings {
|
||||||
|
enabled: bool,
|
||||||
|
provider_id: crate::MarketPriceProviderId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketPriceProviderCommonSettings {
|
||||||
|
/// Creates common settings for one uniquely identified runtime provider instance.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(provider_id: crate::MarketPriceProviderId, enabled: bool) -> Self {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, enabled = enabled, "constructed common market-price provider settings");
|
||||||
|
return Self { enabled, provider_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether this provider instance is enabled.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn enabled(&self) -> bool {
|
||||||
|
return self.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opaque runtime provider identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provider_id(&self) -> &crate::MarketPriceProviderId {
|
||||||
|
return &self.provider_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/market_price_settings.rs"]
|
||||||
|
mod tests;
|
||||||
128
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
Normal file
128
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
// version: 10
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Dependency, observability, hardened HTTP and module-taxonomy canaries for Off-chain Transport.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_manifest_uses_only_generic_runtime_crates_and_no_provider_sdk() {
|
||||||
|
let manifest = include_str!("../Cargo.toml");
|
||||||
|
assert!(manifest.contains("chrono = { workspace = true, features = [\"std\"] }"));
|
||||||
|
assert!(manifest.contains("ksp-core-lib"));
|
||||||
|
assert!(manifest.contains("ksp-logging-lib"));
|
||||||
|
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||||
|
assert!(manifest.contains("serde"));
|
||||||
|
assert!(manifest.contains("serde_json = { workspace = true, features = [\"raw_value\"] }"));
|
||||||
|
assert!(!manifest.contains("ksp-config-lib"));
|
||||||
|
assert!(!manifest.contains("coingecko"));
|
||||||
|
assert!(!manifest.contains("coinmarketcap"));
|
||||||
|
assert!(!manifest.contains("coinpaprika"));
|
||||||
|
assert!(!manifest.contains("jupiter"));
|
||||||
|
assert!(!manifest.contains("birdeye"));
|
||||||
|
assert!(!manifest.contains("dexscreener"));
|
||||||
|
assert!(!manifest.lines().any(|line| return line.trim_start().starts_with("tracing =")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_http_runtime_is_production_active_but_not_a_public_generic_client() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
assert!(crate_root.contains("mod http_admission;"));
|
||||||
|
assert!(crate_root.contains("mod http_client;"));
|
||||||
|
assert!(crate_root.contains("mod http_settings;"));
|
||||||
|
assert!(!crate_root.contains("#[cfg(test)] // RUST-API-008: staged"));
|
||||||
|
assert!(!crate_root.contains("pub use self::http_client::HttpRestClient"));
|
||||||
|
let client = include_str!("../src/http_client.rs");
|
||||||
|
assert!(client.contains(".redirect(reqwest::redirect::Policy::none())"));
|
||||||
|
assert!(client.contains(".referer(false)"));
|
||||||
|
assert!(client.contains(".retry(reqwest::retry::never())"));
|
||||||
|
assert!(client.contains(".no_proxy()"));
|
||||||
|
assert!(client.contains("error.without_url()"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_provider_modules_are_market_price_scoped_and_fixed_origin() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
assert!(crate_root.contains("mod market_price_coinbase_exchange;"));
|
||||||
|
assert!(crate_root.contains("mod market_price_coingecko;"));
|
||||||
|
assert!(crate_root.contains("mod market_price_coinmarketcap;"));
|
||||||
|
assert!(crate_root.contains("mod market_price_coinpaprika;"));
|
||||||
|
assert!(crate_root.contains("mod market_price_kraken;"));
|
||||||
|
let coinbase = include_str!("../src/market_price_coinbase_exchange.rs");
|
||||||
|
let coingecko = include_str!("../src/market_price_coingecko.rs");
|
||||||
|
let coinmarketcap = include_str!("../src/market_price_coinmarketcap.rs");
|
||||||
|
let coinpaprika = include_str!("../src/market_price_coinpaprika.rs");
|
||||||
|
let kraken = include_str!("../src/market_price_kraken.rs");
|
||||||
|
assert!(coinbase.contains("https://api.exchange.coinbase.com/products/SOL-USD/ticker"));
|
||||||
|
assert!(coingecko.contains("https://api.coingecko.com/api/v3/simple/price"));
|
||||||
|
assert!(coinmarketcap.contains("https://pro-api.coinmarketcap.com/public-api/v2/simple/price"));
|
||||||
|
assert!(coinmarketcap.contains("https://pro-api.coinmarketcap.com/v2/simple/price"));
|
||||||
|
assert!(coinpaprika.contains("https://api.coinpaprika.com/v1/tickers/sol-solana"));
|
||||||
|
assert!(kraken.contains("https://api.kraken.com/0/public/Ticker"));
|
||||||
|
assert!(kraken.contains("SOLUSD"));
|
||||||
|
assert!(!coinbase.contains("std::env"));
|
||||||
|
assert!(!coingecko.contains("std::env"));
|
||||||
|
assert!(!coinmarketcap.contains("std::env"));
|
||||||
|
assert!(!coinpaprika.contains("std::env"));
|
||||||
|
assert!(!kraken.contains("std::env"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_jupiter_and_dexscreener_stay_market_price_scoped_fixed_origin_and_discovery_free() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
assert!(crate_root.contains("mod market_price_dexscreener;"));
|
||||||
|
assert!(crate_root.contains("mod market_price_jupiter;"));
|
||||||
|
let dexscreener = include_str!("../src/market_price_dexscreener.rs");
|
||||||
|
let jupiter = include_str!("../src/market_price_jupiter.rs");
|
||||||
|
assert!(dexscreener.contains("https://api.dexscreener.com/latest/dex/pairs/solana"));
|
||||||
|
assert!(dexscreener.contains("Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE") || dexscreener.contains("pair_address"));
|
||||||
|
assert!(!dexscreener.contains("latest/dex/search"));
|
||||||
|
assert!(!dexscreener.contains("token-pairs/v1"));
|
||||||
|
assert!(!dexscreener.contains("tokens/v1"));
|
||||||
|
assert!(jupiter.contains("https://api.jup.ag/price/v3"));
|
||||||
|
assert!(jupiter.contains("So11111111111111111111111111111111111111112"));
|
||||||
|
assert!(!jupiter.contains("/quote"));
|
||||||
|
assert!(!dexscreener.contains("std::env"));
|
||||||
|
assert!(!jupiter.contains("std::env"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_birdeye_and_registry_remain_provider_owned_generic_and_fixed_origin() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
assert!(crate_root.contains("mod market_price_birdeye;"));
|
||||||
|
assert!(crate_root.contains("mod market_price_registry;"));
|
||||||
|
let birdeye = include_str!("../src/market_price_birdeye.rs");
|
||||||
|
let registry = include_str!("../src/market_price_registry.rs");
|
||||||
|
assert!(birdeye.contains("https://public-api.birdeye.so/defi/price"));
|
||||||
|
assert!(birdeye.contains("x-api-key"));
|
||||||
|
assert!(birdeye.contains("x-chain"));
|
||||||
|
assert!(birdeye.contains("So11111111111111111111111111111111111111112"));
|
||||||
|
assert!(!birdeye.contains("std::env"));
|
||||||
|
assert!(!registry.contains("CoinGecko"));
|
||||||
|
assert!(!registry.contains("CoinMarketCap"));
|
||||||
|
assert!(!registry.contains("CoinPaprika"));
|
||||||
|
assert!(!registry.contains("DexScreener"));
|
||||||
|
assert!(!registry.contains("Jupiter"));
|
||||||
|
assert!(!registry.contains("Kraken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_refresh_service_owns_dispatch_while_registry_and_consumer_surface_stay_generic() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
let registry = include_str!("../src/market_price_registry.rs");
|
||||||
|
let service = include_str!("../src/market_price_service.rs");
|
||||||
|
assert!(crate_root.contains("mod market_price_service;"));
|
||||||
|
assert!(crate_root.contains("pub use self::market_price_service::MarketPriceService;"));
|
||||||
|
assert!(!registry.contains("MarketPriceProviderSetup::"));
|
||||||
|
assert!(service.contains("pub async fn refresh("));
|
||||||
|
assert!(service.contains("pub async fn refresh_many("));
|
||||||
|
assert!(service.contains("pub async fn refresh_all("));
|
||||||
|
assert!(service.contains("is_refresh_eligible_at"));
|
||||||
|
assert!(!service.contains("std::thread::sleep"));
|
||||||
|
assert!(!service.contains("tokio::time::sleep"));
|
||||||
|
assert!(!service.contains("fallback_provider"));
|
||||||
|
assert!(!service.contains("fn aggregate("));
|
||||||
|
assert!(!service.contains("fn consensus("));
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/tests/market_price_live_smoke.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Opt-in live SOL/USD smokes for the V1 Off-chain Transport market-price providers.
|
||||||
|
|
||||||
|
use std::io::IsTerminal; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const DEXSCREENER_SOL_USDC_ORCA_PAIR: &str = "7qbRF6YsyGuLUVs6Y1q64bdVrfe4ZcUUz1JRdoVNUJnm";
|
||||||
|
|
||||||
|
fn smoke_error(message: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyless_setups() -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
|
||||||
|
let mut setups = std::vec::Vec::with_capacity(7);
|
||||||
|
let coinbase = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase));
|
||||||
|
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko));
|
||||||
|
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap));
|
||||||
|
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(coinpaprika));
|
||||||
|
let dexscreener =
|
||||||
|
match ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(true, std::option::Option::Some(DEXSCREENER_SOL_USDC_ORCA_PAIR.to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::DexScreener(dexscreener));
|
||||||
|
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter));
|
||||||
|
let kraken = match ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken));
|
||||||
|
return std::result::Result::Ok(setups);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_secret_line(label: &'static str) -> ksp_core_lib::Result<std::string::String> {
|
||||||
|
let mut secret = std::string::String::new();
|
||||||
|
match std::io::stdin().read_line(&mut secret) {
|
||||||
|
std::result::Result::Ok(_) => {},
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(smoke_error("keyed market-price smoke stdin is unreadable")),
|
||||||
|
}
|
||||||
|
let secret = secret.trim().to_owned();
|
||||||
|
if secret.is_empty() {
|
||||||
|
return std::result::Result::Err(smoke_error(label));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyed_setups() -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
|
||||||
|
if std::io::stdin().is_terminal() {
|
||||||
|
return std::result::Result::Err(smoke_error("pipe four provider API keys to the keyed market-price smoke on stdin"));
|
||||||
|
}
|
||||||
|
let birdeye_key = match read_secret_line("Birdeye API key from smoke stdin must not be empty") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coingecko_key = match read_secret_line("CoinGecko Demo API key from smoke stdin must not be empty") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinmarketcap_key = match read_secret_line("CoinMarketCap Basic API key from smoke stdin must not be empty") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let jupiter_key = match read_secret_line("Jupiter Free API key from smoke stdin must not be empty") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut setups = std::vec::Vec::with_capacity(4);
|
||||||
|
let birdeye = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(true, std::option::Option::Some(birdeye_key)) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye));
|
||||||
|
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::Some(coingecko_key)) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko));
|
||||||
|
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::basic(true, std::option::Option::Some(coinmarketcap_key)) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap));
|
||||||
|
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::free(true, std::option::Option::Some(jupiter_key)) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
setups.push(ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter));
|
||||||
|
return std::result::Result::Ok(setups);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_live_outcomes(
|
||||||
|
registry: &ksp_offchain_transport_lib::MarketPriceProviderRegistry,
|
||||||
|
outcomes: &[ksp_offchain_transport_lib::MarketPriceRefreshOutcome],
|
||||||
|
) {
|
||||||
|
let mut failures = std::vec::Vec::new();
|
||||||
|
for outcome in outcomes {
|
||||||
|
let provider_id = outcome.provider_id();
|
||||||
|
let observation = match outcome.observation() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
failures.push(std::format!("{}:{:?}", provider_id.as_str(), outcome.state().availability()));
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(observation.provider_id(), provider_id);
|
||||||
|
assert_eq!(observation.pair(), ksp_offchain_transport_lib::MarketPricePair::SolUsd);
|
||||||
|
assert!(observation.price().coefficient() > 0);
|
||||||
|
assert!(observation.received_at() >= observation.request_started_at());
|
||||||
|
let descriptor = match registry.descriptor(provider_id) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
failures.push(std::format!("{}:missing_registry_descriptor", provider_id.as_str()));
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(observation.semantics(), descriptor.semantics());
|
||||||
|
}
|
||||||
|
assert!(failures.is_empty(), "live market-price providers did not all refresh successfully after every provider was attempted: {failures:?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_live_smoke(setups: std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>) -> ksp_core_lib::Result<()> {
|
||||||
|
let service = match ksp_offchain_transport_lib::MarketPriceService::new(setups) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = service.registry();
|
||||||
|
let outcomes = match service.refresh_all().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(outcomes.len(), registry.len());
|
||||||
|
assert_live_outcomes(®istry, outcomes.as_slice());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[ignore = "opt-in live SOL/USD smoke for seven keyless V1 providers; performs external HTTPS requests"]
|
||||||
|
async fn keyless_market_price_providers_live_smoke() -> ksp_core_lib::Result<()> {
|
||||||
|
let setups = match keyless_setups() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return run_live_smoke(setups).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[ignore = "opt-in live SOL/USD smoke for four free keyed V1 modes; reads API keys from stdin in Birdeye/CoinGecko/CoinMarketCap/Jupiter order"]
|
||||||
|
async fn keyed_market_price_providers_live_smoke() -> ksp_core_lib::Result<()> {
|
||||||
|
let setups = match keyed_setups() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return run_live_smoke(setups).await;
|
||||||
|
}
|
||||||
239
crates/ksp-offchain-transport-lib/tests/public_api.rs
Normal file
239
crates/ksp-offchain-transport-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
// version: 10
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Public API canaries for the market-price foundation and stable Off-chain Transport error-code surface.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_002_market_price_foundation_is_available_from_crate_root() -> ksp_core_lib::Result<()> {
|
||||||
|
assert_eq!(ksp_offchain_transport_lib::MarketPricePair::SolUsd.code(), "SOL/USD");
|
||||||
|
assert_eq!(ksp_offchain_transport_lib::MARKET_PRICE_DECIMAL_MAX_SCALE, 18);
|
||||||
|
let price = match ksp_offchain_transport_lib::MarketPriceDecimal::parse("201.2500") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(price.to_canonical_string(), "201.25");
|
||||||
|
let id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("provider-canary") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let common = ksp_offchain_transport_lib::MarketPriceProviderCommonSettings::new(id.clone(), true);
|
||||||
|
assert_eq!(common.provider_id(), &id);
|
||||||
|
let rate_limit = match ksp_offchain_transport_lib::MarketPriceProviderRateLimit::fixed(
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
std::option::Option::None,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderRateLimitScope::Ip,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let descriptor = match ksp_offchain_transport_lib::MarketPriceProviderDescriptor::new(
|
||||||
|
id.clone(),
|
||||||
|
"Provider Canary",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::None,
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(descriptor.supports_sol_usd());
|
||||||
|
let provenance = match ksp_offchain_transport_lib::MarketPriceProvenance::new("canary") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let timestamp = ksp_offchain_transport_lib::MarketPriceTimestamp::from_unix_millis(1);
|
||||||
|
let observation = match ksp_offchain_transport_lib::MarketPriceObservation::new(
|
||||||
|
id,
|
||||||
|
price,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
std::option::Option::None,
|
||||||
|
provenance,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.price(), price);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_004_aggregator_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
|
||||||
|
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coingecko_provider = match ksp_offchain_transport_lib::MarketPriceCoinGeckoProvider::new(coingecko) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(coingecko_provider.descriptor().id().as_str(), "coingecko");
|
||||||
|
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinmarketcap_provider = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapProvider::new(coinmarketcap) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(coinmarketcap_provider.descriptor().id().as_str(), "coinmarketcap");
|
||||||
|
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinpaprika_provider = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaProvider::new(coinpaprika) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(coinpaprika_provider.descriptor().id().as_str(), "coinpaprika");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_005_exchange_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
|
||||||
|
let kraken = match ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kraken_provider = match ksp_offchain_transport_lib::MarketPriceKrakenProvider::new(kraken) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(kraken_provider.descriptor().id().as_str(), "kraken");
|
||||||
|
assert_eq!(kraken_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade);
|
||||||
|
let coinbase = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinbase_provider = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeProvider::new(coinbase) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(coinbase_provider.descriptor().id().as_str(), "coinbase_exchange");
|
||||||
|
assert_eq!(coinbase_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_006_jupiter_and_dexscreener_settings_and_adapters_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
|
||||||
|
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let jupiter_provider = match ksp_offchain_transport_lib::MarketPriceJupiterProvider::new(jupiter) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(jupiter_provider.descriptor().id().as_str(), "jupiter");
|
||||||
|
assert_eq!(jupiter_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic);
|
||||||
|
let dexscreener = match ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let dexscreener_provider = match ksp_offchain_transport_lib::MarketPriceDexScreenerProvider::new(dexscreener) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(dexscreener_provider.descriptor().id().as_str(), "dexscreener");
|
||||||
|
assert_eq!(dexscreener_provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_007_birdeye_and_provider_registry_are_available_from_crate_root() -> ksp_core_lib::Result<()> {
|
||||||
|
let settings = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider = match ksp_offchain_transport_lib::MarketPriceBirdeyeProvider::new(settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(provider.descriptor().id().as_str(), "birdeye");
|
||||||
|
assert_eq!(provider.descriptor().semantics(), ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot);
|
||||||
|
let request_cost = match provider.descriptor().sol_usd_request_cost() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID,
|
||||||
|
"Birdeye descriptor is missing request-cost metadata",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(request_cost.amount(), 3);
|
||||||
|
assert_eq!(request_cost.unit(), ksp_offchain_transport_lib::MarketPriceProviderQuotaUnit::ComputeUnits);
|
||||||
|
let ready = ksp_offchain_transport_lib::MarketPriceProviderAvailability::Ready;
|
||||||
|
assert!(ready.is_refresh_eligible());
|
||||||
|
assert_eq!(ready.retry_at(), std::option::Option::None);
|
||||||
|
let entry = ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry::new(provider.descriptor().clone(), ready);
|
||||||
|
let registry = match ksp_offchain_transport_lib::MarketPriceProviderRegistry::new(std::vec![entry]) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(registry.len(), 1);
|
||||||
|
assert_eq!(registry.entries()[0].descriptor().id().as_str(), "birdeye");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn public_pre_008_generic_refresh_service_is_available_without_provider_branching_at_callsite() -> ksp_core_lib::Result<()> {
|
||||||
|
let settings = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let service =
|
||||||
|
match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(settings),]) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = service.registry();
|
||||||
|
let provider_id = registry.entries()[0].descriptor().id().clone();
|
||||||
|
let outcome = match service.refresh(&provider_id).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(outcome.provider_id(), &provider_id);
|
||||||
|
assert!(!outcome.refreshed());
|
||||||
|
assert_eq!(outcome.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offchain_error_codes_use_owned_domain() {
|
||||||
|
let codes = [
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_ACCESS_DENIED,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_ADMISSION_DEFERRED,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_CONNECTION_FAILED,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_INVALID_JSON,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_RATE_LIMIT_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_RATE_LIMITED,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_REQUEST_FAILED,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_REQUEST_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_SETTINGS_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_TEMPORARY_FAILURE,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_HTTP_TIMEOUT,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DISABLED,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_OBSERVATION_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID,
|
||||||
|
ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID,
|
||||||
|
];
|
||||||
|
for code in codes {
|
||||||
|
assert_eq!(code.domain(), "offchain_transport");
|
||||||
|
}
|
||||||
|
}
|
||||||
161
crates/ksp-offchain-transport-lib/tests/release_completeness.rs
Normal file
161
crates/ksp-offchain-transport-lib/tests/release_completeness.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/tests/release_completeness.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Release-level completeness canaries for the eight-provider SOL/USD V1 contract.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_exact_eight_provider_inventory_and_semantics_are_stable() -> ksp_core_lib::Result<()> {
|
||||||
|
let setups = match all_disabled_setups() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let service = match ksp_offchain_transport_lib::MarketPriceService::new(setups) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = service.registry();
|
||||||
|
assert_eq!(registry.len(), 8);
|
||||||
|
let expected = [
|
||||||
|
(
|
||||||
|
"birdeye",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::SolanaSpot,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::RequiredApiKey,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"coinbase_exchange",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"coingecko",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"coinmarketcap",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"coinpaprika",
|
||||||
|
ksp_offchain_transport_lib::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None,
|
||||||
|
),
|
||||||
|
("dexscreener", ksp_offchain_transport_lib::MarketPriceSemantics::DexPairUsd, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
|
||||||
|
("jupiter", ksp_offchain_transport_lib::MarketPriceSemantics::SolanaHeuristic, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
|
||||||
|
("kraken", ksp_offchain_transport_lib::MarketPriceSemantics::ExchangeLastTrade, ksp_offchain_transport_lib::MarketPriceProviderAuthMode::None),
|
||||||
|
];
|
||||||
|
for (entry, (provider_id, semantics, auth_mode)) in registry.entries().iter().zip(expected) {
|
||||||
|
assert_eq!(entry.descriptor().id().as_str(), provider_id);
|
||||||
|
assert_eq!(entry.descriptor().semantics(), semantics);
|
||||||
|
assert_eq!(entry.descriptor().auth_mode(), auth_mode);
|
||||||
|
assert!(entry.descriptor().supports_sol_usd());
|
||||||
|
assert_eq!(entry.state().availability(), ksp_offchain_transport_lib::MarketPriceProviderAvailability::Disabled);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_evolutive_public_enums_are_non_exhaustive() -> std::io::Result<()> {
|
||||||
|
let root = crate_root();
|
||||||
|
let provider = match std::fs::read_to_string(root.join("src/market_price_provider.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
for enum_name in [
|
||||||
|
"MarketPricePair",
|
||||||
|
"MarketPriceSemantics",
|
||||||
|
"MarketPriceProviderAuthMode",
|
||||||
|
"MarketPriceProviderRateLimitScope",
|
||||||
|
"MarketPriceProviderRateLimitKind",
|
||||||
|
"MarketPriceProviderQuotaPeriod",
|
||||||
|
"MarketPriceProviderQuotaUnit",
|
||||||
|
"MarketPriceProviderAvailability",
|
||||||
|
] {
|
||||||
|
assert_non_exhaustive(provider.as_str(), enum_name);
|
||||||
|
}
|
||||||
|
let coingecko = match std::fs::read_to_string(root.join("src/market_price_coingecko.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_non_exhaustive(coingecko.as_str(), "MarketPriceCoinGeckoAccessMode");
|
||||||
|
let coinmarketcap = match std::fs::read_to_string(root.join("src/market_price_coinmarketcap.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_non_exhaustive(coinmarketcap.as_str(), "MarketPriceCoinMarketCapAccessMode");
|
||||||
|
let jupiter = match std::fs::read_to_string(root.join("src/market_price_jupiter.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_non_exhaustive(jupiter.as_str(), "MarketPriceJupiterAccessMode");
|
||||||
|
let service = match std::fs::read_to_string(root.join("src/market_price_service.rs")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_non_exhaustive(service.as_str(), "MarketPriceProviderSetup");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_disabled_setups() -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderSetup>> {
|
||||||
|
let birdeye = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinbase = match ksp_offchain_transport_lib::MarketPriceCoinbaseExchangeSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceCoinPaprikaSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let dexscreener = match ksp_offchain_transport_lib::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kraken = match ksp_offchain_transport_lib::MarketPriceKrakenSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(std::vec![
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinbaseExchange(coinbase),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::DexScreener(dexscreener),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Kraken(kraken),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_non_exhaustive(source: &str, enum_name: &str) {
|
||||||
|
let marker = std::format!("pub enum {enum_name}");
|
||||||
|
let position = source.find(marker.as_str());
|
||||||
|
assert!(position.is_some(), "public enum must remain present: {enum_name}");
|
||||||
|
if let std::option::Option::Some(position) = position {
|
||||||
|
let start = position.saturating_sub(256);
|
||||||
|
assert!(source[start..position].contains("#[non_exhaustive]"), "evolutive public enum must be non_exhaustive: {enum_name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crate_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/tests/security_hardening.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Adversarial security canaries for Off-chain Transport diagnostics, credentials and ownership boundaries.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_keyed_settings_and_service_debug_never_expose_credentials() -> ksp_core_lib::Result<()> {
|
||||||
|
let secret = "pre010-api-key-secret-canary";
|
||||||
|
let birdeye = match ksp_offchain_transport_lib::MarketPriceBirdeyeSettings::new(true, std::option::Option::Some(secret.to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coingecko = match ksp_offchain_transport_lib::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::Some(secret.to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinmarketcap = match ksp_offchain_transport_lib::MarketPriceCoinMarketCapSettings::basic(true, std::option::Option::Some(secret.to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let jupiter = match ksp_offchain_transport_lib::MarketPriceJupiterSettings::free(true, std::option::Option::Some(secret.to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
for debug in [std::format!("{birdeye:?}"), std::format!("{coingecko:?}"), std::format!("{coinmarketcap:?}"), std::format!("{jupiter:?}")] {
|
||||||
|
assert!(!debug.contains(secret));
|
||||||
|
assert!(debug.contains("api_key_present"));
|
||||||
|
}
|
||||||
|
let service = match ksp_offchain_transport_lib::MarketPriceService::new(std::vec![
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Birdeye(birdeye),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
|
||||||
|
ksp_offchain_transport_lib::MarketPriceProviderSetup::Jupiter(jupiter),
|
||||||
|
]) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(!std::format!("{service:?}").contains(secret));
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_market_price_sources_do_not_read_environment_or_use_float_price_truth() -> std::io::Result<()> {
|
||||||
|
let src = crate_root().join("src");
|
||||||
|
let entries = match std::fs::read_dir(src) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
if !name.starts_with("market_price_") || !name.ends_with(".rs") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source = match std::fs::read_to_string(entry.path()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(!source.contains("std::env"), "market-price source must not bypass Config environment ownership: {name}");
|
||||||
|
assert!(!source.contains("as_f64("), "market-price source must not use serde_json f64 as canonical price truth: {name}");
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_config_schema_exposes_no_provider_url_or_rate_limit_override() -> std::io::Result<()> {
|
||||||
|
let root = crate_root().join("../..");
|
||||||
|
let schema = match std::fs::read_to_string(root.join("config/schemas/std.offchain_transport.schema.json")) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(!schema.contains("base_url"));
|
||||||
|
assert!(!schema.contains("endpoint_url"));
|
||||||
|
assert!(!schema.contains("rate_limit"));
|
||||||
|
assert!(!schema.contains("requests_per"));
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crate_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_admission_smooths_undocumented_burst_and_refills_deterministically() -> ksp_core_lib::Result<()> {
|
||||||
|
let policy = match crate::HttpAdmissionPolicy::fixed(2, std::time::Duration::from_secs(1), std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let controller = match crate::HttpAdmissionController::new(policy, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Ready));
|
||||||
|
let deferred = controller.try_admit_at(start);
|
||||||
|
let delay = match deferred {
|
||||||
|
crate::HttpAdmissionDecision::Deferred(value) => value,
|
||||||
|
crate::HttpAdmissionDecision::Ready => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "test expected local deferral"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(delay > std::time::Duration::ZERO);
|
||||||
|
let half_second = match start.checked_add(std::time::Duration::from_millis(500)) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "test instant overflow"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(matches!(controller.try_admit_at(half_second), crate::HttpAdmissionDecision::Ready));
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn documented_burst_may_exceed_average_window_budget_and_is_consumed_atomically() -> ksp_core_lib::Result<()> {
|
||||||
|
let policy = match crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::from_secs(1), std::option::Option::Some(2)) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let controller = match crate::HttpAdmissionController::new(policy, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Ready));
|
||||||
|
assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Ready));
|
||||||
|
assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Deferred(_)));
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_retry_after_extends_but_cannot_pathologically_lock_cooldown() -> ksp_core_lib::Result<()> {
|
||||||
|
let controller =
|
||||||
|
match crate::HttpAdmissionController::new(crate::HttpAdmissionPolicy::Dynamic, std::option::Option::Some(std::time::Duration::from_secs(2))) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let applied = controller.record_rate_limited(std::option::Option::Some(std::time::Duration::from_secs(99_999)));
|
||||||
|
assert_eq!(applied, crate::HTTP_MAX_RETRY_AFTER);
|
||||||
|
let remaining = controller.cooldown_remaining();
|
||||||
|
assert!(remaining.is_some());
|
||||||
|
assert!(matches!(controller.try_admit(), crate::HttpAdmissionDecision::Deferred(_)));
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admission_policy_rejects_zero_values_and_invalid_fallback() {
|
||||||
|
assert!(crate::HttpAdmissionPolicy::fixed(0, std::time::Duration::from_secs(1), std::option::Option::None).is_err());
|
||||||
|
assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::ZERO, std::option::Option::None).is_err());
|
||||||
|
assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::from_secs(1), std::option::Option::Some(0)).is_err());
|
||||||
|
}
|
||||||
236
crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
Normal file
236
crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rest_client_accepts_bounded_json_and_never_exposes_request_debug() -> ksp_core_lib::Result<()> {
|
||||||
|
let server_result =
|
||||||
|
spawn_single_response("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\nConnection: close\r\n\r\n{\"price\":123}").await;
|
||||||
|
let server = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
request.append_query_pair("symbol", "SOL/USD");
|
||||||
|
let secret = "sensitive-canary-value";
|
||||||
|
if let std::result::Result::Err(error) = request.insert_sensitive_header("x-api-key", secret) {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let debug = format!("{request:?}");
|
||||||
|
assert!(!debug.contains(secret));
|
||||||
|
assert!(!debug.contains(server.url.as_str()));
|
||||||
|
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = match client.get_json("test-provider", "sol_usd", request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(document.as_bytes(), br#"{"price":123}"#);
|
||||||
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn https_request_builder_accepts_official_style_url_without_exposing_it() -> ksp_core_lib::Result<()> {
|
||||||
|
let mut request = match crate::HttpGetRequest::new_https("https://example.com/price") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
request.append_query_pair("symbol", "SOL/USD");
|
||||||
|
if let std::result::Result::Err(error) = request.insert_sensitive_header("x-api-key", "redaction-canary") {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let debug = format!("{request:?}");
|
||||||
|
assert!(!debug.contains("example.com"));
|
||||||
|
assert!(!debug.contains("redaction-canary"));
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rest_client_rejects_redirects_instead_of_following_them() -> ksp_core_lib::Result<()> {
|
||||||
|
let server_result =
|
||||||
|
spawn_single_response("HTTP/1.1 302 Found\r\nLocation: https://example.invalid/secret\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await;
|
||||||
|
let server = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let error = match client.get_json("test-provider", "redirect", request).await {
|
||||||
|
std::result::Result::Ok(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test expected redirect rejection"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED);
|
||||||
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rest_client_bounds_chunked_body_before_json_deserialization() -> ksp_core_lib::Result<()> {
|
||||||
|
let server_result = spawn_single_response(concat!(
|
||||||
|
"HTTP/1.1 200 OK\r\n",
|
||||||
|
"Content-Type: application/json\r\n",
|
||||||
|
"Transfer-Encoding: chunked\r\n",
|
||||||
|
"Connection: close\r\n\r\n",
|
||||||
|
"8\r\n{\"aaaa\":\r\n",
|
||||||
|
"8\r\n\"bbbbbb\"\r\n",
|
||||||
|
"1\r\n}\r\n0\r\n\r\n",
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let server = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let settings = match crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 8) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let client = match crate::HttpRestClient::new(settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let error = match client.get_json("test-provider", "bounded", request).await {
|
||||||
|
std::result::Result::Ok(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, "test expected body bound"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE);
|
||||||
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rest_client_classifies_429_retry_after_and_never_copies_remote_body() -> ksp_core_lib::Result<()> {
|
||||||
|
let remote_canary = "REMOTE_SECRET_CANARY";
|
||||||
|
let response =
|
||||||
|
format!("HTTP/1.1 429 Too Many Requests\r\nRetry-After: 7\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", remote_canary.len(), remote_canary);
|
||||||
|
let server_result = spawn_single_response(response.as_str()).await;
|
||||||
|
let server = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let error = match client.get_json("test-provider", "rate_limited", request).await {
|
||||||
|
std::result::Result::Ok(_) => return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMITED, "test expected 429")),
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_RATE_LIMITED);
|
||||||
|
assert!(!format!("{error:?}").contains(remote_canary));
|
||||||
|
assert!(!error.to_string().contains(remote_canary));
|
||||||
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rest_client_rejects_invalid_json_after_success_status() -> ksp_core_lib::Result<()> {
|
||||||
|
let server_result = spawn_single_response("HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nnot-json").await;
|
||||||
|
let server = match server_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let error = match client.get_json("test-provider", "invalid_json", request).await {
|
||||||
|
std::result::Result::Ok(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_INVALID_JSON, "test expected invalid JSON"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_INVALID_JSON);
|
||||||
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestServer {
|
||||||
|
join: tokio::task::JoinHandle<std::io::Result<()>>,
|
||||||
|
url: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_single_response(response: &str) -> ksp_core_lib::Result<TestServer> {
|
||||||
|
let listener_result = tokio::net::TcpListener::bind("127.0.0.1:0").await;
|
||||||
|
let listener = match listener_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(io_error(error)),
|
||||||
|
};
|
||||||
|
let address = match listener.local_addr() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(io_error(error)),
|
||||||
|
};
|
||||||
|
let response = response.as_bytes().to_vec();
|
||||||
|
let join = tokio::spawn(async move {
|
||||||
|
let accept_result = listener.accept().await;
|
||||||
|
let (mut socket, _) = match accept_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut request = [0_u8; 4096];
|
||||||
|
if let std::result::Result::Err(error) = tokio::io::AsyncReadExt::read(&mut socket, &mut request).await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = tokio::io::AsyncWriteExt::write_all(&mut socket, response.as_slice()).await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = tokio::io::AsyncWriteExt::shutdown(&mut socket).await {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::io::Result::Ok(());
|
||||||
|
});
|
||||||
|
return std::result::Result::Ok(TestServer { join, url: format!("http://{address}/price") });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish_server(join: tokio::task::JoinHandle<std::io::Result<()>>) -> ksp_core_lib::Result<()> {
|
||||||
|
let joined = join.await;
|
||||||
|
return match joined {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(())) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(error)) => std::result::Result::Err(io_error(error)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(join_error(error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_error(error: std::io::Error) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test HTTP server failed").with_source(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_error(error: tokio::task::JoinError) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test HTTP server task failed").with_source(error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_settings_defaults_are_bounded_and_ordered() {
|
||||||
|
let settings = crate::HttpClientSettings::default();
|
||||||
|
assert!(!settings.connect_timeout().is_zero());
|
||||||
|
assert!(settings.connect_timeout() <= settings.request_timeout());
|
||||||
|
assert!(settings.max_response_body_bytes() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_settings_reject_zero_reversed_and_pathological_bounds() {
|
||||||
|
assert!(crate::HttpClientSettings::new(std::time::Duration::ZERO, std::time::Duration::from_secs(1), 1024).is_err());
|
||||||
|
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(2), std::time::Duration::from_secs(1), 1024).is_err());
|
||||||
|
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 0).is_err());
|
||||||
|
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(31), std::time::Duration::from_secs(31), 1024).is_err());
|
||||||
|
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(121), 1024).is_err());
|
||||||
|
assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 4_194_305).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_adapter.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timestamp_helpers_preserve_valid_values_and_reject_malformed_pre_epoch_and_overflow() {
|
||||||
|
let epoch_fraction = crate::market_price_timestamp_from_rfc3339("1970-01-01T00:00:00.123Z");
|
||||||
|
assert_eq!(epoch_fraction.map(|value| return value.unix_millis()), std::option::Option::Some(123));
|
||||||
|
assert!(crate::market_price_timestamp_from_rfc3339("1969-12-31T23:59:59Z").is_none());
|
||||||
|
assert!(crate::market_price_timestamp_from_rfc3339("not-a-timestamp").is_none());
|
||||||
|
assert_eq!(crate::market_price_timestamp_from_unix_seconds(1).map(|value| return value.unix_millis()), std::option::Option::Some(1_000));
|
||||||
|
assert!(crate::market_price_timestamp_from_unix_seconds(u64::MAX).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_provider_response_error_contains_only_safe_provider_and_field_context() {
|
||||||
|
let error = crate::invalid_provider_response("coingecko", "price");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_PROVIDER_RESPONSE_INVALID);
|
||||||
|
let context = error.context();
|
||||||
|
assert_eq!(context.len(), 2);
|
||||||
|
assert_eq!(context[0].key(), "provider");
|
||||||
|
assert_eq!(context[0].value(), "coingecko");
|
||||||
|
assert_eq!(context[1].key(), "field");
|
||||||
|
assert_eq!(context[1].value(), "price");
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_birdeye.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn birdeye_fixture_maps_exact_spot_price_and_provider_update_time() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::BIRDEYE_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let fixture = br#"{"data":{"value":151.987654321012345678,"updateUnixTime":1778248899,"updateHumanTime":"2026-05-08T19:01:39"},"success":true}"#;
|
||||||
|
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
|
||||||
|
let observation = match super::parse_response(fixture, provider_id, started, received) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
|
||||||
|
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::SolanaSpot);
|
||||||
|
assert_eq!(observation.provider_timestamp().map(|value| return value.unix_millis()), std::option::Option::Some(1_778_248_899_000));
|
||||||
|
assert_eq!(observation.provenance().as_str(), "birdeye:solana:wsol:value");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn birdeye_standard_descriptor_models_account_rate_compute_quota_and_request_cost() -> ksp_core_lib::Result<()> {
|
||||||
|
let settings = match crate::MarketPriceBirdeyeSettings::new(true, std::option::Option::Some("birdeye-standard-canary-secret".to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(!std::format!("{settings:?}").contains("birdeye-standard-canary-secret"));
|
||||||
|
let request = match super::build_request(&settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(request.has_header_for_test(super::BIRDEYE_API_KEY_HEADER));
|
||||||
|
assert!(request.has_header_for_test(super::BIRDEYE_CHAIN_HEADER));
|
||||||
|
assert_eq!(request.url_for_test().query(), std::option::Option::Some("address=So11111111111111111111111111111111111111112"));
|
||||||
|
let provider = match crate::MarketPriceBirdeyeProvider::new(settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let descriptor = provider.descriptor();
|
||||||
|
assert_eq!(descriptor.auth_mode(), crate::MarketPriceProviderAuthMode::RequiredApiKey);
|
||||||
|
assert_eq!(descriptor.semantics(), crate::MarketPriceSemantics::SolanaSpot);
|
||||||
|
assert_eq!(descriptor.rate_limit().requests(), std::option::Option::Some(1));
|
||||||
|
assert_eq!(descriptor.rate_limit().window_seconds(), std::option::Option::Some(1));
|
||||||
|
assert_eq!(descriptor.rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Account);
|
||||||
|
let quota = match descriptor.long_term_quota() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(test_error("quota")),
|
||||||
|
};
|
||||||
|
assert_eq!(quota.amount(), 30_000);
|
||||||
|
assert_eq!(quota.period(), crate::MarketPriceProviderQuotaPeriod::Month);
|
||||||
|
assert_eq!(quota.unit(), crate::MarketPriceProviderQuotaUnit::ComputeUnits);
|
||||||
|
let request_cost = match descriptor.sol_usd_request_cost() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(test_error("request_cost")),
|
||||||
|
};
|
||||||
|
assert_eq!(request_cost.amount(), 3);
|
||||||
|
assert_eq!(request_cost.unit(), crate::MarketPriceProviderQuotaUnit::ComputeUnits);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn birdeye_rejects_missing_key_unsuccessful_missing_price_and_missing_update_time() -> ksp_core_lib::Result<()> {
|
||||||
|
assert!(crate::MarketPriceBirdeyeSettings::new(true, std::option::Option::None).is_err());
|
||||||
|
assert!(crate::MarketPriceBirdeyeSettings::new(false, std::option::Option::None).is_ok());
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::BIRDEYE_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
let unsuccessful = br#"{"data":{"value":151.9,"updateUnixTime":1778248899},"success":false}"#;
|
||||||
|
assert!(super::parse_response(unsuccessful, provider_id.clone(), timestamp, timestamp).is_err());
|
||||||
|
let missing_price = br#"{"data":{"value":null,"updateUnixTime":1778248899},"success":true}"#;
|
||||||
|
assert!(super::parse_response(missing_price, provider_id.clone(), timestamp, timestamp).is_err());
|
||||||
|
let missing_update_time = br#"{"data":{"value":151.9},"success":true}"#;
|
||||||
|
assert!(super::parse_response(missing_update_time, provider_id, timestamp, timestamp).is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID, "Birdeye test expectation failed")
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coinbase_exchange.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coinbase_exchange_fixture_maps_exact_last_trade_and_provider_timestamp() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::COINBASE_EXCHANGE_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let fixture = concat!(
|
||||||
|
r#"{"trade_id":86326522,"price":"151.987654321012345678","size":"0.25","#,
|
||||||
|
r#""time":"2026-08-25T19:00:01.123Z","bid":"151.98","ask":"151.99","volume":"12345.67"}"#
|
||||||
|
)
|
||||||
|
.as_bytes();
|
||||||
|
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
|
||||||
|
let observation = match super::parse_response(fixture, provider_id, started, received) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
|
||||||
|
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
|
||||||
|
assert_eq!(observation.provider_timestamp().map(|value| return value.unix_millis()), std::option::Option::Some(1_787_684_401_123));
|
||||||
|
assert_eq!(observation.provenance().as_str(), "coinbase_exchange:SOL-USD:last_trade");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coinbase_exchange_descriptor_matches_public_token_bucket() -> ksp_core_lib::Result<()> {
|
||||||
|
let settings = match crate::MarketPriceCoinbaseExchangeSettings::new(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider = match crate::MarketPriceCoinbaseExchangeProvider::new(settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(provider.descriptor().id().as_str(), super::COINBASE_EXCHANGE_PROVIDER_ID);
|
||||||
|
assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
|
||||||
|
assert_eq!(provider.descriptor().semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(10));
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1));
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().burst(), std::option::Option::Some(15));
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
|
||||||
|
assert_eq!(provider.descriptor().long_term_quota(), std::option::Option::None);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coinbase_exchange_rejects_invalid_timestamp() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::COINBASE_EXCHANGE_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let fixture = br#"{"price":"151.98","time":"not-a-timestamp"}"#;
|
||||||
|
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
assert!(super::parse_response(fixture, provider_id, timestamp, timestamp).is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coingecko.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coingecko_modes_map_exact_free_capabilities_and_redact_demo_key() -> ksp_core_lib::Result<()> {
|
||||||
|
let keyless = match crate::MarketPriceCoinGeckoSettings::keyless(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let keyless_provider = match crate::MarketPriceCoinGeckoProvider::new(keyless) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(keyless_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
|
||||||
|
assert_eq!(keyless_provider.descriptor().rate_limit().kind(), crate::MarketPriceProviderRateLimitKind::Dynamic);
|
||||||
|
assert_eq!(keyless_provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
|
||||||
|
assert_eq!(keyless_provider.descriptor().long_term_quota(), std::option::Option::None);
|
||||||
|
let demo = match crate::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::Some("demo-secret-canary".to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(!format!("{demo:?}").contains("demo-secret-canary"));
|
||||||
|
let request = match super::build_request(&demo) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(request.url_for_test().host_str(), std::option::Option::Some("api.coingecko.com"));
|
||||||
|
assert!(request.url_for_test().as_str().contains("ids=solana"));
|
||||||
|
assert!(request.url_for_test().as_str().contains("vs_currencies=usd"));
|
||||||
|
assert!(request.has_header_for_test(super::COINGECKO_DEMO_API_KEY_HEADER));
|
||||||
|
let demo_provider = match crate::MarketPriceCoinGeckoProvider::new(demo) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(demo_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::RequiredApiKey);
|
||||||
|
assert_eq!(demo_provider.descriptor().rate_limit().requests(), std::option::Option::Some(100));
|
||||||
|
assert_eq!(demo_provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(60));
|
||||||
|
let quota = match demo_provider.descriptor().long_term_quota() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response("coingecko", "quota")),
|
||||||
|
};
|
||||||
|
assert_eq!(quota.amount(), 10_000);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coingecko_fixture_maps_exact_price_and_real_provider_timestamp() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new("coingecko") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let start = crate::MarketPriceTimestamp::from_unix_millis(1_800_000_000_000);
|
||||||
|
let received = crate::MarketPriceTimestamp::from_unix_millis(1_800_000_000_100);
|
||||||
|
let observation = match super::parse_response(br#"{"solana":{"usd":151.123456789012345678,"last_updated_at":1800000000}}"#, provider_id, start, received) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.price().to_canonical_string(), "151.123456789012345678");
|
||||||
|
assert_eq!(observation.provider_timestamp(), std::option::Option::Some(start));
|
||||||
|
assert_eq!(observation.provenance().as_str(), "coingecko:solana:usd");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coingecko_keyless_rejects_credential_and_enabled_demo_requires_one() {
|
||||||
|
let keyless =
|
||||||
|
crate::MarketPriceCoinGeckoSettings::new(true, crate::MarketPriceCoinGeckoAccessMode::Keyless, std::option::Option::Some("unexpected".to_owned()));
|
||||||
|
assert!(keyless.is_err());
|
||||||
|
assert!(crate::MarketPriceCoinGeckoSettings::demo(true, std::option::Option::None).is_err());
|
||||||
|
assert!(crate::MarketPriceCoinGeckoSettings::demo(false, std::option::Option::None).is_ok());
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coinmarketcap.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coinmarketcap_modes_use_v2_and_map_exact_free_capabilities() -> ksp_core_lib::Result<()> {
|
||||||
|
let keyless = match crate::MarketPriceCoinMarketCapSettings::keyless(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match super::build_request(&keyless) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(request.url_for_test().path(), "/public-api/v2/simple/price");
|
||||||
|
assert_eq!(request.url_for_test().query(), std::option::Option::Some("id=5426&convert=USD&include_last_updated=true"));
|
||||||
|
assert!(!request.has_header_for_test(super::COINMARKETCAP_API_KEY_HEADER));
|
||||||
|
let keyless_provider = match crate::MarketPriceCoinMarketCapProvider::new(keyless) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(keyless_provider.descriptor().rate_limit().kind(), crate::MarketPriceProviderRateLimitKind::Dynamic);
|
||||||
|
let basic = match crate::MarketPriceCoinMarketCapSettings::basic(true, std::option::Option::Some("cmc-secret-canary".to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(!format!("{basic:?}").contains("cmc-secret-canary"));
|
||||||
|
let request = match super::build_request(&basic) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(request.url_for_test().path(), "/v2/simple/price");
|
||||||
|
assert_eq!(request.url_for_test().query(), std::option::Option::Some("id=5426&convert=USD&include_last_updated=true"));
|
||||||
|
assert!(request.has_header_for_test(super::COINMARKETCAP_API_KEY_HEADER));
|
||||||
|
let basic_provider = match crate::MarketPriceCoinMarketCapProvider::new(basic) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(basic_provider.descriptor().rate_limit().requests(), std::option::Option::Some(50));
|
||||||
|
let quota = match basic_provider.descriptor().long_term_quota() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response("coinmarketcap", "quota")),
|
||||||
|
};
|
||||||
|
assert_eq!(quota.amount(), 15_000);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coinmarketcap_v2_fixture_normalizes_string_status_and_exact_price() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new("coinmarketcap") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let start = crate::MarketPriceTimestamp::from_unix_millis(1_775_000_000_000);
|
||||||
|
let received = crate::MarketPriceTimestamp::from_unix_millis(1_775_000_000_100);
|
||||||
|
let observation = match super::parse_response(
|
||||||
|
concat!(
|
||||||
|
r#"{"data":[{"id":5426,"symbol":"SOL","quotes":[{"symbol":"USD","price":151.987654321012345678,"#,
|
||||||
|
r#""last_updated":"2026-04-01T00:00:00.000Z"}]}],"status":{"error_code":"0"}}"#,
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
provider_id,
|
||||||
|
start,
|
||||||
|
received,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
|
||||||
|
assert_eq!(observation.provenance().as_str(), "coinmarketcap:5426:usd:v2");
|
||||||
|
assert!(observation.provider_timestamp().is_some());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coinmarketcap_rejects_nonzero_status_and_wrong_identity() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new("coinmarketcap") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(1);
|
||||||
|
let nonzero = super::parse_response(br#"{"data":[],"status":{"error_code":1001}}"#, provider_id.clone(), timestamp, timestamp);
|
||||||
|
assert!(nonzero.is_err());
|
||||||
|
let wrong = super::parse_response(
|
||||||
|
br#"{"data":[{"id":1,"symbol":"BTC","quotes":[{"symbol":"USD","price":1,"last_updated":"2026-04-01T00:00:00Z"}]}],"status":{"error_code":0}}"#,
|
||||||
|
provider_id,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
assert!(wrong.is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_coinpaprika.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coinpaprika_free_descriptor_maps_ip_rate_and_monthly_request_quota() -> ksp_core_lib::Result<()> {
|
||||||
|
let settings = match crate::MarketPriceCoinPaprikaSettings::new(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request = match super::build_request() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(request.url_for_test().host_str(), std::option::Option::Some("api.coinpaprika.com"));
|
||||||
|
assert_eq!(request.url_for_test().path(), "/v1/tickers/sol-solana");
|
||||||
|
assert!(request.url_for_test().as_str().contains("quotes=USD"));
|
||||||
|
let provider = match crate::MarketPriceCoinPaprikaProvider::new(settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(10));
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1));
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
|
||||||
|
let quota = match provider.descriptor().long_term_quota() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(crate::invalid_provider_response("coinpaprika", "quota")),
|
||||||
|
};
|
||||||
|
assert_eq!(quota.amount(), 20_000);
|
||||||
|
assert_eq!(quota.unit(), crate::MarketPriceProviderQuotaUnit::Requests);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coinpaprika_fixture_validates_sol_identity_and_preserves_timestamp() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new("coinpaprika") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(1_775_000_000_000);
|
||||||
|
let observation = match super::parse_response(
|
||||||
|
br#"{"id":"sol-solana","symbol":"SOL","last_updated":"2026-04-01T00:00:00Z","quotes":{"USD":{"price":151.010203040506070809}}}"#,
|
||||||
|
provider_id.clone(),
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.price().to_canonical_string(), "151.010203040506070809");
|
||||||
|
assert_eq!(observation.provenance().as_str(), "coinpaprika:sol-solana:usd");
|
||||||
|
let wrong = super::parse_response(
|
||||||
|
br#"{"id":"btc-bitcoin","symbol":"BTC","last_updated":"2026-04-01T00:00:00Z","quotes":{"USD":{"price":1}}}"#,
|
||||||
|
provider_id,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
assert!(wrong.is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimal_normalizes_fractional_and_scientific_forms_without_f64() -> ksp_core_lib::Result<()> {
|
||||||
|
let cases = [
|
||||||
|
("123.4500", "123.45", 12_345_u128, 2_u8),
|
||||||
|
("1.2345e2", "123.45", 12_345, 2),
|
||||||
|
("12345e-2", "123.45", 12_345, 2),
|
||||||
|
("1e3", "1000", 1_000, 0),
|
||||||
|
("1e-3", "0.001", 1, 3),
|
||||||
|
];
|
||||||
|
for (source, expected, coefficient, scale) in cases {
|
||||||
|
let value = match crate::MarketPriceDecimal::parse(source) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(value.to_canonical_string(), expected);
|
||||||
|
assert_eq!(value.coefficient(), coefficient);
|
||||||
|
assert_eq!(value.scale(), scale);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimal_rejects_zero_negative_nonfinite_excessive_scale_and_overflow() {
|
||||||
|
let invalid = ["0", "0.000", "-1", "+1", "NaN", "inf", "1e-19", "1e129", "340282366920938463463374607431768211456", " 1", "1 ", ".1", "1."];
|
||||||
|
for source in invalid {
|
||||||
|
let result = crate::MarketPriceDecimal::parse(source);
|
||||||
|
assert!(result.is_err());
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimal_serde_is_canonical_string_and_round_trips_exactly() -> ksp_core_lib::Result<()> {
|
||||||
|
let value = match crate::MarketPriceDecimal::parse("123.4500") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let encoded = match serde_json::to_string(&value) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test serialization failed"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(encoded, "\"123.45\"");
|
||||||
|
let decoded: crate::MarketPriceDecimal = match serde_json::from_str(encoded.as_str()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test deserialization failed"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(decoded, value);
|
||||||
|
assert!(serde_json::from_str::<crate::MarketPriceDecimal>("123.45").is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimal_parses_raw_json_number_and_string_without_f64_round_trip() -> ksp_core_lib::Result<()> {
|
||||||
|
let number = match serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>("151.123456789012345678") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test raw number failed"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let parsed = match crate::MarketPriceDecimal::parse_json_raw(number.as_ref()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(parsed.to_canonical_string(), "151.123456789012345678");
|
||||||
|
let string = match serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>(r#""151.2300""#) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "test raw string failed"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let parsed = match crate::MarketPriceDecimal::parse_json_raw(string.as_ref()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(parsed.to_canonical_string(), "151.23");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimal_raw_json_rejects_non_numeric_and_pathological_values() {
|
||||||
|
let invalid = ["null", "true", "false", "{}", "[]", r#""0""#, r#""-1""#, r#""1e-19""#, r#""1e129""#];
|
||||||
|
for source in invalid {
|
||||||
|
let raw = serde_json::from_str::<std::boxed::Box<serde_json::value::RawValue>>(source);
|
||||||
|
assert!(raw.is_ok(), "adversarial raw JSON fixture must itself be syntactically valid: {source}");
|
||||||
|
if let std::result::Result::Ok(raw) = raw {
|
||||||
|
let parsed = crate::MarketPriceDecimal::parse_json_raw(raw.as_ref());
|
||||||
|
assert!(parsed.is_err(), "non-price raw JSON must not become a successful decimal: {source}");
|
||||||
|
if let std::result::Result::Err(error) = parsed {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
const TEST_PAIR: &str = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dexscreener_fixture_validates_chain_pair_and_sol_identity_and_maps_exact_pair_usd() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::DEXSCREENER_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let pair_address = match TEST_PAIR.parse::<ksp_core_lib::Pubkey>() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(super::provider_settings_error("test_pair")),
|
||||||
|
};
|
||||||
|
let fixture = concat!(
|
||||||
|
r#"{"schemaVersion":"1.0.0","pairs":[{"chainId":"solana","dexId":"orca","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
|
||||||
|
r#""baseToken":{"address":"So11111111111111111111111111111111111111112","name":"Wrapped SOL","symbol":"SOL"},"#,
|
||||||
|
r#""quoteToken":{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","name":"USD Coin","symbol":"USDC"},"#,
|
||||||
|
r#""priceNative":"151.987654321012345678","priceUsd":"151.987654321012345678"}]}"#
|
||||||
|
)
|
||||||
|
.as_bytes();
|
||||||
|
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
|
||||||
|
let observation = match super::parse_response(fixture, provider_id, &pair_address, started, received) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
|
||||||
|
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::DexPairUsd);
|
||||||
|
assert_eq!(observation.provider_timestamp(), std::option::Option::None);
|
||||||
|
assert_eq!(observation.provenance().as_str(), "dexscreener:solana:Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE:priceUsd");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dexscreener_settings_and_descriptor_keep_pair_explicit_and_pair_endpoint_rate_limited() -> ksp_core_lib::Result<()> {
|
||||||
|
let disabled = crate::MarketPriceDexScreenerSettings::new(false, std::option::Option::None);
|
||||||
|
assert!(disabled.is_ok(), "disabled DexScreener settings must not require a fake pair address: {disabled:?}");
|
||||||
|
assert!(crate::MarketPriceDexScreenerSettings::new(true, std::option::Option::Some("not-a-pubkey".to_owned())).is_err());
|
||||||
|
let settings = match crate::MarketPriceDexScreenerSettings::new(true, std::option::Option::Some(TEST_PAIR.to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let pair_address = settings.pair_address();
|
||||||
|
assert!(pair_address.is_some(), "enabled DexScreener settings should retain the configured pair");
|
||||||
|
let pair_address = match pair_address {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID,
|
||||||
|
"enabled DexScreener settings lost their pair address",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(pair_address.to_string(), TEST_PAIR);
|
||||||
|
let request = match super::build_request(pair_address) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(request.url_for_test().as_str(), "https://api.dexscreener.com/latest/dex/pairs/solana/Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE");
|
||||||
|
let provider = match crate::MarketPriceDexScreenerProvider::new(settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
|
||||||
|
assert_eq!(provider.descriptor().semantics(), crate::MarketPriceSemantics::DexPairUsd);
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(300));
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(60));
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Unspecified);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dexscreener_rejects_wrong_chain_pair_base_identity_multiple_pairs_and_missing_price() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::DEXSCREENER_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let pair_address = match TEST_PAIR.parse::<ksp_core_lib::Pubkey>() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(super::provider_settings_error("test_pair")),
|
||||||
|
};
|
||||||
|
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
let wrong_chain = concat!(
|
||||||
|
r#"{"pairs":[{"chainId":"ethereum","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
|
||||||
|
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"}]}"#
|
||||||
|
)
|
||||||
|
.as_bytes();
|
||||||
|
assert!(super::parse_response(wrong_chain, provider_id.clone(), &pair_address, timestamp, timestamp).is_err());
|
||||||
|
let wrong_pair = concat!(
|
||||||
|
r#"{"pairs":[{"chainId":"solana","pairAddress":"58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2","#,
|
||||||
|
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"}]}"#
|
||||||
|
)
|
||||||
|
.as_bytes();
|
||||||
|
assert!(super::parse_response(wrong_pair, provider_id.clone(), &pair_address, timestamp, timestamp).is_err());
|
||||||
|
let wrong_base = concat!(
|
||||||
|
r#"{"pairs":[{"chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
|
||||||
|
r#""baseToken":{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"},"priceUsd":"1.00"}]}"#
|
||||||
|
)
|
||||||
|
.as_bytes();
|
||||||
|
assert!(super::parse_response(wrong_base, provider_id.clone(), &pair_address, timestamp, timestamp).is_err());
|
||||||
|
let multiple = concat!(
|
||||||
|
r#"{"pairs":[{"chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
|
||||||
|
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"},{"#,
|
||||||
|
r#""chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
|
||||||
|
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":"151.98"}]}"#
|
||||||
|
)
|
||||||
|
.as_bytes();
|
||||||
|
assert!(super::parse_response(multiple, provider_id.clone(), &pair_address, timestamp, timestamp).is_err());
|
||||||
|
let missing_price = concat!(
|
||||||
|
r#"{"pairs":[{"chainId":"solana","pairAddress":"Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE","#,
|
||||||
|
r#""baseToken":{"address":"So11111111111111111111111111111111111111112"},"priceUsd":null}]}"#
|
||||||
|
)
|
||||||
|
.as_bytes();
|
||||||
|
assert!(super::parse_response(missing_price, provider_id, &pair_address, timestamp, timestamp).is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jupiter_fixture_maps_exact_heuristic_price_and_block_provenance_without_fake_timestamp() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::JUPITER_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let fixture = concat!(
|
||||||
|
r#"{"So11111111111111111111111111111111111111112":{"createdAt":"2024-06-05T08:55:25.527Z","liquidity":621679197.67,"#,
|
||||||
|
r#""usdPrice":151.987654321012345678,"blockId":348004023,"decimals":9,"priceChange24h":1.29}}"#
|
||||||
|
)
|
||||||
|
.as_bytes();
|
||||||
|
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
|
||||||
|
let observation = match super::parse_response(fixture, provider_id, started, received) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.price().to_canonical_string(), "151.987654321012345678");
|
||||||
|
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::SolanaHeuristic);
|
||||||
|
assert_eq!(observation.provider_timestamp(), std::option::Option::None);
|
||||||
|
assert_eq!(observation.provenance().as_str(), "jupiter:price_v3:So11111111111111111111111111111111111111112:block:348004023");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jupiter_modes_match_current_keyless_and_free_rate_limits_and_redact_key() -> ksp_core_lib::Result<()> {
|
||||||
|
let keyless = match crate::MarketPriceJupiterSettings::keyless(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let keyless_provider = match crate::MarketPriceJupiterProvider::new(keyless) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(keyless_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
|
||||||
|
assert_eq!(keyless_provider.descriptor().semantics(), crate::MarketPriceSemantics::SolanaHeuristic);
|
||||||
|
assert_eq!(keyless_provider.descriptor().rate_limit().requests(), std::option::Option::Some(1));
|
||||||
|
assert_eq!(keyless_provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(2));
|
||||||
|
assert_eq!(keyless_provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Unspecified);
|
||||||
|
let free = match crate::MarketPriceJupiterSettings::free(true, std::option::Option::Some("jup-free-canary-secret".to_owned())) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(!std::format!("{free:?}").contains("jup-free-canary-secret"));
|
||||||
|
let request = match super::build_request(&free) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(request.has_header_for_test(super::JUPITER_API_KEY_HEADER));
|
||||||
|
assert_eq!(request.url_for_test().query(), std::option::Option::Some("ids=So11111111111111111111111111111111111111112"));
|
||||||
|
let free_provider = match crate::MarketPriceJupiterProvider::new(free) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(free_provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::RequiredApiKey);
|
||||||
|
assert_eq!(free_provider.descriptor().rate_limit().requests(), std::option::Option::Some(1));
|
||||||
|
assert_eq!(free_provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1));
|
||||||
|
assert_eq!(free_provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Account);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jupiter_rejects_keyless_credential_missing_price_wrong_identity_and_wrong_decimals() -> ksp_core_lib::Result<()> {
|
||||||
|
assert!(crate::MarketPriceJupiterSettings::free(true, std::option::Option::None).is_err());
|
||||||
|
assert!(crate::MarketPriceJupiterSettings::keyless(true).is_ok());
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::JUPITER_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
let missing_price = br#"{"So11111111111111111111111111111111111111112":{"usdPrice":null,"blockId":348004023,"decimals":9}}"#;
|
||||||
|
assert!(super::parse_response(missing_price, provider_id.clone(), timestamp, timestamp).is_err());
|
||||||
|
let wrong_identity = br#"{"JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN":{"usdPrice":1.0,"blockId":348004023,"decimals":6}}"#;
|
||||||
|
assert!(super::parse_response(wrong_identity, provider_id.clone(), timestamp, timestamp).is_err());
|
||||||
|
let wrong_decimals = br#"{"So11111111111111111111111111111111111111112":{"usdPrice":151.98,"blockId":348004023,"decimals":8}}"#;
|
||||||
|
assert!(super::parse_response(wrong_decimals, provider_id, timestamp, timestamp).is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kraken_fixture_maps_exact_last_trade_without_inventing_provider_timestamp() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::KRAKEN_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let fixture = br#"{"error":[],"result":{"SOLUSD":{"a":["152.10","1","1.0"],"b":["152.00","1","1.0"],"c":["152.012345678901234567","0.25"]}}}"#;
|
||||||
|
let started = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
let received = crate::MarketPriceTimestamp::from_unix_millis(20);
|
||||||
|
let observation = match super::parse_response(fixture, provider_id, started, received) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.price().to_canonical_string(), "152.012345678901234567");
|
||||||
|
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
|
||||||
|
assert_eq!(observation.provider_timestamp(), std::option::Option::None);
|
||||||
|
assert_eq!(observation.provenance().as_str(), "kraken:SOLUSD:last_trade");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kraken_descriptor_is_keyless_exchange_last_trade_with_conservative_public_cadence() -> ksp_core_lib::Result<()> {
|
||||||
|
let settings = match crate::MarketPriceKrakenSettings::new(true) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider = match crate::MarketPriceKrakenProvider::new(settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(provider.descriptor().id().as_str(), super::KRAKEN_PROVIDER_ID);
|
||||||
|
assert_eq!(provider.descriptor().auth_mode(), crate::MarketPriceProviderAuthMode::None);
|
||||||
|
assert_eq!(provider.descriptor().semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().requests(), std::option::Option::Some(1));
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().window_seconds(), std::option::Option::Some(1));
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().burst(), std::option::Option::None);
|
||||||
|
assert_eq!(provider.descriptor().rate_limit().scope(), crate::MarketPriceProviderRateLimitScope::Ip);
|
||||||
|
assert_eq!(provider.descriptor().long_term_quota(), std::option::Option::None);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kraken_rejects_provider_error_and_wrong_result_identity() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(super::KRAKEN_PROVIDER_ID) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let timestamp = crate::MarketPriceTimestamp::from_unix_millis(10);
|
||||||
|
let provider_error = br#"{"error":["EGeneral:Temporary lockout"],"result":{}}"#;
|
||||||
|
assert!(super::parse_response(provider_error, provider_id.clone(), timestamp, timestamp).is_err());
|
||||||
|
let wrong_identity = br#"{"error":[],"result":{"SOLEUR":{"c":["152.01","0.25"]}}}"#;
|
||||||
|
assert!(super::parse_response(wrong_identity, provider_id, timestamp, timestamp).is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observation_preserves_pair_exact_price_semantics_timestamps_and_safe_provenance() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new("kraken") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let price = match crate::MarketPriceDecimal::parse("204.125") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new("market=SOL/USD") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let started = crate::MarketPriceTimestamp::from_unix_millis(10_000);
|
||||||
|
let received = crate::MarketPriceTimestamp::from_unix_millis(10_250);
|
||||||
|
let observation = match crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::ExchangeLastTrade,
|
||||||
|
started,
|
||||||
|
received,
|
||||||
|
std::option::Option::None,
|
||||||
|
provenance,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(observation.pair(), crate::MarketPricePair::SolUsd);
|
||||||
|
assert_eq!(observation.pair().code(), "SOL/USD");
|
||||||
|
assert_eq!(observation.price(), price);
|
||||||
|
assert_eq!(observation.semantics(), crate::MarketPriceSemantics::ExchangeLastTrade);
|
||||||
|
assert_eq!(observation.request_started_at(), started);
|
||||||
|
assert_eq!(observation.received_at(), received);
|
||||||
|
assert_eq!(observation.provider_timestamp(), std::option::Option::None);
|
||||||
|
assert_eq!(observation.provenance().as_str(), "market=SOL/USD");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observation_rejects_reversed_ksp_timestamps_and_unsafe_provenance() -> ksp_core_lib::Result<()> {
|
||||||
|
assert!(crate::MarketPriceProvenance::new("line\nbreak").is_err());
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new("coingecko") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let price = match crate::MarketPriceDecimal::parse("200") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provenance = match crate::MarketPriceProvenance::new("asset=solana") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let result = crate::MarketPriceObservation::new(
|
||||||
|
provider_id,
|
||||||
|
price,
|
||||||
|
crate::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
crate::MarketPriceTimestamp::from_unix_millis(2),
|
||||||
|
crate::MarketPriceTimestamp::from_unix_millis(1),
|
||||||
|
std::option::Option::None,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provenance_accepts_exact_boundary_and_rejects_trim_control_and_oversize() -> ksp_core_lib::Result<()> {
|
||||||
|
let exact = "a".repeat(crate::MARKET_PRICE_PROVENANCE_MAX_BYTES);
|
||||||
|
let provenance = crate::MarketPriceProvenance::new(exact.clone());
|
||||||
|
assert!(provenance.is_ok());
|
||||||
|
if let std::result::Result::Ok(provenance) = provenance {
|
||||||
|
assert_eq!(provenance.as_str(), exact);
|
||||||
|
}
|
||||||
|
assert!(crate::MarketPriceProvenance::new(format!("{exact}a")).is_err());
|
||||||
|
assert!(crate::MarketPriceProvenance::new(" leading").is_err());
|
||||||
|
assert!(crate::MarketPriceProvenance::new("trailing ").is_err());
|
||||||
|
assert!(crate::MarketPriceProvenance::new("tab\tvalue").is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_id_is_opaque_bounded_and_stable() -> ksp_core_lib::Result<()> {
|
||||||
|
let id = match crate::MarketPriceProviderId::new("coingecko-main") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(id.as_str(), "coingecko-main");
|
||||||
|
assert_eq!(id.to_string(), "coingecko-main");
|
||||||
|
for invalid in ["", "CoinGecko", "coin gecko", "coin/gecko", "é"] {
|
||||||
|
assert!(crate::MarketPriceProviderId::new(invalid).is_err());
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_descriptor_preserves_semantics_auth_limits_and_informational_quota() -> ksp_core_lib::Result<()> {
|
||||||
|
let id = match crate::MarketPriceProviderId::new("provider-a") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 2, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let quota = match crate::MarketPriceProviderLongTermQuota::new(
|
||||||
|
10_000,
|
||||||
|
crate::MarketPriceProviderQuotaPeriod::Month,
|
||||||
|
crate::MarketPriceProviderQuotaUnit::Credits,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let descriptor = match crate::MarketPriceProviderDescriptor::new(
|
||||||
|
id,
|
||||||
|
"Provider A",
|
||||||
|
crate::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
crate::MarketPriceProviderAuthMode::OptionalApiKey,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::Some(quota),
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(descriptor.display_name(), "Provider A");
|
||||||
|
assert_eq!(descriptor.sol_usd_request_cost(), std::option::Option::None);
|
||||||
|
assert_eq!(descriptor.semantics(), crate::MarketPriceSemantics::AggregatedMarket);
|
||||||
|
assert_eq!(descriptor.auth_mode(), crate::MarketPriceProviderAuthMode::OptionalApiKey);
|
||||||
|
assert_eq!(descriptor.rate_limit(), rate_limit);
|
||||||
|
assert_eq!(descriptor.long_term_quota(), std::option::Option::Some(quota));
|
||||||
|
assert!(descriptor.supports_sol_usd());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_limit_descriptors_reject_zero_and_model_dynamic_scope() {
|
||||||
|
assert!(crate::MarketPriceProviderRateLimit::fixed(0, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip).is_err());
|
||||||
|
assert!(crate::MarketPriceProviderRateLimit::fixed(1, 0, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Ip).is_err());
|
||||||
|
assert!(crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::Some(0), crate::MarketPriceProviderRateLimitScope::Ip).is_err());
|
||||||
|
let dynamic = crate::MarketPriceProviderRateLimit::dynamic(crate::MarketPriceProviderRateLimitScope::Ip);
|
||||||
|
assert_eq!(dynamic.kind(), crate::MarketPriceProviderRateLimitKind::Dynamic);
|
||||||
|
assert_eq!(dynamic.scope(), crate::MarketPriceProviderRateLimitScope::Ip);
|
||||||
|
assert_eq!(dynamic.requests(), std::option::Option::None);
|
||||||
|
assert_eq!(dynamic.window_seconds(), std::option::Option::None);
|
||||||
|
assert_eq!(dynamic.burst(), std::option::Option::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_request_cost_matches_quota_unit_and_rejects_zero() -> ksp_core_lib::Result<()> {
|
||||||
|
assert!(crate::MarketPriceProviderRequestCost::new(0, crate::MarketPriceProviderQuotaUnit::ComputeUnits).is_err());
|
||||||
|
let id = match crate::MarketPriceProviderId::new("provider-cost") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Account) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let quota = match crate::MarketPriceProviderLongTermQuota::new(
|
||||||
|
30_000,
|
||||||
|
crate::MarketPriceProviderQuotaPeriod::Month,
|
||||||
|
crate::MarketPriceProviderQuotaUnit::ComputeUnits,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let descriptor = match crate::MarketPriceProviderDescriptor::new(
|
||||||
|
id,
|
||||||
|
"Provider Cost",
|
||||||
|
crate::MarketPriceSemantics::SolanaSpot,
|
||||||
|
crate::MarketPriceProviderAuthMode::RequiredApiKey,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::Some(quota),
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request_cost = match crate::MarketPriceProviderRequestCost::new(3, crate::MarketPriceProviderQuotaUnit::ComputeUnits) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let descriptor = match descriptor.with_sol_usd_request_cost(request_cost) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(descriptor.sol_usd_request_cost(), std::option::Option::Some(request_cost));
|
||||||
|
let wrong_unit = match crate::MarketPriceProviderRequestCost::new(3, crate::MarketPriceProviderQuotaUnit::Credits) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert!(descriptor.with_sol_usd_request_cost(wrong_unit).is_err());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_availability_keeps_cooldown_and_outage_distinct() -> ksp_core_lib::Result<()> {
|
||||||
|
let id = match crate::MarketPriceProviderId::new("jupiter") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let retry_at = crate::MarketPriceTimestamp::from_unix_millis(1_777_777_777_000);
|
||||||
|
let state = crate::MarketPriceProviderState::new(id, crate::MarketPriceProviderAvailability::CoolingDown { retry_at });
|
||||||
|
assert_eq!(state.availability(), crate::MarketPriceProviderAvailability::CoolingDown { retry_at });
|
||||||
|
assert!(!state.availability().is_refresh_eligible());
|
||||||
|
assert_eq!(state.availability().retry_at(), std::option::Option::Some(retry_at));
|
||||||
|
assert_ne!(state.availability(), crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(retry_at) });
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_availability_eligibility_at_respects_retry_deadlines_without_consumer_scheduling() {
|
||||||
|
let now = crate::MarketPriceTimestamp::from_unix_millis(100);
|
||||||
|
let future = crate::MarketPriceTimestamp::from_unix_millis(200);
|
||||||
|
let past = crate::MarketPriceTimestamp::from_unix_millis(50);
|
||||||
|
assert!(crate::MarketPriceProviderAvailability::Ready.is_refresh_eligible_at(now));
|
||||||
|
assert!(!crate::MarketPriceProviderAvailability::Disabled.is_refresh_eligible_at(now));
|
||||||
|
assert!(!crate::MarketPriceProviderAvailability::CoolingDown { retry_at: future }.is_refresh_eligible_at(now));
|
||||||
|
assert!(crate::MarketPriceProviderAvailability::CoolingDown { retry_at: past }.is_refresh_eligible_at(now));
|
||||||
|
assert!(crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None }.is_refresh_eligible_at(now));
|
||||||
|
assert!(crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(past) }.is_refresh_eligible_at(now));
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_orders_all_eight_v1_providers_and_exposes_only_generic_entries() -> ksp_core_lib::Result<()> {
|
||||||
|
let entries = match all_v1_entries() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = match crate::MarketPriceProviderRegistry::new(entries) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(registry.len(), 8);
|
||||||
|
let ids = registry.entries().iter().map(|entry| return entry.descriptor().id().as_str()).collect::<std::vec::Vec<_>>();
|
||||||
|
assert_eq!(ids, std::vec!["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken",]);
|
||||||
|
assert!(registry.entries().iter().all(|entry| return entry.state().availability() == crate::MarketPriceProviderAvailability::Disabled));
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_rejects_duplicate_provider_ids_and_updates_generic_availability() -> ksp_core_lib::Result<()> {
|
||||||
|
let descriptor = match simple_descriptor("provider-a") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let duplicate = std::vec![
|
||||||
|
crate::MarketPriceProviderRegistryEntry::new(descriptor.clone(), crate::MarketPriceProviderAvailability::Disabled),
|
||||||
|
crate::MarketPriceProviderRegistryEntry::new(descriptor, crate::MarketPriceProviderAvailability::Ready),
|
||||||
|
];
|
||||||
|
assert!(crate::MarketPriceProviderRegistry::new(duplicate).is_err());
|
||||||
|
let descriptor = match simple_descriptor("provider-b") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider_id = descriptor.id().clone();
|
||||||
|
let retry_at = crate::MarketPriceTimestamp::from_unix_millis(42);
|
||||||
|
let entry = crate::MarketPriceProviderRegistryEntry::new(descriptor, crate::MarketPriceProviderAvailability::CoolingDown { retry_at });
|
||||||
|
let registry = match crate::MarketPriceProviderRegistry::new(std::vec![entry]) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let state = match registry.state(&provider_id) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(test_error("state")),
|
||||||
|
};
|
||||||
|
assert!(!state.availability().is_refresh_eligible());
|
||||||
|
assert_eq!(state.availability().retry_at(), std::option::Option::Some(retry_at));
|
||||||
|
assert_eq!(registry.descriptor(&provider_id).map(|value| return value.id()), std::option::Option::Some(&provider_id));
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_v1_entries() -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderRegistryEntry>> {
|
||||||
|
let birdeye_settings = match crate::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let birdeye = match crate::MarketPriceBirdeyeProvider::new(birdeye_settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinbase_settings = match crate::MarketPriceCoinbaseExchangeSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinbase = match crate::MarketPriceCoinbaseExchangeProvider::new(coinbase_settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coingecko_settings = match crate::MarketPriceCoinGeckoSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coingecko = match crate::MarketPriceCoinGeckoProvider::new(coingecko_settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinmarketcap_settings = match crate::MarketPriceCoinMarketCapSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinmarketcap = match crate::MarketPriceCoinMarketCapProvider::new(coinmarketcap_settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinpaprika_settings = match crate::MarketPriceCoinPaprikaSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinpaprika = match crate::MarketPriceCoinPaprikaProvider::new(coinpaprika_settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let dexscreener_settings = match crate::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let dexscreener = match crate::MarketPriceDexScreenerProvider::new(dexscreener_settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let jupiter_settings = match crate::MarketPriceJupiterSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let jupiter = match crate::MarketPriceJupiterProvider::new(jupiter_settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kraken_settings = match crate::MarketPriceKrakenSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kraken = match crate::MarketPriceKrakenProvider::new(kraken_settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(std::vec![
|
||||||
|
disabled_entry(birdeye.descriptor()),
|
||||||
|
disabled_entry(coinbase.descriptor()),
|
||||||
|
disabled_entry(coingecko.descriptor()),
|
||||||
|
disabled_entry(coinmarketcap.descriptor()),
|
||||||
|
disabled_entry(coinpaprika.descriptor()),
|
||||||
|
disabled_entry(dexscreener.descriptor()),
|
||||||
|
disabled_entry(jupiter.descriptor()),
|
||||||
|
disabled_entry(kraken.descriptor()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disabled_entry(descriptor: &crate::MarketPriceProviderDescriptor) -> crate::MarketPriceProviderRegistryEntry {
|
||||||
|
return crate::MarketPriceProviderRegistryEntry::new(descriptor.clone(), crate::MarketPriceProviderAvailability::Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn simple_descriptor(provider_id: &'static str) -> ksp_core_lib::Result<crate::MarketPriceProviderDescriptor> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new(provider_id) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let rate_limit = match crate::MarketPriceProviderRateLimit::fixed(1, 1, std::option::Option::None, crate::MarketPriceProviderRateLimitScope::Unspecified) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::MarketPriceProviderDescriptor::new(
|
||||||
|
provider_id,
|
||||||
|
"Provider",
|
||||||
|
crate::MarketPriceSemantics::AggregatedMarket,
|
||||||
|
crate::MarketPriceProviderAuthMode::None,
|
||||||
|
rate_limit,
|
||||||
|
std::option::Option::None,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID, "Market-price registry test expectation failed")
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_registry_orders_all_eight_v1_setups_and_keeps_runtime_projection_generic() -> ksp_core_lib::Result<()> {
|
||||||
|
let setups = match all_disabled_setups() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let service = match crate::MarketPriceService::new(setups) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = service.registry();
|
||||||
|
assert_eq!(registry.len(), 8);
|
||||||
|
let ids = registry.entries().iter().map(|entry| return entry.descriptor().id().as_str()).collect::<std::vec::Vec<_>>();
|
||||||
|
assert_eq!(ids, std::vec!["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken",]);
|
||||||
|
assert!(registry.entries().iter().all(|entry| return entry.state().availability() == crate::MarketPriceProviderAvailability::Disabled));
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_all_projects_disabled_providers_without_network_dispatch() -> ksp_core_lib::Result<()> {
|
||||||
|
let setups = match all_disabled_setups() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let service = match crate::MarketPriceService::new(setups) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let outcomes = match service.refresh_all().await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
assert_eq!(outcomes.len(), 8);
|
||||||
|
assert!(outcomes.iter().all(|outcome| return !outcome.refreshed()));
|
||||||
|
assert!(outcomes.iter().all(|outcome| return outcome.state().availability() == crate::MarketPriceProviderAvailability::Disabled));
|
||||||
|
let ids = outcomes.iter().map(|outcome| return outcome.provider_id().as_str()).collect::<std::vec::Vec<_>>();
|
||||||
|
assert_eq!(ids, std::vec!["birdeye", "coinbase_exchange", "coingecko", "coinmarketcap", "coinpaprika", "dexscreener", "jupiter", "kraken",]);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_many_rejects_duplicates_and_unknown_provider_before_dispatch() -> ksp_core_lib::Result<()> {
|
||||||
|
let settings = match crate::MarketPriceCoinPaprikaSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let service = match crate::MarketPriceService::new(std::vec![crate::MarketPriceProviderSetup::CoinPaprika(settings)]) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new("coinpaprika") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let duplicate = service.refresh_many(&[provider_id.clone(), provider_id]).await;
|
||||||
|
let duplicate_error = match duplicate {
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("duplicate")),
|
||||||
|
};
|
||||||
|
assert_eq!(duplicate_error.code(), crate::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID);
|
||||||
|
let unknown_id = match crate::MarketPriceProviderId::new("unknown") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let unknown = service.refresh(&unknown_id).await;
|
||||||
|
let unknown_error = match unknown {
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
std::result::Result::Ok(_) => return std::result::Result::Err(test_error("unknown")),
|
||||||
|
};
|
||||||
|
assert_eq!(unknown_error.code(), crate::ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_error_classification_is_provider_neutral_and_preserves_known_retry_time() {
|
||||||
|
let auth = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_ACCESS_DENIED, "test");
|
||||||
|
assert_eq!(
|
||||||
|
super::availability_from_error(&auth, crate::MarketPriceProviderAuthMode::RequiredApiKey),
|
||||||
|
crate::MarketPriceProviderAvailability::AuthenticationUnavailable
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
super::availability_from_error(&auth, crate::MarketPriceProviderAuthMode::None),
|
||||||
|
crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::None }
|
||||||
|
));
|
||||||
|
let rate = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMITED, "test").with_context("retry_after_seconds", "2");
|
||||||
|
assert!(matches!(
|
||||||
|
super::availability_from_error(&rate, crate::MarketPriceProviderAuthMode::None),
|
||||||
|
crate::MarketPriceProviderAvailability::CoolingDown { .. }
|
||||||
|
));
|
||||||
|
let temporary = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE, "test").with_context("retry_after_seconds", "3");
|
||||||
|
let availability = super::availability_from_error(&temporary, crate::MarketPriceProviderAuthMode::None);
|
||||||
|
assert!(matches!(availability, crate::MarketPriceProviderAvailability::TemporarilyUnavailable { retry_at: std::option::Option::Some(_) }));
|
||||||
|
let local = ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "test");
|
||||||
|
assert_eq!(super::availability_from_error(&local, crate::MarketPriceProviderAuthMode::None), crate::MarketPriceProviderAvailability::Misconfigured);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_disabled_setups() -> ksp_core_lib::Result<std::vec::Vec<crate::MarketPriceProviderSetup>> {
|
||||||
|
let birdeye = match crate::MarketPriceBirdeyeSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinbase = match crate::MarketPriceCoinbaseExchangeSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coingecko = match crate::MarketPriceCoinGeckoSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinmarketcap = match crate::MarketPriceCoinMarketCapSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let coinpaprika = match crate::MarketPriceCoinPaprikaSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let dexscreener = match crate::MarketPriceDexScreenerSettings::new(false, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let jupiter = match crate::MarketPriceJupiterSettings::keyless(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kraken = match crate::MarketPriceKrakenSettings::new(false) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(std::vec![
|
||||||
|
crate::MarketPriceProviderSetup::Birdeye(birdeye),
|
||||||
|
crate::MarketPriceProviderSetup::CoinbaseExchange(coinbase),
|
||||||
|
crate::MarketPriceProviderSetup::CoinGecko(coingecko),
|
||||||
|
crate::MarketPriceProviderSetup::CoinMarketCap(coinmarketcap),
|
||||||
|
crate::MarketPriceProviderSetup::CoinPaprika(coinpaprika),
|
||||||
|
crate::MarketPriceProviderSetup::DexScreener(dexscreener),
|
||||||
|
crate::MarketPriceProviderSetup::Jupiter(jupiter),
|
||||||
|
crate::MarketPriceProviderSetup::Kraken(kraken),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_REFRESH_INVALID, "Market-price service test expectation failed")
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// file: crates/ksp-offchain-transport-lib/unit_tests/market_price_settings.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn common_settings_contain_only_generic_identity_and_enablement() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match crate::MarketPriceProviderId::new("coinpaprika") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let settings = crate::MarketPriceProviderCommonSettings::new(provider_id, true);
|
||||||
|
assert!(settings.enabled());
|
||||||
|
assert_eq!(settings.provider_id().as_str(), "coinpaprika");
|
||||||
|
let serialized = match serde_json::to_value(&settings) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_PROVIDER_SETTINGS_INVALID, "test serialization failed"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(serialized.get("api_key").is_none());
|
||||||
|
assert!(serialized.get("endpoint").is_none());
|
||||||
|
assert!(serialized.get("rate_limit").is_none());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||||
<!-- version: 22 -->
|
<!-- version: 24 -->
|
||||||
|
|
||||||
# `ksp-onchain-transport-lib`
|
# `ksp-onchain-transport-lib`
|
||||||
|
|
||||||
@@ -220,7 +220,6 @@ Pour Helius, l’actor envoie automatiquement un control frame WebSocket `Ping`
|
|||||||
|
|
||||||
LaserStream **gRPC** reste un backend distinct, hors de cette façade, de `WsProtocolKind` et de la Config WebSocket `helius_laserstream`.
|
LaserStream **gRPC** reste un backend distinct, hors de cette façade, de `WsProtocolKind` et de la Config WebSocket `helius_laserstream`.
|
||||||
|
|
||||||
|
|
||||||
## Yellowstone gRPC standard
|
## Yellowstone gRPC standard
|
||||||
|
|
||||||
La foundation `0.2.9` ajoute un troisième backend réseau distinct de HTTP et WebSocket. Le moteur est KSP-owned : `yellowstone-grpc-proto` fournit le wire publié, tandis que Tonic reste encapsulé derrière les types crate-root KSP. Aucun client Tonic brut ni type protobuf upstream n’est réexporté.
|
La foundation `0.2.9` ajoute un troisième backend réseau distinct de HTTP et WebSocket. Le moteur est KSP-owned : `yellowstone-grpc-proto` fournit le wire publié, tandis que Tonic reste encapsulé derrière les types crate-root KSP. Aucun client Tonic brut ni type protobuf upstream n’est réexporté.
|
||||||
@@ -246,6 +245,8 @@ Config Transport V3 peut mapper des `grpc_endpoints` vers ces settings sans inve
|
|||||||
|
|
||||||
Le smoke live opt-in ouvre un `Subscribe` slots authentifié sur PublicNode Mainnet et Testnet, attend un update `Slot` non nul puis ferme de manière bornée. Les deux endpoints sont versionnés. Le harness reçoit ses personal tokens sur stdin ; aucun secret n’est committé ni placé dans l’URL. Le même token a été validé par l’opérateur sur les deux réseaux, même si Config conserve deux variables distinctes pour laisser cette policy flexible.
|
Le smoke live opt-in ouvre un `Subscribe` slots authentifié sur PublicNode Mainnet et Testnet, attend un update `Slot` non nul puis ferme de manière bornée. Les deux endpoints sont versionnés. Le harness reçoit ses personal tokens sur stdin ; aucun secret n’est committé ni placé dans l’URL. Le même token a été validé par l’opérateur sur les deux réseaux, même si Config conserve deux variables distinctes pour laisser cette policy flexible.
|
||||||
|
|
||||||
|
`0.2.10` ajoute le profil `orbitflare_devnet` sur le même moteur provider-neutral. L’endpoint validé est `http://devnet.rpc.orbitflare.com:10000`; la License Key `ORBIT-*` est fournie par Config via `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` et devient la metadata secrète `x-token`. Le smoke live dédié a validé `Subscribe` slots à commitment confirmed, un `Slot` non nul et un `SubscribeUpdate::Ping` standard. Cette preuve confirme qu’aucune façade OrbitFlare, aucun heartbeat provider et aucune modification N1/N2 ne sont nécessaires.
|
||||||
|
|
||||||
## Résilience
|
## Résilience
|
||||||
|
|
||||||
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
||||||
@@ -300,16 +301,20 @@ Transport WebSocket pur : settings programmatiques -> WsSession
|
|||||||
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
|
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
|
||||||
-> getHealth/getGenesisHash/getVersion/getBalance
|
-> getHealth/getGenesisHash/getVersion/getBalance
|
||||||
|
|
||||||
Transport Yellowstone gRPC : settings programmatiques + x-token secret
|
Transport Yellowstone gRPC PublicNode : settings programmatiques + x-token secret
|
||||||
-> PublicNode Mainnet + Testnet
|
-> Mainnet + Testnet
|
||||||
-> TLS -> Subscribe slots -> Slot non nul -> close borné
|
-> TLS -> Subscribe slots -> Slot non nul -> close borné
|
||||||
|
|
||||||
|
Transport Yellowstone gRPC OrbitFlare : settings programmatiques + License Key x-token
|
||||||
|
-> Devnet
|
||||||
|
-> Subscribe slots -> Slot non nul + server Ping -> close borné
|
||||||
```
|
```
|
||||||
|
|
||||||
Le smoke HTTP Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
|
Le smoke HTTP Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
|
||||||
|
|
||||||
Le smoke WebSocket Transport cible uniquement la famille stable `slotSubscribe` sur l'endpoint public Devnet `wss://api.devnet.solana.com`. Il borne connexion, attente de notification, unsubscribe et fermeture ; il ne transforme aucune famille unstable en gate live.
|
Le smoke WebSocket Transport cible uniquement la famille stable `slotSubscribe` sur l'endpoint public Devnet `wss://api.devnet.solana.com`. Il borne connexion, attente de notification, unsubscribe et fermeture ; il ne transforme aucune famille unstable en gate live.
|
||||||
|
|
||||||
Les quatre tests sont `ignored` par défaut ; le test Yellowstone contient deux cas live Mainnet/Testnet. Les trois familles de smokes Transport appartiennent durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée. Un rate-limit, refus externe ou incident Devnet n'est pas assimilé automatiquement à une régression locale.
|
Les cinq tests sont `ignored` par défaut ; le test PublicNode Yellowstone contient deux cas live Mainnet/Testnet et le test OrbitFlare contient un cas Devnet. Les trois familles de smokes Transport appartiennent durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée. Un rate-limit, refus externe ou incident Devnet n'est pas assimilé automatiquement à une régression locale.
|
||||||
|
|
||||||
Aucun smoke Helius live supplémentaire n’est committé en `0.2.8-pre.010`. Un tel test devrait à la fois obtenir `KSP_SECRET_HELIUS_API_KEY` via Config et exercer Transport ; l’ajouter dans Transport violerait l’ownership environnement/secret, tandis que l’ajouter dans Config étendrait l’exception cross-crates que le projet veut au contraire résorber. La première surface KSP d’intégration/orchestration dédiée devra héberger ce smoke. Le scénario live recommandé est alors `helius_devnet -> HeliusLaserStreamWsSession -> slotSubscribe -> notification -> unsubscribe -> close`; `transactionSubscribe` reste un smoke optionnel dépendant des droits provider et ne devient pas un gate stable de release.
|
Aucun smoke Helius live supplémentaire n’est committé en `0.2.8-pre.010`. Un tel test devrait à la fois obtenir `KSP_SECRET_HELIUS_API_KEY` via Config et exercer Transport ; l’ajouter dans Transport violerait l’ownership environnement/secret, tandis que l’ajouter dans Config étendrait l’exception cross-crates que le projet veut au contraire résorber. La première surface KSP d’intégration/orchestration dédiée devra héberger ce smoke. Le scénario live recommandé est alors `helius_devnet -> HeliusLaserStreamWsSession -> slotSubscribe -> notification -> unsubscribe -> close`; `transactionSubscribe` reste un smoke optionnel dépendant des droits provider et ne devient pas un gate stable de release.
|
||||||
|
|
||||||
@@ -325,4 +330,6 @@ Aucun smoke Helius live supplémentaire n’est committé en `0.2.8-pre.010`. Un
|
|||||||
- [`../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée `52/52 + 14/14` et audit `KSP-TRANSPORT-007` global ;
|
- [`../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée `52/52 + 14/14` et audit `KSP-TRANSPORT-007` global ;
|
||||||
- [`../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md`](../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md) — plan Yellowstone gRPC standard/provider-neutral et PublicNode ;
|
- [`../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md`](../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md) — plan Yellowstone gRPC standard/provider-neutral et PublicNode ;
|
||||||
- [`../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md`](../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md) — matrice de compliance Yellowstone ;
|
- [`../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md`](../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md) — matrice de compliance Yellowstone ;
|
||||||
|
- [`../../docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md`](../../docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md) — composition OrbitFlare Devnet sur le standard Yellowstone ;
|
||||||
|
- [`../../docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md`](../../docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md) — validation finale OrbitFlare `x-token`, `Slot + Ping` et non-régression N1/N2 ;
|
||||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard Transport V3 HTTP + WebSocket + gRPC, avec lecture backward V1/V2.
|
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard Transport V3 HTTP + WebSocket + gRPC, avec lecture backward V1/V2.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||||
<!-- version: 22 -->
|
<!-- version: 23 -->
|
||||||
|
|
||||||
# Utilisation de `ksp-onchain-transport-lib`
|
# Utilisation de `ksp-onchain-transport-lib`
|
||||||
|
|
||||||
@@ -331,6 +331,36 @@ let channel = ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(endpoin
|
|||||||
|
|
||||||
`protocol = solana_yellowstone` est validé par Config et reste distinct du descripteur `provider`. Une valeur provider n’autorise pas Transport à introduire une API provider-specific sans divergence réelle.
|
`protocol = solana_yellowstone` est validé par Config et reste distinct du descripteur `provider`. Une valeur provider n’autorise pas Transport à introduire une API provider-specific sans divergence réelle.
|
||||||
|
|
||||||
|
### Profil OrbitFlare Devnet
|
||||||
|
|
||||||
|
Le profil committé `orbitflare_devnet` réutilise exactement le même accès Config -> Transport :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resolved = match engine.load_resolved_transport_config(Some("orbitflare_devnet"), &environment) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let grpc_settings = match resolved.grpc_settings() {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return Err(ksp_core_lib::Error::new(
|
||||||
|
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
|
||||||
|
"selected OrbitFlare profile has no Yellowstone gRPC endpoint",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let endpoint = match grpc_settings.endpoints().iter().find(|candidate| candidate.enabled()) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return Err(ksp_core_lib::Error::new(
|
||||||
|
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
|
||||||
|
"selected OrbitFlare profile has no enabled Yellowstone gRPC endpoint",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let channel = ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(endpoint).await;
|
||||||
|
```
|
||||||
|
|
||||||
|
Config résout `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` vers la metadata secrète `x-token`. Sa valeur effective est la License Key `ORBIT-*` du produit Solana ; `X-ORBIT-KEY` et le Bearer du Customer API ne doivent pas être utilisés pour Yellowstone. Transport ne lit jamais cette variable lui-même.
|
||||||
|
|
||||||
|
L’endpoint validé par `0.2.10` est `http://devnet.rpc.orbitflare.com:10000`. Il reste volontairement en `http` : KSP ne remplace pas le transport provider par `https` sans endpoint TLS explicitement fourni.
|
||||||
|
|
||||||
### Subscribe bidirectionnel
|
### Subscribe bidirectionnel
|
||||||
|
|
||||||
Une session standard part d’une requête typed complète :
|
Une session standard part d’une requête typed complète :
|
||||||
@@ -519,6 +549,26 @@ Testnet https://solana-testnet-yellowstone-grpc.publicnode.com:443
|
|||||||
|
|
||||||
Les profils Config conservent deux variables secrètes distinctes afin d'autoriser des credentials différents si nécessaire ; cette séparation ne signifie pas que PublicNode impose actuellement un token différent par réseau. Un timeout KSP de half-close après réception du slot est accepté par le smoke comme fermeture bornée du provider ; aucune absence de slot ni autre erreur n'est masquée.
|
Les profils Config conservent deux variables secrètes distinctes afin d'autoriser des credentials différents si nécessaire ; cette séparation ne signifie pas que PublicNode impose actuellement un token différent par réseau. Un timeout KSP de half-close après réception du slot est accepté par le smoke comme fermeture bornée du provider ; aucune absence de slot ni autre erreur n'est masquée.
|
||||||
|
|
||||||
|
Le smoke **Transport Yellowstone gRPC OrbitFlare** est lui aussi indépendant de Config. Il lit une seule License Key sur stdin, la classe comme metadata secrète `x-token`, ouvre le standard `Subscribe`, demande `slots` à commitment confirmed, attend un Slot non nul et un `SubscribeUpdate::Ping`, puis ferme la session de manière bornée :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -rsp 'OrbitFlare License Key: ' ORBITFLARE_LICENSE_KEY
|
||||||
|
echo
|
||||||
|
printf '%s\n' "$ORBITFLARE_LICENSE_KEY" \
|
||||||
|
| cargo test -p ksp-onchain-transport-lib \
|
||||||
|
--test yellowstone_orbitflare_smoke \
|
||||||
|
-- --ignored --nocapture
|
||||||
|
unset ORBITFLARE_LICENSE_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint validé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Devnet http://devnet.rpc.orbitflare.com:10000
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate `0.2.10-pre.003` a passé ce scénario en live avec `Slot + Ping`. Le Ping reçu est le message Yellowstone standard auquel N1 sait déjà répondre sans remplacer la dernière requête complète mémorisée ; aucun heartbeat OrbitFlare supplémentaire n’est donc requis.
|
||||||
|
|
||||||
Le smoke de **composition Config -> Transport** reste également disponible :
|
Le smoke de **composition Config -> Transport** reste également disponible :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Opt-in live OrbitFlare Devnet characterization smoke for authenticated provider-neutral Yellowstone gRPC Subscribe.
|
||||||
|
|
||||||
|
use std::io::IsTerminal; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn orbitflare_license_x_token() -> std::string::String {
|
||||||
|
assert!(
|
||||||
|
!std::io::stdin().is_terminal(),
|
||||||
|
"pipe the OrbitFlare Solana Free License Key to this ignored smoke on stdin; never pass it as a command-line argument"
|
||||||
|
);
|
||||||
|
let mut token = std::string::String::new();
|
||||||
|
match std::io::stdin().read_line(&mut token) {
|
||||||
|
std::result::Result::Ok(_) => {},
|
||||||
|
std::result::Result::Err(error) => panic!("OrbitFlare License Key x-token must be readable from smoke stdin: {error}"),
|
||||||
|
}
|
||||||
|
let token = token.trim().to_owned();
|
||||||
|
assert!(!token.is_empty(), "OrbitFlare License Key x-token provided on smoke stdin must not be empty");
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn orbitflare_devnet_endpoint(x_token: &str) -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings> {
|
||||||
|
let url = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse("http://devnet.rpc.orbitflare.com:10000") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let session = ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::new(
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
ksp_onchain_transport_lib::YellowstoneGrpcReconnectSettings::new(0, std::time::Duration::from_millis(250), std::time::Duration::from_secs(2)),
|
||||||
|
8,
|
||||||
|
8,
|
||||||
|
16 * 1024 * 1024,
|
||||||
|
4 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
let endpoint = ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||||
|
"orbitflare_devnet_yellowstone",
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new("orbitflare"),
|
||||||
|
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
let metadata = match ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret("x-token", x_token) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return endpoint.with_metadata(vec![metadata]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot_request() -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneSubscribeRequest> {
|
||||||
|
let mut request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
|
||||||
|
let name = match ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("slots") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
match request.insert_slot_filter(name, ksp_onchain_transport_lib::YellowstoneSubscribeSlotFilter::new()) {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
request.set_commitment(std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||||
|
return std::result::Result::Ok(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[ignore = "opt-in live OrbitFlare Devnet Yellowstone gRPC characterization; reads one Solana Free License Key x-token from stdin, waits for Slot plus standard server Ping, then closes"]
|
||||||
|
async fn orbitflare_devnet_yellowstone_streams_slots_and_emits_standard_server_ping_with_license_x_token() {
|
||||||
|
let x_token = orbitflare_license_x_token();
|
||||||
|
let endpoint = match orbitflare_devnet_endpoint(x_token.as_str()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("programmatic OrbitFlare Devnet Yellowstone settings must accept the License Key as secret x-token metadata: {error:?}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(endpoint.metadata().len(), 1);
|
||||||
|
assert_eq!(endpoint.metadata()[0].key(), "x-token");
|
||||||
|
assert!(endpoint.metadata()[0].is_secret());
|
||||||
|
assert!(!endpoint.url().uses_tls(), "the documented OrbitFlare shared Devnet endpoint is intentionally HTTP/2 plaintext");
|
||||||
|
let endpoint_debug = format!("{endpoint:?}");
|
||||||
|
assert!(!endpoint_debug.contains("orbitflare.com"), "OrbitFlare endpoint URL must not appear in endpoint Debug");
|
||||||
|
assert!(!endpoint_debug.contains(x_token.as_str()), "OrbitFlare License Key x-token must not appear in endpoint Debug");
|
||||||
|
assert!(!endpoint_debug.contains("X-ORBIT-KEY"), "OrbitFlare Customer API credentials must not enter Yellowstone settings");
|
||||||
|
let channel = match ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(&endpoint).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("OrbitFlare Devnet Yellowstone authenticated connection must succeed: {error:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(channel.endpoint_name(), "orbitflare_devnet_yellowstone");
|
||||||
|
assert_eq!(channel.provider().as_str(), "orbitflare");
|
||||||
|
assert_eq!(channel.cluster().as_str(), "devnet");
|
||||||
|
let request = match slot_request() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("OrbitFlare Yellowstone slot request must be valid: {error:?}"),
|
||||||
|
};
|
||||||
|
let mut session = match channel.open_standard_subscribe(request).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("OrbitFlare Devnet standard Subscribe with License Key x-token must open: {error:?}"),
|
||||||
|
};
|
||||||
|
let deadline = tokio::time::sleep(std::time::Duration::from_secs(45));
|
||||||
|
tokio::pin!(deadline);
|
||||||
|
let mut slot_seen = false;
|
||||||
|
let mut ping_seen = false;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
() = &mut deadline => break,
|
||||||
|
next_update = session.next_update() => {
|
||||||
|
let next_update = match next_update {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("OrbitFlare Devnet Yellowstone Subscribe update must decode: {error:?}"),
|
||||||
|
};
|
||||||
|
match next_update {
|
||||||
|
std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneSubscribeUpdate::Slot(update)) => {
|
||||||
|
if update.slot() > 0 {
|
||||||
|
slot_seen = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneSubscribeUpdate::Ping(_)) => ping_seen = true,
|
||||||
|
std::option::Option::Some(_) => {},
|
||||||
|
std::option::Option::None => break,
|
||||||
|
}
|
||||||
|
if slot_seen && ping_seen {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let close_timeout_result = tokio::time::timeout(std::time::Duration::from_secs(7), session.close()).await;
|
||||||
|
let close_result = match close_timeout_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("KSP Yellowstone Subscribe close must remain bounded beyond the configured five-second provider half-close deadline: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
match close_result {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
assert_eq!(
|
||||||
|
error.code(),
|
||||||
|
ksp_onchain_transport_lib::ERROR_CODE_TIMEOUT,
|
||||||
|
"after live characterization, OrbitFlare close may time out waiting for provider half-close but must not fail for another reason: {error:?}"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert!(slot_seen, "OrbitFlare Devnet Yellowstone must publish at least one non-zero confirmed slot during the 45-second characterization window");
|
||||||
|
assert!(
|
||||||
|
ping_seen,
|
||||||
|
"OrbitFlare Devnet must emit the standard Yellowstone server Ping during the 45-second characterization window before any provider-specific heartbeat is justified"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs
|
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum FixtureMode {
|
enum FixtureMode {
|
||||||
@@ -27,7 +27,6 @@ struct FixtureGeyser {
|
|||||||
#[tonic::async_trait]
|
#[tonic::async_trait]
|
||||||
impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||||
type SubscribeStream = super::MpscStream<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>>;
|
type SubscribeStream = super::MpscStream<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>>;
|
||||||
type SubscribeDeshredStream = futures_util::stream::Empty<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>>;
|
|
||||||
|
|
||||||
async fn subscribe(
|
async fn subscribe(
|
||||||
&self,
|
&self,
|
||||||
@@ -163,6 +162,8 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
|||||||
return std::result::Result::Ok(tonic::Response::new(super::MpscStream::new(outbound_rx)));
|
return std::result::Result::Ok(tonic::Response::new(super::MpscStream::new(outbound_rx)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubscribeDeshredStream = futures_util::stream::Empty<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>>;
|
||||||
|
|
||||||
async fn subscribe_deshred(
|
async fn subscribe_deshred(
|
||||||
&self,
|
&self,
|
||||||
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>,
|
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs
|
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct FixtureGeyser;
|
struct FixtureGeyser;
|
||||||
@@ -10,11 +10,6 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
|||||||
type SubscribeStream = std::pin::Pin<
|
type SubscribeStream = std::pin::Pin<
|
||||||
std::boxed::Box<dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>> + Send + 'static>,
|
std::boxed::Box<dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>> + Send + 'static>,
|
||||||
>;
|
>;
|
||||||
type SubscribeDeshredStream = std::pin::Pin<
|
|
||||||
std::boxed::Box<
|
|
||||||
dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>> + Send + 'static,
|
|
||||||
>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
async fn subscribe(
|
async fn subscribe(
|
||||||
&self,
|
&self,
|
||||||
@@ -23,6 +18,12 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
|||||||
return std::result::Result::Err(tonic::Status::unimplemented("streaming is outside the pre.003 fixture"));
|
return std::result::Result::Err(tonic::Status::unimplemented("streaming is outside the pre.003 fixture"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubscribeDeshredStream = std::pin::Pin<
|
||||||
|
std::boxed::Box<
|
||||||
|
dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>> + Send + 'static,
|
||||||
|
>,
|
||||||
|
>;
|
||||||
|
|
||||||
async fn subscribe_deshred(
|
async fn subscribe_deshred(
|
||||||
&self,
|
&self,
|
||||||
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>,
|
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>,
|
||||||
|
|||||||
327
deltas/0.2.10/pre.001.md
Normal file
327
deltas/0.2.10/pre.001.md
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
<!-- file: deltas/0.2.10/pre.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.10-pre.001` — audit/sizing OrbitFlare Yellowstone gRPC
|
||||||
|
|
||||||
|
## 1. Base requise et vérifiée
|
||||||
|
|
||||||
|
Archive autoritaire fournie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
khadhroony-solana-project-v0.2.9-full-from-gitea.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
État vérifié :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.9
|
||||||
|
deltas/0.2.9/rel.001.md présent
|
||||||
|
prompts/015-V0_2_10_START_PROMPT.md présent
|
||||||
|
metadata .git absente de l'archive Gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette livraison ouvre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.10-pre.1
|
||||||
|
commit attendu = v0.2.10-pre.001
|
||||||
|
aucun tag prerelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Objet
|
||||||
|
|
||||||
|
`pre.001` exécute le gate documentaire obligatoire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
lecture base/règles/architecture
|
||||||
|
baseline stable
|
||||||
|
réaudit OrbitFlare actuel
|
||||||
|
réaudit Yellowstone actuel
|
||||||
|
auth control-plane/data-plane
|
||||||
|
endpoints/network/security
|
||||||
|
Free Devnet gRPC
|
||||||
|
heartbeat
|
||||||
|
capabilities/unary/replay
|
||||||
|
limits/quotas
|
||||||
|
architecture N1/N2 immuable
|
||||||
|
threat model
|
||||||
|
sizing et forecast
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun code provider lourd, SDK OrbitFlare, metadata secrète ou heartbeat supplémentaire n'est ajouté.
|
||||||
|
|
||||||
|
## 3. Baseline opérateur acquise
|
||||||
|
|
||||||
|
Preuves fournies le 2026-08-25 sur `v0.2.9` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py OK / clean
|
||||||
|
python3 scripts/audit_markdown_tables.py OK / 87 tables / 261 files
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-onchain-transport-lib --duplicates fourni
|
||||||
|
```
|
||||||
|
|
||||||
|
Transport pendant le workspace test :
|
||||||
|
|
||||||
|
```text
|
||||||
|
383 unit
|
||||||
|
49 public_api
|
||||||
|
43 release_completeness
|
||||||
|
4 doctests
|
||||||
|
```
|
||||||
|
|
||||||
|
Les live smokes restent opt-in/ignored par défaut.
|
||||||
|
|
||||||
|
Le `cargo tree -p ksp-onchain-transport-lib` complet n'a pas encore été fourni et doit être rejoué avant commit.
|
||||||
|
|
||||||
|
## 4. Graphe de dépendances inspecté
|
||||||
|
|
||||||
|
Versions utiles visibles :
|
||||||
|
|
||||||
|
```text
|
||||||
|
yellowstone-grpc-proto 12.6.0
|
||||||
|
tonic 0.14.6
|
||||||
|
tonic-prost 0.14.6
|
||||||
|
prost 0.14.4
|
||||||
|
tokio 1.53.1
|
||||||
|
http 1.5.0
|
||||||
|
reqwest 0.13.4
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune divergence Tonic/Prost/Yellowstone ne justifie une intervention. Les doublons restants sont sans impact matériel pour OrbitFlare.
|
||||||
|
|
||||||
|
## 5. Réaudit OrbitFlare du 2026-08-25
|
||||||
|
|
||||||
|
### Free Devnet
|
||||||
|
|
||||||
|
Le pricing actuel confirme :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Free 0 USD/mois
|
||||||
|
RPC 10 RPS
|
||||||
|
transactions 1 TPS
|
||||||
|
gRPC Devnet only
|
||||||
|
credit limits unlimited
|
||||||
|
```
|
||||||
|
|
||||||
|
Le CLI documente :
|
||||||
|
|
||||||
|
```text
|
||||||
|
RPC Devnet = http://devnet.rpc.orbitflare.com
|
||||||
|
gRPC Devnet = http://devnet.rpc.orbitflare.com:10000
|
||||||
|
```
|
||||||
|
|
||||||
|
Objectif opérationnel fermé : utiliser OrbitFlare comme provider gRPC Devnet gratuit pour les tests KSP futurs, en complément de PublicNode Mainnet/Testnet.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
Classification :
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-ORBIT-KEY Customer API only
|
||||||
|
Bearer Customer API v2
|
||||||
|
RPC api_key HTTP RPC only
|
||||||
|
account API key ne prouve pas une auth Yellowstone
|
||||||
|
gRPC Dashboard token possible selon licence/service
|
||||||
|
x-token mécanisme Yellowstone plausible si token gRPC distinct
|
||||||
|
aucune metadata documentée par SDK Go pour endpoints régionaux
|
||||||
|
```
|
||||||
|
|
||||||
|
La clé de compte opérateur ne doit pas être fournie au dépôt ni injectée par supposition dans gRPC.
|
||||||
|
|
||||||
|
### Heartbeat
|
||||||
|
|
||||||
|
OrbitFlare recommande un `SubscribeRequest::Ping` périodique. Yellowstone upstream envoie déjà un `SubscribeUpdate::Ping` périodique et KSP N1 répond automatiquement par un Ping standard sans modifier le dernier full request mémorisé.
|
||||||
|
|
||||||
|
Décision :
|
||||||
|
|
||||||
|
```text
|
||||||
|
aucun timer OrbitFlare ajouté au moteur
|
||||||
|
aucun changement YellowstoneGrpcSessionSettings
|
||||||
|
aucune modification grpc_stream.rs
|
||||||
|
pre.002 doit observer le Ping serveur live OrbitFlare Devnet
|
||||||
|
```
|
||||||
|
|
||||||
|
Si ce Ping est observé, la policy standard N1 est suffisante. Sinon, une divergence provider doit être démontrée puis composée au-dessus de N1 sans le modifier.
|
||||||
|
|
||||||
|
### Limits
|
||||||
|
|
||||||
|
```text
|
||||||
|
50 connexions gRPC simultanées par IP sur shared
|
||||||
|
subscriptions par connexion annoncées unlimited
|
||||||
|
idle timeout environ 10 minutes
|
||||||
|
RESOURCE_EXHAUSTED au dépassement
|
||||||
|
backoff exponentiel conseillé
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces valeurs restent provider-owned.
|
||||||
|
|
||||||
|
## 6. Réaudit Yellowstone actuel
|
||||||
|
|
||||||
|
État observé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
release GitHub courante v15.1.2+solana.4.2.0
|
||||||
|
yellowstone-grpc-proto publié 12.6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune évolution matérielle ne remet en cause le standard N2 de `0.2.9`. Aucune dependency n'est modifiée.
|
||||||
|
|
||||||
|
## 7. Invariant architectural supersédant l'hypothèse initiale
|
||||||
|
|
||||||
|
Décision opérateur explicite :
|
||||||
|
|
||||||
|
```text
|
||||||
|
le moteur gRPC ne change jamais pour un provider
|
||||||
|
le moteur WebSocket ne change jamais pour un provider
|
||||||
|
les fonctionnalités provider se composent au-dessus du moteur
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour `0.2.10` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
N1 moteur Yellowstone = IMMUTABLE
|
||||||
|
N2 standard Solana = IMMUTABLE
|
||||||
|
N3 OrbitFlare = profile/capability/facade seulement si besoin démontré
|
||||||
|
```
|
||||||
|
|
||||||
|
Le prompt `015` reste autoritatif sauf sur toute formulation qui permettrait de mettre une policy OrbitFlare directement dans le moteur partagé.
|
||||||
|
|
||||||
|
## 8. Config V3
|
||||||
|
|
||||||
|
La shape existante suffit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
profile_id = orbitflare_devnet
|
||||||
|
provider = orbitflare
|
||||||
|
cluster = devnet
|
||||||
|
protocol = solana_yellowstone
|
||||||
|
url = http://devnet.rpc.orbitflare.com:10000
|
||||||
|
metadata = []
|
||||||
|
secret_metadata = []
|
||||||
|
```
|
||||||
|
|
||||||
|
Pas de V4. Pas de secret ajouté avant preuve live. Pas de champ heartbeat moteur.
|
||||||
|
|
||||||
|
## 9. Smoke prévu
|
||||||
|
|
||||||
|
`pre.002` doit commencer par un canari standard N2 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
connect OrbitFlare Devnet Free sans metadata
|
||||||
|
Subscribe slots confirmed
|
||||||
|
recevoir Slot non nul
|
||||||
|
observer SubscribeUpdate::Ping pendant une fenêtre bornée de caractérisation
|
||||||
|
fermer proprement
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis sonder séparément les unary et replay sans rendre le smoke minimal dépendant d'entitlements provider non garantis.
|
||||||
|
|
||||||
|
## 10. Forecast recalibré
|
||||||
|
|
||||||
|
Chemin standard probable :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.001 audit/sizing
|
||||||
|
pre.002 Config Devnet + characterization smoke standard N2
|
||||||
|
pre.003 gate technique/live final
|
||||||
|
pre.004 réconciliation documentaire finale
|
||||||
|
pre.005 publication minimale
|
||||||
|
rel.001 stable
|
||||||
|
```
|
||||||
|
|
||||||
|
Chemin avec divergence provider démontrée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.001 audit/sizing
|
||||||
|
pre.002 Config Devnet + characterization smoke
|
||||||
|
pre.003 provider overlay minimal au-dessus de N1/N2
|
||||||
|
pre.004 gate technique/live final
|
||||||
|
pre.005 réconciliation documentaire finale
|
||||||
|
pre.006 publication minimale
|
||||||
|
rel.001 stable
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||||
|
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||||
|
deltas/0.2.10/pre.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
docs/000-README.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/validation/000-README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Fichiers volontairement inchangés
|
||||||
|
|
||||||
|
```text
|
||||||
|
ROADMAP.md
|
||||||
|
CHANGELOG.md
|
||||||
|
.env.example
|
||||||
|
config/**
|
||||||
|
crates/**
|
||||||
|
docs/architecture/**
|
||||||
|
crates/ksp-onchain-transport-lib/README.md
|
||||||
|
crates/ksp-onchain-transport-lib/USAGE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune implémentation provider ou Config runtime n'est introduite dans `pre.001`.
|
||||||
|
|
||||||
|
## 14. Sources externes réauditées
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://docs.orbitflare.com/llms.txt
|
||||||
|
https://docs.orbitflare.com/cli
|
||||||
|
https://docs.orbitflare.com/authentication
|
||||||
|
https://docs.orbitflare.com/data-streaming/yellowstone
|
||||||
|
https://docs.orbitflare.com/data-streaming/yellowstone-quickstart
|
||||||
|
https://docs.orbitflare.com/sdk/go-grpc
|
||||||
|
https://orbitflare.com/pricing
|
||||||
|
https://orbitflare.com/products/rpc-nodes
|
||||||
|
https://orbitflare.com/products/solana-grpc
|
||||||
|
https://github.com/rpcpool/yellowstone-grpc
|
||||||
|
https://github.com/rpcpool/yellowstone-grpc/releases
|
||||||
|
https://github.com/rpcpool/yellowstone-grpc/blob/master/README.md
|
||||||
|
https://github.com/rpcpool/yellowstone-grpc/blob/master/yellowstone-grpc-proto/proto/geyser.proto
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. Validations de préparation
|
||||||
|
|
||||||
|
Le sandbox a exécuté :
|
||||||
|
|
||||||
|
```text
|
||||||
|
inspection archive/version/rel/prompt OK
|
||||||
|
lecture code N1/N2/Config et docs 0.2.9 OK
|
||||||
|
réaudit OrbitFlare/Yellowstone actuel OK
|
||||||
|
inspection cargo tree --duplicates fourni par opérateur OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Le sandbox ne dispose pas de Cargo ; aucun `cargo check/clippy/test/tree` post-overlay n'est déclaré réussi localement.
|
||||||
|
|
||||||
|
## 16. Validation opérateur attendue avant commit
|
||||||
|
|
||||||
|
Comme `pre.001` change uniquement la version Cargo et la documentation :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-onchain-transport-lib
|
||||||
|
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : aucun changement de dependency et aucun changement sous `crates/`.
|
||||||
196
deltas/0.2.10/pre.002-fix.001.md
Normal file
196
deltas/0.2.10/pre.002-fix.001.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<!-- file: deltas/0.2.10/pre.002-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.10-pre.002-fix.001` — OrbitFlare License Key `x-token`
|
||||||
|
|
||||||
|
## 1. Base et identité
|
||||||
|
|
||||||
|
Base exacte :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.10-pre.002 commité
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fix porte une correction runtime/configuration de l’auth provider. La version Cargo devient donc :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.10-pre.2.fix.1
|
||||||
|
commit attendu = v0.2.10-pre.002-fix.001
|
||||||
|
aucun tag prerelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Défaut démontré par le live `pre.002`
|
||||||
|
|
||||||
|
Le gate statique/workspace `pre.002` est vert. Le smoke live sans metadata a atteint OrbitFlare puis l’ouverture du stream a échoué de manière sûre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
grpc_operation = SubscribeOpen
|
||||||
|
grpc_status = Unauthenticated
|
||||||
|
grpc_code = The request does not have valid authentication credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Le Dashboard opérateur montre un produit `Solana Free` avec License Key `ORBIT-*` et `API Key Mode Active`.
|
||||||
|
|
||||||
|
La référence Yellowstone OrbitFlare précise ensuite le contrat data-plane :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ORBITFLARE_LICENSE_KEY
|
||||||
|
-> metadata gRPC x-token
|
||||||
|
-> Yellowstone
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `X-ORBIT-KEY` et le Bearer Device Flow restent des credentials Customer API et ne sont pas utilisés par Transport Yellowstone.
|
||||||
|
|
||||||
|
## 3. Correction Config V3
|
||||||
|
|
||||||
|
`config/std.transport.json` conserve le même endpoint et ajoute uniquement la metadata secrète requise :
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider = orbitflare
|
||||||
|
cluster = devnet
|
||||||
|
protocol = solana_yellowstone
|
||||||
|
url = http://devnet.rpc.orbitflare.com:10000
|
||||||
|
metadata = []
|
||||||
|
secret_metadata = x-token <- ${KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun format Config V4 n’est créé.
|
||||||
|
|
||||||
|
`.env.example` inventorie désormais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
La valeur locale attendue est la License Key `ORBIT-*`. Une `X-ORBIT-KEY` Customer API ne doit jamais être placée dans cette variable.
|
||||||
|
|
||||||
|
## 4. Preuve Config déterministe
|
||||||
|
|
||||||
|
Le test `committed_v3_orbitflare_devnet_profile_maps_provider_neutral_yellowstone_grpc_with_license_x_token` injecte un canari secret via `ConfigEnvironment` et prouve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
profil explicite orbitflare_devnet
|
||||||
|
companions HTTP/WS Devnet inchangés
|
||||||
|
endpoint Yellowstone OrbitFlare inchangé
|
||||||
|
metadata key = x-token
|
||||||
|
metadata classée secret
|
||||||
|
validation Transport PASS
|
||||||
|
URL provider absente du Debug
|
||||||
|
canari secret absent du Debug
|
||||||
|
X-ORBIT-KEY absent du Transport
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Smoke live corrigé
|
||||||
|
|
||||||
|
`yellowstone_orbitflare_smoke.rs` ne dépend toujours pas de Config et ne lit aucun environnement. Il suit le pattern de sécurité déjà utilisé par les smokes PublicNode :
|
||||||
|
|
||||||
|
```text
|
||||||
|
une License Key lue sur stdin
|
||||||
|
aucun secret en argument de ligne de commande
|
||||||
|
YellowstoneGrpcMetadataEntry::secret("x-token", ...)
|
||||||
|
reconnect max_retries = 0
|
||||||
|
Subscribe slots confirmed
|
||||||
|
fenêtre Slot + Ping = 45 s
|
||||||
|
close borné
|
||||||
|
```
|
||||||
|
|
||||||
|
Le smoke vérifie également que la License Key n’apparaît pas dans `Debug`.
|
||||||
|
|
||||||
|
Commande opérateur :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -rsp 'OrbitFlare License Key: ' ORBITFLARE_LICENSE_KEY; echo
|
||||||
|
printf '%s\n' "$ORBITFLARE_LICENSE_KEY" | cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
|
||||||
|
unset ORBITFLARE_LICENSE_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Invariant architectural
|
||||||
|
|
||||||
|
Toujours inchangés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/grpc_settings.rs
|
||||||
|
src/grpc_channel.rs
|
||||||
|
src/grpc_unary.rs
|
||||||
|
src/grpc_subscribe.rs
|
||||||
|
src/grpc_stream.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Le standard Yellowstone N2 reste inchangé. Aucun SDK OrbitFlare runtime n’est ajouté.
|
||||||
|
|
||||||
|
L’auth provider est entièrement composée par la surface metadata secrète déjà existante.
|
||||||
|
|
||||||
|
## 7. Heartbeat
|
||||||
|
|
||||||
|
Ce fix ne matérialise aucun heartbeat OrbitFlare.
|
||||||
|
|
||||||
|
Le rerun authentifié doit d’abord qualifier :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Slot non nul
|
||||||
|
SubscribeUpdate::Ping serveur
|
||||||
|
```
|
||||||
|
|
||||||
|
Si les deux sont observés, N1 répond déjà automatiquement au Ping standard et aucune couche provider heartbeat n’est requise.
|
||||||
|
|
||||||
|
Si le stream authentifié fonctionne mais n’émet aucun Ping serveur dans la fenêtre de caractérisation, `pre.003` devient une tranche provider-specific au-dessus de N1/N2. Il reste interdit d’ajouter un timer OrbitFlare au moteur gRPC.
|
||||||
|
|
||||||
|
## 8. Validation disponible dans l’environnement de génération
|
||||||
|
|
||||||
|
Exécuté :
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
General Rust rule audit: clean
|
||||||
|
Rust export completeness audit: 0 candidate(s)
|
||||||
|
KSP workspace Rust rule audit: clean
|
||||||
|
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||||
|
Markdown table audit: clean (99 table(s), 99 file(s))
|
||||||
|
|
||||||
|
config/std.transport.json
|
||||||
|
JSON parse PASS
|
||||||
|
JSON Schema validation PASS
|
||||||
|
|
||||||
|
comparaison N1 avec pre.002
|
||||||
|
grpc_settings.rs inchangé
|
||||||
|
grpc_channel.rs inchangé
|
||||||
|
grpc_unary.rs inchangé
|
||||||
|
grpc_subscribe.rs inchangé
|
||||||
|
grpc_stream.rs inchangé
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune commande Cargo n’est déclarée exécutée dans l’environnement de génération.
|
||||||
|
|
||||||
|
## 9. Gates opérateur
|
||||||
|
|
||||||
|
Après application du fix :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-config-lib
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test -p ksp-core-lib --test workspace_dependencies
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis exécuter le live authentifié décrit en section 5.
|
||||||
|
|
||||||
|
## 10. Fichiers ajoutés/modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
.env.example
|
||||||
|
config/std.transport.json
|
||||||
|
crates/ksp-config-lib/unit_tests/transport.rs
|
||||||
|
crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
|
||||||
|
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||||
|
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||||
|
deltas/0.2.10/pre.002-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le delta commité `deltas/0.2.10/pre.002.md` reste immuable.
|
||||||
287
deltas/0.2.10/pre.002.md
Normal file
287
deltas/0.2.10/pre.002.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<!-- file: deltas/0.2.10/pre.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.10-pre.002` — OrbitFlare Devnet Config + characterization smoke
|
||||||
|
|
||||||
|
## 1. Base et version
|
||||||
|
|
||||||
|
Base de travail :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.10-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation opérateur fournie sur cette base le 2026-08-25 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all PASS
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||||
|
python3 scripts/audit_markdown_tables.py PASS / 97 tables / 233 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
cargo test --workspace PASS
|
||||||
|
cargo tree -p ksp-onchain-transport-lib fourni
|
||||||
|
cargo tree -p ksp-onchain-transport-lib --duplicates fourni
|
||||||
|
```
|
||||||
|
|
||||||
|
Le graphe confirme notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
yellowstone-grpc-proto 12.6.0
|
||||||
|
tonic 0.14.6
|
||||||
|
tonic-prost 0.14.6
|
||||||
|
prost 0.14.4
|
||||||
|
tokio 1.53.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette livraison passe à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.10-pre.2
|
||||||
|
commit attendu = v0.2.10-pre.002
|
||||||
|
aucun tag prerelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Invariant architectural
|
||||||
|
|
||||||
|
Aucun moteur physique n'est modifié :
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/grpc_settings.rs inchangé
|
||||||
|
src/grpc_channel.rs inchangé
|
||||||
|
src/grpc_unary.rs inchangé
|
||||||
|
src/grpc_subscribe.rs inchangé
|
||||||
|
src/grpc_stream.rs inchangé
|
||||||
|
```
|
||||||
|
|
||||||
|
Le standard Yellowstone N2 reste également inchangé.
|
||||||
|
|
||||||
|
OrbitFlare est matérialisé uniquement par :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config provider profile
|
||||||
|
provider-neutral Yellowstone smoke
|
||||||
|
provider validation/documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun SDK OrbitFlare, second client Tonic, proto provider, heartbeat moteur ou metadata secrète n'est ajouté.
|
||||||
|
|
||||||
|
## 3. Profil Config V3 `orbitflare_devnet`
|
||||||
|
|
||||||
|
`config/std.transport.json` ajoute un profil dédié :
|
||||||
|
|
||||||
|
```text
|
||||||
|
profile_id = orbitflare_devnet
|
||||||
|
```
|
||||||
|
|
||||||
|
Il conserve les companions standards déjà utilisés sur Devnet :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP = https://api.devnet.solana.com
|
||||||
|
WS = wss://api.devnet.solana.com
|
||||||
|
```
|
||||||
|
|
||||||
|
et ajoute exactement un endpoint Yellowstone :
|
||||||
|
|
||||||
|
```text
|
||||||
|
name = orbitflare_solana_devnet_yellowstone
|
||||||
|
provider = orbitflare
|
||||||
|
cluster = devnet
|
||||||
|
protocol = solana_yellowstone
|
||||||
|
url = http://devnet.rpc.orbitflare.com:10000
|
||||||
|
metadata = []
|
||||||
|
secret_metadata = []
|
||||||
|
```
|
||||||
|
|
||||||
|
Le format reste V3. `.env.example` n'est pas modifié et aucune API key de compte OrbitFlare n'est reclassifiée comme credential gRPC.
|
||||||
|
|
||||||
|
`ksp-config-lib` ajoute un test déterministe qui prouve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
sélection explicite orbitflare_devnet
|
||||||
|
companions HTTP/WS Devnet préservés
|
||||||
|
provider/cluster/protocol gRPC corrects
|
||||||
|
URL plaintext conservée telle quelle
|
||||||
|
aucune metadata inventée
|
||||||
|
Debug gRPC sans URL provider
|
||||||
|
absence de X-ORBIT-KEY dans Transport settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Smoke OrbitFlare Devnet
|
||||||
|
|
||||||
|
Nouveau test opt-in :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Le test utilise directement les API publiques Yellowstone existantes de Transport, sans dépendre de Config :
|
||||||
|
|
||||||
|
```text
|
||||||
|
endpoint Devnet OrbitFlare sans metadata
|
||||||
|
reconnect max_retries = 0
|
||||||
|
Subscribe slots
|
||||||
|
commitment = confirmed
|
||||||
|
fenêtre de caractérisation = 45 s
|
||||||
|
preuve Slot non nul
|
||||||
|
preuve SubscribeUpdate::Ping serveur
|
||||||
|
close borné
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `Ping` reçu reste traité par le moteur N1 déjà livré en `0.2.9`, qui répond automatiquement sur le même stream. Le test n'ajoute aucune policy heartbeat.
|
||||||
|
|
||||||
|
Commande live :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Interprétation :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Slot + Ping PASS
|
||||||
|
OrbitFlare reste standard ; aucune façade/policy provider heartbeat requise
|
||||||
|
|
||||||
|
connection/auth failure
|
||||||
|
classifier le service/licence réel avant toute metadata
|
||||||
|
|
||||||
|
Slot PASS mais Ping absent
|
||||||
|
qualifier une divergence heartbeat provider avant toute implémentation spécifique
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Audit Markdown étendu
|
||||||
|
|
||||||
|
`scripts/audit_markdown_tables.py` passe en version 2 et contrôle désormais aussi l'équivalent structurel de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
\n\s*\n\s*\n
|
||||||
|
```
|
||||||
|
|
||||||
|
c'est-à-dire au moins deux lignes vides consécutives hors fenced code block.
|
||||||
|
|
||||||
|
Le contrôle :
|
||||||
|
|
||||||
|
```text
|
||||||
|
considère une ligne whitespace-only comme vide
|
||||||
|
signale fichier + première ligne + longueur de la séquence
|
||||||
|
n'interprète pas les blancs internes aux ``` / ~~~ fenced blocks
|
||||||
|
ignore les répertoires générés ou tiers en parcours récursif
|
||||||
|
```
|
||||||
|
|
||||||
|
Répertoires récursifs exclus :
|
||||||
|
|
||||||
|
```text
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
target
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette exclusion rend le résultat déterministe entre une archive Gitea propre et un checkout développeur contenant des `node_modules`. Le nombre de fichiers audités peut donc diminuer par rapport à l'ancien script sans signifier une perte de couverture KSP-owned.
|
||||||
|
|
||||||
|
Une seule séquence de deux lignes vides existait dans le scope KSP actif de l'archive : elle est supprimée dans `crates/ksp-onchain-transport-lib/README.md`.
|
||||||
|
|
||||||
|
`docs/rules/RULES_DOCUMENTATION.md` formalise `DOC-BLANK-001` à `DOC-BLANK-003`, y compris l'immuabilité des anciens deltas publiés.
|
||||||
|
|
||||||
|
## 6. Documents de plan/validation
|
||||||
|
|
||||||
|
Le plan 017 et la validation 013 passent en version 2 pour enregistrer :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.001 operator gate fermé
|
||||||
|
profil orbitflare_devnet matérialisé
|
||||||
|
smoke de caractérisation matérialisé
|
||||||
|
N1/N2 inchangés
|
||||||
|
live Devnet encore PENDING
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun README/USAGE OrbitFlare durable n'est finalisé ici ; cette responsabilité reste réservée au couloir documentaire final.
|
||||||
|
|
||||||
|
## 7. Validation disponible dans l'environnement de génération
|
||||||
|
|
||||||
|
Exécuté sans Cargo :
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
General Rust rule audit: clean
|
||||||
|
Rust export completeness audit: 0 candidate(s)
|
||||||
|
KSP workspace Rust rule audit: clean
|
||||||
|
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||||
|
clean / 98 tables / 98 KSP-owned files
|
||||||
|
|
||||||
|
config/std.transport.json
|
||||||
|
JSON parse PASS
|
||||||
|
JSON Schema validation PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Le nouvel audit Markdown a aussi été caractérisé avec des fixtures temporaires :
|
||||||
|
|
||||||
|
```text
|
||||||
|
deux lignes vides hors fence FAIL attendu
|
||||||
|
plusieurs lignes vides dans fence PASS attendu
|
||||||
|
node_modules récursif ignoré attendu
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune commande Cargo n'est déclarée exécutée dans l'environnement de génération.
|
||||||
|
|
||||||
|
## 8. Gates opérateur `pre.002`
|
||||||
|
|
||||||
|
Exécuter :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-config-lib
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test -p ksp-core-lib --test workspace_dependencies
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-onchain-transport-lib
|
||||||
|
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis le live dédié :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.002` ne ferme son gate provider que lorsque le résultat live est classifié. Un échec d'auth ou l'absence de `Ping` ne doit jamais être corrigé par modification du moteur N1.
|
||||||
|
|
||||||
|
## 9. Fichiers ajoutés/modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
config/std.transport.json
|
||||||
|
crates/ksp-config-lib/unit_tests/transport.rs
|
||||||
|
crates/ksp-onchain-transport-lib/README.md
|
||||||
|
crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
|
||||||
|
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||||
|
docs/rules/RULES_DOCUMENTATION.md
|
||||||
|
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||||
|
scripts/audit_markdown_tables.py
|
||||||
|
deltas/0.2.10/pre.002.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Suite conditionnelle
|
||||||
|
|
||||||
|
Si le live retourne `Slot + Ping PASS` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.003 = gate technique/live final
|
||||||
|
```
|
||||||
|
|
||||||
|
Si une divergence provider réelle est démontrée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.003 = tranche provider-specific minimale au-dessus de N1/N2
|
||||||
|
pre.004 = gate technique/live final
|
||||||
|
```
|
||||||
|
|
||||||
|
Le couloir documentaire final puis la prerelease de publication restent distincts dans les deux cas.
|
||||||
252
deltas/0.2.10/pre.003.md
Normal file
252
deltas/0.2.10/pre.003.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<!-- file: deltas/0.2.10/pre.003.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.10-pre.003` — gate technique/live final OrbitFlare Yellowstone
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Base directe attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
livraison : 0.2.10-pre.002-fix.001
|
||||||
|
Cargo : 0.2.10-pre.2.fix.1
|
||||||
|
commit : v0.2.10-pre.002-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Identité de cette tranche :
|
||||||
|
|
||||||
|
```text
|
||||||
|
livraison : 0.2.10-pre.003
|
||||||
|
Cargo : 0.2.10-pre.3
|
||||||
|
commit : v0.2.10-pre.003
|
||||||
|
aucun tag prerelease
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette prerelease est exclusivement le couloir **technique/live final** de `0.2.10`. Elle ne porte aucune réconciliation README/USAGE/plan/validation, aucune finalisation CHANGELOG/ROADMAP et aucun prompt `0.2.11`.
|
||||||
|
|
||||||
|
## 2. Gate acquis de `pre.002-fix.001`
|
||||||
|
|
||||||
|
La validation opérateur reçue le 2026-08-25 ferme le défaut d'auth classifié dans `pre.002`.
|
||||||
|
|
||||||
|
Le gate déterministe est intégralement vert :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all PASS
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py clean / 0 export candidate / clean
|
||||||
|
python3 scripts/audit_markdown_tables.py PASS, 99 tables / 99 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
cargo test --workspace PASS
|
||||||
|
Config unit 114/114 PASS
|
||||||
|
Config ownership 5/5 PASS
|
||||||
|
Config public API 15/15 PASS
|
||||||
|
Transport unit 383/383 PASS
|
||||||
|
Transport public API 49/49 PASS
|
||||||
|
Transport release completeness 43/43 PASS
|
||||||
|
Transport doctests 4/4 PASS
|
||||||
|
workspace dependency canary 3/3 PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Le smoke OrbitFlare Devnet authentifié est également vert :
|
||||||
|
|
||||||
|
```text
|
||||||
|
endpoint http://devnet.rpc.orbitflare.com:10000
|
||||||
|
auth License Key ORBIT-* -> secret metadata x-token
|
||||||
|
Subscribe PASS
|
||||||
|
Slot > 0 PASS
|
||||||
|
server Ping PASS
|
||||||
|
close borné PASS
|
||||||
|
résultat 1 passed / 0 failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette preuve ferme la branche de divergence heartbeat : OrbitFlare émet le `SubscribeUpdate::Ping` Yellowstone standard et le moteur N1 `0.2.9` possède déjà la réponse automatique déterministe. Aucun heartbeat provider, aucune façade OrbitFlare et aucune modification N1/N2 ne sont nécessaires.
|
||||||
|
|
||||||
|
## 3. Responsabilité de `pre.003`
|
||||||
|
|
||||||
|
`pre.003` ne matérialise aucun nouveau comportement. Elle rejoue et ferme les preuves techniques finales après le fix d'auth :
|
||||||
|
|
||||||
|
```text
|
||||||
|
version Cargo prerelease synchronisée
|
||||||
|
workspace complet sans warning
|
||||||
|
invariants Rust/Markdown
|
||||||
|
Config V3 OrbitFlare x-token non régressée
|
||||||
|
Yellowstone N1/N2 non régressés
|
||||||
|
HTTP 52 current + 14 historical non régressés
|
||||||
|
WebSocket standard et Helius non régressés
|
||||||
|
smoke live OrbitFlare Devnet final
|
||||||
|
smokes PublicNode Mainnet/Testnet finaux si credentials opérateur disponibles
|
||||||
|
graphes Cargo Transport et doublons inspectés
|
||||||
|
aucun SDK OrbitFlare runtime
|
||||||
|
aucune dépendance Transport -> Config
|
||||||
|
```
|
||||||
|
|
||||||
|
Le payload de `pre.003` ne change ni code runtime, ni test, ni Config, ni schema, ni dépendance. Le seul changement technique est le signal de version Cargo obligatoire pour une prerelease non-fix.
|
||||||
|
|
||||||
|
## 4. Invariants à fermer
|
||||||
|
|
||||||
|
Les fichiers moteur Yellowstone restent hors modification :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/src/grpc_settings.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/grpc_channel.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/grpc_unary.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/grpc_stream.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate doit confirmer :
|
||||||
|
|
||||||
|
```text
|
||||||
|
N1 gRPC provider-neutral inchangé
|
||||||
|
N2 Yellowstone standard inchangé
|
||||||
|
OrbitFlare composé par endpoint + provider + cluster + x-token Config V3
|
||||||
|
X-ORBIT-KEY absent du data-plane Yellowstone
|
||||||
|
License Key jamais exposée par Debug/CLI
|
||||||
|
pas de format Config V4
|
||||||
|
pas de provider proto
|
||||||
|
pas de second raw Tonic client
|
||||||
|
pas de orbitflare-sdk runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Gate déterministe final
|
||||||
|
|
||||||
|
Après application de `pre.003` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-config-lib
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test -p ksp-core-lib --test workspace_dependencies
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Seuils minimaux attendus :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config unit 114
|
||||||
|
Config ownership 5
|
||||||
|
Config public API 15
|
||||||
|
Transport unit 383
|
||||||
|
Transport public API 49
|
||||||
|
Transport release completeness 43
|
||||||
|
Transport doctests 4
|
||||||
|
workspace dependency canary 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Toute régression imputable à KSP ouvre `pre.003-fix.NNN`. Elle ne doit pas être repoussée dans le couloir documentaire `pre.004`.
|
||||||
|
|
||||||
|
## 6. Smoke OrbitFlare Devnet final
|
||||||
|
|
||||||
|
Saisir la License Key sans l'inscrire dans l'historique :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -rsp 'OrbitFlare License Key: ' ORBITFLARE_LICENSE_KEY; echo
|
||||||
|
printf '%s\n' "$ORBITFLARE_LICENSE_KEY" \
|
||||||
|
| cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
|
||||||
|
unset ORBITFLARE_LICENSE_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu :
|
||||||
|
|
||||||
|
```text
|
||||||
|
orbitflare_devnet_yellowstone_streams_slots_and_emits_standard_server_ping_with_license_x_token ... ok
|
||||||
|
1 passed
|
||||||
|
0 failed
|
||||||
|
0 ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
Le smoke doit encore prouver dans une même session bornée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
auth x-token acceptée
|
||||||
|
Subscribe standard ouvert
|
||||||
|
Slot non nul reçu
|
||||||
|
SubscribeUpdate::Ping standard observé
|
||||||
|
fermeture bornée
|
||||||
|
```
|
||||||
|
|
||||||
|
Un incident externe OrbitFlare/réseau démontré comme tel est documenté comme `EXTERNAL BLOCK`. Un échec de mapping, metadata, moteur, protocol ou fermeture imputable à KSP ouvre un fix technique.
|
||||||
|
|
||||||
|
## 7. PublicNode et graphes finaux
|
||||||
|
|
||||||
|
Les smokes PublicNode Mainnet/Testnet restent les canaris provider du standard livré par `0.2.9`. Si les deux credentials opérateur sont disponibles pendant ce gate, les rejouer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -rsp 'PublicNode Mainnet Yellowstone x-token: ' PUBLICNODE_MAINNET_TOKEN; echo
|
||||||
|
read -rsp 'PublicNode Testnet Yellowstone x-token: ' PUBLICNODE_TESTNET_TOKEN; echo
|
||||||
|
printf '%s\n%s\n' "$PUBLICNODE_MAINNET_TOKEN" "$PUBLICNODE_TESTNET_TOKEN" \
|
||||||
|
| cargo test -p ksp-onchain-transport-lib --test yellowstone_publicnode_smoke -- --ignored --nocapture
|
||||||
|
unset PUBLICNODE_MAINNET_TOKEN PUBLICNODE_TESTNET_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu lorsque rejoué :
|
||||||
|
|
||||||
|
```text
|
||||||
|
2 passed
|
||||||
|
0 failed
|
||||||
|
0 ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspecter ensuite :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo tree -p ksp-onchain-transport-lib
|
||||||
|
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||||
|
cargo tree --duplicates
|
||||||
|
```
|
||||||
|
|
||||||
|
Points obligatoires :
|
||||||
|
|
||||||
|
```text
|
||||||
|
yellowstone-grpc-proto 12.6.x sur la stack attendue
|
||||||
|
Tonic/Prost KSP-owned sans seconde génération incompatible
|
||||||
|
aucun yellowstone-grpc-client runtime
|
||||||
|
aucun orbitflare-sdk runtime
|
||||||
|
aucune dépendance ksp-onchain-transport-lib -> ksp-config-lib
|
||||||
|
aucun tracing direct Transport
|
||||||
|
aucun nouveau doublon introduit par 0.2.10
|
||||||
|
```
|
||||||
|
|
||||||
|
Les smokes PublicNode ne deviennent pas un blocker si leurs credentials ne sont simplement pas disponibles au moment du gate : leur non-régression déterministe reste couverte par les suites Transport/Config et le code live n'a pas changé. En revanche, tout échec effectivement observé lors d'un rerun doit être classifié, jamais ignoré.
|
||||||
|
|
||||||
|
## 8. Critère de fermeture technique
|
||||||
|
|
||||||
|
`pre.003` est fermée lorsque :
|
||||||
|
|
||||||
|
```text
|
||||||
|
gate déterministe final vert
|
||||||
|
OrbitFlare Devnet Subscribe -> Slot + Ping live PASS
|
||||||
|
graphes Cargo finaux inspectés
|
||||||
|
N1 inchangé
|
||||||
|
N2 inchangé
|
||||||
|
Config V3 x-token cohérente et redacted
|
||||||
|
aucun provider overlay nécessaire
|
||||||
|
aucun heartbeat provider nécessaire
|
||||||
|
aucun SDK OrbitFlare ajouté
|
||||||
|
aucun défaut technique reporté vers pre.004
|
||||||
|
```
|
||||||
|
|
||||||
|
Une fois ce gate acquis, la prochaine tranche est obligatoirement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.10-pre.004 — réconciliation documentaire finale
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle portera plan, validation, README/USAGE et références durables. Elle ne doit pas porter de nouveau correctif technique.
|
||||||
|
|
||||||
|
## 9. Modifications de cette livraison
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
# version: 264 -> 265
|
||||||
|
workspace.package.version: 0.2.10-pre.2.fix.1 -> 0.2.10-pre.3
|
||||||
|
|
||||||
|
deltas/0.2.10/pre.003.md
|
||||||
|
ajout du présent gate technique/live final
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun autre fichier n'est modifié.
|
||||||
128
deltas/0.2.10/pre.004.md
Normal file
128
deltas/0.2.10/pre.004.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<!-- file: deltas/0.2.10/pre.004.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.10-pre.004` — réconciliation documentaire finale OrbitFlare Yellowstone
|
||||||
|
|
||||||
|
## 1. Base
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.10-pre.003
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette tranche est exclusivement le couloir de réconciliation documentaire finale. Le gate technique/live de `pre.003` est vert ; aucun correctif runtime, Config exécutable, test, schema, dependency ou feature n'est introduit ici.
|
||||||
|
|
||||||
|
## 2. Version technique
|
||||||
|
|
||||||
|
Comme toute prerelease non-fix, la version Cargo est synchronisée mécaniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.10-pre.4
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune sémantique runtime ne change.
|
||||||
|
|
||||||
|
## 3. Preuve technique réconciliée
|
||||||
|
|
||||||
|
Le gate opérateur `pre.003` du 2026-08-25 est fermé avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all PASS
|
||||||
|
Rust workspace audit PASS / 0 export candidate
|
||||||
|
Markdown audit PASS / 99 tables / 100 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
Config unit 114/114 PASS
|
||||||
|
Config ownership 5/5 PASS
|
||||||
|
Config public API 15/15 PASS
|
||||||
|
Transport unit 383/383 PASS
|
||||||
|
Transport public API 49/49 PASS
|
||||||
|
Transport release completeness 43/43 PASS
|
||||||
|
Transport doctests 4/4 PASS
|
||||||
|
workspace dependency canary 3/3 PASS
|
||||||
|
cargo test --workspace PASS
|
||||||
|
OrbitFlare Devnet Subscribe + Slot + Ping 1/1 PASS, 5.19 s
|
||||||
|
```
|
||||||
|
|
||||||
|
Les graphes Cargo direct Transport, doublons Transport et doublons workspace ont été inspectés. La pile reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
yellowstone-grpc-proto 12.6.0
|
||||||
|
tonic / tonic-prost 0.14.6
|
||||||
|
prost / prost-types 0.14.4
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `yellowstone-grpc-client` runtime, aucun `orbitflare-sdk` runtime et aucune dépendance `Transport -> Config` n'ont été ajoutés.
|
||||||
|
|
||||||
|
## 4. OrbitFlare final
|
||||||
|
|
||||||
|
Contrat durable réconcilié :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Devnet endpoint http://devnet.rpc.orbitflare.com:10000
|
||||||
|
auth License Key ORBIT-* -> secret metadata x-token
|
||||||
|
Config secret KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN
|
||||||
|
Subscribe standard Yellowstone N2
|
||||||
|
live Slot non nul + server Ping
|
||||||
|
N1 inchangé
|
||||||
|
N2 inchangé
|
||||||
|
provider facade non nécessaire
|
||||||
|
heartbeat N3 non nécessaire
|
||||||
|
Config V4 non nécessaire
|
||||||
|
```
|
||||||
|
|
||||||
|
Le premier échec `pre.002` sans metadata reste conservé dans son delta historique comme caractérisation ayant conduit au fix d'auth `pre.002-fix.001`.
|
||||||
|
|
||||||
|
## 5. Documents réconciliés
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||||
|
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||||
|
crates/ksp-onchain-transport-lib/README.md
|
||||||
|
crates/ksp-onchain-transport-lib/USAGE.md
|
||||||
|
docs/000-README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Les anciennes affirmations `PENDING` sont remplacées par les résultats `pre.003` réellement observés. README/USAGE documentent désormais le profil OrbitFlare Devnet et son smoke live durable.
|
||||||
|
|
||||||
|
## 6. Frontière avec `pre.005`
|
||||||
|
|
||||||
|
Cette tranche ne touche pas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
CHANGELOG.md
|
||||||
|
ROADMAP.md
|
||||||
|
prompt suivant
|
||||||
|
```
|
||||||
|
|
||||||
|
La décision sur la release suivante est prise pendant cette réconciliation, sans générer encore son prompt : Helius LaserStream gRPC est reporté dans les TODO Yellowstone sans numéro de release faute d'accès live raisonnable ; la séquence active avance et `0.2.11` devient `off-chain price transport`, suivie de `0.2.12` Price Desk + intégration Wallet Desk, `0.2.13` interface/wire foundation et `0.2.14` program-api foundation. Le futur `ROADMAP.md` de `pre.005` devra refléter exactement ce report et cette renumérotation.
|
||||||
|
|
||||||
|
Une fois cette décision prise, `pre.005` reste strictement publication-minimal : prompt suivant + `CHANGELOG.md` + `ROADMAP.md` + signal Cargo + delta.
|
||||||
|
|
||||||
|
Si un défaut technique de `0.2.10` est découvert après ce gate documentaire, il ne doit pas être glissé dans `pre.005`; ouvrir une nouvelle prerelease du couloir approprié puis rejouer les tranches finales nécessaires.
|
||||||
|
|
||||||
|
## 7. Fichiers modifiés/ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
docs/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||||
|
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||||
|
crates/ksp-onchain-transport-lib/README.md
|
||||||
|
crates/ksp-onchain-transport-lib/USAGE.md
|
||||||
|
deltas/0.2.10/pre.004.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Gate opérateur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun smoke live ni `cargo tree` n'est à rejouer si le delta est appliqué exactement : aucune surface technique ou dépendance n'est modifiée.
|
||||||
132
deltas/0.2.10/pre.005.md
Normal file
132
deltas/0.2.10/pre.005.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<!-- file: deltas/0.2.10/pre.005.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.10-pre.005` — préparation de publication minimale
|
||||||
|
|
||||||
|
## 1. Base
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.10-pre.004
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette tranche est exclusivement le couloir de préparation de publication. Elle ne modifie aucun code, test, schema, config, README, USAGE, plan, validation ou règle normative.
|
||||||
|
|
||||||
|
## 2. Gate `pre.003` et réconciliation `pre.004`
|
||||||
|
|
||||||
|
Le gate technique final `pre.003` a confirmé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Rust workspace audit PASS
|
||||||
|
Markdown audit PASS
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
cargo test --workspace PASS
|
||||||
|
OrbitFlare live Subscribe -> Slot+Ping PASS en 5.19 s
|
||||||
|
graphes Cargo inspectés
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.004` a ensuite réconcilié les documents durables : plan/validation OrbitFlare, README/USAGE Transport, index documentaire et séquence fonctionnelle. Helius LaserStream gRPC a été reporté dans les TODO faute d'accès live raisonnable sans abonnement payant, sans remise en cause de sa compatibilité wire Yellowstone largement observée.
|
||||||
|
|
||||||
|
## 3. Version technique
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.10-pre.5
|
||||||
|
```
|
||||||
|
|
||||||
|
La modification est mécanique ; aucune sémantique runtime ne change.
|
||||||
|
|
||||||
|
## 4. `CHANGELOG.md`
|
||||||
|
|
||||||
|
Le changelog stable est préparé pour `0.2.10 — OrbitFlare Yellowstone gRPC` avec les faits consolidés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
N1/N2 Yellowstone inchangés
|
||||||
|
Config V3 orbitflare_devnet
|
||||||
|
x-token = License Key via secret_metadata
|
||||||
|
premier live sans metadata -> Unauthenticated
|
||||||
|
fix auth documenté
|
||||||
|
live Subscribe -> Slot + standard Ping PASS
|
||||||
|
second live final PASS
|
||||||
|
aucun heartbeat/provider SDK/Config V4 ajouté
|
||||||
|
workspace + graphes finaux verts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. `ROADMAP.md`
|
||||||
|
|
||||||
|
`0.2.10` passe à l'état réalisé. Helius LaserStream gRPC quitte la séquence numérotée et rejoint les TODO Yellowstone. La séquence active devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11 off-chain price transport
|
||||||
|
0.2.12 Price Desk + intégration prix Wallet Desk
|
||||||
|
0.2.13 interface/wire foundation
|
||||||
|
0.2.14 program-api foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Audit préparatoire de la future `0.2.11`
|
||||||
|
|
||||||
|
Avant de rédiger le prompt suivant, un audit externe rapide au 2026-08-25 a comparé plusieurs familles de sources :
|
||||||
|
|
||||||
|
```text
|
||||||
|
CoinGecko agrégateur, REST Demo, multi quote USD/EUR
|
||||||
|
Jupiter V3 prix Solana heuristique issu des swaps, USD seulement
|
||||||
|
Pyth Hermes oracle, REST/SSE, publish_time/confidence, auth en transition immédiate
|
||||||
|
Birdeye market data Solana, spot/batch/historique, API key
|
||||||
|
DexScreener prix DEX par pair/pool, accès public courant
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce snapshot ne choisit pas le provider. Il démontre surtout que les sémantiques diffèrent et que `0.2.11-pre.001` doit auditer/choisir explicitement le contrat prix, le modèle numérique, la fraîcheur et le provider initial avant code lourd.
|
||||||
|
|
||||||
|
Pyth annonce un changement d'authentification le 2026-08-26 à 16:00 UTC ; le prompt exige donc un réaudit immédiat de Pyth à l'ouverture et interdit de figer le snapshot de `pre.005`.
|
||||||
|
|
||||||
|
## 7. Prompt `0.2.11`
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/016-V0_2_11_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le prompt ouvre `0.2.11 — Off-chain price transport` uniquement depuis `v0.2.10` et impose `pre.001 = lecture + audit actuel providers + matrice sémantique + numeric model + threat model + sizing + planification`.
|
||||||
|
|
||||||
|
Le scope initial reste volontairement borné :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-offchain-transport-lib
|
||||||
|
prix SOL/USD + SOL/EUR
|
||||||
|
premier provider live-testable sans abonnement payant dédié
|
||||||
|
Config -> Off-chain Transport
|
||||||
|
pas de Price Desk
|
||||||
|
pas de Wallet Desk
|
||||||
|
pas d'OHLCV/historique/quotes/metadata/multi-provider complexe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Fichiers modifiés/ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
CHANGELOG.md
|
||||||
|
ROADMAP.md
|
||||||
|
prompts/016-V0_2_11_START_PROMPT.md
|
||||||
|
deltas/0.2.10/pre.005.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun autre fichier ne doit changer dans cette tranche.
|
||||||
|
|
||||||
|
## 9. Gate opérateur
|
||||||
|
|
||||||
|
Comme cette tranche ne modifie aucun code/runtime/config/schema :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun smoke live ni `cargo tree` n'est à rejouer si le delta est appliqué exactement.
|
||||||
|
|
||||||
|
## 10. Suite
|
||||||
|
|
||||||
|
Si ce gate est vert, `0.2.10-rel.001` est la seule étape restante. Elle est limitée à la mécanique de publication stable : version Cargo `0.2.10`, delta `rel.001`, commit final et tag stable `v0.2.10`, sans rattrapage technique ou documentaire.
|
||||||
197
deltas/0.2.10/rel.001.md
Normal file
197
deltas/0.2.10/rel.001.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<!-- file: deltas/0.2.10/rel.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.10-rel.001` — publication stable OrbitFlare Yellowstone gRPC
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Base directe attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.10-pre.005
|
||||||
|
workspace.package.version = 0.2.10-pre.5
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit attendu pour cette livraison :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.10-rel.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Tag stable attendu après validation :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.10
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Responsabilité de `rel.001`
|
||||||
|
|
||||||
|
Cette livraison effectue uniquement la mécanique de publication stable définie par le cycle KSP.
|
||||||
|
|
||||||
|
Elle :
|
||||||
|
|
||||||
|
- passe `workspace.package.version` de `0.2.10-pre.5` à `0.2.10` ;
|
||||||
|
- ajoute le présent delta `rel.001` ;
|
||||||
|
- ne corrige ni code, ni test, ni config/schema, ni README/USAGE, ni plan/validation, ni règle, ni prompt, ni `CHANGELOG.md`, ni `ROADMAP.md`.
|
||||||
|
|
||||||
|
Tout défaut appartenant à un couloir antérieur doit renvoyer vers une prerelease appropriée ; `rel.001` n'est jamais une tranche de rattrapage.
|
||||||
|
|
||||||
|
## 3. Preuves techniques acquises avant publication
|
||||||
|
|
||||||
|
Le gate technique/live final `0.2.10-pre.003` a été validé le **25 août 2026** avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all PASS
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||||
|
General Rust rule audit clean
|
||||||
|
Rust export completeness audit 0 candidate(s)
|
||||||
|
KSP workspace Rust rule audit clean
|
||||||
|
python3 scripts/audit_markdown_tables.py PASS
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
cargo test -p ksp-config-lib PASS
|
||||||
|
cargo test -p ksp-onchain-transport-lib PASS
|
||||||
|
cargo test -p ksp-core-lib --test workspace_dependencies PASS
|
||||||
|
cargo test --workspace PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Les suites directement concernées restent notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config unit 114 passed
|
||||||
|
Transport unit 383 passed
|
||||||
|
Transport public API 49 passed
|
||||||
|
Transport release completeness 43 passed
|
||||||
|
Transport doctests 4 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
Le smoke live final OrbitFlare a également été rejoué en `pre.003` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
OrbitFlare Devnet Yellowstone
|
||||||
|
x-token = License Key
|
||||||
|
Subscribe -> Slot + standard server Ping
|
||||||
|
1/1 PASS en 5.19 s
|
||||||
|
```
|
||||||
|
|
||||||
|
Les graphes Cargo finaux ont été inspectés dans le même couloir. `pre.004` et `pre.005` n'ont ensuite modifié aucune source runtime, dépendance, config ou schema.
|
||||||
|
|
||||||
|
## 4. Version stable publiée
|
||||||
|
|
||||||
|
La version Cargo devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.10
|
||||||
|
```
|
||||||
|
|
||||||
|
La release stable publiée est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.10 — OrbitFlare Yellowstone gRPC
|
||||||
|
```
|
||||||
|
|
||||||
|
Les acquis fonctionnels et documentaires finalisés avant `rel.001` comprennent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
réutilisation stricte du moteur N1 Yellowstone gRPC existant
|
||||||
|
réutilisation stricte du contrat N2 Yellowstone standard existant
|
||||||
|
aucune modification provider du moteur gRPC
|
||||||
|
profil Config V3 orbitflare_devnet
|
||||||
|
endpoint Devnet http://devnet.rpc.orbitflare.com:10000
|
||||||
|
authentification gRPC x-token via License Key secrète
|
||||||
|
premier smoke sans metadata -> Unauthenticated, caractérisé puis corrigé
|
||||||
|
live authentifié Subscribe -> Slot + standard Ping PASS
|
||||||
|
aucun heartbeat provider supplémentaire
|
||||||
|
aucun SDK OrbitFlare ajouté
|
||||||
|
aucun Config V4
|
||||||
|
```
|
||||||
|
|
||||||
|
La release confirme donc qu'OrbitFlare compose au-dessus de N1/N2 par configuration provider et metadata secrète, sans divergence protocolaire nécessitant une façade gRPC dédiée.
|
||||||
|
|
||||||
|
## 5. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.10/rel.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Fichiers supprimés
|
||||||
|
|
||||||
|
```text
|
||||||
|
aucun
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Validations de préparation du delta
|
||||||
|
|
||||||
|
Contrôles statiques de l'overlay :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml version 0.2.10
|
||||||
|
payload rel.001 2 fichiers exactement
|
||||||
|
aucun fichier de rattrapage présent
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune nouvelle compilation n'est requise pour construire l'archive elle-même. Le gate stable final doit toutefois être exécuté après application du delta et avant le commit/tag.
|
||||||
|
|
||||||
|
## 9. Validation stable après application
|
||||||
|
|
||||||
|
Exécuter avant publication :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Le changement étant limité au signal de version Cargo et au présent delta :
|
||||||
|
|
||||||
|
- aucun smoke live OrbitFlare n'est à rejouer si l'overlay appliqué est exactement `0.2.10-rel.001` ;
|
||||||
|
- aucun `cargo tree` n'est à rejouer ;
|
||||||
|
- aucun smoke PublicNode n'est à rejouer.
|
||||||
|
|
||||||
|
## 10. Commit et tag stable
|
||||||
|
|
||||||
|
Après succès du gate stable :
|
||||||
|
|
||||||
|
```text
|
||||||
|
commit : v0.2.10-rel.001
|
||||||
|
tag : v0.2.10
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun tag intermédiaire `rel.001` n'est requis.
|
||||||
|
|
||||||
|
Commandes Git typiques :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Cargo.toml deltas/0.2.10/rel.001.md
|
||||||
|
git commit -m "v0.2.10-rel.001"
|
||||||
|
git tag v0.2.10
|
||||||
|
git push
|
||||||
|
git push origin v0.2.10
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Suite
|
||||||
|
|
||||||
|
Après publication du tag stable `v0.2.10`, ouvrir :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.001 — Off-chain price transport
|
||||||
|
```
|
||||||
|
|
||||||
|
avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/016-V0_2_11_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.001` reste une tranche d'audit actuel des providers, sémantiques de prix, modèle numérique, fraîcheur/provenance, threat model et sizing avant toute implémentation lourde.
|
||||||
|
|
||||||
|
Helius LaserStream gRPC reste reporté dans les TODO Yellowstone jusqu'à disponibilité d'un accès live pertinent ; il ne fait plus partie de la séquence numérotée active.
|
||||||
131
deltas/0.2.11/pre.001-fix.001.md
Normal file
131
deltas/0.2.11/pre.001-fix.001.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.001-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.001-fix.001` — forecast souple éditable
|
||||||
|
|
||||||
|
## 1. Base
|
||||||
|
|
||||||
|
Ce fix s'applique exclusivement à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.11-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit attendu :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.11-pre.001-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun tag prerelease n'est attendu.
|
||||||
|
|
||||||
|
Le fix est documentaire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version reste 0.2.11-pre.1
|
||||||
|
aucun code Rust modifié
|
||||||
|
aucune dépendance modifiée
|
||||||
|
aucun provider ajouté ou retiré
|
||||||
|
aucun contrat technique modifié
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Objet du fix
|
||||||
|
|
||||||
|
Le forecast de `docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md` était présenté sous forme de tableau. Cette forme est correcte pour une vue statique mais peu pratique pendant une release active : elle rend moins naturelle la modification du statut d'une tranche et l'ajout de fixes rattachés à une prerelease.
|
||||||
|
|
||||||
|
Le forecast est donc remplacé par une suite de petits paragraphes structurés par titres `###`.
|
||||||
|
|
||||||
|
Chaque prerelease possède désormais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
un titre ### stable
|
||||||
|
un statut directement modifiable
|
||||||
|
un court paragraphe de responsabilité
|
||||||
|
la possibilité d'ajouter des #### pour pre.xxx-fix.yyy
|
||||||
|
```
|
||||||
|
|
||||||
|
Le présent fix est lui-même ajouté sous `pre.001` comme premier exemple de sous-section `####`.
|
||||||
|
|
||||||
|
## 3. Clarification de durée
|
||||||
|
|
||||||
|
Le plan précise désormais que la cible historique de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
15 à 20 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
est une **cible de granularité**, pas une durée maximale contractuelle.
|
||||||
|
|
||||||
|
Une tranche peut dépasser cette durée lorsqu'un build, un live test, un diagnostic ou une difficulté réelle le justifie. À l'inverse, des tranches peuvent être fusionnées ou recalibrées si cela améliore le découpage sans mélanger des responsabilités incompatibles.
|
||||||
|
|
||||||
|
## 4. Clarification de session
|
||||||
|
|
||||||
|
Le nombre de prereleases du forecast n'implique pas une session de conversation par prerelease.
|
||||||
|
|
||||||
|
Le plan autorise explicitement plusieurs prereleases au cours d'une même session de travail. Aucune scission de session n'est requise structurellement par le forecast ; elle ne devient nécessaire que si une contrainte réelle l'impose, par exemple un blocage opérateur, un changement de scope ou une limite de contexte de l'outil de conversation.
|
||||||
|
|
||||||
|
## 5. Forecast technique inchangé
|
||||||
|
|
||||||
|
La séquence fonctionnelle reste la même :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.001 audit/sizing
|
||||||
|
pre.002 crate + contrats + décimal
|
||||||
|
pre.003 HTTP commun + errors + rate limiter
|
||||||
|
pre.004 CoinGecko + CMC + CoinPaprika
|
||||||
|
pre.005 Kraken + Coinbase
|
||||||
|
pre.006 Jupiter + DexScreener
|
||||||
|
pre.007 Birdeye + registry availability
|
||||||
|
pre.008 refresh single/multiple cross-provider
|
||||||
|
pre.009 Config std.offchain_transport
|
||||||
|
pre.010 hardening/API/tests + docs techniques draft
|
||||||
|
pre.011 gate technique/live final
|
||||||
|
pre.012 réconciliation documentaire finale
|
||||||
|
pre.013 publication minimale prompt 0.2.12 + CHANGELOG + ROADMAP
|
||||||
|
rel.001 stable
|
||||||
|
```
|
||||||
|
|
||||||
|
Seule sa représentation éditoriale change.
|
||||||
|
|
||||||
|
## 6. Validations opérateur de la base `pre.001`
|
||||||
|
|
||||||
|
Les validations fournies par l'opérateur avant ce fix sont toutes positives :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all PASS
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py PASS / clean / 0 candidate export
|
||||||
|
python3 scripts/audit_markdown_tables.py ... deltas/0.2.11 PASS / 115 tables / 100 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
cargo test --workspace PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests explicitement `ignored` dans la sortie restent les smokes/live ou probes opt-in déjà prévus par leurs contrats ; aucun échec de test n'est signalé.
|
||||||
|
|
||||||
|
## 7. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.11/pre.001-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Fichiers supprimés
|
||||||
|
|
||||||
|
```text
|
||||||
|
aucun
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Validation attendue après application
|
||||||
|
|
||||||
|
Le fix étant documentaire, le minimum attendu est :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo fmt/check/clippy/test` n'ont pas besoin d'être rejoués pour ce seul changement documentaire sauf choix opérateur de refaire un gate complet.
|
||||||
311
deltas/0.2.11/pre.001.md
Normal file
311
deltas/0.2.11/pre.001.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.001` — audit/sizing Off-chain price SOL/USD multi-provider
|
||||||
|
|
||||||
|
## 1. Base requise et vérifiée
|
||||||
|
|
||||||
|
Archive autoritaire fournie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
khadhroony-solana-project-v0.2.10-full-from-gitea.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
État vérifié :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.10
|
||||||
|
deltas/0.2.10/rel.001.md présent
|
||||||
|
prompts/016-V0_2_11_START_PROMPT.md présent
|
||||||
|
ksp-offchain-transport-lib absente
|
||||||
|
metadata .git absente de l'archive Gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette livraison ouvre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.11-pre.1
|
||||||
|
commit attendu = v0.2.11-pre.001
|
||||||
|
aucun tag prerelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Objet
|
||||||
|
|
||||||
|
`pre.001` exécute exclusivement le gate de cadrage obligatoire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
lecture base/règles/architecture
|
||||||
|
inventaire primitives HTTP existantes
|
||||||
|
réaudit actuel des providers de prix
|
||||||
|
comparaison des sémantiques
|
||||||
|
scope SOL/USD
|
||||||
|
choix multi-provider gratuit
|
||||||
|
absence de SDK provider
|
||||||
|
numeric safety
|
||||||
|
provider registry et availability
|
||||||
|
rate limiting et refresh multiple
|
||||||
|
Config provider-capability-aware
|
||||||
|
cas DexScreener SOL/USD
|
||||||
|
frontière future ksp-app-solprices-desk
|
||||||
|
threat model
|
||||||
|
sizing et forecast
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun client provider, crate Off-chain Transport, Config runtime ou secret n'est ajouté dans cette tranche.
|
||||||
|
|
||||||
|
## 3. Décisions opérateur supersédant le prompt préparatoire
|
||||||
|
|
||||||
|
Le prompt `016` préparait SOL/USD + SOL/EUR et laissait le provider initial ouvert. Le brainstorming d'ouverture fixe désormais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
V1 = SOL/USD uniquement
|
||||||
|
0.2.11 peut et doit implémenter plusieurs providers gratuits
|
||||||
|
HTTP REST via reqwest uniquement
|
||||||
|
aucun SDK provider
|
||||||
|
SOL/EUR reporté
|
||||||
|
aucun consensus/fallback automatique
|
||||||
|
future app = ksp-app-solprices-desk
|
||||||
|
future app = HID pure sans connaissance provider
|
||||||
|
Off-chain Transport possède limits/exceptions/availability
|
||||||
|
DexScreener V1 = paire SOL/USD explicite uniquement
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces décisions sont répercutées dans le plan, la validation, l'architecture durable, la séquence fonctionnelle et le ROADMAP global d'ouverture.
|
||||||
|
|
||||||
|
## 4. Providers retenus
|
||||||
|
|
||||||
|
Audit officiel du 2026-08-25 :
|
||||||
|
|
||||||
|
| Provider | Gratuit V1 | Auth possible V1 | Contrainte principale | Verdict |
|
||||||
|
|-------------------|------------|----------------------|---------------------------------------------|---------|
|
||||||
|
| CoinGecko | Oui | keyless ou Demo key | dynamic IP ou Demo 100/min et 10k/mois | IN |
|
||||||
|
| CoinMarketCap | Oui | keyless ou Basic key | dynamic IP ou Basic 50/min et 15k/mois | IN |
|
||||||
|
| CoinPaprika | Oui | aucune | 10/s IP, 20k/mois, refresh Free moyen 5 min | IN |
|
||||||
|
| Kraken | Oui | aucune | 1/s ou moins recommandé sur public REST | IN |
|
||||||
|
| Coinbase Exchange | Oui | aucune | 10/s IP, burst 15 | IN |
|
||||||
|
| Jupiter Price V3 | Oui | keyless ou Free key | 0.5 RPS keyless, 1 RPS Free | IN |
|
||||||
|
| Birdeye | Oui | API key | Standard 1 RPS, 30k CU, Price Single 3 CU | IN |
|
||||||
|
| DexScreener | Oui | aucune | 300/min endpoints pair/token | IN |
|
||||||
|
|
||||||
|
Pyth est écarté de V1 à cause de la transition d'accès imminente et de l'absence de gratuité durable confirmée après trial. Binance est écarté tant que KSP n'a pas retenu un vrai marché SOL/USD ; SOL/USDT n'est pas assimilé à SOL/USD.
|
||||||
|
|
||||||
|
## 5. Sémantique et numérique
|
||||||
|
|
||||||
|
Décisions :
|
||||||
|
|
||||||
|
```text
|
||||||
|
une observation conserve provider + sémantique + timestamps KSP
|
||||||
|
provider update timestamp reste optionnel
|
||||||
|
aucune fraîcheur provider inventée
|
||||||
|
f64 n'est pas le canon public
|
||||||
|
prix canonique = décimal KSP borné sans nouvelle crate decimal
|
||||||
|
wire JSON number/string -> parser textuel sûr
|
||||||
|
zéro/négatif/overflow/forme invalide rejetés
|
||||||
|
```
|
||||||
|
|
||||||
|
Les classes de sémantique distingueront notamment agrégateur, exchange last trade, heuristique Solana, spot Solana et paire DEX.
|
||||||
|
|
||||||
|
## 6. Registry, availability et refresh
|
||||||
|
|
||||||
|
`ksp-offchain-transport-lib` sera propriétaire de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
inventory/registry provider
|
||||||
|
descriptors/capabilities
|
||||||
|
adapters/wires privés
|
||||||
|
rate limits/cooldowns
|
||||||
|
classification 401/403/429/5xx/transport/schema
|
||||||
|
état provider ready/unavailable/misconfigured/quota/auth
|
||||||
|
refresh provider générique
|
||||||
|
refresh multiple/all générique
|
||||||
|
```
|
||||||
|
|
||||||
|
Un refresh multiple lance les providers actuellement éligibles sans attendre ceux en cooldown et retourne un outcome par provider. La future UI ne contient aucune logique de scheduling.
|
||||||
|
|
||||||
|
## 7. DexScreener V1
|
||||||
|
|
||||||
|
Décision :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pair address Solana configurée
|
||||||
|
endpoint pair direct
|
||||||
|
chain et identité SOL validées
|
||||||
|
lecture de priceUsd uniquement pour le contrat commun
|
||||||
|
aucune découverte/selection automatique de pool
|
||||||
|
aucun tri liquidité
|
||||||
|
aucun consensus entre pools
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette policy pourra être changée dans une release ultérieure sans exposer DexScreener à l'application.
|
||||||
|
|
||||||
|
## 8. Config et sécurité
|
||||||
|
|
||||||
|
Config reste propriétaire des fichiers/env/secrets et adaptera un document standard Off-chain Transport futur.
|
||||||
|
|
||||||
|
Les branches seront provider-capability-aware :
|
||||||
|
|
||||||
|
```text
|
||||||
|
providers keyless sans faux credential obligatoire
|
||||||
|
modes keyless/keyed représentés quand les deux existent
|
||||||
|
Birdeye credential obligatoire si activé
|
||||||
|
DexScreener pair address obligatoire si activé
|
||||||
|
origines HTTP provider fixes en V1
|
||||||
|
aucun base_url arbitraire
|
||||||
|
aucune limite provider librement augmentable par Config/UI
|
||||||
|
```
|
||||||
|
|
||||||
|
Le threat model couvre credential exfiltration, SSRF, redirects, proxy implicite, body non borné, numeric abuse, wrong pair, stale data, rate limit, provider outage et schema drift.
|
||||||
|
|
||||||
|
## 9. Future `ksp-app-solprices-desk`
|
||||||
|
|
||||||
|
Architecture fixée pour `0.2.12` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HID pure
|
||||||
|
aucune connaissance provider
|
||||||
|
aucune requête HTTP provider
|
||||||
|
aucun rate limit provider côté UI
|
||||||
|
liste issue du registry générique
|
||||||
|
prix/état/cooldown issus du service générique
|
||||||
|
refresh row générique
|
||||||
|
refresh multiple générique
|
||||||
|
```
|
||||||
|
|
||||||
|
`0.2.11` ne crée pas l'application.
|
||||||
|
|
||||||
|
## 10. Forecast
|
||||||
|
|
||||||
|
Chemin souple retenu :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.001 audit/sizing
|
||||||
|
pre.002 crate + contrats + décimal
|
||||||
|
pre.003 HTTP commun + errors + rate limiter
|
||||||
|
pre.004 CoinGecko + CMC + CoinPaprika
|
||||||
|
pre.005 Kraken + Coinbase
|
||||||
|
pre.006 Jupiter + DexScreener
|
||||||
|
pre.007 Birdeye + registry availability
|
||||||
|
pre.008 refresh single/multiple cross-provider
|
||||||
|
pre.009 Config std.offchain_transport
|
||||||
|
pre.010 hardening/API/tests + docs techniques draft
|
||||||
|
pre.011 gate technique/live final
|
||||||
|
pre.012 réconciliation documentaire finale
|
||||||
|
pre.013 publication minimale prompt 0.2.12 + CHANGELOG + ROADMAP
|
||||||
|
rel.001 stable
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
deltas/0.2.11/pre.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/architecture/002-LAYERS_AND_DEPENDENCIES.md
|
||||||
|
docs/architecture/003-COMPONENT_CONTRACTS.md
|
||||||
|
docs/architecture/004-COMPONENT_INVENTORY.md
|
||||||
|
docs/architecture/005-DEPENDENCY_GRAPH.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/validation/000-README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Fichiers supprimés
|
||||||
|
|
||||||
|
```text
|
||||||
|
aucun
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Fichiers volontairement inchangés
|
||||||
|
|
||||||
|
```text
|
||||||
|
CHANGELOG.md
|
||||||
|
prompts/016-V0_2_11_START_PROMPT.md
|
||||||
|
.env.example
|
||||||
|
config/**
|
||||||
|
crates/**
|
||||||
|
README/USAGE de crates existantes
|
||||||
|
```
|
||||||
|
|
||||||
|
Le prompt d'ouverture reste historique ; les supersessions sont portées par le plan/delta de la release active.
|
||||||
|
|
||||||
|
## 15. Sources externes réauditées
|
||||||
|
|
||||||
|
```text
|
||||||
|
CoinGecko keyless/simple-price/pricing
|
||||||
|
CoinMarketCap keyless/simple-price/pricing
|
||||||
|
CoinPaprika REST/ticker/plans
|
||||||
|
Kraken public market REST/rate limits
|
||||||
|
Coinbase Exchange ticker/rate limits
|
||||||
|
Jupiter Developer Platform/Price V3
|
||||||
|
Birdeye pricing/rate limiting/Price Single
|
||||||
|
DexScreener API reference
|
||||||
|
Pyth Core transition pour décision OUT V1
|
||||||
|
```
|
||||||
|
|
||||||
|
Les URLs détaillées sont conservées dans le plan 018.
|
||||||
|
|
||||||
|
Le gate distingue désormais explicitement accès gratuit technique et conditions d'usage : CoinGecko/CMC keyless sont orientés évaluation/prototypage selon leurs documents actuels et CoinPaprika Free est annoncé pour usage personnel. Ces points n'empêchent pas l'implémentation V1 mais seront réaudités avant la stable ; KSP ne promet aucune gratuité commerciale durable.
|
||||||
|
|
||||||
|
## 16. Validations exécutées dans le sandbox
|
||||||
|
|
||||||
|
```text
|
||||||
|
inspection archive/version/rel/prompt PASS
|
||||||
|
inspection absence metadata .git PASS
|
||||||
|
inspection absence ksp-offchain-transport-lib PASS
|
||||||
|
inventaire workspace dependencies PASS
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py PASS / clean / 0 candidate export
|
||||||
|
audit Markdown ciblé des 12 fichiers touchés PASS / 16 tables
|
||||||
|
audit Markdown scope session complet PASS / 115 tables / 100 files
|
||||||
|
```
|
||||||
|
|
||||||
|
Le sandbox ne fournit ni `cargo` ni `rustc`. Aucun `cargo fmt/check/clippy/test` n'est donc déclaré exécuté localement. Aucun smoke provider live KSP n'est déclaré PASS dans `pre.001` puisque la crate n'existe pas encore.
|
||||||
|
|
||||||
|
## 17. Validations non exécutées dans `pre.001`
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all non exécuté, Cargo absent du sandbox
|
||||||
|
cargo check --workspace non exécuté, Cargo absent du sandbox
|
||||||
|
cargo clippy --workspace --all-targets non exécuté, Cargo absent du sandbox
|
||||||
|
cargo test --workspace non exécuté, Cargo absent du sandbox
|
||||||
|
aucun test adapter provider code absent
|
||||||
|
aucun smoke live provider KSP code absent
|
||||||
|
aucun test Config Off-chain document absent
|
||||||
|
aucun build ksp-app-solprices-desk application hors scope
|
||||||
|
```
|
||||||
|
|
||||||
|
## 18. Validation opérateur attendue avant commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun smoke réseau provider n'est attendu pour cette tranche documentaire de cadrage.
|
||||||
|
|
||||||
|
## 19. Questions ouvertes
|
||||||
|
|
||||||
|
Les questions fonctionnelles majeures sont closes pour démarrer l'implémentation. Restent à fixer au niveau du code dans les prereleases prévues :
|
||||||
|
|
||||||
|
```text
|
||||||
|
noms Rust exacts des descriptors/outcomes/status
|
||||||
|
bornes exactes coefficient/scale du décimal KSP
|
||||||
|
body limit et timeouts par défaut
|
||||||
|
forme exacte du rate limiter générique
|
||||||
|
noms des variables KSP_SECRET provider keyed
|
||||||
|
pair address DexScreener opérateur à configurer et live-valider
|
||||||
|
endpoint/version CMC exact à figer après canari live courant
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces points ne remettent pas en cause le gate de sizing.
|
||||||
193
deltas/0.2.11/pre.002-fix.001.md
Normal file
193
deltas/0.2.11/pre.002-fix.001.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.002-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.002-fix.001` — Rustdoc, Logging et taxonomie Off-chain
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Ce correctif s'applique exclusivement après :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.10
|
||||||
|
+ 0.2.11-pre.001
|
||||||
|
+ 0.2.11-pre.001-fix.001
|
||||||
|
+ 0.2.11-pre.002
|
||||||
|
```
|
||||||
|
|
||||||
|
Conformément à `VER-ID-007` et `VER-ID-010`, la version Cargo devient `0.2.11-pre.2.fix.1` car ce correctif modifie du code Rust et un manifeste de crate.
|
||||||
|
|
||||||
|
## 2. Motifs du correctif
|
||||||
|
|
||||||
|
La validation opérateur de `pre.002` a montré que `cargo check` et les tests passaient, mais que `cargo clippy --workspace --all-targets` émettait deux warnings `missing_docs` sur les crates d'intégration :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate n'était donc pas propre malgré le succès fonctionnel. Les résultats opérateur complets de `pre.002` restent l'autorité sur cette constatation.
|
||||||
|
|
||||||
|
Une relecture des règles a également confirmé les contrats suivants :
|
||||||
|
|
||||||
|
```text
|
||||||
|
RUST-DOC-001/003 rustdocs utiles sur les surfaces concernées
|
||||||
|
DEP-LOG-004 runtime KSP instrumenté via ksp-logging-lib
|
||||||
|
DEP-LOG-010 target explicite dans src/constants.rs
|
||||||
|
DEP-LOG-011 pas de target dérivé implicitement de Cargo
|
||||||
|
FILE_CONTRACTS modules séparés selon responsabilités/invariants
|
||||||
|
```
|
||||||
|
|
||||||
|
Enfin, la fondation initiale utilisait des noms internes trop génériques (`decimal.rs`, `observation.rs`, `provider.rs`, `settings.rs`) alors que `ksp-offchain-transport-lib` est destinée à porter plusieurs familles off-chain hétérogènes.
|
||||||
|
|
||||||
|
## 3. Corrections Rustdoc
|
||||||
|
|
||||||
|
Les deux tests d'intégration possèdent désormais une rustdoc de crate explicite, ce qui supprime les warnings `missing_docs` attendus sous `#![warn(missing_docs)]`.
|
||||||
|
|
||||||
|
Aucun lint n'est désactivé ou abaissé.
|
||||||
|
|
||||||
|
## 4. Logging KSP
|
||||||
|
|
||||||
|
`ksp-offchain-transport-lib` dépend désormais réellement de `ksp-logging-lib`.
|
||||||
|
|
||||||
|
Le fichier suivant est ajouté :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/src/constants.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
avec le target propriétaire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-offchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Les validations/constructions comportementales de la fondation émettent uniquement via les macros `ksp-logging-lib` et `crate::TRACING_TARGET`. Aucune dépendance directe à `tracing`, `tracing-subscriber` ou `tracing-appender` n'est ajoutée.
|
||||||
|
|
||||||
|
Les logs de validation ne recopient ni identifiant rejeté, ni display name rejeté, ni futur credential.
|
||||||
|
|
||||||
|
## 5. Taxonomie de modules
|
||||||
|
|
||||||
|
La crate est explicitement traitée comme un transport off-chain hétérogène et non comme une crate dédiée exclusivement aux prix.
|
||||||
|
|
||||||
|
Les modules V1 de prix sont renommés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
decimal.rs -> market_price_decimal.rs
|
||||||
|
observation.rs -> market_price_observation.rs
|
||||||
|
provider.rs -> market_price_provider.rs
|
||||||
|
settings.rs -> market_price_settings.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests unitaires en miroir suivent la même convention.
|
||||||
|
|
||||||
|
La façade publique reste au crate-root ; aucun `pub mod` n'est introduit. Les noms publics `Price*` restent ceux du contrat V1, tandis que le classement physique privé rend explicite leur appartenance à la famille `market_price`.
|
||||||
|
|
||||||
|
La taxonomie durable prévue est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
constants.rs / error.rs partagé crate-wide
|
||||||
|
http_* mécanique HTTP partageable lorsqu'elle existe réellement
|
||||||
|
market_price_* prix de marché / spot
|
||||||
|
swap_quote_* futures quotes montant/route
|
||||||
|
autres familles ajoutées seulement avec un scope réel
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette organisation suit le précédent KSP des familles `http_*`, `ws_*`, `grpc_*` et `rpc_*` de On-chain Transport.
|
||||||
|
|
||||||
|
## 6. Lecture/écriture
|
||||||
|
|
||||||
|
`market_price` V1 reste strictement read-only. La crate n'est pas déclarée définitivement read-only pour toute évolution future : la sémantique métier décide de l'ownership, pas le simple verbe HTTP.
|
||||||
|
|
||||||
|
Une future quote Jupiter appartient à une famille distincte `swap_quote`; une soumission ou signature de transaction Solana reste hors de cette crate. Toute vraie mutation d'un service distant demandera un contrat explicite avant implémentation.
|
||||||
|
|
||||||
|
## 7. Tests et canaries modifiés
|
||||||
|
|
||||||
|
`dependency_boundary.rs` vérifie désormais au minimum :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-logging-lib présent
|
||||||
|
aucune dépendance tracing directe
|
||||||
|
constants.rs et TRACING_TARGET explicites
|
||||||
|
modules V1 préfixés market_price_*
|
||||||
|
anciens modules internes génériques absents
|
||||||
|
Config, reqwest et SDK providers toujours absents de pre.002
|
||||||
|
```
|
||||||
|
|
||||||
|
`public_api.rs` conserve les canaries de façade de `pre.002` avec sa rustdoc de crate.
|
||||||
|
|
||||||
|
## 8. Validations demandées après application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Le résultat attendu de Clippy est **sans warning rustdoc** pour `ksp-offchain-transport-lib`.
|
||||||
|
|
||||||
|
## 9. Scope inchangé
|
||||||
|
|
||||||
|
Le correctif n'ajoute aucun client HTTP, limiter actif, adapter provider, credential Config, quote Jupiter ou nouvelle paire. `pre.003` reste propriétaire du premier runtime HTTP commun.
|
||||||
|
|
||||||
|
## 10. Validation opérateur de `pre.002` ayant déclenché le fix
|
||||||
|
|
||||||
|
La validation fournie par l'opérateur avant ce correctif donne :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all exécuté
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py clean
|
||||||
|
python3 scripts/audit_markdown_tables.py clean, 115 tables / 102 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS fonctionnel avec 2 warnings missing_docs
|
||||||
|
cargo test -p ksp-offchain-transport-lib PASS, 10 unit + 1 boundary + 2 public API
|
||||||
|
cargo test --workspace PASS, ignored opt-in/diagnostic inchangés
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux warnings Clippy sont précisément la raison rustdoc du présent fix ; ils ne sont pas requalifiés en PASS propre.
|
||||||
|
|
||||||
|
## 11. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/src/constants.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_observation.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_settings.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_settings.rs
|
||||||
|
deltas/0.2.11/pre.002-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Fichiers supprimés par renommage
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/src/decimal.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/observation.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/settings.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/decimal.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/observation.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/settings.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces suppressions font partie du correctif : les anciens fichiers génériques ne doivent pas coexister avec les fichiers `market_price_*` après application.
|
||||||
93
deltas/0.2.11/pre.002-fix.002.md
Normal file
93
deltas/0.2.11/pre.002-fix.002.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.002-fix.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.002-fix.002` — conformité Clippy `implicit_return`
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Ce delta s'applique après :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.10
|
||||||
|
+ 0.2.11-pre.001
|
||||||
|
+ 0.2.11-pre.001-fix.001
|
||||||
|
+ 0.2.11-pre.002
|
||||||
|
+ 0.2.11-pre.002-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo attendue avant application est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.2.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Motif du correctif
|
||||||
|
|
||||||
|
La validation opérateur de `pre.002-fix.001` est propre pour `cargo fmt`, les audits Rust/Markdown et `cargo check --workspace`. Le test ciblé `cargo test -p ksp-offchain-transport-lib` passe également avec 13 tests réussis.
|
||||||
|
|
||||||
|
Le gate :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
échoue toutefois sur un unique diagnostic dans `crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
clippy::implicit-return
|
||||||
|
```
|
||||||
|
|
||||||
|
La closure passée à `Iterator::any` doit utiliser un `return` explicite conformément à la configuration Clippy du workspace.
|
||||||
|
|
||||||
|
## 3. Correction
|
||||||
|
|
||||||
|
Le canari de dépendance conserve exactement la même vérification — absence d'une dépendance directe `tracing = ...` — mais adopte la forme KSP déjà utilisée dans d'autres tests :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
|line| return line.trim_start().starts_with("tracing =")
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune logique runtime, aucun contrat public et aucune taxonomie de module ne sont modifiés.
|
||||||
|
|
||||||
|
## 4. Version technique
|
||||||
|
|
||||||
|
Comme le correctif modifie un fichier Rust, `VER-ID-007` et `VER-ID-010` imposent la synchronisation du workspace sur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.2.fix.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `Cargo.toml` racine est donc modifié dans ce delta.
|
||||||
|
|
||||||
|
## 5. Validations opérateur demandées
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate de sortie exige notamment un `cargo clippy --workspace --all-targets` sans warning ni erreur.
|
||||||
|
|
||||||
|
## 6. Scope inchangé
|
||||||
|
|
||||||
|
`pre.002-fix.002` ne fait pas avancer `pre.003`. Aucun client HTTP, aucun limiter actif, aucun adapter provider et aucune nouvelle dépendance réseau ne sont introduits.
|
||||||
|
|
||||||
|
## 7. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.11/pre.002-fix.002.md
|
||||||
|
```
|
||||||
185
deltas/0.2.11/pre.002-fix.003.md
Normal file
185
deltas/0.2.11/pre.002-fix.003.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.002-fix.003.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.002-fix.003` — nommage public `MarketPrice*`
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Ce correctif s'applique exclusivement après :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.10
|
||||||
|
+ 0.2.11-pre.001
|
||||||
|
+ 0.2.11-pre.001-fix.001
|
||||||
|
+ 0.2.11-pre.002
|
||||||
|
+ 0.2.11-pre.002-fix.001
|
||||||
|
+ 0.2.11-pre.002-fix.002
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo attendue avant application est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.2.fix.2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Motif du correctif
|
||||||
|
|
||||||
|
`pre.002-fix.001` a correctement classé la première capacité de `ksp-offchain-transport-lib` sous les modules privés `market_price_*`, mais sa façade crate-root conservait des noms publics génériques `Price*`.
|
||||||
|
|
||||||
|
Cette asymétrie créerait une dette publique dès l'apparition d'autres familles hétérogènes, notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
swap_quote_*
|
||||||
|
metadata_*
|
||||||
|
autres données off-chain futures
|
||||||
|
```
|
||||||
|
|
||||||
|
Des noms tels que `PriceProviderId`, `PriceProviderState` ou `PriceTimestamp` pourraient alors être interprétés comme des abstractions crate-wide alors qu'ils ne sont actuellement prouvés que pour la famille `market_price`.
|
||||||
|
|
||||||
|
Le correctif applique donc la même frontière de responsabilité aux modules privés et à la façade publique avant l'introduction du runtime HTTP de `pre.003`.
|
||||||
|
|
||||||
|
## 3. Convention publique retenue
|
||||||
|
|
||||||
|
Toute API spécifique à la famille `market_price` porte désormais le préfixe :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPrice*
|
||||||
|
```
|
||||||
|
|
||||||
|
et toute constante correspondante :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MARKET_PRICE_*
|
||||||
|
ERROR_CODE_MARKET_PRICE_*
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples principaux :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PriceDecimal -> MarketPriceDecimal
|
||||||
|
SolUsdPriceObservation -> MarketPriceObservation
|
||||||
|
PriceTimestamp -> MarketPriceTimestamp
|
||||||
|
PriceProvenance -> MarketPriceProvenance
|
||||||
|
PricePair -> MarketPricePair
|
||||||
|
PriceSemantics -> MarketPriceSemantics
|
||||||
|
PriceProviderId -> MarketPriceProviderId
|
||||||
|
PriceProviderDescriptor -> MarketPriceProviderDescriptor
|
||||||
|
PriceProviderRateLimit -> MarketPriceProviderRateLimit
|
||||||
|
PriceProviderAvailability -> MarketPriceProviderAvailability
|
||||||
|
PriceProviderState -> MarketPriceProviderState
|
||||||
|
PriceProviderCommonSettings -> MarketPriceProviderCommonSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
Les enums de rate-limit/quota/auth suivent la même règle.
|
||||||
|
|
||||||
|
Aucun alias public `Price*` n'est conservé. La release est encore en prerelease et conserver ces aliases figerait précisément l'ambiguïté que ce correctif doit supprimer.
|
||||||
|
|
||||||
|
## 4. Observation V1
|
||||||
|
|
||||||
|
`SolUsdPriceObservation` devient `MarketPriceObservation` plutôt que `MarketPriceSolUsdObservation`.
|
||||||
|
|
||||||
|
La paire réellement supportée reste portée explicitement par :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPricePair::SolUsd
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette forme évite un nouveau renommage du type observation si une version ultérieure étend proprement la famille `market_price` à d'autres paires, tout en conservant le gate V1 strictement limité à SOL/USD.
|
||||||
|
|
||||||
|
## 5. Codes d'erreur
|
||||||
|
|
||||||
|
Les identifiants publics et les codes textuels spécifiques à cette famille sont également namespacés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
market_price_decimal_invalid
|
||||||
|
market_price_provider_descriptor_invalid
|
||||||
|
market_price_provider_id_invalid
|
||||||
|
market_price_observation_invalid
|
||||||
|
market_price_provider_settings_invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
Le domaine `ksp_core_lib::ErrorCode` reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
offchain_transport
|
||||||
|
```
|
||||||
|
|
||||||
|
Une future famille peut ainsi ajouter ses propres codes sans collision sémantique avec `market_price`.
|
||||||
|
|
||||||
|
## 6. Version technique
|
||||||
|
|
||||||
|
Le correctif modifie du Rust public. `VER-ID-007` et `VER-ID-010` imposent donc :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = 0.2.11-pre.2.fix.3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Validation opérateur acquise avant ce fix
|
||||||
|
|
||||||
|
L'état `0.2.11-pre.002-fix.002` a été validé par l'opérateur le `2026-08-25` avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all exécuté
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py clean
|
||||||
|
python3 scripts/audit_markdown_tables.py clean, 116 tables / 104 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo test -p ksp-offchain-transport-lib PASS, 10 unit + 1 boundary + 2 public API
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` n'a pas été rejoué après `pre.002-fix.002` et n'est donc pas déclaré PASS pour cet état exact.
|
||||||
|
|
||||||
|
## 8. Validations demandées après application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Le canari public doit notamment compiler exclusivement avec les noms `MarketPrice*` et ne plus trouver de surface publique `Price*` historique.
|
||||||
|
|
||||||
|
## 9. Scope inchangé
|
||||||
|
|
||||||
|
Ce correctif reste strictement rattaché à la fondation `pre.002` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
aucun reqwest
|
||||||
|
aucun client HTTP
|
||||||
|
aucun limiter actif
|
||||||
|
aucun adapter provider
|
||||||
|
aucun credential provider
|
||||||
|
aucune nouvelle paire
|
||||||
|
aucune quote Jupiter
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.003` reste propriétaire du premier runtime HTTP commun.
|
||||||
|
|
||||||
|
## 10. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_observation.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_settings.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_observation.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_settings.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.11/pre.002-fix.003.md
|
||||||
|
```
|
||||||
278
deltas/0.2.11/pre.002.md
Normal file
278
deltas/0.2.11/pre.002.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.002` — fondation Off-chain Transport SOL/USD
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Cette tranche s'applique exclusivement sur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.10
|
||||||
|
+ 0.2.11-pre.001
|
||||||
|
+ 0.2.11-pre.001-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo d'entrée attendue est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo de sortie est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Objectif
|
||||||
|
|
||||||
|
Créer la fondation de `ksp-offchain-transport-lib` sans avancer le client HTTP commun ni aucun adapter provider.
|
||||||
|
|
||||||
|
La tranche matérialise uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
surface publique SOL/USD V1
|
||||||
|
PriceDecimal exact sans f64 canonique
|
||||||
|
observation/provenance/timestamps provider-neutral
|
||||||
|
identifiant/descripteur provider opaque
|
||||||
|
sémantique de prix explicite
|
||||||
|
auth/rate-limit/quota descriptifs
|
||||||
|
settings communs minimaux
|
||||||
|
availability/state provider-neutral
|
||||||
|
```
|
||||||
|
|
||||||
|
`reqwest`, le limiter actif, les credentials provider et les DTOs wire restent réservés aux prereleases suivantes.
|
||||||
|
|
||||||
|
## 3. Décisions matérialisées
|
||||||
|
|
||||||
|
### 3.1 Décimal exact
|
||||||
|
|
||||||
|
`PriceDecimal` possède :
|
||||||
|
|
||||||
|
```text
|
||||||
|
coefficient positif u128
|
||||||
|
scale maximale 18
|
||||||
|
entrée textuelle maximale 96 octets
|
||||||
|
normalisation des zéros fractionnaires terminaux
|
||||||
|
notation scientifique bornée
|
||||||
|
serialization Serde canonique sous forme de string décimale
|
||||||
|
zéro, négatif, NaN/Inf, overflow et scale excessive rejetés
|
||||||
|
```
|
||||||
|
|
||||||
|
Le type ne passe jamais par `f64` comme vérité canonique.
|
||||||
|
|
||||||
|
Le zéro étant invalide pour un prix réussi, l'absence de prix ne peut pas être silencieusement transformée en `0`.
|
||||||
|
|
||||||
|
### 3.2 Paire et observation V1
|
||||||
|
|
||||||
|
La seule paire publique reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PricePair::SolUsd
|
||||||
|
```
|
||||||
|
|
||||||
|
`SolUsdPriceObservation` conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider_id opaque
|
||||||
|
PriceDecimal
|
||||||
|
PriceSemantics
|
||||||
|
request_started_at
|
||||||
|
received_at
|
||||||
|
provider_timestamp optionnel
|
||||||
|
PriceProvenance sûre et bornée
|
||||||
|
```
|
||||||
|
|
||||||
|
Les timestamps sont projetés en millisecondes UTC depuis Unix epoch via `PriceTimestamp`.
|
||||||
|
|
||||||
|
Le constructeur rejette une réception antérieure au départ de requête. Aucun timestamp provider n'est obligatoire ou inventé.
|
||||||
|
|
||||||
|
### 3.3 Provider-neutral descriptors
|
||||||
|
|
||||||
|
`PriceProviderId` est un identifiant opaque borné. Il n'expose pas les identifiants wire propriétaires des providers.
|
||||||
|
|
||||||
|
`PriceProviderDescriptor` conserve uniquement des informations génériques utiles aux consumers :
|
||||||
|
|
||||||
|
```text
|
||||||
|
id
|
||||||
|
nom d'affichage
|
||||||
|
PriceSemantics
|
||||||
|
PriceProviderAuthMode
|
||||||
|
PriceProviderRateLimit
|
||||||
|
PriceProviderLongTermQuota optionnel
|
||||||
|
support SOL/USD
|
||||||
|
```
|
||||||
|
|
||||||
|
Les limites fixes exigent un nombre de requêtes et une fenêtre non nuls. Une limite dynamique/server-driven est représentée séparément.
|
||||||
|
|
||||||
|
Les quotas longs termes sont informatifs : aucun compteur local ne prétend connaître le quota restant réel d'un compte partagé ou consommé par un autre processus.
|
||||||
|
|
||||||
|
### 3.4 Settings communs minimaux
|
||||||
|
|
||||||
|
`PriceProviderCommonSettings` ne contient que :
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider_id
|
||||||
|
enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
Il ne contient volontairement ni endpoint, ni API key, ni cadence configurable.
|
||||||
|
|
||||||
|
Les settings propres à CoinGecko, CoinMarketCap, Jupiter, Birdeye ou DexScreener seront ajoutés uniquement avec leurs adapters afin que chaque configuration corresponde aux capacités réelles du provider.
|
||||||
|
|
||||||
|
### 3.5 Availability générique
|
||||||
|
|
||||||
|
`PriceProviderAvailability` peut représenter :
|
||||||
|
|
||||||
|
```text
|
||||||
|
authentication unavailable
|
||||||
|
cooling down + retry_at
|
||||||
|
disabled
|
||||||
|
misconfigured
|
||||||
|
quota unavailable
|
||||||
|
ready
|
||||||
|
temporarily unavailable + retry_at optionnel
|
||||||
|
```
|
||||||
|
|
||||||
|
`PriceProviderState` associe cette projection à un `PriceProviderId` sans demander au consumer de parser une erreur provider.
|
||||||
|
|
||||||
|
Le registry et les transitions runtime effectives restent prévus en `pre.007`.
|
||||||
|
|
||||||
|
## 4. Tests ajoutés
|
||||||
|
|
||||||
|
La crate contient des tests unitaires physiquement séparés pour :
|
||||||
|
|
||||||
|
```text
|
||||||
|
parsing/normalisation/scientific notation PriceDecimal
|
||||||
|
rejets numeric safety
|
||||||
|
round-trip Serde PriceDecimal
|
||||||
|
validation PriceProviderId
|
||||||
|
descriptors auth/rate-limit/quota
|
||||||
|
availability générique
|
||||||
|
observation SOL/USD et ordre des timestamps
|
||||||
|
provenance sûre
|
||||||
|
settings communs sans endpoint/API key/rate-limit
|
||||||
|
```
|
||||||
|
|
||||||
|
Deux tests d'intégration vérifient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
surface publique pre.002 au crate-root
|
||||||
|
error codes domaine offchain_transport
|
||||||
|
absence de dépendance Config
|
||||||
|
absence de reqwest/provider SDK dans le manifest pre.002
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/decimal.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/observation.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/settings.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/decimal.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/observation.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/settings.rs
|
||||||
|
deltas/0.2.11/pre.002.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Fichiers supprimés
|
||||||
|
|
||||||
|
```text
|
||||||
|
aucun
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Fichiers volontairement inchangés
|
||||||
|
|
||||||
|
```text
|
||||||
|
CHANGELOG.md
|
||||||
|
ROADMAP.md
|
||||||
|
README.md
|
||||||
|
.env.example
|
||||||
|
config/**
|
||||||
|
crates/ksp-config-lib/**
|
||||||
|
crates/ksp-onchain-transport-lib/**
|
||||||
|
prompts/**
|
||||||
|
```
|
||||||
|
|
||||||
|
`ROADMAP.md` et `CHANGELOG.md` restent hors de cette tranche conformément à leur ownership de release.
|
||||||
|
|
||||||
|
## 9. Validations exécutées dans le sandbox
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
General Rust rule audit: clean
|
||||||
|
Rust export completeness audit: 0 candidate(s)
|
||||||
|
KSP workspace Rust rule audit: clean
|
||||||
|
|
||||||
|
python3 scripts/audit_markdown_tables.py docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
clean / 14 tables / 2 files
|
||||||
|
|
||||||
|
contrôle lignes Rust > 160 sur la nouvelle crate
|
||||||
|
PASS / aucune
|
||||||
|
|
||||||
|
inspection manifest nouvelle crate
|
||||||
|
ksp-config-lib absent
|
||||||
|
reqwest absent en pre.002
|
||||||
|
SDK provider absent
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Validations non exécutées dans le sandbox
|
||||||
|
|
||||||
|
Le sandbox de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`.
|
||||||
|
|
||||||
|
Les validations suivantes doivent donc être exécutées par l'opérateur après application du delta :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Les validations Cargo fournies par l'opérateur pour `pre.001` étaient vertes avant cette tranche ; elles constituent une baseline d'entrée mais ne sont pas comptées comme validation du nouveau code `pre.002`.
|
||||||
|
|
||||||
|
## 11. Questions ouvertes
|
||||||
|
|
||||||
|
Aucune question de design ne bloque `pre.003`.
|
||||||
|
|
||||||
|
Les points volontairement différés restent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP reqwest commun
|
||||||
|
conversion JSON number/string provider vers PriceDecimal
|
||||||
|
classification HTTP/transport
|
||||||
|
Retry-After
|
||||||
|
limiter/cooldown actif
|
||||||
|
credentials provider
|
||||||
|
adapters provider
|
||||||
|
registry runtime effectif
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Suite prévue
|
||||||
|
|
||||||
|
La tranche suivante reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.003 — HTTP REST commun et rate limiting
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle pourra ajouter `reqwest` et `ksp-logging-lib` à la nouvelle crate, les bornes HTTP, la redaction, la classification commune des erreurs et le limiter/cooldown générique, sans encore implémenter les adapters provider de `pre.004+`.
|
||||||
220
deltas/0.2.11/pre.003-fix.001.md
Normal file
220
deltas/0.2.11/pre.003-fix.001.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.003-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.003-fix.001` — conformité crate-root, staging HTTP et burst documenté
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Ce correctif s'applique exclusivement après :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.10
|
||||||
|
+ 0.2.11-pre.001
|
||||||
|
+ 0.2.11-pre.001-fix.001
|
||||||
|
+ 0.2.11-pre.002
|
||||||
|
+ 0.2.11-pre.002-fix.001
|
||||||
|
+ 0.2.11-pre.002-fix.002
|
||||||
|
+ 0.2.11-pre.002-fix.003
|
||||||
|
+ 0.2.11-pre.003
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo attendue à l'entrée est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.3
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo de sortie est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.3.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Motif du correctif
|
||||||
|
|
||||||
|
Le gate opérateur de `pre.003` a révélé plusieurs écarts avant poursuite vers `pre.004` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
RUST-FMT-104 : 6 violations dans src/error.rs
|
||||||
|
cargo check : 6 warnings de réexports pub(crate) HTTP inutilisés
|
||||||
|
cargo clippy --workspace --all-targets : mêmes warnings de réexports
|
||||||
|
cargo test -p ksp-offchain-transport-lib : 20 PASS, 1 FAIL
|
||||||
|
```
|
||||||
|
|
||||||
|
Le test en échec était :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http_admission::tests::documented_burst_is_consumed_atomically_before_refill
|
||||||
|
```
|
||||||
|
|
||||||
|
La cause était une validation incorrecte refusant `burst > requests`. Cette contrainte contredit un cas déjà retenu dans le plan : Coinbase documente `10 req/s` avec `burst 15`.
|
||||||
|
|
||||||
|
Une relecture de `docs/rules/RULES_RUST.md` confirme également deux règles applicables :
|
||||||
|
|
||||||
|
```text
|
||||||
|
RUST-IMPORT-009 / RUST-API-004
|
||||||
|
un item pub/pub(crate) crate-wide est consommé via la façade crate::Item,
|
||||||
|
y compris depuis son module de déclaration.
|
||||||
|
|
||||||
|
RUST-API-008
|
||||||
|
un helper préparatoire sans consumer de production reste sous #[cfg(test)]
|
||||||
|
jusqu'à sa première consommation réelle ; #[allow(dead_code)] n'est pas utilisé
|
||||||
|
pour conserver artificiellement du code mort.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Correction de façade crate-root
|
||||||
|
|
||||||
|
Les items HTTP partagés continuent d'être réexportés au crate-root dans le build de test, et leurs implémentations utilisent désormais leur chemin de façade :
|
||||||
|
|
||||||
|
```text
|
||||||
|
impl crate::HttpAdmissionPolicy
|
||||||
|
impl crate::HttpAdmissionController
|
||||||
|
impl crate::HttpJsonDocument
|
||||||
|
impl crate::HttpGetRequest
|
||||||
|
impl crate::HttpRestClient
|
||||||
|
impl crate::HttpClientSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
Les signatures et références crate-wide correspondantes utilisent également `crate::Item` lorsqu'elles traversent un module.
|
||||||
|
|
||||||
|
Les types strictement privés, tels que `HttpTokenBucketState`, restent locaux et ne sont pas réexportés.
|
||||||
|
|
||||||
|
## 4. Staging `RUST-API-008`
|
||||||
|
|
||||||
|
Aucun adapter de production ne consomme encore les primitives `http_*` en `pre.003`.
|
||||||
|
|
||||||
|
Les trois modules préparatoires sont donc compilés sous :
|
||||||
|
|
||||||
|
```text
|
||||||
|
#[cfg(test)]
|
||||||
|
```
|
||||||
|
|
||||||
|
jusqu'à `pre.004`, où les premiers adapters de production les consommeront réellement.
|
||||||
|
|
||||||
|
Les anciens :
|
||||||
|
|
||||||
|
```text
|
||||||
|
#[allow(dead_code)]
|
||||||
|
```
|
||||||
|
|
||||||
|
sont supprimés.
|
||||||
|
|
||||||
|
Le staging concerne :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http_admission
|
||||||
|
http_client
|
||||||
|
http_settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Les codes d'erreur HTTP publics restent disponibles dans le build normal ; aucune API HTTP générique n'est rendue publique aux consumers.
|
||||||
|
|
||||||
|
## 5. Correction du burst
|
||||||
|
|
||||||
|
La policy fixe conserve les invariants suivants :
|
||||||
|
|
||||||
|
```text
|
||||||
|
requests > 0
|
||||||
|
window > 0
|
||||||
|
window <= borne défensive
|
||||||
|
burst > 0 lorsqu'il est explicite
|
||||||
|
burst absent -> capacité locale conservatrice de 1
|
||||||
|
```
|
||||||
|
|
||||||
|
En revanche :
|
||||||
|
|
||||||
|
```text
|
||||||
|
burst <= requests
|
||||||
|
```
|
||||||
|
|
||||||
|
n'est plus un invariant.
|
||||||
|
|
||||||
|
Un burst documenté est une capacité instantanée distincte de la cadence moyenne et peut légitimement lui être supérieur.
|
||||||
|
|
||||||
|
Le canari déterministe couvre explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
1 req/s
|
||||||
|
burst 2
|
||||||
|
2 admissions immédiates
|
||||||
|
3e admission différée
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette propriété protège notamment la représentabilité future du cas Coinbase `10 req/s, burst 15`.
|
||||||
|
|
||||||
|
## 6. Ordre des constantes
|
||||||
|
|
||||||
|
Le bloc `const` de `src/error.rs` est réordonné alphabétiquement conformément à `RUST-FMT-005` / `RUST-FMT-104` sans modifier les valeurs des codes d'erreur.
|
||||||
|
|
||||||
|
Aucun code d'erreur n'est ajouté, supprimé ou renommé.
|
||||||
|
|
||||||
|
## 7. Test HTTP complémentaire
|
||||||
|
|
||||||
|
Un canari supplémentaire construit une requête HTTPS officielle-style sans effectuer de réseau, ajoute query pair et header sensible, puis vérifie que la représentation `Debug` ne contient ni host ni credential.
|
||||||
|
|
||||||
|
Ce canari consomme également le constructeur `HttpGetRequest::new_https` pendant le staging `cfg(test)`.
|
||||||
|
|
||||||
|
## 8. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_admission.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_client.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_settings.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.11/pre.003-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Fichiers volontairement inchangés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/tests/**
|
||||||
|
CHANGELOG.md
|
||||||
|
ROADMAP.md
|
||||||
|
README.md
|
||||||
|
.env.example
|
||||||
|
config/**
|
||||||
|
crates/ksp-config-lib/**
|
||||||
|
crates/ksp-onchain-transport-lib/**
|
||||||
|
prompts/**
|
||||||
|
```
|
||||||
|
|
||||||
|
Le manifeste de la crate hérite toujours de `workspace.package.version`; seul le `Cargo.toml` racine porte le bump technique.
|
||||||
|
|
||||||
|
## 11. Validations sandbox
|
||||||
|
|
||||||
|
Exécutées après le correctif :
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
General Rust rule audit: clean
|
||||||
|
Rust export completeness audit: 0 candidate(s)
|
||||||
|
KSP workspace Rust rule audit: clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Le sandbox ne fournit pas Cargo ; les gates opérateur restent à rejouer sur l'état exact du correctif.
|
||||||
|
|
||||||
|
## 12. Gate opérateur demandé
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Le passage à `pre.004` est conditionné à un gate propre sans warning et au retour au vert du test de burst.
|
||||||
300
deltas/0.2.11/pre.003.md
Normal file
300
deltas/0.2.11/pre.003.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.003.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.003` — HTTP REST commun et rate limiting
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Cette tranche s'applique exclusivement après :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.10
|
||||||
|
+ 0.2.11-pre.001
|
||||||
|
+ 0.2.11-pre.001-fix.001
|
||||||
|
+ 0.2.11-pre.002
|
||||||
|
+ 0.2.11-pre.002-fix.001
|
||||||
|
+ 0.2.11-pre.002-fix.002
|
||||||
|
+ 0.2.11-pre.002-fix.003
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo attendue à l'entrée est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.2.fix.3
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo de sortie est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Gate d'entrée acquis
|
||||||
|
|
||||||
|
L'opérateur a validé `0.2.11-pre.002-fix.003` le `2026-08-25` avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all exécuté
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py clean
|
||||||
|
python3 scripts/audit_markdown_tables.py clean, 116 tables / 105 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
cargo test -p ksp-offchain-transport-lib PASS, 10 unit + 1 boundary + 2 public API
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` n'a pas été fourni pour cet état exact et n'est pas déclaré PASS ici.
|
||||||
|
|
||||||
|
## 3. Objectif
|
||||||
|
|
||||||
|
Matérialiser les primitives HTTP REST communes de `ksp-offchain-transport-lib` et le rate limiting local sans avancer aucun provider.
|
||||||
|
|
||||||
|
La tranche doit préparer directement `pre.004+` tout en conservant les frontières :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http_* = mécanique transport crate-wide et crate-private
|
||||||
|
market_price_* = première capacité métier, SOL/USD uniquement
|
||||||
|
aucun client HTTP générique exporté aux consumers
|
||||||
|
aucun SDK provider
|
||||||
|
aucun Config -> Off-chain ajouté avant pre.009
|
||||||
|
aucun refresh registry/service avant pre.007/pre.008
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. HTTP REST commun
|
||||||
|
|
||||||
|
`http_client.rs` matérialise un `reqwest::Client` réutilisable par les adapters internes.
|
||||||
|
|
||||||
|
La construction impose explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
rustls
|
||||||
|
redirects désactivés
|
||||||
|
Referer automatique désactivé
|
||||||
|
proxy système désactivé
|
||||||
|
retries implicites reqwest désactivés
|
||||||
|
User-Agent KSP explicite
|
||||||
|
connect timeout borné
|
||||||
|
request timeout borné
|
||||||
|
```
|
||||||
|
|
||||||
|
`reqwest` reste une dépendance directe de la crate et aucun SDK provider n'est ajouté.
|
||||||
|
|
||||||
|
La requête GET interne :
|
||||||
|
|
||||||
|
```text
|
||||||
|
accepte uniquement HTTPS pour les adapters runtime
|
||||||
|
refuse credentials dans l'URL
|
||||||
|
encode les query pairs via Url
|
||||||
|
permet des headers sensibles marqués sensitive
|
||||||
|
redacte URL et headers dans Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Un constructeur HTTP non HTTPS existe uniquement sous `cfg(test)` pour les serveurs loopback déterministes.
|
||||||
|
|
||||||
|
## 5. Bornes de réponse et JSON
|
||||||
|
|
||||||
|
Les valeurs initiales sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
connect timeout défaut = 5 s
|
||||||
|
connect timeout hard max = 30 s
|
||||||
|
request timeout défaut = 10 s
|
||||||
|
request timeout hard max = 120 s
|
||||||
|
body défaut = 1 MiB
|
||||||
|
body hard max = 4 MiB
|
||||||
|
```
|
||||||
|
|
||||||
|
Le body est borné pendant sa lecture par chunks. La présence ou l'absence de `Content-Length` ne permet donc pas de contourner la limite.
|
||||||
|
|
||||||
|
Après lecture, la syntaxe JSON est validée avec `serde::de::IgnoredAny`. Le document brut borné reste disponible pour le futur adapter typed, ce qui évite de faire passer un nombre provider par une représentation générique `f64` avant le parsing `MarketPriceDecimal`.
|
||||||
|
|
||||||
|
## 6. Classification HTTP
|
||||||
|
|
||||||
|
Les codes crate-wide ajoutés sous le domaine `offchain_transport` distinguent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http_settings_invalid
|
||||||
|
http_client_build_failed
|
||||||
|
http_request_invalid
|
||||||
|
http_connection_failed
|
||||||
|
http_timeout
|
||||||
|
http_request_failed
|
||||||
|
http_access_denied
|
||||||
|
http_rate_limited
|
||||||
|
http_temporary_failure
|
||||||
|
http_response_too_large
|
||||||
|
http_invalid_json
|
||||||
|
http_rate_limit_invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
La classification de statut est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
401/403 -> access denied
|
||||||
|
429 -> rate limited
|
||||||
|
408 et 5xx -> temporary failure
|
||||||
|
autre non-2xx -> request failed
|
||||||
|
2xx -> body borné puis JSON syntaxiquement validé
|
||||||
|
```
|
||||||
|
|
||||||
|
Les erreurs `reqwest` sont converties après `without_url()`. Aucun body remote n'est recopié dans `KspError`.
|
||||||
|
|
||||||
|
`Retry-After` est actuellement exploité sous sa forme `delta-seconds`, bornée défensivement à une heure. Une forme HTTP-date non parseable reste ignorée plutôt que devinée.
|
||||||
|
|
||||||
|
## 7. Admission et cooldown
|
||||||
|
|
||||||
|
`http_admission.rs` fournit un limiter non bloquant.
|
||||||
|
|
||||||
|
Les policies internes sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fixed
|
||||||
|
Dynamic
|
||||||
|
Unlimited
|
||||||
|
```
|
||||||
|
|
||||||
|
Une policy fixe utilise un token bucket lissé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
requests/window
|
||||||
|
burst explicite uniquement lorsqu'il est documenté
|
||||||
|
burst absent -> capacité locale conservatrice de 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Une policy dynamique n'invente aucune cadence locale. Elle applique seulement les cooldowns appris du provider.
|
||||||
|
|
||||||
|
`try_admit()` ne dort jamais : il retourne immédiatement `Ready` ou `Deferred(duration)`. Cette propriété prépare le `refresh multiple/all` de `pre.008`, qui devra continuer avec les autres providers au lieu d'attendre un provider en cooldown.
|
||||||
|
|
||||||
|
Un `429` peut étendre le cooldown avec `Retry-After`; la durée provider est bornée à une heure et comparée au fallback local.
|
||||||
|
|
||||||
|
## 8. Tests ajoutés
|
||||||
|
|
||||||
|
Les nouveaux tests unitaires couvrent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
settings HTTP par défaut et bornes pathologiques
|
||||||
|
policy fixe invalide
|
||||||
|
burst absent lissé conservativement
|
||||||
|
burst documenté consommé atomiquement
|
||||||
|
refill déterministe du token bucket
|
||||||
|
cooldown provider et borne Retry-After
|
||||||
|
Debug request sans URL/credential
|
||||||
|
GET JSON valide
|
||||||
|
redirect refusé
|
||||||
|
body chunked dépassant la limite
|
||||||
|
429 + Retry-After sans propagation du body canari
|
||||||
|
JSON invalide après statut 2xx
|
||||||
|
```
|
||||||
|
|
||||||
|
Les canaries d'intégration vérifient aussi :
|
||||||
|
|
||||||
|
```text
|
||||||
|
reqwest rustls présent
|
||||||
|
Config toujours absent
|
||||||
|
SDK providers toujours absents
|
||||||
|
tracing direct toujours absent
|
||||||
|
modules http_* présents
|
||||||
|
HttpRestClient non exporté publiquement
|
||||||
|
redirect/no-proxy/no-retry/without_url explicites dans le client
|
||||||
|
codes d'erreur HTTP dans le domaine offchain_transport
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_admission.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_client.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_settings.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs
|
||||||
|
deltas/0.2.11/pre.003.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Fichiers supprimés
|
||||||
|
|
||||||
|
```text
|
||||||
|
aucun
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Fichiers volontairement inchangés
|
||||||
|
|
||||||
|
```text
|
||||||
|
CHANGELOG.md
|
||||||
|
ROADMAP.md
|
||||||
|
README.md
|
||||||
|
.env.example
|
||||||
|
config/**
|
||||||
|
crates/ksp-config-lib/**
|
||||||
|
crates/ksp-onchain-transport-lib/**
|
||||||
|
prompts/**
|
||||||
|
```
|
||||||
|
|
||||||
|
`ROADMAP.md` et `CHANGELOG.md` restent hors de la tranche conformément à leur ownership de release.
|
||||||
|
|
||||||
|
## 13. Validations exécutées dans le sandbox
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
General Rust rule audit: clean
|
||||||
|
Rust export completeness audit: 0 candidate(s)
|
||||||
|
KSP workspace Rust rule audit: clean
|
||||||
|
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
clean
|
||||||
|
|
||||||
|
contrôle lignes Rust > 160 sur ksp-offchain-transport-lib
|
||||||
|
PASS
|
||||||
|
|
||||||
|
inspection diff contre 0.2.11-pre.002-fix.003
|
||||||
|
aucun provider adapter ajouté
|
||||||
|
aucune dépendance ksp-config-lib ajoutée
|
||||||
|
aucune dépendance tracing directe ajoutée
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Validations non exécutées dans le sandbox
|
||||||
|
|
||||||
|
Le sandbox de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`.
|
||||||
|
|
||||||
|
Après application, exécuter :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. Décisions et points différés
|
||||||
|
|
||||||
|
Aucune question ne bloque `pre.004`.
|
||||||
|
|
||||||
|
Restent volontairement différés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
mapping MarketPriceProviderRateLimit -> HttpAdmissionPolicy par provider
|
||||||
|
CoinGecko/CoinMarketCap/CoinPaprika wire DTOs et endpoints
|
||||||
|
credentials provider effectifs
|
||||||
|
registry et availability runtime
|
||||||
|
refresh single/multiple
|
||||||
|
Config std.offchain_transport
|
||||||
|
Retry-After HTTP-date si un provider retenu l'exige réellement
|
||||||
|
POST/écritures HTTP pour futures familles off-chain
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.004` reste propriétaire du premier lot d'adapters : CoinGecko, CoinMarketCap et CoinPaprika.
|
||||||
86
deltas/0.2.11/pre.004-fix.001.md
Normal file
86
deltas/0.2.11/pre.004-fix.001.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.004-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.004-fix.001` — Gate statique, Clippy et fixture CoinMarketCap V2
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Ce correctif s'applique exclusivement après `0.2.11-pre.004`.
|
||||||
|
|
||||||
|
Version Cargo attendue à l'entrée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.4
|
||||||
|
```
|
||||||
|
|
||||||
|
Version Cargo de sortie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.4.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Motif du correctif
|
||||||
|
|
||||||
|
Le gate opérateur du `2026-08-25` a produit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all exécuté
|
||||||
|
audit Rust workspace 1 x RUST-FMT-104
|
||||||
|
audit Markdown PASS, 118 tables / 108 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets 2 warnings collapsible_if
|
||||||
|
cargo test -p ksp-offchain-transport-lib FAIL, 30 PASS / 1 FAIL
|
||||||
|
```
|
||||||
|
|
||||||
|
L'unique test en échec était `coinmarketcap_v2_fixture_normalizes_string_status_and_exact_price`.
|
||||||
|
|
||||||
|
## 3. Corrections
|
||||||
|
|
||||||
|
### Ordre des constantes
|
||||||
|
|
||||||
|
Le bloc `ERROR_CODE_*` de `src/error.rs` est remis dans l'ordre alphabétique complet attendu par `RUST-FMT-104`. Aucun code textuel ni domaine d'erreur ne change.
|
||||||
|
|
||||||
|
### Clippy
|
||||||
|
|
||||||
|
Les builders CoinGecko et CoinMarketCap remplacent les deux `if let` imbriqués par des `let`-chains. La logique reste identique : le header sensible n'est ajouté que lorsqu'une clé est présente et toute erreur d'insertion est retournée immédiatement.
|
||||||
|
|
||||||
|
### Fixture CoinMarketCap V2
|
||||||
|
|
||||||
|
La fixture concaténée omettait le guillemet ouvrant de la clé JSON `last_updated` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
avant : ...,151.987654321012345678,last_updated...
|
||||||
|
après : ...,151.987654321012345678,"last_updated"...
|
||||||
|
```
|
||||||
|
|
||||||
|
Le parser CoinMarketCap, les DTOs wire, l'endpoint Simple Price V2 et le contrat exact du prix ne sont pas modifiés.
|
||||||
|
|
||||||
|
## 4. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinmarketcap.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
deltas/0.2.11/pre.004-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Gate opérateur requis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` reste recommandé avant poursuite lorsque le temps opérateur le permet.
|
||||||
|
|
||||||
|
## 6. Hors scope
|
||||||
|
|
||||||
|
Aucun provider supplémentaire, registry, Config, refresh multiple, live smoke, changement d'endpoint ou changement de rate limit n'est introduit.
|
||||||
402
deltas/0.2.11/pre.004.md
Normal file
402
deltas/0.2.11/pre.004.md
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.004.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.004` — CoinGecko, CoinMarketCap et CoinPaprika
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Cette tranche s'applique exclusivement après :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.10
|
||||||
|
+ 0.2.11-pre.001
|
||||||
|
+ 0.2.11-pre.001-fix.001
|
||||||
|
+ 0.2.11-pre.002
|
||||||
|
+ 0.2.11-pre.002-fix.001
|
||||||
|
+ 0.2.11-pre.002-fix.002
|
||||||
|
+ 0.2.11-pre.002-fix.003
|
||||||
|
+ 0.2.11-pre.003
|
||||||
|
+ 0.2.11-pre.003-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo attendue à l'entrée est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.3.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo de sortie est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.4
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Gate d'entrée et reprise volontaire de `RUST-FMT-108`
|
||||||
|
|
||||||
|
L'opérateur a validé `0.2.11-pre.003-fix.001` le `2026-08-25` avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all exécuté
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py 1 violation RUST-FMT-108 uniquement
|
||||||
|
python3 scripts/audit_markdown_tables.py clean, 117 tables / 107 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
cargo test -p ksp-offchain-transport-lib PASS, 22 unit + 2 boundary + 2 public API
|
||||||
|
```
|
||||||
|
|
||||||
|
`RUST-FMT-108` provenait uniquement du staging `#[cfg(test)]` des modules `http_*` imposé en `pre.003-fix.001` tant qu'aucun consumer de production n'existait. L'opérateur a explicitement demandé de ne pas créer un fix artificiel supplémentaire puisque `pre.004` est précisément la tranche qui active ces modules avec leurs premiers consumers runtime.
|
||||||
|
|
||||||
|
`pre.004` retire donc le staging crate-root de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http_admission
|
||||||
|
http_client
|
||||||
|
http_settings
|
||||||
|
```
|
||||||
|
|
||||||
|
La cause structurelle de `RUST-FMT-108` disparaît sans exception ni `allow`.
|
||||||
|
|
||||||
|
## 3. Objectif
|
||||||
|
|
||||||
|
Implémenter les trois premiers providers SOL/USD de la famille `market_price_*` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
CoinGecko
|
||||||
|
CoinMarketCap
|
||||||
|
CoinPaprika
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contrat reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
SOL/USD uniquement
|
||||||
|
reqwest direct
|
||||||
|
aucun SDK provider
|
||||||
|
endpoints officiels fixes
|
||||||
|
DTOs wire privés
|
||||||
|
prix public exact, aucun canon f64
|
||||||
|
credentials absents des Debug/logs/errors
|
||||||
|
aucun Config dans Off-chain Transport
|
||||||
|
aucun registry global ni refresh multiple avant leurs tranches dédiées
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Pont HTTP -> Market Price
|
||||||
|
|
||||||
|
`market_price_adapter.rs` matérialise uniquement les mécanismes réellement partagés par les adapters de prix :
|
||||||
|
|
||||||
|
```text
|
||||||
|
admission non bloquante
|
||||||
|
capture des timestamps KSP
|
||||||
|
GET JSON borné via le client HTTP commun
|
||||||
|
feedback 429 / Retry-After vers le cooldown local
|
||||||
|
conversion RFC3339 ou Unix seconds vers MarketPriceTimestamp
|
||||||
|
erreurs de schéma provider sûres sans copie du payload distant
|
||||||
|
construction du runtime HTTP + admission depuis le descriptor provider
|
||||||
|
```
|
||||||
|
|
||||||
|
Les primitives `http_*` restent crate-wide et crate-private afin d'être réutilisables plus tard par d'autres familles telles que `swap_quote_*`, sans devenir un client HTTP générique public pour les consumers.
|
||||||
|
|
||||||
|
## 5. Credential market-price partagé
|
||||||
|
|
||||||
|
`market_price_api_key.rs` ajoute un holder privé `MarketPriceApiKey` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
longueur maximale 512 octets
|
||||||
|
valeur vide rejetée
|
||||||
|
whitespace externe rejeté
|
||||||
|
caractères de contrôle rejetés
|
||||||
|
Debug toujours redacted
|
||||||
|
aucun logging de la valeur
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce holder ne lit aucun environnement. Config reste l'unique futur propriétaire de la résolution des secrets.
|
||||||
|
|
||||||
|
## 6. CoinGecko
|
||||||
|
|
||||||
|
Le provider public est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceCoinGeckoProvider
|
||||||
|
MarketPriceCoinGeckoSettings
|
||||||
|
MarketPriceCoinGeckoAccessMode
|
||||||
|
```
|
||||||
|
|
||||||
|
Modes V1 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keyless
|
||||||
|
Demo
|
||||||
|
```
|
||||||
|
|
||||||
|
La configuration respecte les capacités réelles :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keyless -> aucun credential accepté
|
||||||
|
Demo activé -> API key obligatoire
|
||||||
|
Demo désactivé -> credential optionnel pour permettre une config inactive
|
||||||
|
```
|
||||||
|
|
||||||
|
Le request contract est fixe :
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET https://api.coingecko.com/api/v3/simple/price
|
||||||
|
ids=solana
|
||||||
|
vs_currencies=usd
|
||||||
|
include_last_updated_at=true
|
||||||
|
header x-cg-demo-api-key uniquement en mode Demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Le descriptor conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keyless -> auth none, rate limit dynamique scope IP
|
||||||
|
Demo -> API key requise, 100 requêtes / 60 s scope account, 10k credits / mois
|
||||||
|
semantics -> AggregatedMarket
|
||||||
|
```
|
||||||
|
|
||||||
|
La réponse exige `solana.usd` et `solana.last_updated_at`. Le prix est lu via `RawValue` puis `MarketPriceDecimal`; le timestamp provider Unix est conservé.
|
||||||
|
|
||||||
|
## 7. CoinMarketCap
|
||||||
|
|
||||||
|
Le provider public est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceCoinMarketCapProvider
|
||||||
|
MarketPriceCoinMarketCapSettings
|
||||||
|
MarketPriceCoinMarketCapAccessMode
|
||||||
|
```
|
||||||
|
|
||||||
|
Modes V1 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keyless
|
||||||
|
Basic
|
||||||
|
```
|
||||||
|
|
||||||
|
La configuration suit la même discipline : keyless refuse un credential et Basic activé exige une API key.
|
||||||
|
|
||||||
|
Le réaudit `pre.004` retient la surface **Simple Price V2 actuelle** et n'introduit pas la surface V1 deprecated.
|
||||||
|
|
||||||
|
Requests fixes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keyless -> GET https://pro-api.coinmarketcap.com/public-api/v2/simple/price
|
||||||
|
Basic -> GET https://pro-api.coinmarketcap.com/v2/simple/price
|
||||||
|
ids=5426
|
||||||
|
convert=USD
|
||||||
|
include_last_updated=true
|
||||||
|
header x-cmc_pro_api_key uniquement en mode Basic
|
||||||
|
```
|
||||||
|
|
||||||
|
Le descriptor conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keyless -> auth none, rate limit dynamique scope IP
|
||||||
|
Basic -> API key requise, 50 requêtes / 60 s scope account, 15k credits / mois
|
||||||
|
semantics -> AggregatedMarket
|
||||||
|
```
|
||||||
|
|
||||||
|
Le parser vérifie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
status.error_code = 0
|
||||||
|
un seul item SOL id 5426
|
||||||
|
symbol = SOL
|
||||||
|
une seule quote USD
|
||||||
|
price exact via RawValue
|
||||||
|
last_updated RFC3339 valide
|
||||||
|
```
|
||||||
|
|
||||||
|
La provenance normalisée conserve explicitement la génération `v2`.
|
||||||
|
|
||||||
|
## 8. CoinPaprika
|
||||||
|
|
||||||
|
Le provider public est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceCoinPaprikaProvider
|
||||||
|
MarketPriceCoinPaprikaSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
Il est keyless uniquement : aucun faux champ credential n'est introduit.
|
||||||
|
|
||||||
|
Request fixe :
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET https://api.coinpaprika.com/v1/tickers/sol-solana
|
||||||
|
quotes=USD
|
||||||
|
```
|
||||||
|
|
||||||
|
Le descriptor conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
auth none
|
||||||
|
10 requêtes / seconde scope IP
|
||||||
|
20k requests / mois
|
||||||
|
semantics -> AggregatedMarket
|
||||||
|
```
|
||||||
|
|
||||||
|
Le parser vérifie `id = sol-solana`, `symbol = SOL`, lit `quotes.USD.price` exactement via `RawValue` et conserve `last_updated` RFC3339.
|
||||||
|
|
||||||
|
## 9. Numeric safety
|
||||||
|
|
||||||
|
`MarketPriceDecimal` gagne un parser crate-private de `serde_json::value::RawValue`.
|
||||||
|
|
||||||
|
Il accepte :
|
||||||
|
|
||||||
|
```text
|
||||||
|
nombre JSON exact
|
||||||
|
ou chaîne JSON contenant un décimal valide
|
||||||
|
```
|
||||||
|
|
||||||
|
Il ne passe jamais par `f64`. Les tests couvrent notamment une mantisse dont les derniers chiffres seraient perdus par une conversion flottante intermédiaire.
|
||||||
|
|
||||||
|
## 10. Availability et rate limiting dans cette tranche
|
||||||
|
|
||||||
|
Chaque provider possède son `HttpAdmissionController` local construit depuis son descriptor.
|
||||||
|
|
||||||
|
Le chemin runtime :
|
||||||
|
|
||||||
|
```text
|
||||||
|
fetch_sol_usd
|
||||||
|
-> enabled check
|
||||||
|
-> try_admit non bloquant
|
||||||
|
-> request timestamp
|
||||||
|
-> GET provider
|
||||||
|
-> si 429 : Retry-After enregistré dans le cooldown
|
||||||
|
-> receive timestamp
|
||||||
|
-> parser typed provider
|
||||||
|
-> MarketPriceObservation
|
||||||
|
```
|
||||||
|
|
||||||
|
Un provider localement non admissible retourne `http_admission_deferred` avec un délai safe ; aucun sleep n'est introduit dans l'adapter.
|
||||||
|
|
||||||
|
Le registry global et la projection générique d'availability restent réservés à `pre.007`, et l'orchestration multiple à `pre.008`.
|
||||||
|
|
||||||
|
## 11. Tests ajoutés ou étendus
|
||||||
|
|
||||||
|
Les tests déterministes couvrent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
modes keyless/keyed et credentials requis/interdits
|
||||||
|
Debug settings sans secret
|
||||||
|
headers sensibles présents uniquement dans le bon mode
|
||||||
|
URLs et query fixes
|
||||||
|
capabilities descriptors et quotas
|
||||||
|
réponses SOL/USD valides
|
||||||
|
prix JSON exact sans f64
|
||||||
|
provider timestamps
|
||||||
|
identités provider incorrectes rejetées
|
||||||
|
schema/provider status incohérents rejetés
|
||||||
|
endpoint CMC V2 exact
|
||||||
|
absence de SDK provider
|
||||||
|
absence de Config et tracing direct
|
||||||
|
HTTP production actif mais non exporté publiquement
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun smoke réseau n'est ajouté dans cette tranche.
|
||||||
|
|
||||||
|
## 12. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_adapter.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_api_key.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_coingecko.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_coinmarketcap.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_coinpaprika.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_coingecko.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinmarketcap.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinpaprika.rs
|
||||||
|
deltas/0.2.11/pre.004.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_admission.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_client.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_settings.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_decimal.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Fichiers supprimés
|
||||||
|
|
||||||
|
```text
|
||||||
|
aucun
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. Fichiers volontairement inchangés
|
||||||
|
|
||||||
|
```text
|
||||||
|
CHANGELOG.md
|
||||||
|
ROADMAP.md
|
||||||
|
README.md
|
||||||
|
.env.example
|
||||||
|
config/**
|
||||||
|
crates/ksp-config-lib/**
|
||||||
|
crates/ksp-onchain-transport-lib/**
|
||||||
|
prompts/**
|
||||||
|
```
|
||||||
|
|
||||||
|
`ROADMAP.md` et `CHANGELOG.md` restent hors de cette prerelease conformément à leur ownership de release.
|
||||||
|
|
||||||
|
## 16. Validations exécutées dans le sandbox
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
General Rust rule audit: clean
|
||||||
|
Rust export completeness audit: 0 candidate(s)
|
||||||
|
KSP workspace Rust rule audit: clean
|
||||||
|
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
clean, 118 tables / 108 files
|
||||||
|
|
||||||
|
contrôle lignes Rust > 160 sur ksp-offchain-transport-lib
|
||||||
|
PASS, 0 ligne
|
||||||
|
```
|
||||||
|
|
||||||
|
Le sandbox de génération ne possède pas Cargo/Rust. `cargo fmt`, `cargo check`, `cargo clippy` et les tests Cargo ne sont donc pas déclarés exécutés ici.
|
||||||
|
|
||||||
|
## 17. Gate opérateur demandé
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Les smokes live restent hors de `pre.004`.
|
||||||
|
|
||||||
|
## 18. Scope non avancé
|
||||||
|
|
||||||
|
```text
|
||||||
|
Kraken
|
||||||
|
Coinbase Exchange
|
||||||
|
Jupiter
|
||||||
|
DexScreener
|
||||||
|
Birdeye
|
||||||
|
registry provider global
|
||||||
|
availability service global
|
||||||
|
refresh single/multiple générique
|
||||||
|
Config std.offchain_transport
|
||||||
|
.env.example secrets provider
|
||||||
|
ksp-app-solprices-desk
|
||||||
|
fallback automatique
|
||||||
|
consensus de prix
|
||||||
|
SOL/EUR
|
||||||
|
prix SPL générique
|
||||||
|
```
|
||||||
202
deltas/0.2.11/pre.005.md
Normal file
202
deltas/0.2.11/pre.005.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.005.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.005` — Kraken + Coinbase Exchange
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Cette tranche s'applique exclusivement après `0.2.11-pre.004-fix.001` validé par l'opérateur.
|
||||||
|
|
||||||
|
Version Cargo attendue à l'entrée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.4.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Version Cargo de sortie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.5
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate d'entrée du `2026-08-25` est intégralement PASS : audit Rust clean, audit Markdown clean, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-offchain-transport-lib` et `cargo test --workspace`.
|
||||||
|
|
||||||
|
## 2. Objet strict de la tranche
|
||||||
|
|
||||||
|
`pre.005` ajoute uniquement les deux providers exchange prévus par le forecast :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Kraken Spot
|
||||||
|
Coinbase Exchange
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contrat reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
SOL/USD uniquement
|
||||||
|
HTTP REST direct via reqwest
|
||||||
|
aucun SDK provider
|
||||||
|
aucun credential provider pour Kraken/Coinbase Exchange
|
||||||
|
MarketPriceSemantics::ExchangeLastTrade
|
||||||
|
pas de registry global
|
||||||
|
pas de Config
|
||||||
|
pas de refresh multiple
|
||||||
|
pas de smoke live revendiqué
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Kraken Spot
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
market_price_kraken.rs
|
||||||
|
unit_tests/market_price_kraken.rs
|
||||||
|
MarketPriceKrakenSettings
|
||||||
|
MarketPriceKrakenProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
Le provider est keyless et utilise l'endpoint public fixe :
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://api.kraken.com/0/public/Ticker?pair=SOLUSD
|
||||||
|
```
|
||||||
|
|
||||||
|
Le dernier trade est lu dans `c[0]` et normalisé par `MarketPriceDecimal::parse_json_raw` sans passage par `f64`.
|
||||||
|
|
||||||
|
Le ticker public Kraken ne porte pas de timestamp du dernier trade dans cette forme. KSP conserve donc :
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider_timestamp = None
|
||||||
|
```
|
||||||
|
|
||||||
|
et n'invente aucune heure provider à partir de l'heure de réception locale.
|
||||||
|
|
||||||
|
La sémantique reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceSemantics::ExchangeLastTrade
|
||||||
|
```
|
||||||
|
|
||||||
|
La documentation Kraken indique qu'une cadence de `1 req/s` ou moins reste dans les limites des endpoints REST publics. Le descriptor V1 retient donc une limite locale conservatrice :
|
||||||
|
|
||||||
|
```text
|
||||||
|
1 req/s
|
||||||
|
scope IP
|
||||||
|
burst non documenté
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Coinbase Exchange
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
market_price_coinbase_exchange.rs
|
||||||
|
unit_tests/market_price_coinbase_exchange.rs
|
||||||
|
MarketPriceCoinbaseExchangeSettings
|
||||||
|
MarketPriceCoinbaseExchangeProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
Le provider est keyless et utilise l'endpoint public fixe :
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://api.exchange.coinbase.com/products/SOL-USD/ticker
|
||||||
|
```
|
||||||
|
|
||||||
|
KSP conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
price
|
||||||
|
provider timestamp RFC3339 time
|
||||||
|
```
|
||||||
|
|
||||||
|
Le prix est parsé exactement via `RawValue` et le timestamp est converti dans le contrat millisecondes UTC existant.
|
||||||
|
|
||||||
|
Le descriptor conserve le token bucket public documenté par Coinbase Exchange :
|
||||||
|
|
||||||
|
```text
|
||||||
|
10 req/s par IP
|
||||||
|
burst 15
|
||||||
|
```
|
||||||
|
|
||||||
|
La sémantique reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceSemantics::ExchangeLastTrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Façade publique
|
||||||
|
|
||||||
|
La taxonomie publique reste capability-scoped :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceKraken*
|
||||||
|
MarketPriceCoinbaseExchange*
|
||||||
|
```
|
||||||
|
|
||||||
|
Le nom `CoinbaseExchange` est volontairement explicite afin de ne pas confondre cette surface avec Advanced Trade ou d'autres APIs Coinbase futures.
|
||||||
|
|
||||||
|
Aucun type wire provider n'est exporté. Les primitives `http_*` restent crate-private.
|
||||||
|
|
||||||
|
## 6. Tests déterministes ajoutés
|
||||||
|
|
||||||
|
Les nouveaux canaries couvrent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Kraken exact last trade decimal
|
||||||
|
Kraken provider_timestamp absent
|
||||||
|
Kraken descriptor keyless + 1 req/s IP
|
||||||
|
Kraken provider error rejetée
|
||||||
|
Kraken résultat d'une autre paire rejeté
|
||||||
|
Coinbase exact last trade decimal
|
||||||
|
Coinbase timestamp RFC3339 conservé
|
||||||
|
Coinbase descriptor keyless + 10 req/s IP + burst 15
|
||||||
|
Coinbase timestamp invalide rejeté
|
||||||
|
façade publique des deux adapters
|
||||||
|
endpoints fixes et absence de std::env
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Documentation réconciliée
|
||||||
|
|
||||||
|
Mise à jour de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan marque `pre.004-fix.001` comme gate opérateur complet PASS et `pre.005` comme réalisé en attente de son gate Cargo.
|
||||||
|
|
||||||
|
## 8. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_coinbase_exchange.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_kraken.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_coinbase_exchange.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_kraken.rs
|
||||||
|
deltas/0.2.11/pre.005.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Validation attendue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.006` ne doit commencer qu'après un gate propre de `pre.005` ou un delta fix explicite.
|
||||||
243
deltas/0.2.11/pre.006.md
Normal file
243
deltas/0.2.11/pre.006.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.006.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.006` — Jupiter Price V3 + DexScreener
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Cette tranche s'applique exclusivement après `0.2.11-pre.005` accepté par l'opérateur.
|
||||||
|
|
||||||
|
Version Cargo attendue à l'entrée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.5
|
||||||
|
```
|
||||||
|
|
||||||
|
Version Cargo de sortie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.6
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate d'entrée ciblé du `2026-08-25` est PASS : `cargo fmt --all`, audit Rust clean avec 0 candidate export, audit Markdown clean à 119 tables / 110 fichiers, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test -p ksp-offchain-transport-lib` avec 37 unitaires + 3 boundary + 4 public API. `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.005` avant le passage explicite à cette tranche.
|
||||||
|
|
||||||
|
## 2. Objet strict de la tranche
|
||||||
|
|
||||||
|
`pre.006` ajoute uniquement les deux providers prévus par le forecast :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Jupiter Price V3
|
||||||
|
DexScreener
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contrat reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
SOL/USD uniquement
|
||||||
|
HTTP REST direct via reqwest
|
||||||
|
aucun SDK provider
|
||||||
|
aucune découverte automatique de pool
|
||||||
|
aucun fallback/consensus/agrégation KSP
|
||||||
|
pas de registry global
|
||||||
|
pas de Config
|
||||||
|
pas de refresh multiple
|
||||||
|
pas de smoke live revendiqué
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Jupiter Price V3
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
market_price_jupiter.rs
|
||||||
|
unit_tests/market_price_jupiter.rs
|
||||||
|
MarketPriceJupiterAccessMode
|
||||||
|
MarketPriceJupiterSettings
|
||||||
|
MarketPriceJupiterProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
Le réaudit officiel courant confirme le Developer Platform unifié :
|
||||||
|
|
||||||
|
```text
|
||||||
|
base URL https://api.jup.ag
|
||||||
|
endpoint /price/v3
|
||||||
|
mint SOL So11111111111111111111111111111111111111112
|
||||||
|
keyless 0.5 RPS
|
||||||
|
Free avec API key 1 RPS
|
||||||
|
header keyed x-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux modes utilisent la même origine officielle. Le mode keyless n'accepte aucun credential ; le mode Free exige une clé lorsqu'il est activé. La clé reste dans `MarketPriceApiKey`, n'est jamais lue depuis l'environnement par cette crate et n'apparaît pas dans `Debug`, les logs ou les erreurs.
|
||||||
|
|
||||||
|
Price V3 expose `usdPrice` comme prix USD unique. KSP le lit via `serde_json::RawValue` puis `MarketPriceDecimal`, sans passage canonique par `f64`.
|
||||||
|
|
||||||
|
Le contrat V3 expose également `blockId`, qui peut servir à raisonner sur la récence du prix. KSP le conserve uniquement dans la provenance sûre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
jupiter:price_v3:<mint>:block:<blockId>
|
||||||
|
```
|
||||||
|
|
||||||
|
`createdAt` décrit la création du token et n'est pas un timestamp de prix. `blockId` n'est pas un timestamp non plus. L'observation conserve donc explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider_timestamp = None
|
||||||
|
MarketPriceSemantics::SolanaHeuristic
|
||||||
|
```
|
||||||
|
|
||||||
|
Le parser exige la clé exacte du mint WSOL, `decimals = 9`, un `usdPrice` présent et positif, et un `blockId` présent. Une réponse sans prix est une réponse provider non exploitable pour une observation réussie ; le registry/availability générique sera matérialisé en `pre.007`.
|
||||||
|
|
||||||
|
## 4. DexScreener
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
market_price_dexscreener.rs
|
||||||
|
unit_tests/market_price_dexscreener.rs
|
||||||
|
MarketPriceDexScreenerSettings
|
||||||
|
MarketPriceDexScreenerProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
Le V1 suit strictement la décision opérateur : un client SOL/USD lié à **une paire Solana explicitement configurée**.
|
||||||
|
|
||||||
|
L'endpoint officiel utilisé est uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://api.dexscreener.com/latest/dex/pairs/solana/{pairId}
|
||||||
|
```
|
||||||
|
|
||||||
|
La documentation DexScreener le borne actuellement à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
300 requêtes / minute
|
||||||
|
```
|
||||||
|
|
||||||
|
Le pair address est validé comme `ksp_core_lib::Pubkey` dans les settings. Le client construit le chemin à partir de l'origine officielle fixe et du seul segment Pubkey validé ; aucune base URL configurable n'est introduite.
|
||||||
|
|
||||||
|
Avant d'accepter `priceUsd`, le parser vérifie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
une seule paire retournée
|
||||||
|
chainId = solana
|
||||||
|
pairAddress = pair address configuré
|
||||||
|
baseToken.address = So11111111111111111111111111111111111111112
|
||||||
|
priceUsd présent et exact
|
||||||
|
```
|
||||||
|
|
||||||
|
KSP ne consulte pas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
/latest/dex/search
|
||||||
|
/token-pairs/v1
|
||||||
|
/tokens/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
et ne fait aucun pool discovery, ranking de liquidité, sélection automatique, moyenne ou consensus.
|
||||||
|
|
||||||
|
La provenance conserve le pair address, considéré comme sûr :
|
||||||
|
|
||||||
|
```text
|
||||||
|
dexscreener:solana:<pairAddress>:priceUsd
|
||||||
|
```
|
||||||
|
|
||||||
|
La sémantique publique reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceSemantics::DexPairUsd
|
||||||
|
provider_timestamp = None
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. HTTP commun
|
||||||
|
|
||||||
|
`HttpGetRequest` gagne uniquement une primitive crate-private :
|
||||||
|
|
||||||
|
```text
|
||||||
|
append_path_segment
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle permet de construire un endpoint provider à origine fixe lorsque le contrat nécessite un segment dynamique validé, ici le Pubkey de la paire DexScreener. Cette évolution ne crée aucune API HTTP publique et ne permet pas au consumer de fournir une URL arbitraire.
|
||||||
|
|
||||||
|
## 6. Façade publique
|
||||||
|
|
||||||
|
La taxonomie reste capability-scoped :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceJupiter*
|
||||||
|
MarketPriceDexScreener*
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun type wire provider, client HTTP générique ou primitive d'admission n'est exporté aux consumers.
|
||||||
|
|
||||||
|
Le futur `ksp-app-solprices-desk` ne devra pas construire ces settings provider directement ; `pre.009` donnera cette responsabilité à Config et le service générique prévu en `pre.007`/`pre.008` masquera les variantes provider à l'HID.
|
||||||
|
|
||||||
|
## 7. Tests déterministes ajoutés
|
||||||
|
|
||||||
|
Les nouveaux canaries couvrent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Jupiter keyless 0.5 RPS
|
||||||
|
Jupiter Free 1 RPS + API key redacted
|
||||||
|
query Jupiter fixée au mint WSOL
|
||||||
|
Jupiter prix exact via RawValue
|
||||||
|
Jupiter blockId conservé en provenance
|
||||||
|
Jupiter provider_timestamp absent
|
||||||
|
Jupiter prix absent rejeté
|
||||||
|
Jupiter identité/decimals incohérents rejetés
|
||||||
|
DexScreener pair address Pubkey explicite
|
||||||
|
URL pair directe construite sans discovery
|
||||||
|
DexScreener 300 req/min
|
||||||
|
chainId Solana vérifié
|
||||||
|
pairAddress retourné vérifié
|
||||||
|
base token WSOL vérifié
|
||||||
|
priceUsd exact
|
||||||
|
réponse multi-pair rejetée
|
||||||
|
prix absent rejeté
|
||||||
|
absence des endpoints search/token discovery
|
||||||
|
façade publique des deux adapters
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Documentation réconciliée
|
||||||
|
|
||||||
|
Mise à jour de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan enregistre également le réaudit Jupiter/DexScreener courant et distingue explicitement `blockId` Jupiter d'un timestamp provider.
|
||||||
|
|
||||||
|
## 9. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_dexscreener.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_jupiter.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_dexscreener.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_jupiter.rs
|
||||||
|
deltas/0.2.11/pre.006.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/http_client.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Validation attendue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.007` ne doit commencer qu'après un gate propre de `pre.006` ou un delta fix explicite.
|
||||||
280
deltas/0.2.11/pre.007.md
Normal file
280
deltas/0.2.11/pre.007.md
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.007.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.007` — Birdeye + registry + availability
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Cette tranche s'applique exclusivement après `0.2.11-pre.006` accepté par l'opérateur.
|
||||||
|
|
||||||
|
Version Cargo attendue à l'entrée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.6
|
||||||
|
```
|
||||||
|
|
||||||
|
Version Cargo de sortie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.7
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate d'entrée ciblé du `2026-08-25` est PASS : `cargo fmt --all`, audit Rust clean avec 0 candidate export, audit Markdown clean à 120 tables / 111 fichiers, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test -p ksp-offchain-transport-lib` avec 43 unitaires + 4 boundary + 5 public API. `cargo test --workspace` n'a pas été rejoué sur l'état exact `pre.006` avant le passage explicite à cette tranche.
|
||||||
|
|
||||||
|
## 2. Objet strict de la tranche
|
||||||
|
|
||||||
|
`pre.007` ferme l'inventaire V1 des huit providers et matérialise le registry provider-neutral :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Birdeye Standard
|
||||||
|
MarketPriceProviderRegistry
|
||||||
|
MarketPriceProviderRegistryEntry
|
||||||
|
availability générique consultable
|
||||||
|
metadata de coût provider informatif
|
||||||
|
```
|
||||||
|
|
||||||
|
La tranche n'ajoute pas encore :
|
||||||
|
|
||||||
|
```text
|
||||||
|
refresh(provider_id)
|
||||||
|
refresh multiple/all
|
||||||
|
service de dispatch provider
|
||||||
|
Config Off-chain Transport
|
||||||
|
smoke live
|
||||||
|
fallback/consensus
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Birdeye Standard
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
market_price_birdeye.rs
|
||||||
|
unit_tests/market_price_birdeye.rs
|
||||||
|
MarketPriceBirdeyeSettings
|
||||||
|
MarketPriceBirdeyeProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
Le réaudit officiel courant confirme :
|
||||||
|
|
||||||
|
```text
|
||||||
|
base URL https://public-api.birdeye.so
|
||||||
|
endpoint GET /defi/price
|
||||||
|
mint SOL So11111111111111111111111111111111111111112
|
||||||
|
chain header x-chain: solana
|
||||||
|
auth header X-API-KEY
|
||||||
|
Standard 0 USD, 30 000 compute units inclus
|
||||||
|
limite compte 1 RPS
|
||||||
|
Price Single 3 compute units par appel
|
||||||
|
```
|
||||||
|
|
||||||
|
Une clé Birdeye est obligatoire lorsque le provider est activé. Un provider désactivé peut être construit sans secret afin que Config et le futur service puissent représenter un état non actif sans inventer de credential.
|
||||||
|
|
||||||
|
La clé reste dans `MarketPriceApiKey`, n'est jamais lue directement depuis l'environnement par Off-chain Transport et n'apparaît ni dans `Debug`, ni dans les logs, ni dans les erreurs.
|
||||||
|
|
||||||
|
Le request builder utilise uniquement l'origine officielle fixe, le mint WSOL fixe et `x-chain = solana`.
|
||||||
|
|
||||||
|
La réponse Price Single est normalisée depuis :
|
||||||
|
|
||||||
|
```text
|
||||||
|
data.value
|
||||||
|
data.updateUnixTime
|
||||||
|
success
|
||||||
|
```
|
||||||
|
|
||||||
|
`data.value` passe par `serde_json::RawValue` puis `MarketPriceDecimal` sans round-trip `f64`. `updateUnixTime` devient un vrai `provider_timestamp` en secondes Unix converties en millisecondes.
|
||||||
|
|
||||||
|
La sémantique retenue est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceSemantics::SolanaSpot
|
||||||
|
```
|
||||||
|
|
||||||
|
La provenance sûre est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
birdeye:solana:wsol:value
|
||||||
|
```
|
||||||
|
|
||||||
|
Une réponse `success = false`, un prix absent/null, un timestamp absent/pathologique ou un JSON incompatible sont rejetés sans copier le body distant dans les diagnostics.
|
||||||
|
|
||||||
|
## 4. Quota et coût provider informatifs
|
||||||
|
|
||||||
|
Birdeye utilise des compute units. Pour ne pas les confondre avec des crédits abstraits ou des requêtes HTTP, `MarketPriceProviderQuotaUnit` gagne :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ComputeUnits
|
||||||
|
```
|
||||||
|
|
||||||
|
`MarketPriceProviderRequestCost` représente un coût unitaire documenté pour l'opération SOL/USD courante.
|
||||||
|
|
||||||
|
Le descriptor Birdeye expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
long_term_quota = 30 000 ComputeUnits / Month
|
||||||
|
sol_usd_request_cost = 3 ComputeUnits
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces valeurs sont informatives. KSP ne décrémente pas localement le quota et ne prétend pas connaître un solde authoritative : un autre processus ou une autre API du même compte peut consommer les mêmes compute units.
|
||||||
|
|
||||||
|
`MarketPriceProviderDescriptor::with_sol_usd_request_cost` vérifie que le coût et le quota utilisent la même unité lorsqu'un quota est présent.
|
||||||
|
|
||||||
|
## 5. Registry provider-neutral
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
market_price_registry.rs
|
||||||
|
unit_tests/market_price_registry.rs
|
||||||
|
MarketPriceProviderRegistry
|
||||||
|
MarketPriceProviderRegistryEntry
|
||||||
|
ERROR_CODE_MARKET_PRICE_REGISTRY_INVALID
|
||||||
|
```
|
||||||
|
|
||||||
|
Une entrée contient seulement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceProviderDescriptor
|
||||||
|
MarketPriceProviderState
|
||||||
|
```
|
||||||
|
|
||||||
|
Le registry :
|
||||||
|
|
||||||
|
```text
|
||||||
|
est borné à 64 entrées
|
||||||
|
trie les entrées par provider_id
|
||||||
|
rejette les provider_id dupliqués
|
||||||
|
permet lookup descriptor/state/entry par identifiant opaque
|
||||||
|
expose une slice déterministe d'entrées
|
||||||
|
ne contient aucun nom ou dispatch provider spécifique
|
||||||
|
```
|
||||||
|
|
||||||
|
Le constructeur reste public et provider-neutral, comme `MarketPriceProviderDescriptor::new` déjà existant. Cela ne donne aucun accès à un endpoint ou à un client HTTP. Le futur service `pre.008` possédera les transitions runtime et le dispatch des adapters ; la HID ne devra pas construire ou matcher des providers spécifiques.
|
||||||
|
|
||||||
|
Un canari de complétude construit les huit adapters V1 en mode désactivé et vérifie l'inventaire ordonné :
|
||||||
|
|
||||||
|
```text
|
||||||
|
birdeye
|
||||||
|
coinbase_exchange
|
||||||
|
coingecko
|
||||||
|
coinmarketcap
|
||||||
|
coinpaprika
|
||||||
|
dexscreener
|
||||||
|
jupiter
|
||||||
|
kraken
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Availability générique
|
||||||
|
|
||||||
|
`MarketPriceProviderAvailability` gagne deux helpers publics provider-neutral :
|
||||||
|
|
||||||
|
```text
|
||||||
|
is_refresh_eligible()
|
||||||
|
retry_at()
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils permettent à un consumer d'interpréter `Ready`, `CoolingDown` ou `TemporarilyUnavailable` sans parser des erreurs ni connaître le fournisseur.
|
||||||
|
|
||||||
|
Les catégories existantes restent distinctes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
AuthenticationUnavailable
|
||||||
|
CoolingDown
|
||||||
|
Disabled
|
||||||
|
Misconfigured
|
||||||
|
QuotaUnavailable
|
||||||
|
Ready
|
||||||
|
TemporarilyUnavailable
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.007` ne relie pas encore automatiquement les erreurs d'un refresh à ces transitions. Cette mutation runtime appartient au service de `pre.008`.
|
||||||
|
|
||||||
|
## 7. Façade publique
|
||||||
|
|
||||||
|
La taxonomie reste capability-scoped :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceBirdeye*
|
||||||
|
MarketPriceProviderRegistry*
|
||||||
|
MarketPriceProviderRequestCost
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun DTO wire Birdeye, `HttpRestClient`, limiter interne ou primitive `reqwest` n'est exporté.
|
||||||
|
|
||||||
|
Le registry public reste générique ; aucun consumer n'a besoin d'un enum CoinGecko/Birdeye/Jupiter pour l'inspecter.
|
||||||
|
|
||||||
|
## 8. Tests déterministes ajoutés
|
||||||
|
|
||||||
|
Les nouveaux canaries couvrent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Birdeye API key obligatoire si enabled
|
||||||
|
redaction du secret Birdeye
|
||||||
|
endpoint fixe et headers attendus
|
||||||
|
mint WSOL fixe
|
||||||
|
1 RPS par compte
|
||||||
|
30 000 compute units informatifs
|
||||||
|
3 compute units par Price Single
|
||||||
|
prix exact via RawValue
|
||||||
|
updateUnixTime -> provider_timestamp
|
||||||
|
success false rejeté
|
||||||
|
prix null rejeté
|
||||||
|
timestamp absent rejeté
|
||||||
|
request cost nul rejeté
|
||||||
|
mismatch unité quota/coût rejeté
|
||||||
|
registry des huit providers complet
|
||||||
|
ordre déterministe du registry
|
||||||
|
doublons provider_id rejetés
|
||||||
|
lookup descriptor/state générique
|
||||||
|
availability is_refresh_eligible/retry_at
|
||||||
|
absence de noms provider dans le module registry
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Documentation réconciliée
|
||||||
|
|
||||||
|
Mise à jour de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan enregistre le réaudit Birdeye courant et précise que quotas/cost metadata restent non-authoritative.
|
||||||
|
|
||||||
|
## 10. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_birdeye.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_birdeye.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_registry.rs
|
||||||
|
deltas/0.2.11/pre.007.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Validation attendue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.008` ne doit commencer qu'après un gate propre de `pre.007` ou un delta fix explicite.
|
||||||
126
deltas/0.2.11/pre.008-fix.001.md
Normal file
126
deltas/0.2.11/pre.008-fix.001.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.008-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.008-fix.001` — Clippy et canari boundary du service de refresh
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Ce correctif s'applique exclusivement après `0.2.11-pre.008`.
|
||||||
|
|
||||||
|
Version Cargo attendue à l'entrée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.8
|
||||||
|
```
|
||||||
|
|
||||||
|
Version Cargo de sortie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.8.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Motif du correctif
|
||||||
|
|
||||||
|
Le gate opérateur du `2026-08-26` a produit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all exécuté
|
||||||
|
audit Rust workspace PASS
|
||||||
|
audit Markdown PASS, 122 tables / 113 files
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets 1 warning collapsible_if
|
||||||
|
cargo test -p ksp-offchain-transport-lib 54 unit PASS, dependency boundary FAIL
|
||||||
|
```
|
||||||
|
|
||||||
|
Le canari en échec était :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre_008_refresh_service_owns_dispatch_while_registry_and_consumer_surface_stay_generic
|
||||||
|
```
|
||||||
|
|
||||||
|
Il utilisait une interdiction lexicale trop large :
|
||||||
|
|
||||||
|
```text
|
||||||
|
!service.contains("consensus")
|
||||||
|
```
|
||||||
|
|
||||||
|
alors que `market_price_service.rs` contient légitimement une rustdoc indiquant que `refresh_many` fonctionne « without fallback or consensus ».
|
||||||
|
|
||||||
|
## 3. Correction Clippy
|
||||||
|
|
||||||
|
`MarketPriceService::update_availability` collapse les deux `if` imbriqués en une `let`-chain conforme à `clippy::collapsible_if`.
|
||||||
|
|
||||||
|
Le comportement ne change pas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
set_availability réussi + état retrouvé -> clone de l'état registry
|
||||||
|
sinon -> projection de secours avec la même availability
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune transition d'availability, classification d'erreur, cadence ou logique réseau n'est modifiée.
|
||||||
|
|
||||||
|
## 4. Correction du canari boundary
|
||||||
|
|
||||||
|
Le test ne bannit plus le mot documentaire `consensus`.
|
||||||
|
|
||||||
|
Il vérifie désormais l'absence de surfaces exécutables concrètes hors scope :
|
||||||
|
|
||||||
|
```text
|
||||||
|
fallback_provider
|
||||||
|
fn aggregate(
|
||||||
|
fn consensus(
|
||||||
|
```
|
||||||
|
|
||||||
|
Les rustdocs peuvent donc documenter explicitement l'absence de fallback/consensus sans faire échouer le canari, tandis qu'une implémentation réelle de ces mécanismes resterait détectée.
|
||||||
|
|
||||||
|
Les assertions existantes sur l'absence de `std::thread::sleep` et `tokio::time::sleep` sont conservées.
|
||||||
|
|
||||||
|
## 5. Scope fonctionnel inchangé
|
||||||
|
|
||||||
|
Ce fix ne modifie pas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
les huit adapters V1
|
||||||
|
MarketPriceProviderSetup
|
||||||
|
MarketPriceService public
|
||||||
|
refresh / refresh_many / refresh_all
|
||||||
|
registry et availability
|
||||||
|
rate limits / cooldowns
|
||||||
|
classification HTTP/provider
|
||||||
|
Config
|
||||||
|
scheduler périodique
|
||||||
|
fallback
|
||||||
|
consensus
|
||||||
|
agrégation multi-provider
|
||||||
|
smoke live
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_service.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.11/pre.008-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Gate opérateur requis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.009` ne doit commencer qu'après ce gate propre.
|
||||||
242
deltas/0.2.11/pre.008.md
Normal file
242
deltas/0.2.11/pre.008.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<!-- file: deltas/0.2.11/pre.008.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.11-pre.008` — Refresh individuel + multiple/all
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
Cette tranche s'applique exclusivement après `0.2.11-pre.007` accepté par l'opérateur.
|
||||||
|
|
||||||
|
Version Cargo attendue à l'entrée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.7
|
||||||
|
```
|
||||||
|
|
||||||
|
Version Cargo de sortie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.11-pre.8
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate opérateur du `2026-08-26` est entièrement PASS : `cargo fmt --all`, audit Rust clean avec 0 candidate export, audit Markdown clean à 121 tables / 112 fichiers, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-offchain-transport-lib` avec 49 unitaires + 5 boundary + 6 public API, puis `cargo test --workspace` complet PASS avec uniquement les smokes/diagnostics explicitement ignorés.
|
||||||
|
|
||||||
|
## 2. Objet strict de la tranche
|
||||||
|
|
||||||
|
`pre.008` matérialise l'orchestrateur générique de la famille `market_price` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceService
|
||||||
|
MarketPriceProviderSetup
|
||||||
|
MarketPriceRefreshOutcome
|
||||||
|
refresh(provider_id)
|
||||||
|
refresh_many(provider_ids)
|
||||||
|
refresh_all()
|
||||||
|
transitions runtime d'availability
|
||||||
|
```
|
||||||
|
|
||||||
|
La tranche n'ajoute pas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config Off-chain Transport
|
||||||
|
lecture directe d'environnement
|
||||||
|
scheduler périodique
|
||||||
|
fallback provider
|
||||||
|
consensus / moyenne / agrégation de prix
|
||||||
|
smoke live provider
|
||||||
|
nouvelle famille off-chain
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Frontière composition / runtime
|
||||||
|
|
||||||
|
`MarketPriceProviderSetup` est l'unique enum provider-specific introduit par l'orchestrateur. Il consomme les settings déjà publics des huit adapters V1 et est destiné à la couche de composition, notamment au futur adapter Config de `pre.009`.
|
||||||
|
|
||||||
|
Une fois le service construit, les opérations runtime ne prennent plus aucun type provider-specific :
|
||||||
|
|
||||||
|
```text
|
||||||
|
registry()
|
||||||
|
refresh(&MarketPriceProviderId)
|
||||||
|
refresh_many(&[MarketPriceProviderId])
|
||||||
|
refresh_all()
|
||||||
|
```
|
||||||
|
|
||||||
|
La future HID peut donc rester indépendante de CoinGecko, Birdeye, Kraken, Jupiter ou de tout autre provider concret.
|
||||||
|
|
||||||
|
## 4. Registry runtime et availability
|
||||||
|
|
||||||
|
`MarketPriceService` possède les adapters concrets et une copie mutable interne du `MarketPriceProviderRegistry`.
|
||||||
|
|
||||||
|
Le registry public reste une projection détachable `descriptor + state`. Deux helpers crate-private permettent au service de remplacer l'availability sans modifier l'identité du provider.
|
||||||
|
|
||||||
|
`MarketPriceProviderAvailability` gagne :
|
||||||
|
|
||||||
|
```text
|
||||||
|
is_refresh_eligible_at(now)
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette méthode autorise :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ready
|
||||||
|
CoolingDown dont retry_at est expiré
|
||||||
|
TemporarilyUnavailable sans retry_at
|
||||||
|
TemporarilyUnavailable dont retry_at est expiré
|
||||||
|
```
|
||||||
|
|
||||||
|
et bloque :
|
||||||
|
|
||||||
|
```text
|
||||||
|
AuthenticationUnavailable
|
||||||
|
Disabled
|
||||||
|
Misconfigured
|
||||||
|
QuotaUnavailable
|
||||||
|
CoolingDown non expiré
|
||||||
|
TemporarilyUnavailable avec retry_at futur
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun timer/sleep n'est lancé pour rendre un provider éligible : l'état est simplement réévalué lors du prochain appel explicite.
|
||||||
|
|
||||||
|
## 5. Classification générique des erreurs
|
||||||
|
|
||||||
|
Le service absorbe les erreurs provider/HTTP après dispatch et retourne un `MarketPriceRefreshOutcome` générique.
|
||||||
|
|
||||||
|
Les classes principales sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider disabled -> Disabled
|
||||||
|
401/403 sur mode avec API key -> AuthenticationUnavailable
|
||||||
|
401/403 sur mode keyless -> TemporarilyUnavailable
|
||||||
|
admission locale deferred -> CoolingDown
|
||||||
|
HTTP 429 -> CoolingDown
|
||||||
|
HTTP 408/5xx -> TemporarilyUnavailable
|
||||||
|
client/settings/request localement invalides -> Misconfigured
|
||||||
|
autre erreur provider/transport -> TemporarilyUnavailable
|
||||||
|
```
|
||||||
|
|
||||||
|
Un `Retry-After` exploitable ou un délai local de defer est converti en `retry_at` absolu. Aucun message/body provider n'est projeté au consumer ; le code d'erreur est seulement journalisé via `ksp-logging-lib` avec le provider id validé.
|
||||||
|
|
||||||
|
`QuotaUnavailable` reste une catégorie publique mais n'est pas inventée automatiquement lorsque le provider ne fournit pas une preuve exploitable de quota épuisé.
|
||||||
|
|
||||||
|
## 6. Refresh individuel
|
||||||
|
|
||||||
|
`MarketPriceService::refresh` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
résout le provider par identifiant opaque
|
||||||
|
projette immédiatement les états non éligibles
|
||||||
|
n'effectue aucun sleep
|
||||||
|
appelle uniquement l'adapter propriétaire du provider
|
||||||
|
met l'état à Ready après succès
|
||||||
|
classe un échec dans l'availability générique
|
||||||
|
retourne toujours une outcome provider-neutral pour un provider configuré
|
||||||
|
```
|
||||||
|
|
||||||
|
Un provider inconnu retourne `ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND` avant tout réseau.
|
||||||
|
|
||||||
|
## 7. Refresh multiple / all
|
||||||
|
|
||||||
|
`refresh_many` conserve l'ordre fourni par le caller et valide la liste complète avant dispatch :
|
||||||
|
|
||||||
|
```text
|
||||||
|
maximum 64 providers
|
||||||
|
doublons interdits
|
||||||
|
provider inconnu interdit
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque provider configuré retourne son outcome propre. Une erreur réseau/provider normalisée ne court-circuite donc pas les providers suivants.
|
||||||
|
|
||||||
|
`refresh_all` utilise l'ordre stable des provider ids du service. Les providers non éligibles sont inclus comme projections d'état mais ne déclenchent pas de requête réseau.
|
||||||
|
|
||||||
|
V1 reste volontairement séquentielle et déterministe. Cette décision ne constitue pas un scheduler : aucun provider n'est attendu par sleep et aucun fallback ou consensus n'est exécuté.
|
||||||
|
|
||||||
|
## 8. Façade publique
|
||||||
|
|
||||||
|
Ajouts publics :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceProviderSetup
|
||||||
|
MarketPriceRefreshOutcome
|
||||||
|
MarketPriceService
|
||||||
|
ERROR_CODE_MARKET_PRICE_PROVIDER_NOT_FOUND
|
||||||
|
ERROR_CODE_MARKET_PRICE_REFRESH_INVALID
|
||||||
|
MarketPriceProviderAvailability::is_refresh_eligible_at
|
||||||
|
```
|
||||||
|
|
||||||
|
`MarketPriceRefreshOutcome` expose uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider_id()
|
||||||
|
state()
|
||||||
|
observation()
|
||||||
|
refreshed()
|
||||||
|
```
|
||||||
|
|
||||||
|
Le consumer n'a pas accès au dispatcher interne, aux clients HTTP, aux limiters ou aux DTOs wire.
|
||||||
|
|
||||||
|
## 9. Tests déterministes ajoutés
|
||||||
|
|
||||||
|
Les nouveaux canaries couvrent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
construction du service avec les huit setups V1 désactivés
|
||||||
|
registry de service trié et provider-neutral
|
||||||
|
refresh_all de huit providers désactivés sans réseau
|
||||||
|
ordre déterministe des outcomes
|
||||||
|
doublon dans refresh_many rejeté avant dispatch
|
||||||
|
provider inconnu rejeté avant dispatch
|
||||||
|
401/403 keyed vs keyless
|
||||||
|
429 -> CoolingDown
|
||||||
|
5xx + retry_after -> TemporarilyUnavailable daté
|
||||||
|
erreur locale de request -> Misconfigured
|
||||||
|
éligibilité avant/après retry_at
|
||||||
|
absence de sleep / fallback / consensus dans le service
|
||||||
|
façade publique générique depuis la crate root
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Documentation réconciliée
|
||||||
|
|
||||||
|
Mise à jour de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate complet de `pre.007` fourni par l'opérateur est enregistré comme PASS.
|
||||||
|
|
||||||
|
## 11. Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_service.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_service.rs
|
||||||
|
deltas/0.2.11/pre.008.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-offchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_provider.rs
|
||||||
|
crates/ksp-offchain-transport-lib/src/market_price_registry.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-offchain-transport-lib/tests/public_api.rs
|
||||||
|
crates/ksp-offchain-transport-lib/unit_tests/market_price_provider.rs
|
||||||
|
docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md
|
||||||
|
docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Validation attendue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-offchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.009` ne doit commencer qu'après un gate propre de `pre.008` ou un delta fix explicite.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user