Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c3ecf1f18 | |||
| 79b67f8eae | |||
| e0a7ac0bf8 | |||
| 598474438b | |||
| f5d98c4e69 | |||
| 14bcbf2cfb | |||
| ac1b1033c4 | |||
| bc71fba289 | |||
| 9c1568ee1c | |||
| c1cea6e813 | |||
| ed978179d8 | |||
| babe7d9f2b | |||
| d3fc0c6d69 | |||
| 0cff0406ab | |||
| d98d152f08 | |||
| 624202c363 | |||
| 24cf2c5a11 | |||
| e721464a7c | |||
| 259bff6707 | |||
| 7d40de3249 |
14
.env.example
14
.env.example
@@ -1,10 +1,22 @@
|
|||||||
# file: .env.example
|
# file: .env.example
|
||||||
# version: 2
|
# version: 3
|
||||||
|
|
||||||
# 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.
|
||||||
KSP_LOGS_DIRECTORY=logs
|
KSP_LOGS_DIRECTORY=logs
|
||||||
|
|
||||||
|
# Optional public Solana Devnet HTTP endpoint override used by config/std.transport.json.
|
||||||
|
# The committed Transport document falls back to https://api.devnet.solana.com when this variable is absent.
|
||||||
|
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
|
||||||
|
|
||||||
|
# Optional public Solana Mainnet HTTP endpoint override used by config/std.transport.json and its example.
|
||||||
|
# The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent.
|
||||||
|
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
|
||||||
|
|
||||||
|
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
||||||
|
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
||||||
|
# KSP_SECRET_SOLANA_HTTP_URL=https://provider.example/?api-key=replace-me
|
||||||
|
|
||||||
# Minimum time in milliseconds that a KSP desk splash remains visible after its frontend is ready.
|
# Minimum time in milliseconds that a KSP desk splash remains visible after its frontend is ready.
|
||||||
KSP_DESK_SPLASH_MINIMUM_MS=1200
|
KSP_DESK_SPLASH_MINIMUM_MS=1200
|
||||||
|
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,10 +1,18 @@
|
|||||||
<!-- file: CHANGELOG.md -->
|
<!-- file: CHANGELOG.md -->
|
||||||
<!-- version: 3 -->
|
<!-- version: 5 -->
|
||||||
|
|
||||||
# Changelog KSP
|
# Changelog KSP
|
||||||
|
|
||||||
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.1 — HTTP Solana foundation — 2026-08-17
|
||||||
|
|
||||||
|
`0.2.1` stabilise `ksp-onchain-transport-lib` comme foundation HTTP JSON-RPC Solana provider-neutral : settings publics, endpoints/pool/rôles, priorités et fairness, RPS/burst/concurrence/cooldown, deadline commune, retry/backoff, classification no-resend après dispatch ambigu, snapshots sûrs et exécution HTTP réelle via `reqwest`/rustls. La release fige un registre audité de 52 méthodes HTTP courantes et 14 méthodes historiques Deprecated/Removed, avec quatre wrappers typés canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) et une partition explicite des 48 méthodes restantes sur `0.2.2`–`0.2.4`. Elle ajoute `std.transport`, son schema/exemple et l'adapter `ksp-config-lib -> ksp-onchain-transport-lib`, sans dépendance inverse, ainsi que la redaction des URLs/provider credentials, la neutralisation des URLs contenues dans les `reqwest::Error`, un sink Logging Transport dédié à `info`, des fixtures HTTP déterministes, des canaries de complétude et un smoke Devnet opt-in validant la composition Config -> Transport. Le smoke cross-crates reste temporairement hébergé dans Config et doit migrer vers une future surface d'intégration/orchestration ; ce placement n'est pas un modèle pour les futurs smokes `Config + autre crate`. Le prompt `prompts/007-V0_2_2_START_PROMPT.md` ouvre `0.2.2 — HTTP Accounts + Tokens + Cluster`.
|
||||||
|
|
||||||
|
## 0.2.0 — Audit bot3 et planification de la série `0.2.x` — 2026-08-17
|
||||||
|
|
||||||
|
`0.2.0` stabilise le cadrage de la prochaine phase fonctionnelle de KSP après audit de `khadhroony-bot3`. La release fixe l'ordre `0.2.1+` autour du transport HTTP Solana, du Wallet `.kspwallet`, de Wallet Desk, des transports WebSocket/LaserStream/Yellowstone, du transport off-chain de prix, de `ksp-interface-lib` et de `ksp-program-api`; elle impose la couverture exhaustive des surfaces Transport documentées avec warnings KSP pour les opérations deprecated/obsolete encore fonctionnelles et unstable/experimental. Elle stabilise également la progression durable `RAW -> CORE -> DECODE -> SPECIALIZED`, RAW/CORE sans décodage Program, puis des vertical slices complets par groupe à partir de DECODE, avec priorité Solana Core, SPL token/trading, metadata token, Anchor, Meteora/Raydium/Pump/Orca, routing et Market Desk progressive. Le prompt `prompts/006-V0_2_1_START_PROMPT.md` ouvre `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation` avec un gate de sizing imposant qu'une release concrète reste clôturable dans une seule session.
|
||||||
|
|
||||||
## 0.1.4 — Config Desk — 2026-08-17
|
## 0.1.4 — Config Desk — 2026-08-17
|
||||||
|
|
||||||
`0.1.4` stabilise `ksp-app-config-desk` comme première application desktop/Tauri spécialisée et modèle de référence des futures applications KSP. La release valide de bout en bout les contrats de `ksp-config-lib` et le lifecycle de `ksp-logging-lib` : shell splash/main, inventaire et diagnostics des documents Config, profils et provenance sûre, management `.env` avec shadowing et reveal Secret privilégié, éditeur Logging typé multi-profils/multi-sinks, persistence atomique, hot reload transactionnel, rollback, sélection runtime explicite, génération observable, fichiers de logs distincts par lancement, bridge frontend vers la façade KSP et panneau Test Logging pour démontrer le routing niveau/target/domain. Elle ajoute également les audits desktop/ownership/sécurité, un registre extensible `file_id -> éditeur spécialisé`, une baseline Logging de release `info`/`warn`, et prépare `0.2.0` comme release intermédiaire d’audit de `khadhroony-bot3` et de planification du reste de `0.2.x`.
|
`0.1.4` stabilise `ksp-app-config-desk` comme première application desktop/Tauri spécialisée et modèle de référence des futures applications KSP. La release valide de bout en bout les contrats de `ksp-config-lib` et le lifecycle de `ksp-logging-lib` : shell splash/main, inventaire et diagnostics des documents Config, profils et provenance sûre, management `.env` avec shadowing et reveal Secret privilégié, éditeur Logging typé multi-profils/multi-sinks, persistence atomique, hot reload transactionnel, rollback, sélection runtime explicite, génération observable, fichiers de logs distincts par lancement, bridge frontend vers la façade KSP et panneau Test Logging pour démontrer le routing niveau/target/domain. Elle ajoute également les audits desktop/ownership/sécurité, un registre extensible `file_id -> éditeur spécialisé`, une baseline Logging de release `info`/`warn`, et prépare `0.2.0` comme release intermédiaire d’audit de `khadhroony-bot3` et de planification du reste de `0.2.x`.
|
||||||
|
|||||||
17
Cargo.toml
17
Cargo.toml
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 92
|
# version: 109
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.4"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
@@ -15,15 +15,16 @@ publish = false
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
fs2 = { version = "^0.4" }
|
fs2 = { version = "^0.4" }
|
||||||
serde = { version = "^1.0", features = ["derive"] }
|
serde = { version = "^1.0" }
|
||||||
serde_json = { version = "^1.0" }
|
serde_json = { version = "^1.0" }
|
||||||
jsonschema = { version = "^0.49", default-features = false }
|
jsonschema = { version = "^0.49", default-features = false }
|
||||||
|
reqwest = { version = "^0.13", default-features = false }
|
||||||
solana-pubkey = { version = "^4.3", default-features = false }
|
solana-pubkey = { version = "^4.3", default-features = false }
|
||||||
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
tracing = { version = "^0.1", default-features = false }
|
||||||
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] }
|
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||||
tracing-appender = { version = "^0.2", default-features = false }
|
tracing-appender = { version = "^0.2", default-features = false }
|
||||||
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time"] }
|
tokio = { version = "^1.53", default-features = false }
|
||||||
chrono = { version = "^0.4", default-features = false, features = ["std", "now"] }
|
chrono = { version = "^0.4", default-features = false }
|
||||||
tauri = { version = "^2.11" }
|
tauri = { version = "^2.11" }
|
||||||
tauri-build = { version = "^2.6" }
|
tauri-build = { version = "^2.6" }
|
||||||
tauri-plugin-tracing = { version = "^0.3" }
|
tauri-plugin-tracing = { version = "^0.3" }
|
||||||
|
|||||||
188
ROADMAP.md
188
ROADMAP.md
@@ -1,11 +1,9 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 20 -->
|
<!-- version: 34 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues pour y parvenir. Une série `X.Y.x` regroupe une famille fonctionnelle de travaux ; elle peut contenir plusieurs releases concrètes et plusieurs sessions.
|
Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. Une série `X.Y.x` regroupe une famille fonctionnelle ; chaque release concrète reste une unité de développement/session distincte.
|
||||||
|
|
||||||
Les décisions architecturales négatives ou de prudence n'apparaissent pas comme des tâches à cocher. Elles sont conservées dans les règles et documents d'architecture.
|
|
||||||
|
|
||||||
## Légende
|
## Légende
|
||||||
|
|
||||||
@@ -15,6 +13,14 @@ Les décisions architecturales négatives ou de prudence n'apparaissent pas comm
|
|||||||
- `[C]` — annulé ;
|
- `[C]` — annulé ;
|
||||||
- `[R]` — reporté.
|
- `[R]` — reporté.
|
||||||
|
|
||||||
|
## Discipline de livraison
|
||||||
|
|
||||||
|
- Une prerelease vise environ **15 à 20 minutes de travail effectif**.
|
||||||
|
- Une release concrète doit être dimensionnée pour pouvoir être ouverte et clôturée dans **une seule session de chat**.
|
||||||
|
- Cette règle fixe une durée maximale par release, pas une obligation de changer de session après chaque release : si une release est clôturée plus vite que prévu, la même session peut ouvrir puis clôturer la release suivante si son propre sizing reste positif et si toutes les frontières version/delta/validation sont conservées.
|
||||||
|
- Si `pre.001` révèle qu'une release est trop grosse, elle est scindée avant implémentation fonctionnelle lourde.
|
||||||
|
- À partir des couches Program/Decode, KSP progresse verticalement groupe par groupe plutôt que par grandes vagues horizontales de decoders/materializers/executors séparés.
|
||||||
|
|
||||||
## 0.0.x — Fondation
|
## 0.0.x — Fondation
|
||||||
|
|
||||||
- [X] `0.0.1` — Initialiser le dépôt.
|
- [X] `0.0.1` — Initialiser le dépôt.
|
||||||
@@ -25,97 +31,127 @@ Les décisions architecturales négatives ou de prudence n'apparaissent pas comm
|
|||||||
|
|
||||||
## 0.1.x — Fondations N1
|
## 0.1.x — Fondations N1
|
||||||
|
|
||||||
### Objectifs
|
- [X] `0.1.1` — `ksp-core-lib` : Error/Result, Program IDs fondamentaux et primitives N1.
|
||||||
|
- [X] `0.1.2` — `ksp-logging-lib` : façade KSP de tracing.
|
||||||
|
- [X] `0.1.3` — `ksp-config-lib` : documents, profils, environnement, persistence et adapters.
|
||||||
|
- [X] `0.1.4` — `ksp-app-config-desk` : première application Tauri de référence.
|
||||||
|
|
||||||
Regrouper les releases consacrées aux fondations N1. Chaque release concrète est une unité de développement/session distincte et commence par son propre `pre.001` de brainstorming/audit/planification.
|
## 0.2.x — Accès Solana, Wallet et contrats d'extension initiaux
|
||||||
|
|
||||||
### Releases concrètes
|
### Cadrage
|
||||||
|
|
||||||
- [X] `0.1.1` — Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1.
|
- [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/CORE/DECODE/SPECIALIZED stabilisés.
|
||||||
- [X] `0.1.2` — Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`.
|
- [X] `0.2.1` — HTTP foundation stable : matrice 52+14, runtime/routing/résilience/exécution HTTP, 4 canaris typés, `std.transport`, adapter Config -> Transport, canaries de clôture, smoke Devnet opt-in et documentation durable validés ; les 48 wrappers typés restants sont reportés à `0.2.2`–`0.2.4`.
|
||||||
- [X] `0.1.3` — Stabiliser `ksp-config-lib` : documents, profils, résolution, validation, environnement KSP/KSPB, management/persistence et adapter Logging.
|
|
||||||
- [X] `0.1.4` — Introduire `ksp-app-config-desk` pour valider réellement Config et la frontière Tauri.
|
|
||||||
|
|
||||||
`0.1.1`, `0.1.2`, `0.1.3` et `0.1.4` sont désormais stables. `0.1.4` publie `ksp-app-config-desk` comme première validation desktop/Tauri de Config et modèle de référence des futures applications Tauri KSP. La prochaine session est `0.2.0`, release intermédiaire d’audit de `khadhroony-bot3` et de planification du reste de `0.2.x`.
|
### Releases fonctionnelles décidées/pressenties
|
||||||
|
|
||||||
Les contrats publics supplémentaires ne sont introduits que lorsqu'une release concrète en démontre le besoin.
|
- [X] `0.2.1` — **HTTP transport foundation réduite par le gate `pre.001`** : crate/settings/JSON-RPC/registry 52 current + 14 deprecated historiques, pool/rôles/limites/retry, Config adapter, documentation et 4 méthodes typées canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) publiés stables.
|
||||||
|
- [ ] `0.2.2` — Compléter HTTP Accounts + Tokens + Cluster : 5 méthodes Accounts restantes + 5 Tokens + 12 Cluster restantes, soit 22 méthodes.
|
||||||
|
- [ ] `0.2.3` — Compléter les 11 méthodes HTTP Transactions, y compris write/submission technique avec politique no-resend ambigu.
|
||||||
|
- [ ] `0.2.4` — Compléter les 10 méthodes HTTP Blocks + 5 Economics et exécuter la compliance finale de toute la surface HTTP 52 current + 14 deprecated historiques.
|
||||||
|
- [ ] `0.2.5` — Introduire `ksp-wallet-lib`, le format `.kspwallet`, la gestion sûre des secrets et une architecture d'import/export extensible ; exclure `WalletPolicy`.
|
||||||
|
- [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet.
|
||||||
|
- [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe.
|
||||||
|
- [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.
|
||||||
|
- [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
|
||||||
|
- [ ] `0.2.10` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
||||||
|
- [ ] `0.2.11` — Introduire une petite application desk de visualisation des prix.
|
||||||
|
- [ ] `0.2.12` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
||||||
|
- [ ] `0.2.13` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
||||||
|
|
||||||
## 0.2.x — Accès Solana et fondation programmes
|
### Règles Transport pour toute la série
|
||||||
|
|
||||||
### Release de cadrage `0.2.0`
|
- [ ] Couvrir toutes les méthodes/opérations documentées pour la surface normative ciblée par chaque release.
|
||||||
|
- [ ] Conserver les méthodes deprecated/obsolete encore fonctionnelles et émettre un `warn` KSP lors de leur utilisation.
|
||||||
|
- [ ] Implémenter les méthodes unstable/experimental ciblées et émettre un `warn` KSP lors de leur utilisation.
|
||||||
|
- [ ] Centraliser la metadata de statut des méthodes plutôt que disperser des warnings ad hoc.
|
||||||
|
- [ ] Garder `ksp-onchain-transport-lib` indépendant de `ksp-config-lib`, du Store et des modèles Program/métier.
|
||||||
|
|
||||||
- [ ] `0.2.0` — Auditer les fonctionnalités pertinentes de `khadhroony-bot3`, décider ce qui doit être repris, refondu, abandonné ou ajouté dans KSP, puis découper et ordonner les releases fonctionnelles restantes de `0.2.x`.
|
## Architecture de données — progression canonique
|
||||||
|
|
||||||
`0.2.0` est une release intermédiaire de transition et de planification de série. Elle ne doit pas démarrer par l'implémentation arbitraire d'un composant N2 : elle établit d'abord la cartographie fonctionnelle, les écarts avec KSP, les dépendances, les contrats à préserver ou redéfinir et le découpage concret de `0.2.1`, `0.2.2`, etc.
|
La chaîne durable cible est :
|
||||||
|
|
||||||
### Capacités à répartir dans les releases fonctionnelles suivantes
|
```text
|
||||||
|
RAW -> CORE -> DECODE -> SPECIALIZED
|
||||||
|
```
|
||||||
|
|
||||||
- [ ] Introduire `ksp-onchain-transport-lib` avec des modèles de transport homogènes indépendants du store.
|
- **RAW** et **CORE** ne nécessitent aucun décodage Program.
|
||||||
- [ ] Introduire `ksp-wallet-lib` et `ksp-app-wallet-desk`.
|
- À la fin de chaque couche horizontale RAW/CORE, ajouter les jobs/workers/apps nécessaires pour la rendre réellement exploitable avant d'ouvrir la couche suivante.
|
||||||
- [ ] Développer la première surface utile de `ksp-interface-lib`.
|
- À partir de **DECODE**, avancer verticalement groupe par groupe : wire -> decode -> matérialisation -> projection spécialisée si utile -> préparation d'exécution -> policy -> execution -> scénarios Devnet.
|
||||||
- [ ] Introduire `ksp-program-api` puis `ksp-program-lib`.
|
|
||||||
- [ ] Définir `ksp-execution-policy-api` comme contrat de policy commun à plusieurs contextes.
|
|
||||||
- [ ] Introduire `ksp-execution-lib` lorsque le premier cycle d'exécution réel justifie l'orchestration programme/policy/wallet/transport.
|
|
||||||
- [ ] Introduire `ksp-offchain-transport-lib` seulement au premier besoin réel.
|
|
||||||
|
|
||||||
## 0.3.x — Données, stockage et acquisition raw
|
## 0.3.x — RAW / acquisition persistée
|
||||||
|
|
||||||
- [ ] Introduire `ksp-materializer-api` / `ksp-materializer-lib`.
|
- [ ] `0.3.1` — Introduire `ksp-store-api` + `ksp-store-lib` avec PostgreSQL de référence et **modèles/persistence RAW uniquement**.
|
||||||
- [ ] Introduire `ksp-store-api` / `ksp-store-lib` avec PostgreSQL de référence.
|
- [ ] `0.3.2` — Étendre `ksp-interface-lib` avec les wires génériques nécessaires aux acquisitions et à la future normalisation CORE.
|
||||||
- [ ] Établir les niveaux durables D1 Raw, D2 Core, D3 journal de matérialisation générique et D4 projections de domaine.
|
- [ ] `0.3.3` — Introduire `ksp-job-api` et un job de backfill historique concret.
|
||||||
- [ ] Garantir des replays indépendants D1 -> D2, D2 -> D3 et D3 -> D4.
|
- [ ] `0.3.4` — Introduire une application spécialisée de backfill/inspection RAW.
|
||||||
- [ ] Introduire `ksp-app-store-desk`.
|
- [ ] Compléter ensuite la couche RAW avec le worker/service live, son contrôle et les outils d'exploitation réellement nécessaires avant de passer à CORE.
|
||||||
- [ ] Introduire `ksp-worker-api` et `ksp-worker-raw-retriever`.
|
|
||||||
- [ ] Introduire `ksp-worker-control-lib` lorsque le manager W1 crée le premier besoin concret.
|
|
||||||
- [ ] Introduire `ksp-job-api` et `ksp-job-backfill`.
|
|
||||||
- [ ] Introduire les pipelines spécialisés raw ingestion, Core processing, generic materialization et domain projection lorsque leurs premières frontières fonctionnelles sont développées.
|
|
||||||
- [ ] Introduire les jobs de replay indépendants D1 -> D2, D2 -> D3 et D3 -> D4.
|
|
||||||
- [ ] Normaliser les notifications de données persistées indépendamment de leur producteur et conserver le Store comme source de vérité du backlog.
|
|
||||||
- [ ] Mettre en place claim/lease, outcomes durables et reprise après crash pour les traitements concurrents.
|
|
||||||
|
|
||||||
## 0.4.x — Baseline Solana, SPL et metadata
|
## Série CORE suivante
|
||||||
|
|
||||||
- [ ] Ajouter progressivement les decoders et `ProgramExecutionPreparer` Core/SPL nécessaires.
|
- [ ] Définir la persistence CORE canonique Solana générique.
|
||||||
- [ ] Ajouter Token, Token-2022, ATA et metadata utiles.
|
- [ ] Implémenter `RAW -> CORE` sans decoder Program : blocs, slots, signatures, transactions/messages, comptes, instructions/CPI brutes, logs/meta et relations structurelles.
|
||||||
- [ ] Introduire `ksp-offchain-transport-lib` au plus tard au premier besoin externe.
|
- [ ] Ajouter replay/backfill RAW -> CORE.
|
||||||
- [ ] Ajouter materializers et jobs ponctuels nécessaires.
|
- [ ] Ajouter worker/service CORE.
|
||||||
- [ ] Ajouter les crates `ksp-scenario-<domain>-lib` spécialisées.
|
- [ ] Ajouter l'application de contrôle/inspection CORE utile.
|
||||||
- [ ] Ajouter les demos `ksp-app-scenario-<domain>-<environment>-desk-demo` correspondantes.
|
|
||||||
- [ ] Garder Memo, Token, ATA, Token-2022 séparés ; regrouper uniquement Metaplex Token Metadata + Token-2022 Metadata dans la famille metadata ; garder SPM séparé.
|
|
||||||
|
|
||||||
## 0.5.x — Anchor et protocoles trading
|
## Séries DECODE/SPECIALIZED/EXECUTION — progression verticale
|
||||||
|
|
||||||
- [ ] Introduire Anchor.
|
### Priorité 1 — Solana Core Programs
|
||||||
- [ ] Étendre Meteora par surfaces bornées.
|
|
||||||
- [ ] Étendre Raydium par surfaces bornées.
|
|
||||||
- [ ] Ajouter progressivement Pump, Orca, Jupiter, OKX et autres intégrations utiles.
|
|
||||||
- [ ] Ajouter interfaces, program implementations, materializers, jobs/scénarios/demos nécessaires pour chaque surface.
|
|
||||||
|
|
||||||
## 0.6.x — Processing autonome et orchestration
|
- [ ] Wire/decoding des programmes Core nécessaires transversalement.
|
||||||
|
- [ ] Matérialisation et projections utiles.
|
||||||
|
- [ ] Préparation d'exécution, policy et scénarios Devnet pour les opérations retenues.
|
||||||
|
|
||||||
- [ ] Introduire `ksp-worker-core-processor` pour D1 Raw -> D2 Core canonique.
|
### Priorité 2 — SPL token/trading
|
||||||
- [ ] Introduire `ksp-worker-generic-materializer` pour D2 Core -> D3 journal de matérialisation générique.
|
|
||||||
- [ ] Introduire `ksp-worker-domain-projector` pour D3 -> D4 projections spécialisées ; nom révisable.
|
|
||||||
- [ ] Exploiter les mêmes pipelines spécialisés pour les workers live et les jobs de replay afin d'éviter la duplication des frontières de processing.
|
|
||||||
- [ ] Étendre `ksp-worker-control-lib` à la gouvernance de plusieurs workers autonomes.
|
|
||||||
- [ ] Fournir pour chaque worker un mode service autonome, avec logique réutilisable séparée du binaire d'enveloppe.
|
|
||||||
- [ ] Construire d'abord les applications spécialisées nécessaires au développement, test et exploitation de chaque capacité.
|
|
||||||
- [ ] Garder jobs et workers sous des lifecycle APIs séparées.
|
|
||||||
|
|
||||||
## 0.7.x — Trading Intelligence
|
- [ ] SPL Token.
|
||||||
|
- [ ] Associated Token Account.
|
||||||
|
- [ ] Token-2022 et extensions pertinentes.
|
||||||
|
- [ ] Pour chaque famille : decode -> materialize -> specialized -> prepare -> policy -> execute -> scenarios.
|
||||||
|
|
||||||
- [ ] Statistiques et métriques.
|
### Priorité 3 — Metadata token
|
||||||
- [ ] Features et datasets historiques.
|
|
||||||
- [ ] Signaux et risque.
|
- [ ] Metaplex Token Metadata.
|
||||||
|
- [ ] Token-2022 Metadata.
|
||||||
|
- [R] Solana Program Metadata (SPM) — redéveloppement plus tard avec le décodage généraliste.
|
||||||
|
|
||||||
|
### Priorité 4 — Anchor
|
||||||
|
|
||||||
|
- [ ] Introduire les contrats et mécanismes Anchor nécessaires aux protocoles trading suivants.
|
||||||
|
|
||||||
|
### Priorité 5 — DEX à fort intérêt
|
||||||
|
|
||||||
|
- [ ] Meteora, y compris vaults/fees/positions/états auxiliaires nécessaires à son groupe.
|
||||||
|
- [ ] Raydium, y compris programmes satellites nécessaires.
|
||||||
|
- [ ] Pump, y compris fee program et composants launch/bonding/pool nécessaires.
|
||||||
|
- [ ] Orca, y compris programmes satellites nécessaires.
|
||||||
|
- [ ] Chaque groupe est terminé verticalement avant de devenir secondaire au profit du suivant.
|
||||||
|
|
||||||
|
### Market Desk V1
|
||||||
|
|
||||||
|
- [ ] Après les premiers groupes Meteora/Raydium/Pump/Orca, introduire une petite `ksp-app-market-desk` spécialisée.
|
||||||
|
- [ ] Visualiser tokens, pools/markets, liquidité, swaps/trades, prix, volumes, OHLC/candles et activité live/récente lorsque disponible.
|
||||||
|
- [ ] Lire les projections SPECIALIZED KSP ; ne pas reconstruire la logique protocolaire dans l'UI.
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
- [ ] Jupiter.
|
||||||
|
- [ ] OKX et autres routeurs selon besoin réel.
|
||||||
|
- [ ] Enrichir Market Desk avec routes, legs, DEX impliqués, fees/slippage et comparaison quote/execution lorsque disponible.
|
||||||
|
|
||||||
|
### Trading-adjacent puis décodage généraliste
|
||||||
|
|
||||||
|
- [ ] Ajouter ensuite les programmes indépendants utiles au trading : oracles, locks/vesting indépendants, lifecycle token, risk/signaux, etc.
|
||||||
|
- [ ] Ne jamais y repousser un satellite appartenant à un groupe DEX déjà ciblé.
|
||||||
|
- [ ] Étendre enfin le décodage au reste de Solana selon valeur fonctionnelle.
|
||||||
|
|
||||||
|
## Trading Intelligence et produits ultérieurs
|
||||||
|
|
||||||
|
- [ ] Statistiques/features/datasets historiques.
|
||||||
|
- [ ] Signaux, risque, anomalies et patterns.
|
||||||
- [ ] Replay analytique/backtests.
|
- [ ] Replay analytique/backtests.
|
||||||
- [ ] Patterns/anomalies.
|
- [ ] XGBoost puis autres modèles lorsque les données et contrats sont stables.
|
||||||
- [ ] XGBoost puis autres modèles lorsque les contrats sont stables.
|
- [ ] Construire ensuite les produits de trading opérationnel au-dessus de ces couches.
|
||||||
|
- [ ] Faire évoluer Market Desk vers davantage d'analyse sans la confondre avec l'application globale ou l'orchestrateur.
|
||||||
## 0.8.x et suivantes — Trading opérationnel et expansion produits
|
- [ ] Étudier plus tard les autres applications Wallet : mobile, extensions navigateur et web.
|
||||||
|
|
||||||
- [ ] Construire les couches puis l'application de trading monoposte au-dessus de Trading Intelligence.
|
|
||||||
- [ ] Étendre l'automatisation de trading.
|
|
||||||
- [ ] Étendre continuellement Program IDs, decoders, execution preparers et materializers.
|
|
||||||
- [ ] Construire progressivement l'explorer Solana.
|
|
||||||
- [ ] Construire progressivement l'explorer/analyse DEX.
|
|
||||||
- [ ] Étudier plus tard d'autres applications utilisant `ksp-wallet-lib`, notamment mobile, extensions navigateur et web.
|
|
||||||
|
|||||||
@@ -61,4 +61,4 @@
|
|||||||
"target_filters": []
|
"target_filters": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
64
config/examples/std.transport.example.json
Normal file
64
config/examples/std.transport.example.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"retry": {
|
||||||
|
"max_retries": 3,
|
||||||
|
"initial_backoff_ms": 150,
|
||||||
|
"max_backoff_ms": 3000
|
||||||
|
},
|
||||||
|
"default_profile": "mainnet_mixed",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet_mixed",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "mainnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": ["*"],
|
||||||
|
"priority": 200,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mainnet_private",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "private-provider",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"url": "${KSP_SECRET_SOLANA_HTTP_URL:-https://example.invalid}",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 10000,
|
||||||
|
"max_idle_connections_per_host": 16,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": ["*"],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 20,
|
||||||
|
"burst_capacity": 40,
|
||||||
|
"max_concurrent_requests": 16,
|
||||||
|
"pause_after_rate_limit_ms": 750
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -263,4 +263,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
124
config/schemas/std.transport.schema.json
Normal file
124
config/schemas/std.transport.schema.json
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "urn:ksp:schema:std.transport:v1",
|
||||||
|
"title": "KSP standard HTTP Transport configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["format_version", "retry", "default_profile", "profiles"],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {"const": 1},
|
||||||
|
"retry": {"$ref": "#/$defs/retry"},
|
||||||
|
"default_profile": {"$ref": "#/$defs/profileId"},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {"$ref": "#/$defs/profile"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"profileId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||||
|
},
|
||||||
|
"descriptor": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^\\S(?:.*\\S)?$"
|
||||||
|
},
|
||||||
|
"positiveMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 4294967295
|
||||||
|
},
|
||||||
|
"positiveU32": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 4294967295
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["max_retries", "initial_backoff_ms", "max_backoff_ms"],
|
||||||
|
"properties": {
|
||||||
|
"max_retries": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||||
|
"initial_backoff_ms": {"$ref": "#/$defs/positiveMs"},
|
||||||
|
"max_backoff_ms": {"$ref": "#/$defs/positiveMs"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"requests_per_second": {"$ref": "#/$defs/positiveU32"},
|
||||||
|
"burst_capacity": {"$ref": "#/$defs/positiveU32"},
|
||||||
|
"max_concurrent_requests": {"$ref": "#/$defs/positiveU32"},
|
||||||
|
"pause_after_rate_limit_ms": {"$ref": "#/$defs/positiveMs"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["role", "enabled", "request_kinds", "priority", "limits"],
|
||||||
|
"properties": {
|
||||||
|
"role": {"$ref": "#/$defs/descriptor"},
|
||||||
|
"enabled": {"type": "boolean"},
|
||||||
|
"request_kinds": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {"$ref": "#/$defs/descriptor"},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {"contains": {"const": "*"}},
|
||||||
|
"then": {"maxItems": 1}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"priority": {"type": "integer", "minimum": 0, "maximum": 4294967295},
|
||||||
|
"limits": {"$ref": "#/$defs/limits"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
"provider",
|
||||||
|
"cluster",
|
||||||
|
"url",
|
||||||
|
"connect_timeout_ms",
|
||||||
|
"request_timeout_ms",
|
||||||
|
"roles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {"$ref": "#/$defs/descriptor"},
|
||||||
|
"enabled": {"type": "boolean"},
|
||||||
|
"provider": {"$ref": "#/$defs/descriptor"},
|
||||||
|
"cluster": {"$ref": "#/$defs/descriptor"},
|
||||||
|
"url": {"type": "string", "minLength": 1},
|
||||||
|
"connect_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
||||||
|
"request_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
||||||
|
"max_idle_connections_per_host": {"type": "integer", "minimum": 1},
|
||||||
|
"roles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {"$ref": "#/$defs/role"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["profile_id", "endpoints"],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {"$ref": "#/$defs/profileId"},
|
||||||
|
"endpoints": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {"$ref": "#/$defs/endpoint"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,23 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"output_id": "file.onchain_transport.info",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "transport/onchain/ksp-onchain-transport.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "human",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {
|
||||||
|
"level": "info",
|
||||||
|
"targets": [
|
||||||
|
"ksp-onchain-transport-lib"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"output_id": "file.config.error",
|
"output_id": "file.config.error",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -70,6 +87,10 @@
|
|||||||
{
|
{
|
||||||
"target_prefix": "ksp-app-config-desk",
|
"target_prefix": "ksp-app-config-desk",
|
||||||
"level": "info"
|
"level": "info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_prefix": "ksp-onchain-transport-lib",
|
||||||
|
"level": "info"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
69
config/std.transport.json
Normal file
69
config/std.transport.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"retry": {
|
||||||
|
"max_retries": 2,
|
||||||
|
"initial_backoff_ms": 100,
|
||||||
|
"max_backoff_ms": 2000
|
||||||
|
},
|
||||||
|
"default_profile": "devnet_public",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet_public",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet_public",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": ["*"],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-app-config-desk/Cargo.toml
|
# file: crates/ksp-app-config-desk/Cargo.toml
|
||||||
# version: 7
|
# version: 8
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-app-config-desk"
|
name = "ksp-app-config-desk"
|
||||||
@@ -26,12 +26,12 @@ fs2.workspace = true
|
|||||||
ksp-config-lib = { path = "../ksp-config-lib" }
|
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
serde.workspace = true
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tauri.workspace = true
|
tauri.workspace = true
|
||||||
tauri-plugin-tracing.workspace = true
|
tauri-plugin-tracing.workspace = true
|
||||||
chrono.workspace = true
|
chrono = { workspace = true, features = ["std", "now"] }
|
||||||
tokio.workspace = true
|
tokio = { workspace = true, features = ["time"] }
|
||||||
ts-rs.workspace = true
|
ts-rs.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-app-config-desk/README.md -->
|
<!-- file: crates/ksp-app-config-desk/README.md -->
|
||||||
<!-- version: 23 -->
|
<!-- version: 24 -->
|
||||||
|
|
||||||
# `ksp-app-config-desk`
|
# `ksp-app-config-desk`
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ Deux audits d'intégration applicatifs complètent les audits Config/Logging exi
|
|||||||
Les artefacts frontend construits ne sont pas versionnés. `tauri.conf.json` fixe :
|
Les artefacts frontend construits ne sont pas versionnés. `tauri.conf.json` fixe :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
||||||
```
|
```
|
||||||
|
|
||||||
`vite.config.ts` résout cette même destination depuis la racine de la crate, ce qui maintient `dist` hors du workspace source et l'aligne avec la stratégie `.cargo/config.toml` pour les artefacts Rust.
|
`vite.config.ts` résout cette même destination depuis la racine de la crate, ce qui maintient `dist` hors du workspace source et l'aligne avec la stratégie `.cargo/config.toml` pour les artefacts Rust.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
||||||
<!-- version: 23 -->
|
<!-- version: 24 -->
|
||||||
|
|
||||||
# Utilisation de `ksp-app-config-desk`
|
# Utilisation de `ksp-app-config-desk`
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ npm run build
|
|||||||
Vite construit les pages `main.html` et `splash.html` vers :
|
Vite construit les pages `main.html` et `splash.html` vers :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
||||||
```
|
```
|
||||||
|
|
||||||
Cette destination est résolue depuis la racine de la crate dans `vite.config.ts` et correspond au `frontendDist` de `tauri.conf.json`.
|
Cette destination est résolue depuis la racine de la crate dans `vite.config.ts` et correspond au `frontendDist` de `tauri.conf.json`.
|
||||||
|
|||||||
@@ -10,4 +10,4 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"tracing:default"
|
"tracing:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
|
// file: crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
// Pulse 5.3.8
|
// Pulse 5.3.8
|
||||||
// Bootswatch
|
// Bootswatch
|
||||||
@@ -157,4 +157,4 @@
|
|||||||
color: $list-group-disabled-color;
|
color: $list-group-disabled-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
|
// file: crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
//@use '@fortawesome/fontawesome-free/scss/variables' with (
|
//@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||||
// // customizing $font-path - make sure it points to where your webfonts are stored in your project
|
// // customizing $font-path - make sure it points to where your webfonts are stored in your project
|
||||||
@@ -16,4 +16,4 @@
|
|||||||
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
|
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
|
||||||
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
|
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
|
||||||
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
|
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
|
||||||
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
|
// file: crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
/* Rtl support */
|
/* Rtl support */
|
||||||
[data-simplebar] {
|
[data-simplebar] {
|
||||||
@@ -245,4 +245,4 @@
|
|||||||
|
|
||||||
.simplebar-hover {
|
.simplebar-hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/frontend/sass/_variables.scss
|
// file: crates/ksp-app-config-desk/frontend/sass/_variables.scss
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
// Pulse 5.3.8
|
// Pulse 5.3.8
|
||||||
// Bootswatch
|
// Bootswatch
|
||||||
@@ -92,4 +92,4 @@ $list-group-border-color: transparent !default;
|
|||||||
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
|
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
|
||||||
$list-group-active-color: $white !default;
|
$list-group-active-color: $white !default;
|
||||||
$list-group-active-bg: $list-group-bg !default;
|
$list-group-active-bg: $list-group-bg !default;
|
||||||
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devUrl": "http://localhost:1430",
|
"devUrl": "http://localhost:1430",
|
||||||
"beforeBuildCommand": "npm run build",
|
"beforeBuildCommand": "npm run build",
|
||||||
"frontendDist": "../../builds/khadhroony-solana-project/ksp-app-config-desk/dist"
|
"frontendDist": "../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
|
|||||||
@@ -28,4 +28,4 @@
|
|||||||
"frontend",
|
"frontend",
|
||||||
"vite.config.ts"
|
"vite.config.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_inventory_exposes_logging_default_and_available_profiles() {
|
fn profile_inventory_exposes_registered_standard_profile_documents() {
|
||||||
let management = fixture_management();
|
let management = fixture_management();
|
||||||
assert!(management.is_ok(), "fixture management should construct: {management:?}");
|
assert!(management.is_ok(), "fixture management should construct: {management:?}");
|
||||||
if let std::result::Result::Ok(management) = management {
|
if let std::result::Result::Ok(management) = management {
|
||||||
let inventory = super::inventory_from_management(&management);
|
let inventory = super::inventory_from_management(&management);
|
||||||
assert!(inventory.is_ok(), "profile inventory should resolve: {inventory:?}");
|
assert!(inventory.is_ok(), "profile inventory should resolve: {inventory:?}");
|
||||||
if let std::result::Result::Ok(inventory) = inventory {
|
if let std::result::Result::Ok(inventory) = inventory {
|
||||||
assert_eq!(inventory.len(), 1);
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
assert_eq!(inventory[0].file_id, ksp_config_lib::FILE_ID_STD_LOGGING);
|
return document.file_id == ksp_config_lib::FILE_ID_STD_LOGGING;
|
||||||
assert!(!inventory[0].default_profile.is_empty());
|
|
||||||
assert!(inventory[0].profile_ids.iter().any(|profile_id| {
|
|
||||||
return profile_id == &inventory[0].default_profile;
|
|
||||||
}));
|
}));
|
||||||
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
|
return document.file_id == ksp_config_lib::FILE_ID_STD_TRANSPORT;
|
||||||
|
}));
|
||||||
|
for document in inventory {
|
||||||
|
assert!(!document.default_profile.is_empty());
|
||||||
|
assert!(document.profile_ids.iter().any(|profile_id| -> bool {
|
||||||
|
return profile_id == &document.default_profile;
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { defineConfig, normalizePath } from "vite";
|
|||||||
|
|
||||||
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||||
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
|
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
|
||||||
const frontendDist = normalizePath(resolve(appRoot, "../../builds/khadhroony-solana-project/ksp-app-config-desk/dist"));
|
const frontendDist = normalizePath(resolve(appRoot, "../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist"));
|
||||||
const devHost = process.env.TAURI_DEV_HOST;
|
const devHost = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-config-lib/Cargo.toml
|
# file: crates/ksp-config-lib/Cargo.toml
|
||||||
# version: 3
|
# version: 6
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-config-lib"
|
name = "ksp-config-lib"
|
||||||
@@ -10,9 +10,13 @@ repository.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
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" }
|
||||||
serde.workspace = true
|
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
jsonschema.workspace = true
|
jsonschema.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-config-lib/README.md -->
|
<!-- file: crates/ksp-config-lib/README.md -->
|
||||||
<!-- version: 3 -->
|
<!-- version: 5 -->
|
||||||
|
|
||||||
# ksp-config-lib
|
# ksp-config-lib
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
|||||||
- la classification `Public`, `Internal`, `Secret` ;
|
- la classification `Public`, `Internal`, `Secret` ;
|
||||||
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
|
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
|
||||||
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
||||||
|
- l'adapter du document HTTP Transport effectif vers `ksp_onchain_transport_lib::HttpTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
||||||
- 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`.
|
||||||
@@ -32,9 +33,11 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
|||||||
Le registre par défaut connaît :
|
Le registre par défaut connaît :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
cfg.std.logging -> config/std.logging.json
|
cfg.std.logging -> config/std.logging.json
|
||||||
schema.std.logging -> config/schemas/std.logging.schema.json
|
cfg.std.transport -> config/std.transport.json
|
||||||
schema.composite -> config/schemas/composite.schema.json
|
schema.std.logging -> config/schemas/std.logging.schema.json
|
||||||
|
schema.std.transport -> config/schemas/std.transport.schema.json
|
||||||
|
schema.composite -> config/schemas/composite.schema.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`ConfigFileRegistry::descriptors()` expose ces descripteurs en lecture seule et dans un ordre déterministe par `file_id`. Une application de management peut ainsi découvrir les fichiers connus sans maintenir une liste parallèle ni dépendre de leurs filenames physiques.
|
`ConfigFileRegistry::descriptors()` expose ces descripteurs en lecture seule et dans un ordre déterministe par `file_id`. Une application de management peut ainsi découvrir les fichiers connus sans maintenir une liste parallèle ni dépendre de leurs filenames physiques.
|
||||||
@@ -59,15 +62,15 @@ 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 depuis le JSON.
|
- reconstruire elles-mêmes la configuration Logging ou Transport depuis le JSON.
|
||||||
|
|
||||||
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result` et de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`.
|
`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.
|
||||||
|
|
||||||
La dépendance inverse est interdite : `ksp-core-lib` et `ksp-logging-lib` ne dépendent pas de Config.
|
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib` et `ksp-onchain-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`.
|
||||||
|
|
||||||
Tauri et les DTO TS-RS restent hors de cette crate. La future `ksp-app-config-desk` doit rester une frontière applicative mince au-dessus des APIs Config.
|
Tauri et les DTO TS-RS restent hors de cette crate. `ksp-app-config-desk` reste une frontière applicative mince au-dessus des APIs Config et découvre les documents standards via le registre Config sans déplacer leur logique métier dans l'application.
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
@@ -75,12 +78,13 @@ 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 Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport les accepte pour les URLs endpoint : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
|
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
|
||||||
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ;
|
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ;
|
||||||
- [`../../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) — premier document standard concret ;
|
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
|
||||||
|
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard HTTP Transport ;
|
||||||
- [`../../.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/TODO.md -->
|
<!-- file: crates/ksp-config-lib/TODO.md -->
|
||||||
<!-- version: 3 -->
|
<!-- version: 4 -->
|
||||||
|
|
||||||
# TODO ksp-config-lib
|
# TODO ksp-config-lib
|
||||||
|
|
||||||
@@ -31,11 +31,15 @@ La validation applicative desktop appartient à `ksp-app-config-desk` :
|
|||||||
|
|
||||||
Ces points ne nécessitent pas de duplication de logique dans `ksp-config-lib`; toute lacune réelle révélée par l'application ouvrira un delta Config explicite.
|
Ces points ne nécessitent pas de duplication de logique dans `ksp-config-lib`; toute lacune réelle révélée par l'application ouvrira un delta Config explicite.
|
||||||
|
|
||||||
|
## Extension `0.2.1` — Transport HTTP
|
||||||
|
|
||||||
|
`0.2.1-pre.006` introduit le premier nouveau domaine standard depuis Logging : `std.transport.json`, son schema, son exemple, son enregistrement et l'adapter Config -> `HttpTransportSettings`. Cette extension confirme que les nouveaux domaines restent ajoutés à la demande d'un consumer réel, sans transformer Config en propriétaire du runtime Transport.
|
||||||
|
|
||||||
## Futur, uniquement au besoin
|
## Futur, uniquement au besoin
|
||||||
|
|
||||||
Les capacités suivantes sont différées jusqu'à l'apparition de composants réels :
|
Les capacités suivantes sont différées jusqu'à l'apparition de composants réels :
|
||||||
|
|
||||||
- nouveaux documents `std.<domain>.json` et schemas associés ;
|
- documents `std.<domain>.json` supplémentaires et schemas associés ;
|
||||||
- descriptors `cfg.composite.<consumer>` pour de vrais consumers ;
|
- descriptors `cfg.composite.<consumer>` pour de vrais consumers ;
|
||||||
- contrats typés de management supplémentaires pour les nouveaux documents ;
|
- contrats typés de management supplémentaires pour les nouveaux documents ;
|
||||||
- watcher filesystem/reload automatique si une application ou un service démontre le besoin ;
|
- watcher filesystem/reload automatique si une application ou un service démontre le besoin ;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||||
<!-- version: 4 -->
|
<!-- version: 5 -->
|
||||||
|
|
||||||
# Utilisation de ksp-config-lib
|
# Utilisation de ksp-config-lib
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Les arguments compris par Config sont :
|
|||||||
--cfgpath=/path/to/config
|
--cfgpath=/path/to/config
|
||||||
--schemapath=/path/to/schemas
|
--schemapath=/path/to/schemas
|
||||||
--filemap=cfg.std.logging=my-logging.json
|
--filemap=cfg.std.logging=my-logging.json
|
||||||
|
--filemap=cfg.std.transport=my-transport.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`cfgpath` et `schemapath` ne sont jamais lus depuis JSON, `.env` ou une variable KSP : cette règle évite un bootstrap récursif.
|
`cfgpath` et `schemapath` ne sont jamais lus depuis JSON, `.env` ou une variable KSP : cette règle évite un bootstrap récursif.
|
||||||
@@ -122,6 +123,23 @@ Un `logs_directory` relatif est ancré sur le current working directory du proce
|
|||||||
|
|
||||||
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
|
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
|
||||||
|
|
||||||
|
### 4.1 Construire le Transport HTTP depuis Config
|
||||||
|
|
||||||
|
Config possède également l'adapter du document `std.transport` vers le contrat runtime de `ksp-onchain-transport-lib` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let transport = match engine.load_resolved_transport_config(std::option::Option::None, &environment) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let transport_settings = transport.into_settings();
|
||||||
|
```
|
||||||
|
|
||||||
|
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. Les URLs peuvent provenir de `KSP_PUBLIC_*` ou de `KSP_SECRET_*`; dans ce dernier cas la valeur réelle reste disponible au runtime Transport, mais `ResolvedTransportConfig::effective().safe_value()` et les représentations `Debug` sont redacted.
|
||||||
|
|
||||||
|
La dépendance reste unidirectionnelle : Config connaît le contrat Transport pour le construire ; Transport ne connaît ni Config, ni `.env`, ni les variables KSP.
|
||||||
|
|
||||||
## 5. Profils et composites
|
## 5. Profils et composites
|
||||||
|
|
||||||
Pour un document standard profilé :
|
Pour un document standard profilé :
|
||||||
@@ -213,6 +231,7 @@ Ce guide reste volontairement indépendant des numéros de release. Les contrats
|
|||||||
| Environnement | `ConfigEnvironment`, `ConfigEnvironmentSource`, `ConfigEnvironmentValue`, `DEFAULT_DOTENV_PATH`, `DEFAULT_DOTENV_EXAMPLE_PATH` | §3, §7–8 |
|
| Environnement | `ConfigEnvironment`, `ConfigEnvironmentSource`, `ConfigEnvironmentValue`, `DEFAULT_DOTENV_PATH`, `DEFAULT_DOTENV_EXAMPLE_PATH` | §3, §7–8 |
|
||||||
| Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 |
|
| Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 |
|
||||||
| Logging effectif | `ResolvedLoggingConfig` | §4 |
|
| Logging effectif | `ResolvedLoggingConfig` | §4 |
|
||||||
|
| Transport effectif | `ResolvedTransportConfig` | §4.1 |
|
||||||
| Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §6–7 |
|
| Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §6–7 |
|
||||||
| Source Logging typée | `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig`, `LoggingTargetFilterConfig` | §6 et exemple ci-dessous |
|
| Source Logging typée | `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig`, `LoggingTargetFilterConfig` | §6 et exemple ci-dessous |
|
||||||
| Erreurs Config | constantes `ERROR_CODE_*` réexportées par la crate | exemple ci-dessous |
|
| Erreurs Config | constantes `ERROR_CODE_*` réexportées par la crate | exemple ci-dessous |
|
||||||
@@ -324,4 +343,3 @@ if let std::result::Result::Err(error) = loaded {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Le message/context d'erreur reste destiné au diagnostic ; l'identité machine-readable passe par `ErrorCode`.
|
Le message/context d'erreur reste destiné au diagnostic ; l'identité machine-readable passe par `ErrorCode`.
|
||||||
|
|
||||||
|
|||||||
7
crates/ksp-config-lib/src/constants.rs
Normal file
7
crates/ksp-config-lib/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/constants.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Config-owned tracing constants.
|
||||||
|
|
||||||
|
/// Owning tracing target for events emitted by the Config crate.
|
||||||
|
pub(crate) const TRACING_TARGET: &str = "ksp-config-lib";
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/environment.rs
|
// file: crates/ksp-config-lib/src/environment.rs
|
||||||
// version: 4
|
// version: 5
|
||||||
|
|
||||||
/// Default local environment file read by Config from the process launch directory.
|
/// Default local environment file read by Config from the process launch directory.
|
||||||
pub const DEFAULT_DOTENV_PATH: &str = ".env";
|
pub const DEFAULT_DOTENV_PATH: &str = ".env";
|
||||||
@@ -7,7 +7,6 @@ pub const DEFAULT_DOTENV_PATH: &str = ".env";
|
|||||||
/// Versioned environment contract template expected at the repository/runtime root.
|
/// Versioned environment contract template expected at the repository/runtime root.
|
||||||
pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example";
|
pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example";
|
||||||
|
|
||||||
const LOGGING_TARGET: &str = "ksp-config-lib";
|
|
||||||
const LOGGING_DOMAIN: &str = "config.environment";
|
const LOGGING_DOMAIN: &str = "config.environment";
|
||||||
|
|
||||||
/// Source that supplied one resolved Config environment variable.
|
/// Source that supplied one resolved Config environment variable.
|
||||||
@@ -556,7 +555,7 @@ fn is_generic_dotenv_name(variable_name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn emit_missing_variable_warning(variable_name: &str) {
|
fn emit_missing_variable_warning(variable_name: &str) {
|
||||||
ksp_logging_lib::warn!(target: LOGGING_TARGET, domain = LOGGING_DOMAIN, variable_name = variable_name, "Config environment variable is missing");
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = LOGGING_DOMAIN, variable_name = variable_name, "Config environment variable is missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn missing_variable_error(variable_name: &str) -> ksp_core_lib::Error {
|
fn missing_variable_error(variable_name: &str) -> ksp_core_lib::Error {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/lib.rs
|
// file: crates/ksp-config-lib/src/lib.rs
|
||||||
// version: 10
|
// version: 12
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
@@ -8,11 +8,12 @@
|
|||||||
//!
|
//!
|
||||||
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
||||||
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
||||||
//! provenance. The standard Logging document maps explicitly to `ksp_logging_lib::LoggingSettings`, while the management surface provides typed Logging
|
//! provenance. Standard Logging and HTTP Transport documents map explicitly to their runtime settings contracts, while the management surface provides
|
||||||
//! mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
|
//! typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
|
||||||
|
|
||||||
mod bootstrap;
|
mod bootstrap;
|
||||||
mod composite;
|
mod composite;
|
||||||
|
mod constants;
|
||||||
mod document;
|
mod document;
|
||||||
mod environment;
|
mod environment;
|
||||||
mod error;
|
mod error;
|
||||||
@@ -22,6 +23,9 @@ mod persistence;
|
|||||||
mod profile;
|
mod profile;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod sensitivity;
|
mod sensitivity;
|
||||||
|
mod transport;
|
||||||
|
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET;
|
||||||
|
|
||||||
/// Bootstrap argument used to replace the configuration document root.
|
/// Bootstrap argument used to replace the configuration document root.
|
||||||
pub use self::bootstrap::ARG_CFG_PATH;
|
pub use self::bootstrap::ARG_CFG_PATH;
|
||||||
@@ -141,12 +145,20 @@ pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
|
|||||||
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
|
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
|
||||||
/// Default physical filename for the standard Logging JSON Schema document.
|
/// Default physical filename for the standard Logging JSON Schema document.
|
||||||
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
||||||
|
/// Default physical filename for the standard HTTP Transport configuration document.
|
||||||
|
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
||||||
|
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
||||||
|
pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
|
||||||
/// Logical file identifier for the generic composite JSON Schema document.
|
/// Logical file identifier for the generic composite JSON Schema document.
|
||||||
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
||||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||||
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
||||||
|
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
|
||||||
|
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
||||||
/// Logical file identifier for the standard Logging configuration document.
|
/// Logical file identifier for the standard Logging configuration document.
|
||||||
pub use self::registry::FILE_ID_STD_LOGGING;
|
pub use self::registry::FILE_ID_STD_LOGGING;
|
||||||
|
/// Logical file identifier for the standard HTTP Transport configuration document.
|
||||||
|
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
||||||
/// Sensitivity assigned to one Config value after environment resolution.
|
/// Sensitivity assigned to one Config value after environment resolution.
|
||||||
pub use self::sensitivity::ConfigSensitivity;
|
pub use self::sensitivity::ConfigSensitivity;
|
||||||
/// Provenance segment participating in one resolved Config value.
|
/// Provenance segment participating in one resolved Config value.
|
||||||
@@ -157,3 +169,5 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
|
|||||||
pub use self::sensitivity::ResolvedConfigJson;
|
pub use self::sensitivity::ResolvedConfigJson;
|
||||||
/// One resolved Config string preserving real/safe representations and provenance.
|
/// One resolved Config string preserving real/safe representations and provenance.
|
||||||
pub use self::sensitivity::ResolvedConfigText;
|
pub use self::sensitivity::ResolvedConfigText;
|
||||||
|
/// Effective standard HTTP Transport configuration mapped to `ksp_onchain_transport_lib::HttpTransportSettings`.
|
||||||
|
pub use self::transport::ResolvedTransportConfig;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/persistence.rs
|
// file: crates/ksp-config-lib/src/persistence.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ fn cleanup_temporary_file(path: &std::path::Path) {
|
|||||||
&& error.kind() != std::io::ErrorKind::NotFound
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
{
|
{
|
||||||
ksp_logging_lib::warn!(
|
ksp_logging_lib::warn!(
|
||||||
target: "ksp-config-lib",
|
target: crate::TRACING_TARGET,
|
||||||
domain = "config.persistence",
|
domain = "config.persistence",
|
||||||
path = %path.to_string_lossy(),
|
path = %path.to_string_lossy(),
|
||||||
error = %error,
|
error = %error,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/registry.rs
|
// file: crates/ksp-config-lib/src/registry.rs
|
||||||
// version: 4
|
// version: 5
|
||||||
|
|
||||||
/// 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";
|
||||||
@@ -7,12 +7,20 @@ pub const ARG_FILE_MAP: &str = "--filemap";
|
|||||||
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 Logging JSON Schema document.
|
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||||
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
||||||
|
/// Logical file identifier for the standard HTTP Transport configuration document.
|
||||||
|
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
||||||
|
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
|
||||||
|
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
||||||
/// Logical file identifier for the generic composite JSON Schema document.
|
/// Logical file identifier for the generic composite JSON Schema document.
|
||||||
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
||||||
/// Default physical filename for the standard Logging configuration document.
|
/// Default physical filename for the standard Logging configuration document.
|
||||||
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
||||||
/// Default physical filename for the standard Logging JSON Schema document.
|
/// Default physical filename for the standard Logging JSON Schema document.
|
||||||
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
||||||
|
/// Default physical filename for the standard HTTP Transport configuration document.
|
||||||
|
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
||||||
|
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
||||||
|
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
|
||||||
/// Default physical filename for the generic composite JSON Schema document.
|
/// Default physical filename for the generic composite JSON Schema document.
|
||||||
pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
||||||
|
|
||||||
@@ -135,13 +143,29 @@ 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 transport = ConfigFileDescriptor::new(
|
||||||
|
FILE_ID_STD_TRANSPORT,
|
||||||
|
ConfigFileKind::Config,
|
||||||
|
DEFAULT_STD_TRANSPORT_FILENAME,
|
||||||
|
std::option::Option::Some(FILE_ID_SCHEMA_STD_TRANSPORT),
|
||||||
|
);
|
||||||
|
let transport = match transport {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let transport_schema =
|
||||||
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_TRANSPORT, ConfigFileKind::Schema, DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME, std::option::Option::None);
|
||||||
|
let transport_schema = match transport_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
let composite_schema =
|
let composite_schema =
|
||||||
ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None);
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None);
|
||||||
let composite_schema = match composite_schema {
|
let composite_schema = match composite_schema {
|
||||||
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 build_registry([logging, logging_schema, composite_schema]);
|
return build_registry([logging, logging_schema, transport, transport_schema, composite_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.
|
||||||
|
|||||||
316
crates/ksp-config-lib/src/transport.rs
Normal file
316
crates/ksp-config-lib/src/transport.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/transport.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Effective standard HTTP Transport configuration resolved from Config and mapped to the Transport runtime contract.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ResolvedTransportConfig {
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
source_path: std::path::PathBuf,
|
||||||
|
profile_id: String,
|
||||||
|
selection_source: crate::ConfigProfileSelectionSource,
|
||||||
|
effective: crate::ResolvedConfigJson,
|
||||||
|
settings: ksp_onchain_transport_lib::HttpTransportSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedTransportConfig {
|
||||||
|
/// 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 physical source Config document path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn source_path(&self) -> &std::path::Path {
|
||||||
|
return self.source_path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected standard Transport profile identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the source that selected the standard Transport profile.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||||
|
return self.selection_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the detailed environment-resolved effective Config view.
|
||||||
|
///
|
||||||
|
/// The real tree is available to legitimate runtime consumers. The safe tree redacts values originating from `KSP_SECRET_*` or `KSPB_SECRET_*`
|
||||||
|
/// placeholders and is the only representation used by this type's [`std::fmt::Debug`] implementation.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||||
|
return &self.effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated runtime HTTP Transport settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes this resolved Config and returns the mapped runtime HTTP Transport settings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
|
||||||
|
return self.settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ResolvedTransportConfig {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ResolvedTransportConfig")
|
||||||
|
.field("file_id", &self.file_id)
|
||||||
|
.field("source_path", &self.source_path)
|
||||||
|
.field("profile_id", &self.profile_id)
|
||||||
|
.field("selection_source", &self.selection_source)
|
||||||
|
.field("effective", &self.effective)
|
||||||
|
.finish_non_exhaustive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads the standard HTTP Transport document, selects a profile, resolves environment placeholders and maps it to Transport runtime settings.
|
||||||
|
///
|
||||||
|
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed
|
||||||
|
/// because the Transport URL wrapper owns runtime redaction. Invalid environment-resolved values are reported as
|
||||||
|
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] without copying endpoint URL values into ordinary error context.
|
||||||
|
pub fn load_resolved_transport_config(
|
||||||
|
&self,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
environment: &crate::ConfigEnvironment,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_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_transport_profile(&profile, environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveTransportSource {
|
||||||
|
format_version: u32,
|
||||||
|
profile_id: String,
|
||||||
|
retry: EffectiveRetrySource,
|
||||||
|
endpoints: std::vec::Vec<EffectiveEndpointSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveRetrySource {
|
||||||
|
max_retries: u32,
|
||||||
|
initial_backoff_ms: u64,
|
||||||
|
max_backoff_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveEndpointSource {
|
||||||
|
name: String,
|
||||||
|
enabled: bool,
|
||||||
|
provider: String,
|
||||||
|
cluster: String,
|
||||||
|
url: String,
|
||||||
|
connect_timeout_ms: u64,
|
||||||
|
request_timeout_ms: u64,
|
||||||
|
max_idle_connections_per_host: std::option::Option<usize>,
|
||||||
|
roles: std::vec::Vec<EffectiveRoleSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveRoleSource {
|
||||||
|
role: String,
|
||||||
|
enabled: bool,
|
||||||
|
request_kinds: std::vec::Vec<String>,
|
||||||
|
priority: u32,
|
||||||
|
limits: EffectiveLimitsSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveLimitsSource {
|
||||||
|
requests_per_second: std::option::Option<u32>,
|
||||||
|
burst_capacity: std::option::Option<u32>,
|
||||||
|
max_concurrent_requests: std::option::Option<u32>,
|
||||||
|
pause_after_rate_limit_ms: std::option::Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||||
|
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::<EffectiveTransportSource>(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 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 Transport format_version is unsupported"));
|
||||||
|
}
|
||||||
|
if source.profile_id != profile.profile_id() {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Transport profile_id does not match the selected profile"));
|
||||||
|
}
|
||||||
|
let retry = ksp_onchain_transport_lib::HttpRetrySettings::new(
|
||||||
|
source.retry.max_retries,
|
||||||
|
std::time::Duration::from_millis(source.retry.initial_backoff_ms),
|
||||||
|
std::time::Duration::from_millis(source.retry.max_backoff_ms),
|
||||||
|
);
|
||||||
|
let endpoints = map_endpoints(source.endpoints, profile);
|
||||||
|
let endpoints = match endpoints {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(endpoints, retry);
|
||||||
|
let validation = settings.validate();
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(transport_contract_error(profile, "effective Transport settings fail the Transport runtime contract", &error));
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
profile_id = profile.profile_id(),
|
||||||
|
endpoint_count = settings.endpoints().len(),
|
||||||
|
"mapped standard Transport Config to runtime settings"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(ResolvedTransportConfig {
|
||||||
|
file_id: profile.file_id().clone(),
|
||||||
|
source_path: profile.path().to_path_buf(),
|
||||||
|
profile_id: profile.profile_id().to_owned(),
|
||||||
|
selection_source: profile.selection_source(),
|
||||||
|
effective,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_endpoints(
|
||||||
|
sources: std::vec::Vec<EffectiveEndpointSource>,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointSettings>> {
|
||||||
|
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::HttpEndpointSettings>::with_capacity(sources.len());
|
||||||
|
for source in sources {
|
||||||
|
let endpoint_name = source.name.clone();
|
||||||
|
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse(source.url);
|
||||||
|
let url = match url {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
transport_contract_error(profile, "effective endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let roles = map_roles(source.roles, profile, endpoint_name.as_str());
|
||||||
|
let roles = match roles {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
endpoints.push(ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||||
|
source.name,
|
||||||
|
source.enabled,
|
||||||
|
ksp_onchain_transport_lib::HttpProviderName::new(source.provider),
|
||||||
|
ksp_onchain_transport_lib::HttpClusterName::new(source.cluster),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_millis(source.connect_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(source.request_timeout_ms),
|
||||||
|
source.max_idle_connections_per_host,
|
||||||
|
roles,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(endpoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_roles(
|
||||||
|
sources: std::vec::Vec<EffectiveRoleSource>,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
endpoint_name: &str,
|
||||||
|
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointRoleSettings>> {
|
||||||
|
let mut roles = std::vec::Vec::<ksp_onchain_transport_lib::HttpEndpointRoleSettings>::with_capacity(sources.len());
|
||||||
|
for source in sources {
|
||||||
|
let requests_per_second = map_non_zero(source.limits.requests_per_second, profile, "limits.requests_per_second", endpoint_name);
|
||||||
|
let requests_per_second = match requests_per_second {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let burst_capacity = map_non_zero(source.limits.burst_capacity, profile, "limits.burst_capacity", endpoint_name);
|
||||||
|
let burst_capacity = match burst_capacity {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let max_concurrent_requests = map_non_zero(source.limits.max_concurrent_requests, profile, "limits.max_concurrent_requests", endpoint_name);
|
||||||
|
let max_concurrent_requests = match max_concurrent_requests {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let pause_after_rate_limit = match source.limits.pause_after_rate_limit_ms {
|
||||||
|
std::option::Option::Some(value) => std::option::Option::Some(std::time::Duration::from_millis(value)),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
let limits = ksp_onchain_transport_lib::HttpRoleLimits::new(requests_per_second, burst_capacity, max_concurrent_requests, pause_after_rate_limit);
|
||||||
|
let mut request_kinds = std::vec::Vec::<ksp_onchain_transport_lib::HttpRequestKind>::with_capacity(source.request_kinds.len());
|
||||||
|
for request_kind in source.request_kinds {
|
||||||
|
request_kinds.push(ksp_onchain_transport_lib::HttpRequestKind::new(request_kind));
|
||||||
|
}
|
||||||
|
roles.push(ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new(source.role),
|
||||||
|
source.enabled,
|
||||||
|
request_kinds,
|
||||||
|
source.priority,
|
||||||
|
limits,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_non_zero(
|
||||||
|
value: std::option::Option<u32>,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
field: &'static str,
|
||||||
|
endpoint_name: &str,
|
||||||
|
) -> ksp_core_lib::Result<std::option::Option<std::num::NonZeroU32>> {
|
||||||
|
let value = match value {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||||
|
};
|
||||||
|
let non_zero = std::num::NonZeroU32::new(value);
|
||||||
|
return match non_zero {
|
||||||
|
std::option::Option::Some(value) => std::result::Result::Ok(std::option::Option::Some(value)),
|
||||||
|
std::option::Option::None => std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective Transport role limit must be greater than zero")
|
||||||
|
.with_context("field", field)
|
||||||
|
.with_context("endpoint_name", endpoint_name),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transport_contract_error(profile: &crate::ResolvedConfigProfile, reason: &'static str, transport_error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||||
|
return effective_error(profile, reason)
|
||||||
|
.with_context("transport_error_domain", transport_error.code().domain())
|
||||||
|
.with_context("transport_error_code", transport_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/transport.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/tests/ownership.rs
|
// file: crates/ksp-config-lib/tests/ownership.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
//! Workspace ownership audits for KSP application configuration boundaries.
|
//! Workspace ownership audits for KSP application configuration boundaries.
|
||||||
|
|
||||||
@@ -239,7 +239,14 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
|
|||||||
std::result::Result::Ok(value) => non_comment_source(value.as_str()),
|
std::result::Result::Ok(value) => non_comment_source(value.as_str()),
|
||||||
std::result::Result::Err(_) => continue,
|
std::result::Result::Err(_) => continue,
|
||||||
};
|
};
|
||||||
for token in ["\".env\"", "\"std.logging.json\"", "\"std.logging.schema.json\"", "\"composite.schema.json\""] {
|
for token in [
|
||||||
|
"\".env\"",
|
||||||
|
"\"std.logging.json\"",
|
||||||
|
"\"std.logging.schema.json\"",
|
||||||
|
"\"std.transport.json\"",
|
||||||
|
"\"std.transport.schema.json\"",
|
||||||
|
"\"composite.schema.json\"",
|
||||||
|
] {
|
||||||
assert!(!source.contains(token), "{} hardcodes Config-managed physical resource {token}; use ksp-config-lib contracts", rust_file.display());
|
assert!(!source.contains(token), "{} hardcodes Config-managed physical resource {token}; use ksp-config-lib contracts", rust_file.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// file: crates/ksp-config-lib/tests/public_api.rs
|
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||||
// version: 16
|
// version: 17
|
||||||
|
|
||||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and
|
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
|
||||||
//! management contracts.
|
//! Logging/Transport adapters and management contracts.
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bootstrap_contract_is_available_from_crate_root() {
|
fn bootstrap_contract_is_available_from_crate_root() {
|
||||||
@@ -80,15 +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(), 3);
|
assert_eq!(descriptors.len(), 5);
|
||||||
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
|
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
assert_eq!(descriptors[0].kind(), ksp_config_lib::ConfigFileKind::Config);
|
assert_eq!(descriptors[0].kind(), ksp_config_lib::ConfigFileKind::Config);
|
||||||
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
||||||
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
assert_eq!(descriptors[1].kind(), ksp_config_lib::ConfigFileKind::Config);
|
||||||
let schema_file_id = descriptors[0].schema_file_id();
|
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
assert!(schema_file_id.is_some(), "public descriptor inventory should preserve schema association");
|
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||||
|
let schema_file_id = descriptors[1].schema_file_id();
|
||||||
|
assert!(schema_file_id.is_some(), "public Transport 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_LOGGING);
|
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,3 +244,13 @@ fn management_contracts_are_available_from_crate_root() {
|
|||||||
let _ = (save_source_candidate, reveal_effective, reveal_dotenv);
|
let _ = (save_source_candidate, reveal_effective, reveal_dotenv);
|
||||||
assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED);
|
assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_adapter_contract_is_available_from_crate_root() {
|
||||||
|
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_transport_config;
|
||||||
|
assert!(std::mem::size_of::<ksp_config_lib::ResolvedTransportConfig>() > 0);
|
||||||
|
assert_eq!(ksp_config_lib::FILE_ID_STD_TRANSPORT, "cfg.std.transport");
|
||||||
|
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT, "schema.std.transport");
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json");
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME, "std.transport.schema.json");
|
||||||
|
}
|
||||||
|
|||||||
32
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
Normal file
32
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// file: crates/ksp-config-lib/tests/transport_devnet_smoke.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Opt-in live Devnet smoke for the Config -> Transport foundation path.
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[ignore = "opt-in live Solana Devnet smoke; performs external network requests"]
|
||||||
|
async fn committed_devnet_transport_profile_reaches_all_foundation_canaries() {
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"))
|
||||||
|
.expect("committed Config roots must be valid");
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::defaults().expect("default Config registry must be valid");
|
||||||
|
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let environment = ksp_config_lib::ConfigEnvironment::load().expect("Config must capture the opt-in smoke environment");
|
||||||
|
let resolved = engine
|
||||||
|
.load_resolved_transport_config(std::option::Option::Some("devnet_public"), &environment)
|
||||||
|
.expect("committed devnet_public Transport profile must resolve");
|
||||||
|
assert_eq!(resolved.profile_id(), "devnet_public");
|
||||||
|
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings()).expect("resolved Transport settings must construct the HTTP pool");
|
||||||
|
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
|
||||||
|
let health = pool.get_health(&role).await.expect("Devnet getHealth smoke must succeed");
|
||||||
|
assert_eq!(health, ksp_onchain_transport_lib::SolanaNodeHealth::Healthy);
|
||||||
|
let genesis_hash = pool.get_genesis_hash(&role).await.expect("Devnet getGenesisHash smoke must succeed");
|
||||||
|
assert!(!genesis_hash.as_str().is_empty());
|
||||||
|
let version = pool.get_version(&role).await.expect("Devnet getVersion smoke must succeed");
|
||||||
|
assert!(!version.solana_core().is_empty());
|
||||||
|
let balance = pool
|
||||||
|
.get_balance(&role, &ksp_core_lib::PRGIDPK_SOLANA_SYSTEM, std::option::Option::None)
|
||||||
|
.await
|
||||||
|
.expect("Devnet getBalance smoke must succeed");
|
||||||
|
assert!(balance.context().slot() > 0);
|
||||||
|
}
|
||||||
@@ -22,4 +22,4 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
40
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
Normal file
40
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"retry": {
|
||||||
|
"max_retries": 4,
|
||||||
|
"initial_backoff_ms": 125,
|
||||||
|
"max_backoff_ms": 2500
|
||||||
|
},
|
||||||
|
"default_profile": "secret_test",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "secret_test",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "fixture_private",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "fixture-provider",
|
||||||
|
"cluster": "fixture-cluster",
|
||||||
|
"url": "${KSP_SECRET_TRANSPORT_TEST_URL:-https://fallback.invalid}",
|
||||||
|
"connect_timeout_ms": 750,
|
||||||
|
"request_timeout_ms": 2500,
|
||||||
|
"max_idle_connections_per_host": 3,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": ["*"],
|
||||||
|
"priority": 7,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 9,
|
||||||
|
"burst_capacity": 12,
|
||||||
|
"max_concurrent_requests": 4,
|
||||||
|
"pause_after_rate_limit_ms": 650
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
||||||
// version: 4
|
// version: 5
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
|
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
|
||||||
@@ -7,19 +7,29 @@ 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<&super::ConfigFileDescriptor> = registry.descriptors().collect();
|
let descriptors: std::vec::Vec<&super::ConfigFileDescriptor> = registry.descriptors().collect();
|
||||||
assert_eq!(descriptors.len(), 3);
|
assert_eq!(descriptors.len(), 5);
|
||||||
assert_eq!(descriptors[0].file_id().as_str(), super::FILE_ID_STD_LOGGING);
|
assert_eq!(descriptors[0].file_id().as_str(), super::FILE_ID_STD_LOGGING);
|
||||||
assert_eq!(descriptors[0].kind(), super::ConfigFileKind::Config);
|
assert_eq!(descriptors[0].kind(), super::ConfigFileKind::Config);
|
||||||
assert_eq!(descriptors[0].filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_FILENAME));
|
assert_eq!(descriptors[0].filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_FILENAME));
|
||||||
let schema_file_id = descriptors[0].schema_file_id();
|
let logging_schema_file_id = descriptors[0].schema_file_id();
|
||||||
assert!(schema_file_id.is_some(), "logging descriptor should expose its validation schema");
|
assert!(logging_schema_file_id.is_some(), "logging descriptor should expose its validation schema");
|
||||||
if let std::option::Option::Some(schema_file_id) = schema_file_id {
|
if let std::option::Option::Some(schema_file_id) = logging_schema_file_id {
|
||||||
assert_eq!(schema_file_id.as_str(), super::FILE_ID_SCHEMA_STD_LOGGING);
|
assert_eq!(schema_file_id.as_str(), super::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
}
|
}
|
||||||
assert_eq!(descriptors[1].file_id().as_str(), super::FILE_ID_SCHEMA_COMPOSITE);
|
assert_eq!(descriptors[1].file_id().as_str(), super::FILE_ID_STD_TRANSPORT);
|
||||||
assert_eq!(descriptors[1].kind(), super::ConfigFileKind::Schema);
|
assert_eq!(descriptors[1].kind(), super::ConfigFileKind::Config);
|
||||||
assert_eq!(descriptors[2].file_id().as_str(), super::FILE_ID_SCHEMA_STD_LOGGING);
|
assert_eq!(descriptors[1].filename(), std::path::Path::new(super::DEFAULT_STD_TRANSPORT_FILENAME));
|
||||||
|
let transport_schema_file_id = descriptors[1].schema_file_id();
|
||||||
|
assert!(transport_schema_file_id.is_some(), "transport descriptor should expose its validation schema");
|
||||||
|
if let std::option::Option::Some(schema_file_id) = transport_schema_file_id {
|
||||||
|
assert_eq!(schema_file_id.as_str(), super::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||||
|
}
|
||||||
|
assert_eq!(descriptors[2].file_id().as_str(), super::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
assert_eq!(descriptors[2].kind(), super::ConfigFileKind::Schema);
|
assert_eq!(descriptors[2].kind(), super::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(descriptors[3].file_id().as_str(), super::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
assert_eq!(descriptors[3].kind(), super::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(descriptors[4].file_id().as_str(), super::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||||
|
assert_eq!(descriptors[4].kind(), super::ConfigFileKind::Schema);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +93,31 @@ fn defaults_register_logging_document_and_schema_with_distinct_roots() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_register_transport_document_and_schema_with_distinct_roots() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
|
let transport_id = super::ConfigFileId::new(super::FILE_ID_STD_TRANSPORT);
|
||||||
|
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||||
|
assert!(transport_id.is_ok(), "transport file_id should be valid: {transport_id:?}");
|
||||||
|
assert!(schema_id.is_ok(), "transport schema file_id should be valid: {schema_id:?}");
|
||||||
|
if let (std::result::Result::Ok(transport_id), std::result::Result::Ok(schema_id)) = (transport_id, schema_id) {
|
||||||
|
let transport = registry.descriptor(&transport_id);
|
||||||
|
let schema = registry.descriptor(&schema_id);
|
||||||
|
assert!(transport.is_ok(), "transport descriptor should exist: {transport:?}");
|
||||||
|
assert!(schema.is_ok(), "transport schema descriptor should exist: {schema:?}");
|
||||||
|
if let (std::result::Result::Ok(transport), std::result::Result::Ok(schema)) = (transport, schema) {
|
||||||
|
assert_eq!(transport.kind(), super::ConfigFileKind::Config);
|
||||||
|
assert_eq!(transport.filename(), std::path::Path::new(super::DEFAULT_STD_TRANSPORT_FILENAME));
|
||||||
|
assert_eq!(transport.schema_file_id(), std::option::Option::Some(&schema_id));
|
||||||
|
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_path_uses_descriptor_kind_to_select_bootstrap_root() {
|
fn resolve_path_uses_descriptor_kind_to_select_bootstrap_root() {
|
||||||
let registry = super::ConfigFileRegistry::defaults();
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
|||||||
202
crates/ksp-config-lib/unit_tests/transport.rs
Normal file
202
crates/ksp-config-lib/unit_tests/transport.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/transport.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_transport_profile_maps_complete_runtime_contract() {
|
||||||
|
let engine = fixture_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "fixture Transport Config should map: {resolved:?}");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
|
||||||
|
assert_eq!(resolved.profile_id(), "secret_test");
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
assert_eq!(resolved.settings().retry().max_retries(), 4);
|
||||||
|
assert_eq!(resolved.settings().retry().initial_backoff(), std::time::Duration::from_millis(125));
|
||||||
|
assert_eq!(resolved.settings().retry().max_backoff(), std::time::Duration::from_millis(2500));
|
||||||
|
assert_eq!(resolved.settings().endpoints().len(), 1);
|
||||||
|
let endpoint = &resolved.settings().endpoints()[0];
|
||||||
|
assert_eq!(endpoint.name(), "fixture_private");
|
||||||
|
assert_eq!(endpoint.provider().as_str(), "fixture-provider");
|
||||||
|
assert_eq!(endpoint.cluster().as_str(), "fixture-cluster");
|
||||||
|
assert_eq!(endpoint.url().as_str(), "https://fallback.invalid");
|
||||||
|
assert_eq!(endpoint.connect_timeout(), std::time::Duration::from_millis(750));
|
||||||
|
assert_eq!(endpoint.request_timeout(), std::time::Duration::from_millis(2500));
|
||||||
|
assert_eq!(endpoint.max_idle_connections_per_host(), std::option::Option::Some(3));
|
||||||
|
assert_eq!(endpoint.roles().len(), 1);
|
||||||
|
let role = &endpoint.roles()[0];
|
||||||
|
assert_eq!(role.role().as_str(), "default");
|
||||||
|
assert!(role.enabled());
|
||||||
|
assert_eq!(role.priority(), 7);
|
||||||
|
assert_eq!(role.request_kinds().len(), 1);
|
||||||
|
assert!(role.request_kinds()[0].is_wildcard());
|
||||||
|
assert_eq!(role.limits().requests_per_second().map(std::num::NonZeroU32::get), std::option::Option::Some(9));
|
||||||
|
assert_eq!(role.limits().burst_capacity().map(std::num::NonZeroU32::get), std::option::Option::Some(12));
|
||||||
|
assert_eq!(role.limits().max_concurrent_requests().map(std::num::NonZeroU32::get), std::option::Option::Some(4));
|
||||||
|
assert_eq!(role.limits().pause_after_rate_limit(), std::option::Option::Some(std::time::Duration::from_millis(650)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_transport_document_maps_default_and_explicit_profiles() {
|
||||||
|
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 default = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||||
|
let mainnet = engine.load_resolved_transport_config(std::option::Option::Some("mainnet_public"), &environment);
|
||||||
|
assert!(default.is_ok(), "committed default Transport profile should map: {default:?}");
|
||||||
|
assert!(mainnet.is_ok(), "committed explicit Transport profile should map: {mainnet:?}");
|
||||||
|
if let std::result::Result::Ok(default) = default {
|
||||||
|
assert_eq!(default.profile_id(), "devnet_public");
|
||||||
|
assert_eq!(default.settings().endpoints()[0].cluster().as_str(), "devnet");
|
||||||
|
assert_eq!(default.settings().endpoints()[0].url().as_str(), "https://api.devnet.solana.com");
|
||||||
|
}
|
||||||
|
if let std::result::Result::Ok(mainnet) = mainnet {
|
||||||
|
assert_eq!(mainnet.profile_id(), "mainnet_public");
|
||||||
|
assert_eq!(mainnet.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||||
|
assert_eq!(mainnet.settings().endpoints()[0].cluster().as_str(), "mainnet-beta");
|
||||||
|
assert_eq!(mainnet.settings().endpoints()[0].url().as_str(), "https://api.mainnet-beta.solana.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_profile_preserves_global_and_profile_origin() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_TRANSPORT);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
assert!(profile.is_ok(), "committed Transport profile should resolve: {profile:?}");
|
||||||
|
if let std::result::Result::Ok(profile) = profile {
|
||||||
|
assert_eq!(profile.origin("retry"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
|
assert_eq!(profile.origin("endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
|
||||||
|
assert_eq!(profile.origin("format_version"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_transport_url_is_runtime_available_but_safe_projection_is_redacted() {
|
||||||
|
let engine = fixture_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "https://secret-provider.invalid/?api-key=transport-secret-canary";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_TRANSPORT_TEST_URL".to_owned(), canary.to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "secret Transport endpoint should map without being rejected: {resolved:?}");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.effective().sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), canary);
|
||||||
|
let safe_url = resolved.effective().safe_value().pointer("/endpoints/0/url").and_then(serde_json::Value::as_str);
|
||||||
|
assert_eq!(safe_url, std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains("transport-secret-canary"));
|
||||||
|
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_secret_url_provenance_uses_process_and_process_beats_dotenv() {
|
||||||
|
let engine = fixture_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_TRANSPORT_TEST_URL".to_owned(), "https://process.invalid".to_owned());
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_SECRET_TRANSPORT_TEST_URL".to_owned(), "https://dotenv.invalid".to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, dotenv);
|
||||||
|
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "secret Transport environment precedence should map: {resolved:?}");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), "https://process.invalid");
|
||||||
|
let provenance = resolved.effective().provenance_at("/endpoints/0/url");
|
||||||
|
assert!(provenance.is_some(), "endpoint URL should retain environment provenance");
|
||||||
|
if let std::option::Option::Some(provenance) = provenance {
|
||||||
|
assert_eq!(provenance.len(), 1);
|
||||||
|
assert_eq!(provenance[0].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||||
|
assert_eq!(provenance[0].variable_name(), std::option::Option::Some("KSP_SECRET_TRANSPORT_TEST_URL"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_secret_transport_url_is_effective_config_error_without_secret_leak() {
|
||||||
|
let engine = fixture_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "transport-invalid-secret-canary";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_TRANSPORT_TEST_URL".to_owned(), canary.to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_err(), "invalid secret-derived endpoint URL must fail effective mapping");
|
||||||
|
if let std::result::Result::Err(error) = resolved {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
let debug = format!("{error:?}");
|
||||||
|
assert!(!debug.contains(canary));
|
||||||
|
assert!(error.context().iter().any(|item| -> bool {
|
||||||
|
return item.key() == "transport_error_code" && item.value() == "invalid_settings";
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures");
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
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 workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
68
crates/ksp-core-lib/tests/workspace_dependencies.rs
Normal file
68
crates/ksp-core-lib/tests/workspace_dependencies.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let parent = manifest_directory.parent();
|
||||||
|
assert!(parent.is_some(), "core crate must have a crates directory parent");
|
||||||
|
let parent = match parent {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return manifest_directory.to_path_buf(),
|
||||||
|
};
|
||||||
|
let root = parent.parent();
|
||||||
|
assert!(root.is_some(), "core crate must have a workspace root");
|
||||||
|
return match root {
|
||||||
|
std::option::Option::Some(value) => value.to_path_buf(),
|
||||||
|
std::option::Option::None => parent.to_path_buf(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_dependency_table_does_not_activate_consumer_features() {
|
||||||
|
let manifest_path = workspace_root().join("Cargo.toml");
|
||||||
|
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
||||||
|
assert!(manifest.is_ok(), "workspace manifest must be readable during integration tests");
|
||||||
|
let manifest = match manifest {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let workspace_dependencies = match manifest.split("[workspace.dependencies]").nth(1) {
|
||||||
|
std::option::Option::Some(tail) => tail.split("[workspace.lints.rust]").next(),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
assert!(workspace_dependencies.is_some(), "workspace dependencies section must exist");
|
||||||
|
let workspace_dependencies = match workspace_dependencies {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
for line in workspace_dependencies.lines() {
|
||||||
|
let content = match line.split('#').next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
for inline_field in content.split(',') {
|
||||||
|
let key = match inline_field.split('=').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim().trim_start_matches('{').trim(),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
assert_ne!(key, "features", "consumer feature activation must stay in member manifests");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_manifest_preserves_ksp_dependency_firewall() {
|
||||||
|
let manifest_path = workspace_root().join("crates/ksp-onchain-transport-lib/Cargo.toml");
|
||||||
|
let manifest = std::fs::read_to_string(manifest_path).expect("transport manifest must be readable during workspace integration tests");
|
||||||
|
for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] {
|
||||||
|
assert!(!manifest.contains(forbidden), "forbidden direct transport dependency detected: {forbidden}");
|
||||||
|
}
|
||||||
|
assert!(manifest.contains("ksp-core-lib"));
|
||||||
|
assert!(manifest.contains("ksp-logging-lib"));
|
||||||
|
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||||
|
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }"));
|
||||||
|
assert!(manifest.contains("[dev-dependencies]"));
|
||||||
|
assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }"));
|
||||||
|
}
|
||||||
121
crates/ksp-core-lib/tests/workspace_logging.rs
Normal file
121
crates/ksp-core-lib/tests/workspace_logging.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// file: crates/ksp-core-lib/tests/workspace_logging.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Workspace-level logging ownership canaries for KSP behavioral crates.
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let parent = manifest_directory.parent();
|
||||||
|
assert!(parent.is_some(), "core crate must have a crates directory parent");
|
||||||
|
let parent = match parent {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return manifest_directory.to_path_buf(),
|
||||||
|
};
|
||||||
|
let root = parent.parent();
|
||||||
|
assert!(root.is_some(), "core crate must have a workspace root");
|
||||||
|
return match root {
|
||||||
|
std::option::Option::Some(value) => value.to_path_buf(),
|
||||||
|
std::option::Option::None => parent.to_path_buf(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_source_files(directory: &std::path::Path) -> std::vec::Vec<std::path::PathBuf> {
|
||||||
|
let mut files = std::vec::Vec::new();
|
||||||
|
let entries = std::fs::read_dir(directory);
|
||||||
|
if entries.is_err() {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return files,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
files.extend(rust_source_files(path.as_path()));
|
||||||
|
} else if path.extension() == std::option::Option::Some(std::ffi::OsStr::new("rs")) {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package_name(manifest: &str) -> std::option::Option<&str> {
|
||||||
|
for line in manifest.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.starts_with("name = ") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return trimmed.split('"').nth(1);
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn behavioral_crates_own_explicit_tracing_targets() {
|
||||||
|
let crates_root = workspace_root().join("crates");
|
||||||
|
let entries = std::fs::read_dir(crates_root.as_path());
|
||||||
|
assert!(entries.is_ok(), "workspace crates directory must be readable");
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let crate_root = entry.path();
|
||||||
|
if !crate_root.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source_root = crate_root.join("src");
|
||||||
|
let source_files = rust_source_files(source_root.as_path());
|
||||||
|
let mut emits_ksp_logs = false;
|
||||||
|
for source_file in source_files.iter() {
|
||||||
|
let source = std::fs::read_to_string(source_file.as_path());
|
||||||
|
assert!(source.is_ok(), "Rust source must be readable: {}", source_file.display());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
if source.contains("ksp_logging_lib::") {
|
||||||
|
emits_ksp_logs = true;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!source.contains("target: env!(\"CARGO_PKG_NAME\")"),
|
||||||
|
"KSP tracing target must be explicit rather than derived from Cargo metadata: {}",
|
||||||
|
source_file.display()
|
||||||
|
);
|
||||||
|
assert!(!source.contains("target: \"ksp-"), "KSP tracing target literals must be owned by src/constants.rs: {}", source_file.display());
|
||||||
|
}
|
||||||
|
if !emits_ksp_logs {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let manifest = std::fs::read_to_string(crate_root.join("Cargo.toml"));
|
||||||
|
assert!(manifest.is_ok(), "behavioral crate manifest must be readable: {}", crate_root.display());
|
||||||
|
let manifest = match manifest {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let package_name = package_name(manifest.as_str());
|
||||||
|
assert!(package_name.is_some(), "behavioral crate package name must be discoverable: {}", crate_root.display());
|
||||||
|
let package_name = match package_name {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let constants_path = source_root.join("constants.rs");
|
||||||
|
let constants = std::fs::read_to_string(constants_path.as_path());
|
||||||
|
assert!(constants.is_ok(), "behavioral crate must own src/constants.rs: {}", crate_root.display());
|
||||||
|
let constants = match constants {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let expected = std::format!("pub(crate) const TRACING_TARGET: &str = \"{package_name}\";");
|
||||||
|
assert!(constants.contains(expected.as_str()), "behavioral crate must own its Cargo-name tracing target: {}", crate_root.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-logging-lib/Cargo.toml
|
# file: crates/ksp-logging-lib/Cargo.toml
|
||||||
# version: 4
|
# version: 5
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-logging-lib"
|
name = "ksp-logging-lib"
|
||||||
@@ -9,12 +9,12 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
tracing.workspace = true
|
tracing = { workspace = true, features = ["std"] }
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber = { workspace = true, features = ["fmt", "json", "ansi"] }
|
||||||
tracing-appender.workspace = true
|
tracing-appender.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio.workspace = true
|
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
22
crates/ksp-onchain-transport-lib/Cargo.toml
Normal file
22
crates/ksp-onchain-transport-lib/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# file: crates/ksp-onchain-transport-lib/Cargo.toml
|
||||||
|
# version: 3
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-onchain-transport-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
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
|
||||||
|
tokio = { workspace = true, features = ["macros", "sync", "time"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["rt"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
135
crates/ksp-onchain-transport-lib/README.md
Normal file
135
crates/ksp-onchain-transport-lib/README.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# `ksp-onchain-transport-lib`
|
||||||
|
|
||||||
|
`ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Sa première surface est le transport HTTP JSON-RPC ; les extensions WebSocket et gRPC sont introduites séparément lorsque leur release les cible.
|
||||||
|
|
||||||
|
## Responsabilités
|
||||||
|
|
||||||
|
La crate possède :
|
||||||
|
|
||||||
|
- les settings runtime HTTP publics ;
|
||||||
|
- les endpoints nommés et leurs metadata provider/cluster ;
|
||||||
|
- les rôles, capabilities/request kinds et priorités ;
|
||||||
|
- la sélection/fairness/fallback du pool ;
|
||||||
|
- les limites RPS/burst/concurrence et le cooldown ;
|
||||||
|
- les deadlines et timeouts ;
|
||||||
|
- le retry/backoff borné et la règle no-resend après dispatch ambigu ;
|
||||||
|
- les enveloppes JSON-RPC 2.0 et leur validation ;
|
||||||
|
- le registre audité des méthodes Solana HTTP ;
|
||||||
|
- l'exécution générique des méthodes standard supportées ;
|
||||||
|
- les wrappers typés explicitement livrés par KSP ;
|
||||||
|
- les snapshots runtime sûrs ;
|
||||||
|
- l'observabilité Transport via `ksp-logging-lib`.
|
||||||
|
|
||||||
|
La crate ne possède ni documents Config, ni persistence Store, ni modèles Program/métier.
|
||||||
|
|
||||||
|
## Frontières de dépendances
|
||||||
|
|
||||||
|
La direction autorisée est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib
|
||||||
|
-> ksp-onchain-transport-lib
|
||||||
|
-> ksp-core-lib
|
||||||
|
-> ksp-logging-lib
|
||||||
|
-> reqwest / tokio / serde
|
||||||
|
```
|
||||||
|
|
||||||
|
La direction inverse est interdite :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-onchain-transport-lib -X-> ksp-config-lib
|
||||||
|
ksp-onchain-transport-lib -X-> Store
|
||||||
|
ksp-onchain-transport-lib -X-> Program
|
||||||
|
ksp-onchain-transport-lib -X-> tracing direct
|
||||||
|
```
|
||||||
|
|
||||||
|
`ksp-config-lib` peut donc charger `std.transport.json` et construire `HttpTransportSettings`, tandis que Transport reste directement utilisable par un consumer qui fournit lui-même ses settings.
|
||||||
|
|
||||||
|
## Surface HTTP standard
|
||||||
|
|
||||||
|
Le registre KSP conserve deux inventaires distincts :
|
||||||
|
|
||||||
|
```text
|
||||||
|
52 méthodes HTTP courantes
|
||||||
|
14 méthodes historiques Deprecated / runtime Removed
|
||||||
|
```
|
||||||
|
|
||||||
|
Le registre porte notamment :
|
||||||
|
|
||||||
|
- catégorie ;
|
||||||
|
- request kind ;
|
||||||
|
- statut documentaire ;
|
||||||
|
- statut runtime ;
|
||||||
|
- forme de requête stable/legacy ;
|
||||||
|
- type d'opération ;
|
||||||
|
- classe de retry ;
|
||||||
|
- remplacement historique éventuel ;
|
||||||
|
- release de couverture typée KSP.
|
||||||
|
|
||||||
|
Les quatre wrappers typés de la foundation sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
getBalance
|
||||||
|
getGenesisHash
|
||||||
|
getHealth
|
||||||
|
getVersion
|
||||||
|
```
|
||||||
|
|
||||||
|
Les autres méthodes courantes peuvent déjà passer par l'exécuteur JSON-RPC standard générique lorsqu'un consumer fournit explicitement descriptor et paramètres JSON. Cette surface raw/générique **ne vaut pas couverture typée** : les wrappers et DTOs typés restants sont introduits selon la matrice HTTP KSP.
|
||||||
|
|
||||||
|
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
|
||||||
|
|
||||||
|
## Résilience
|
||||||
|
|
||||||
|
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
||||||
|
|
||||||
|
1. rôle et capability ;
|
||||||
|
2. priorité croissante ;
|
||||||
|
3. round-robin dans le meilleur tier ;
|
||||||
|
4. RPS/burst ;
|
||||||
|
5. concurrence ;
|
||||||
|
6. cooldown rate-limit ;
|
||||||
|
7. fallback vers les pairs puis les tiers inférieurs ;
|
||||||
|
8. deadline commune à l'opération et ses retries.
|
||||||
|
|
||||||
|
Les retries ne sont autorisés que lorsque la metadata de méthode et l'état de dispatch les rendent sûrs. `WriteSubmission / NeverAfterDispatch` interdit tout resend automatique après un dispatch ambigu.
|
||||||
|
|
||||||
|
Les erreurs JSON-RPC applicatives ne sont pas transformées en retries transport génériques.
|
||||||
|
|
||||||
|
## Sécurité et diagnostics
|
||||||
|
|
||||||
|
Les URLs d'endpoint peuvent contenir des credentials. Elles ne sont donc pas exposées par les `Debug`, snapshots ou logs ordinaires.
|
||||||
|
|
||||||
|
Les `reqwest::Error` attachées comme source sont neutralisées avec `without_url()` avant exposition dans le contrat d'erreur KSP.
|
||||||
|
|
||||||
|
Le target de tracing est possédé explicitement par :
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/constants.rs
|
||||||
|
TRACING_TARGET = "ksp-onchain-transport-lib"
|
||||||
|
```
|
||||||
|
|
||||||
|
La configuration Logging de référence conserve un fichier dédié Transport à niveau `info`. Un niveau `debug`/`trace` ciblé peut être réactivé temporairement via Config lors d'un développement ou diagnostic explicite.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Les tests par défaut sont déterministes et n'exigent pas Internet : fixtures JSON et serveur HTTP local couvrent requêtes, réponses, retry, 429, timeout, redaction et routing.
|
||||||
|
|
||||||
|
Un smoke Devnet live existe côté `ksp-config-lib` afin de tester la chaîne réelle :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config -> std.transport/devnet_public -> HttpTransportPool
|
||||||
|
-> getHealth/getGenesisHash/getVersion/getBalance
|
||||||
|
```
|
||||||
|
|
||||||
|
Il est `ignored` par défaut et doit être exécuté explicitement.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw et inspection runtime ;
|
||||||
|
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — plan et matrice HTTP ;
|
||||||
|
- [`../../docs/validation/003-V0_2_1_ONCHAIN_HTTP.md`](../../docs/validation/003-V0_2_1_ONCHAIN_HTTP.md) — matrice de clôture ;
|
||||||
|
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP.
|
||||||
164
crates/ksp-onchain-transport-lib/USAGE.md
Normal file
164
crates/ksp-onchain-transport-lib/USAGE.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Utilisation de `ksp-onchain-transport-lib`
|
||||||
|
|
||||||
|
Ce guide présente les surfaces publiques destinées aux consumers. Les notes de release restent dans `CHANGELOG.md` et les deltas.
|
||||||
|
|
||||||
|
## 1. Construction directe du runtime
|
||||||
|
|
||||||
|
Transport peut être utilisé sans Config. Le consumer construit les settings publics puis le pool :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let url = match ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://api.devnet.solana.com") {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
|
||||||
|
100,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleLimits::new(None, None, None, None),
|
||||||
|
);
|
||||||
|
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||||
|
"solana_devnet_public",
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::HttpProviderName::new("solana-public"),
|
||||||
|
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
Some(8),
|
||||||
|
vec![role],
|
||||||
|
);
|
||||||
|
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||||
|
vec![endpoint],
|
||||||
|
ksp_onchain_transport_lib::HttpRetrySettings::new(
|
||||||
|
2,
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(settings) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`HttpTransportSettings::validate()` peut être appelé explicitement avant la construction du pool lorsque le consumer veut séparer validation et initialisation.
|
||||||
|
|
||||||
|
## 2. Construction via `ksp-config-lib`
|
||||||
|
|
||||||
|
Lorsque le consumer utilise Config, la direction reste Config -> Transport :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resolved = match engine.load_resolved_transport_config(Some("devnet_public"), &environment) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings()) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Le document standard peut contenir une URL provenant d'un `KSP_SECRET_*`. La valeur réelle est transmise au runtime, mais les projections sûres et `Debug` restent redacted.
|
||||||
|
|
||||||
|
## 3. Appels typés
|
||||||
|
|
||||||
|
Les wrappers typés se trouvent directement sur `HttpTransportPool`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
|
||||||
|
let health = pool.get_health(&role).await;
|
||||||
|
let genesis_hash = pool.get_genesis_hash(&role).await;
|
||||||
|
let version = pool.get_version(&role).await;
|
||||||
|
let balance = pool
|
||||||
|
.get_balance(
|
||||||
|
&role,
|
||||||
|
&ksp_core_lib::PRGIDPK_SOLANA_SYSTEM,
|
||||||
|
Some(&ksp_onchain_transport_lib::GetBalanceConfig::new(
|
||||||
|
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||||
|
None,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
```
|
||||||
|
|
||||||
|
Les types de retour associés sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
SolanaNodeHealth
|
||||||
|
SolanaGenesisHash
|
||||||
|
SolanaNodeVersion
|
||||||
|
GetBalanceResult
|
||||||
|
SolanaRpcContext
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetBalanceResult::value()` renvoie les lamports et `context()` fournit le slot/API version retournés par Solana.
|
||||||
|
|
||||||
|
## 4. Exécution JSON-RPC standard générique
|
||||||
|
|
||||||
|
Une méthode courante auditée peut être appelée via son descriptor :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if let Some(descriptor) = ksp_onchain_transport_lib::find_http_rpc_method("getSlot") {
|
||||||
|
let _result = pool.execute_standard_rpc(&role, descriptor, vec![]).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette API retourne un `serde_json::Value`. Elle est utile pour les consumers techniques et pour préparer les futures surfaces typées, mais elle ne remplace pas le wrapper typé d'une méthode dans la matrice de couverture KSP.
|
||||||
|
|
||||||
|
Avant exécution, `ensure_runtime_supported()` est appliqué. Une méthode historique `Removed` retourne `ERROR_CODE_METHOD_REMOVED` au lieu d'émettre un appel réseau fictif.
|
||||||
|
|
||||||
|
## 5. Sélection et admission sans exécuter la requête
|
||||||
|
|
||||||
|
Pour inspecter le routing :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if let Some(descriptor) = ksp_onchain_transport_lib::find_http_rpc_method("getBalance") {
|
||||||
|
let _selection = pool.select_for_method(&role, descriptor);
|
||||||
|
let _permit = pool.acquire_for_method(&role, descriptor).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans le même bloc, `acquire_for_method()` réserve réellement la capacité RPS/concurrence sous deadline.
|
||||||
|
|
||||||
|
`HttpRequestPermit` détient la capacité de concurrence jusqu'à sa destruction. Aucun verrou synchrone n'est conservé pendant l'attente réseau.
|
||||||
|
|
||||||
|
## 6. Snapshots runtime
|
||||||
|
|
||||||
|
`HttpTransportPool::snapshot()` fournit une vue sûre des endpoints/rôles : disponibilité, limites, requêtes en vol, cooldown restant et compteurs runtime.
|
||||||
|
|
||||||
|
Les URLs d'endpoint n'y apparaissent jamais.
|
||||||
|
|
||||||
|
## 7. Retry et write submissions
|
||||||
|
|
||||||
|
La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`.
|
||||||
|
|
||||||
|
Les reads/simulations classés `RetrySafe` peuvent être réessayés dans le budget configuré lorsqu'une cause transport est explicitement retryable.
|
||||||
|
|
||||||
|
Pour une opération `WriteSubmission / NeverAfterDispatch`, un timeout ou autre résultat ambigu après dispatch arrête la resoumission automatique. Le consumer métier ne doit pas contourner cette protection avec une boucle de retry externe aveugle.
|
||||||
|
|
||||||
|
## 8. Logging
|
||||||
|
|
||||||
|
Les événements Transport utilisent le target :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-onchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne jamais journaliser l'URL complète, un token provider, un body massif, une transaction complète ou une réponse complète.
|
||||||
|
|
||||||
|
La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement.
|
||||||
|
|
||||||
|
## 9. Smoke Devnet opt-in
|
||||||
|
|
||||||
|
Le smoke live est volontairement hors des tests par défaut :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Il charge le profil Config `devnet_public`, construit le pool puis appelle les quatre wrappers typés. Les endpoints publics Solana étant rate-limités et non destinés à la production, un échec réseau externe n'est pas interprété comme un échec déterministe de la suite locale.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"3.1.8","slot":123456789},"value":424242},"id":1}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"jsonrpc":"2.0","result":"GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC","id":1}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"jsonrpc":"2.0","result":"ok","id":1}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"jsonrpc":"2.0","result":{"solana-core":"3.1.8","feature-set":2891131721},"id":1}
|
||||||
505
crates/ksp-onchain-transport-lib/src/client.rs
Normal file
505
crates/ksp-onchain-transport-lib/src/client.rs
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/client.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
/// Passive runtime availability reported for one logical HTTP endpoint or role.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum HttpEndpointAvailability {
|
||||||
|
/// The endpoint or role is administratively disabled and cannot be selected.
|
||||||
|
Disabled,
|
||||||
|
/// The endpoint or role is enabled and currently eligible for selection.
|
||||||
|
Available,
|
||||||
|
/// The endpoint or role is enabled but degraded by recent passive runtime observations.
|
||||||
|
Degraded,
|
||||||
|
/// The endpoint or role is temporarily excluded after provider rate limiting.
|
||||||
|
RateLimited,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe routing and resilience snapshot for one configured endpoint role.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HttpEndpointRoleSnapshot {
|
||||||
|
role: std::string::String,
|
||||||
|
enabled: bool,
|
||||||
|
request_kinds: std::vec::Vec<std::string::String>,
|
||||||
|
priority: u32,
|
||||||
|
availability: crate::HttpEndpointAvailability,
|
||||||
|
requests_per_second: std::option::Option<u32>,
|
||||||
|
burst_capacity: std::option::Option<u32>,
|
||||||
|
max_concurrent_requests: std::option::Option<u32>,
|
||||||
|
in_flight_requests: std::option::Option<u32>,
|
||||||
|
cooldown_remaining: std::option::Option<std::time::Duration>,
|
||||||
|
success_count: u64,
|
||||||
|
failure_count: u64,
|
||||||
|
rate_limit_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpEndpointRoleSnapshot {
|
||||||
|
/// Returns the logical role name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn role(&self) -> &str {
|
||||||
|
return self.role.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the role is enabled.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn enabled(&self) -> bool {
|
||||||
|
return self.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns request-kind descriptors accepted by the role.
|
||||||
|
#[must_use]
|
||||||
|
pub fn request_kinds(&self) -> &[std::string::String] {
|
||||||
|
return self.request_kinds.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the routing priority where lower values are preferred.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn priority(&self) -> u32 {
|
||||||
|
return self.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the passive runtime availability of this role.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn availability(&self) -> crate::HttpEndpointAvailability {
|
||||||
|
return self.availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured requests-per-second limit.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn requests_per_second(&self) -> std::option::Option<u32> {
|
||||||
|
return self.requests_per_second;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured token-bucket burst capacity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn burst_capacity(&self) -> std::option::Option<u32> {
|
||||||
|
return self.burst_capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured maximum concurrent request count.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_concurrent_requests(&self) -> std::option::Option<u32> {
|
||||||
|
return self.max_concurrent_requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of in-flight requests when concurrency is bounded.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn in_flight_requests(&self) -> std::option::Option<u32> {
|
||||||
|
return self.in_flight_requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remaining provider cooldown when this role is rate-limited.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
|
||||||
|
return self.cooldown_remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of successful requests passively recorded for this role.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn success_count(&self) -> u64 {
|
||||||
|
return self.success_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of failed requests passively recorded for this role.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn failure_count(&self) -> u64 {
|
||||||
|
return self.failure_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of provider rate-limit observations recorded for this role.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn rate_limit_count(&self) -> u64 {
|
||||||
|
return self.rate_limit_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe metadata snapshot for one logical HTTP endpoint.
|
||||||
|
///
|
||||||
|
/// Endpoint URLs are intentionally absent because they can contain provider credentials.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HttpEndpointSnapshot {
|
||||||
|
name: std::string::String,
|
||||||
|
provider: std::string::String,
|
||||||
|
cluster: std::string::String,
|
||||||
|
enabled: bool,
|
||||||
|
availability: crate::HttpEndpointAvailability,
|
||||||
|
roles: std::vec::Vec<crate::HttpEndpointRoleSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpEndpointSnapshot {
|
||||||
|
/// Returns the configured endpoint identity.
|
||||||
|
#[must_use]
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
return self.name.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provider(&self) -> &str {
|
||||||
|
return self.provider.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the cluster descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn cluster(&self) -> &str {
|
||||||
|
return self.cluster.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the endpoint is administratively enabled.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn enabled(&self) -> bool {
|
||||||
|
return self.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the passive runtime availability.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn availability(&self) -> crate::HttpEndpointAvailability {
|
||||||
|
return self.availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns safe role snapshots in declaration order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn roles(&self) -> &[crate::HttpEndpointRoleSnapshot] {
|
||||||
|
return self.roles.as_slice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct HttpEndpointHttpResponse {
|
||||||
|
status: u16,
|
||||||
|
retry_after: std::option::Option<std::time::Duration>,
|
||||||
|
body: std::vec::Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpEndpointHttpResponse {
|
||||||
|
pub(crate) const fn status(&self) -> u16 {
|
||||||
|
return self.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const fn retry_after(&self) -> std::option::Option<std::time::Duration> {
|
||||||
|
return self.retry_after;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn body(&self) -> &[u8] {
|
||||||
|
return self.body.as_slice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shareable logical HTTP endpoint client owned by KSP Transport.
|
||||||
|
///
|
||||||
|
/// The underlying `reqwest::Client` owns socket pooling. KSP keeps the configured URL private from diagnostics and exposes only safe routing metadata.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpEndpointClient {
|
||||||
|
inner: std::sync::Arc<HttpEndpointClientInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HttpEndpointClientInner {
|
||||||
|
settings: crate::HttpEndpointSettings,
|
||||||
|
client: reqwest::Client,
|
||||||
|
role_runtimes: std::vec::Vec<std::sync::Arc<crate::resilience::HttpRoleRuntime>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpEndpointClient {
|
||||||
|
/// Builds one logical endpoint client from KSP-owned runtime settings.
|
||||||
|
pub fn new(settings: crate::HttpEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::new_with_notify(settings, std::sync::Arc::new(tokio::sync::Notify::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_with_notify(settings: crate::HttpEndpointSettings, notify: std::sync::Arc<tokio::sync::Notify>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let validation = crate::settings::validate_endpoint_settings(&settings);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let client_result = build_reqwest_client(&settings);
|
||||||
|
let client = match client_result {
|
||||||
|
std::result::Result::Ok(client) => client,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CONNECTION_FAILED, "HTTP endpoint client could not be initialized")
|
||||||
|
.with_context("endpoint_name", settings.name())
|
||||||
|
.with_source(error.without_url()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut role_runtimes = std::vec::Vec::with_capacity(settings.roles().len());
|
||||||
|
for role in settings.roles() {
|
||||||
|
role_runtimes.push(std::sync::Arc::new(crate::resilience::HttpRoleRuntime::new(role, std::sync::Arc::clone(¬ify))));
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = settings.name(),
|
||||||
|
provider = settings.provider().as_str(),
|
||||||
|
cluster = settings.cluster().as_str(),
|
||||||
|
enabled = settings.enabled(),
|
||||||
|
role_count = settings.roles().len(),
|
||||||
|
"created logical HTTP endpoint client"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(Self { inner: std::sync::Arc::new(HttpEndpointClientInner { settings, client, role_runtimes }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the endpoint identity used for safe diagnostics and routing.
|
||||||
|
#[must_use]
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
return self.inner.settings.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provider(&self) -> &crate::HttpProviderName {
|
||||||
|
return self.inner.settings.provider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the cluster descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn cluster(&self) -> &crate::HttpClusterName {
|
||||||
|
return self.inner.settings.cluster();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the endpoint is administratively enabled.
|
||||||
|
#[must_use]
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
return self.inner.settings.enabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured end-to-end request timeout.
|
||||||
|
#[must_use]
|
||||||
|
pub fn request_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.inner.settings.request_timeout();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn post_json_rpc(&self, payload: &str, timeout: std::time::Duration) -> ksp_core_lib::Result<HttpEndpointHttpResponse> {
|
||||||
|
if timeout.is_zero() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, "HTTP JSON-RPC request budget expired before dispatch")
|
||||||
|
.with_context("endpoint_name", self.name()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let send_result = self
|
||||||
|
.inner
|
||||||
|
.client
|
||||||
|
.post(self.inner.settings.url().as_str())
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(payload.to_owned())
|
||||||
|
.timeout(timeout)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
let response = match send_result {
|
||||||
|
std::result::Result::Ok(response) => response,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
|
||||||
|
};
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let retry_after = parse_retry_after(response.headers());
|
||||||
|
let body_result = response.bytes().await;
|
||||||
|
let body = match body_result {
|
||||||
|
std::result::Result::Ok(body) => body.to_vec(),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(HttpEndpointHttpResponse { status, retry_after, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether one enabled role can serve the requested capability structurally.
|
||||||
|
#[must_use]
|
||||||
|
pub fn supports(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> bool {
|
||||||
|
return self.matching_role(role, request_kind).is_some();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a safe endpoint snapshot with no URL or provider credential material.
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(&self) -> crate::HttpEndpointSnapshot {
|
||||||
|
let mut roles = std::vec::Vec::with_capacity(self.inner.settings.roles().len());
|
||||||
|
for (role_index, role) in self.inner.settings.roles().iter().enumerate() {
|
||||||
|
let mut request_kinds = std::vec::Vec::with_capacity(role.request_kinds().len());
|
||||||
|
for request_kind in role.request_kinds() {
|
||||||
|
request_kinds.push(request_kind.as_str().to_owned());
|
||||||
|
}
|
||||||
|
let runtime = self.inner.role_runtimes.get(role_index);
|
||||||
|
let role_snapshot = match runtime {
|
||||||
|
std::option::Option::Some(runtime) => role_snapshot(role, request_kinds, runtime),
|
||||||
|
std::option::Option::None => fallback_role_snapshot(role, request_kinds),
|
||||||
|
};
|
||||||
|
roles.push(role_snapshot);
|
||||||
|
}
|
||||||
|
return crate::HttpEndpointSnapshot {
|
||||||
|
name: self.name().to_owned(),
|
||||||
|
provider: self.provider().as_str().to_owned(),
|
||||||
|
cluster: self.cluster().as_str().to_owned(),
|
||||||
|
enabled: self.enabled(),
|
||||||
|
availability: self.availability(),
|
||||||
|
roles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn matching_role<'a>(
|
||||||
|
&'a self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
) -> std::option::Option<&'a crate::HttpEndpointRoleSettings> {
|
||||||
|
if !self.enabled() {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
for candidate_role in self.inner.settings.roles() {
|
||||||
|
if !candidate_role.enabled() || candidate_role.role() != role {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for capability in candidate_role.request_kinds() {
|
||||||
|
if capability.is_wildcard() || capability == request_kind {
|
||||||
|
return std::option::Option::Some(candidate_role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn matching_role_runtime(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
) -> std::option::Option<(u32, std::sync::Arc<crate::resilience::HttpRoleRuntime>)> {
|
||||||
|
if !self.enabled() {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
for (role_index, candidate_role) in self.inner.settings.roles().iter().enumerate() {
|
||||||
|
if !candidate_role.enabled() || candidate_role.role() != role {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut handles = false;
|
||||||
|
for capability in candidate_role.request_kinds() {
|
||||||
|
if capability.is_wildcard() || capability == request_kind {
|
||||||
|
handles = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !handles {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let runtime = self.inner.role_runtimes.get(role_index);
|
||||||
|
if let std::option::Option::Some(runtime) = runtime {
|
||||||
|
return std::option::Option::Some((candidate_role.priority(), std::sync::Arc::clone(runtime)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn availability(&self) -> crate::HttpEndpointAvailability {
|
||||||
|
if !self.enabled() {
|
||||||
|
return crate::HttpEndpointAvailability::Disabled;
|
||||||
|
}
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let mut enabled_role_count = 0_usize;
|
||||||
|
let mut rate_limited_count = 0_usize;
|
||||||
|
let mut degraded = false;
|
||||||
|
for (role_index, role) in self.inner.settings.roles().iter().enumerate() {
|
||||||
|
if !role.enabled() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
enabled_role_count = enabled_role_count.saturating_add(1);
|
||||||
|
let runtime = self.inner.role_runtimes.get(role_index);
|
||||||
|
let availability = match runtime {
|
||||||
|
std::option::Option::Some(runtime) => runtime.availability(now),
|
||||||
|
std::option::Option::None => crate::HttpEndpointAvailability::Degraded,
|
||||||
|
};
|
||||||
|
if availability == crate::HttpEndpointAvailability::RateLimited {
|
||||||
|
rate_limited_count = rate_limited_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
if availability == crate::HttpEndpointAvailability::Degraded || availability == crate::HttpEndpointAvailability::RateLimited {
|
||||||
|
degraded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if enabled_role_count > 0 && rate_limited_count == enabled_role_count {
|
||||||
|
return crate::HttpEndpointAvailability::RateLimited;
|
||||||
|
}
|
||||||
|
if degraded || enabled_role_count == 0 {
|
||||||
|
return crate::HttpEndpointAvailability::Degraded;
|
||||||
|
}
|
||||||
|
return crate::HttpEndpointAvailability::Available;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for HttpEndpointClient {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.debug_struct("HttpEndpointClient").field("snapshot", &self.snapshot()).finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_snapshot(
|
||||||
|
role: &crate::HttpEndpointRoleSettings,
|
||||||
|
request_kinds: std::vec::Vec<std::string::String>,
|
||||||
|
runtime: &crate::resilience::HttpRoleRuntime,
|
||||||
|
) -> crate::HttpEndpointRoleSnapshot {
|
||||||
|
let availability = if role.enabled() { runtime.availability(std::time::Instant::now()) } else { crate::HttpEndpointAvailability::Disabled };
|
||||||
|
return crate::HttpEndpointRoleSnapshot {
|
||||||
|
role: role.role().as_str().to_owned(),
|
||||||
|
enabled: role.enabled(),
|
||||||
|
request_kinds,
|
||||||
|
priority: role.priority(),
|
||||||
|
availability,
|
||||||
|
requests_per_second: role.limits().requests_per_second().map(|value| return value.get()),
|
||||||
|
burst_capacity: role.limits().burst_capacity().map(|value| return value.get()),
|
||||||
|
max_concurrent_requests: runtime.max_concurrent_requests(),
|
||||||
|
in_flight_requests: runtime.in_flight_requests(),
|
||||||
|
cooldown_remaining: runtime.cooldown_remaining(),
|
||||||
|
success_count: runtime.success_count(),
|
||||||
|
failure_count: runtime.failure_count(),
|
||||||
|
rate_limit_count: runtime.rate_limit_count(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_role_snapshot(role: &crate::HttpEndpointRoleSettings, request_kinds: std::vec::Vec<std::string::String>) -> crate::HttpEndpointRoleSnapshot {
|
||||||
|
return crate::HttpEndpointRoleSnapshot {
|
||||||
|
role: role.role().as_str().to_owned(),
|
||||||
|
enabled: role.enabled(),
|
||||||
|
request_kinds,
|
||||||
|
priority: role.priority(),
|
||||||
|
availability: crate::HttpEndpointAvailability::Degraded,
|
||||||
|
requests_per_second: role.limits().requests_per_second().map(|value| return value.get()),
|
||||||
|
burst_capacity: role.limits().burst_capacity().map(|value| return value.get()),
|
||||||
|
max_concurrent_requests: role.limits().max_concurrent_requests().map(|value| return value.get()),
|
||||||
|
in_flight_requests: std::option::Option::None,
|
||||||
|
cooldown_remaining: std::option::Option::None,
|
||||||
|
success_count: 0,
|
||||||
|
failure_count: 0,
|
||||||
|
rate_limit_count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_reqwest_error(endpoint_name: &str, error: reqwest::Error) -> ksp_core_lib::Error {
|
||||||
|
let code = if error.is_timeout() {
|
||||||
|
crate::ERROR_CODE_TIMEOUT
|
||||||
|
} else if error.is_connect() {
|
||||||
|
crate::ERROR_CODE_HTTP_CONNECTION_FAILED
|
||||||
|
} else {
|
||||||
|
crate::ERROR_CODE_HTTP_REQUEST_FAILED
|
||||||
|
};
|
||||||
|
return ksp_core_lib::Error::new(code, "HTTP JSON-RPC request failed").with_context("endpoint_name", endpoint_name).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(text) => text.trim(),
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let seconds = match text.parse::<u64>() {
|
||||||
|
std::result::Result::Ok(seconds) => seconds,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(std::time::Duration::from_secs(seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::Result<reqwest::Client, reqwest::Error> {
|
||||||
|
let mut builder = reqwest::Client::builder()
|
||||||
|
.connect_timeout(settings.connect_timeout())
|
||||||
|
.timeout(settings.request_timeout())
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.no_proxy()
|
||||||
|
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")));
|
||||||
|
if let std::option::Option::Some(max_idle) = settings.max_idle_connections_per_host() {
|
||||||
|
builder = builder.pool_max_idle_per_host(max_idle);
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/client.rs"]
|
||||||
|
mod tests;
|
||||||
7
crates/ksp-onchain-transport-lib/src/constants.rs
Normal file
7
crates/ksp-onchain-transport-lib/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/constants.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Transport-owned tracing constants.
|
||||||
|
|
||||||
|
/// Owning tracing target for events emitted by the on-chain transport crate.
|
||||||
|
pub(crate) const TRACING_TARGET: &str = "ksp-onchain-transport-lib";
|
||||||
27
crates/ksp-onchain-transport-lib/src/error.rs
Normal file
27
crates/ksp-onchain-transport-lib/src/error.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Error code used when HTTP transport runtime settings are invalid.
|
||||||
|
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_settings");
|
||||||
|
/// Error code used when no logical endpoint can satisfy a request.
|
||||||
|
pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
|
||||||
|
/// Error code used when an HTTP connection cannot be established.
|
||||||
|
pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_connection_failed");
|
||||||
|
/// Error code used when an HTTP request fails after a connection exists.
|
||||||
|
pub const ERROR_CODE_HTTP_REQUEST_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_request_failed");
|
||||||
|
/// Error code used when a transport deadline expires.
|
||||||
|
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
||||||
|
/// Error code used when an endpoint or provider rate-limits a request.
|
||||||
|
pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rate_limited");
|
||||||
|
/// Error code used when a JSON-RPC request cannot be encoded.
|
||||||
|
pub const ERROR_CODE_JSON_ENCODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_encode_failed");
|
||||||
|
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
|
||||||
|
pub const ERROR_CODE_JSON_DECODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_decode_failed");
|
||||||
|
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
|
||||||
|
pub const ERROR_CODE_JSON_RPC_PROTOCOL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_rpc_protocol_invalid");
|
||||||
|
/// Error code used when a remote JSON-RPC endpoint returns an application-level RPC error.
|
||||||
|
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
|
||||||
|
/// Error code used when a historically documented RPC method is no longer supported by the targeted runtime.
|
||||||
|
pub const ERROR_CODE_METHOD_REMOVED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "method_removed");
|
||||||
|
/// Error code used when a decoded response cannot satisfy the KSP transport contract expected by the caller.
|
||||||
|
pub const ERROR_CODE_INVALID_RESPONSE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_response");
|
||||||
232
crates/ksp-onchain-transport-lib/src/executor.rs
Normal file
232
crates/ksp-onchain-transport-lib/src/executor.rs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/executor.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
const HTTP_REQUEST_TIMEOUT: u16 = 408;
|
||||||
|
const HTTP_TOO_MANY_REQUESTS: u16 = 429;
|
||||||
|
const HTTP_INTERNAL_SERVER_ERROR: u16 = 500;
|
||||||
|
const HTTP_BAD_GATEWAY: u16 = 502;
|
||||||
|
const HTTP_SERVICE_UNAVAILABLE: u16 = 503;
|
||||||
|
const HTTP_GATEWAY_TIMEOUT: u16 = 504;
|
||||||
|
|
||||||
|
impl crate::HttpTransportPool {
|
||||||
|
/// Executes one audited standard Solana HTTP JSON-RPC method through KSP routing, admission and bounded retry policy.
|
||||||
|
///
|
||||||
|
/// This generic transport surface intentionally returns the raw JSON result. Typed method coverage remains explicit and is provided separately by
|
||||||
|
/// method-specific KSP adapters.
|
||||||
|
pub async fn execute_standard_rpc(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
method: &crate::HttpRpcMethodDescriptor,
|
||||||
|
params: std::vec::Vec<serde_json::Value>,
|
||||||
|
) -> ksp_core_lib::Result<serde_json::Value> {
|
||||||
|
let support = method.ensure_runtime_supported();
|
||||||
|
if let std::result::Result::Err(error) = support {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let request_id = self.next_request_id();
|
||||||
|
let request_result = crate::JsonRpcRequest::new(request_id, method.method(), params);
|
||||||
|
let request = match request_result {
|
||||||
|
std::result::Result::Ok(request) => request,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let payload_result = request.to_json_string();
|
||||||
|
let payload = match payload_result {
|
||||||
|
std::result::Result::Ok(payload) => payload,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let request_kind = crate::HttpRequestKind::new(method.request_kind());
|
||||||
|
let timeout_result = self.common_request_timeout(role, &request_kind);
|
||||||
|
let timeout = match timeout_result {
|
||||||
|
std::result::Result::Ok(timeout) => timeout,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let deadline = match started.checked_add(timeout) {
|
||||||
|
std::option::Option::Some(deadline) => deadline,
|
||||||
|
std::option::Option::None => return execution_timeout(method, "HTTP JSON-RPC execution deadline could not be represented"),
|
||||||
|
};
|
||||||
|
let mut completed_retries = 0_u32;
|
||||||
|
loop {
|
||||||
|
let remaining = remaining_budget(deadline);
|
||||||
|
if remaining.is_zero() {
|
||||||
|
return execution_timeout(method, "HTTP JSON-RPC execution deadline expired before transport attempt");
|
||||||
|
}
|
||||||
|
let permit_result = self.acquire_for_request_kind_with_timeout(role, &request_kind, remaining).await;
|
||||||
|
let permit = match permit_result {
|
||||||
|
std::result::Result::Ok(permit) => permit,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let send_timeout = std::cmp::min(remaining_budget(deadline), permit.client().request_timeout());
|
||||||
|
let response_result = permit.client().post_json_rpc(payload.as_str(), send_timeout).await;
|
||||||
|
let response = match response_result {
|
||||||
|
std::result::Result::Ok(response) => response,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
permit.record_failure();
|
||||||
|
let cause = retry_cause_for_error(&error);
|
||||||
|
let dispatch_state = dispatch_state_for_error(&error);
|
||||||
|
let decision =
|
||||||
|
crate::evaluate_transport_retry(method, self.retry_settings(), cause, dispatch_state, completed_retries, std::option::Option::None);
|
||||||
|
drop(permit);
|
||||||
|
if let std::option::Option::Some(delay) = decision.delay() {
|
||||||
|
let waited = wait_retry_delay(delay, deadline).await;
|
||||||
|
if waited {
|
||||||
|
completed_retries = completed_retries.saturating_add(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return execution_timeout(method, "HTTP JSON-RPC retry delay exceeded the common request deadline");
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let status = response.status();
|
||||||
|
if status == HTTP_TOO_MANY_REQUESTS {
|
||||||
|
let provider_retry_after = response.retry_after();
|
||||||
|
permit.record_rate_limited(provider_retry_after);
|
||||||
|
let decision = crate::evaluate_transport_retry(
|
||||||
|
method,
|
||||||
|
self.retry_settings(),
|
||||||
|
crate::HttpRetryCause::RateLimited,
|
||||||
|
crate::HttpDispatchState::DispatchedAmbiguous,
|
||||||
|
completed_retries,
|
||||||
|
provider_retry_after,
|
||||||
|
);
|
||||||
|
drop(permit);
|
||||||
|
if let std::option::Option::Some(delay) = decision.delay() {
|
||||||
|
let waited = wait_retry_delay(delay, deadline).await;
|
||||||
|
if waited {
|
||||||
|
completed_retries = completed_retries.saturating_add(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return execution_timeout(method, "HTTP JSON-RPC rate-limit retry exceeded the common request deadline");
|
||||||
|
}
|
||||||
|
return rate_limited_error(method, provider_retry_after);
|
||||||
|
}
|
||||||
|
if is_temporary_http_status(status) {
|
||||||
|
permit.record_failure();
|
||||||
|
let decision = crate::evaluate_transport_retry(
|
||||||
|
method,
|
||||||
|
self.retry_settings(),
|
||||||
|
crate::HttpRetryCause::TemporaryHttp,
|
||||||
|
crate::HttpDispatchState::DispatchedAmbiguous,
|
||||||
|
completed_retries,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
drop(permit);
|
||||||
|
if let std::option::Option::Some(delay) = decision.delay() {
|
||||||
|
let waited = wait_retry_delay(delay, deadline).await;
|
||||||
|
if waited {
|
||||||
|
completed_retries = completed_retries.saturating_add(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return execution_timeout(method, "HTTP JSON-RPC temporary-status retry exceeded the common request deadline");
|
||||||
|
}
|
||||||
|
return http_status_error(method, status);
|
||||||
|
}
|
||||||
|
if !(200..300).contains(&status) {
|
||||||
|
permit.record_success();
|
||||||
|
return http_status_error(method, status);
|
||||||
|
}
|
||||||
|
let response_text = match std::str::from_utf8(response.body()) {
|
||||||
|
std::result::Result::Ok(text) => text,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
permit.record_failure();
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "HTTP JSON-RPC response body is not valid UTF-8")
|
||||||
|
.with_context("rpc_method", method.method())
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let parsed_result = crate::parse_json_rpc_response_text(response_text, request_id);
|
||||||
|
let parsed = match parsed_result {
|
||||||
|
std::result::Result::Ok(parsed) => parsed,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
permit.record_failure();
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
permit.record_success();
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = permit.selection().endpoint_name(),
|
||||||
|
role = permit.selection().role().as_str(),
|
||||||
|
rpc_method = method.method(),
|
||||||
|
request_id,
|
||||||
|
completed_retries,
|
||||||
|
http_status = status,
|
||||||
|
"completed Solana HTTP JSON-RPC request"
|
||||||
|
);
|
||||||
|
return parsed.into_result();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_cause_for_error(error: &ksp_core_lib::Error) -> crate::HttpRetryCause {
|
||||||
|
if error.code() == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
|
||||||
|
return crate::HttpRetryCause::Connection;
|
||||||
|
}
|
||||||
|
if error.code() == crate::ERROR_CODE_TIMEOUT {
|
||||||
|
return crate::HttpRetryCause::Timeout;
|
||||||
|
}
|
||||||
|
return crate::HttpRetryCause::Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_state_for_error(error: &ksp_core_lib::Error) -> crate::HttpDispatchState {
|
||||||
|
if error.code() == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
|
||||||
|
return crate::HttpDispatchState::NotDispatched;
|
||||||
|
}
|
||||||
|
return crate::HttpDispatchState::DispatchedAmbiguous;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn is_temporary_http_status(status: u16) -> bool {
|
||||||
|
return status == HTTP_REQUEST_TIMEOUT
|
||||||
|
|| status == HTTP_INTERNAL_SERVER_ERROR
|
||||||
|
|| status == HTTP_BAD_GATEWAY
|
||||||
|
|| status == HTTP_SERVICE_UNAVAILABLE
|
||||||
|
|| status == HTTP_GATEWAY_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remaining_budget(deadline: std::time::Instant) -> std::time::Duration {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return std::time::Duration::ZERO;
|
||||||
|
}
|
||||||
|
return deadline.duration_since(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_retry_delay(delay: std::time::Duration, deadline: std::time::Instant) -> bool {
|
||||||
|
let remaining = remaining_budget(deadline);
|
||||||
|
if remaining.is_zero() || delay >= remaining {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
return std::time::Instant::now() < deadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execution_timeout(method: &crate::HttpRpcMethodDescriptor, message: &str) -> ksp_core_lib::Result<serde_json::Value> {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message).with_context("rpc_method", method.method()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rate_limited_error(
|
||||||
|
method: &crate::HttpRpcMethodDescriptor,
|
||||||
|
provider_retry_after: std::option::Option<std::time::Duration>,
|
||||||
|
) -> ksp_core_lib::Result<serde_json::Value> {
|
||||||
|
let mut error = ksp_core_lib::Error::new(crate::ERROR_CODE_RATE_LIMITED, "Solana HTTP endpoint rate-limited the JSON-RPC request")
|
||||||
|
.with_context("rpc_method", method.method());
|
||||||
|
if let std::option::Option::Some(delay) = provider_retry_after {
|
||||||
|
error = error.with_context("retry_after_seconds", delay.as_secs().to_string());
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_status_error(method: &crate::HttpRpcMethodDescriptor, status: u16) -> ksp_core_lib::Result<serde_json::Value> {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "Solana HTTP endpoint returned an unsuccessful status")
|
||||||
|
.with_context("rpc_method", method.method())
|
||||||
|
.with_context("http_status", status.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/executor.rs"]
|
||||||
|
mod tests;
|
||||||
280
crates/ksp-onchain-transport-lib/src/json_rpc.rs
Normal file
280
crates/ksp-onchain-transport-lib/src/json_rpc.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/json_rpc.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
const JSON_RPC_VERSION: &str = "2.0";
|
||||||
|
|
||||||
|
/// JSON-RPC 2.0 HTTP request envelope emitted by KSP.
|
||||||
|
#[derive(Clone, PartialEq, serde::Serialize)]
|
||||||
|
pub struct JsonRpcRequest {
|
||||||
|
jsonrpc: &'static str,
|
||||||
|
id: u64,
|
||||||
|
method: std::string::String,
|
||||||
|
params: std::vec::Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcRequest {
|
||||||
|
/// Creates a JSON-RPC 2.0 request with a KSP-owned numeric identifier.
|
||||||
|
pub fn new(id: u64, method: impl std::convert::Into<std::string::String>, params: std::vec::Vec<serde_json::Value>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let method = method.into();
|
||||||
|
if method.trim().is_empty() || method.trim() != method {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, "JSON-RPC method must be a non-empty trimmed string")
|
||||||
|
.with_context("field", "method"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { jsonrpc: JSON_RPC_VERSION, id, method, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the numeric request identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> u64 {
|
||||||
|
return self.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the RPC method name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn method(&self) -> &str {
|
||||||
|
return self.method.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns ordered request parameters.
|
||||||
|
#[must_use]
|
||||||
|
pub fn params(&self) -> &[serde_json::Value] {
|
||||||
|
return self.params.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes the request into compact JSON text.
|
||||||
|
pub fn to_json_string(&self) -> ksp_core_lib::Result<std::string::String> {
|
||||||
|
let serialization_result = serde_json::to_string(self);
|
||||||
|
return match serialization_result {
|
||||||
|
std::result::Result::Ok(text) => std::result::Result::Ok(text),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_ENCODE_FAILED, "cannot encode JSON-RPC request")
|
||||||
|
.with_context("method", self.method())
|
||||||
|
.with_source(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for JsonRpcRequest {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("JsonRpcRequest")
|
||||||
|
.field("jsonrpc", &self.jsonrpc)
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("method", &self.method)
|
||||||
|
.field("param_count", &self.params.len())
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
||||||
|
#[derive(Clone, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct JsonRpcErrorObject {
|
||||||
|
code: i64,
|
||||||
|
message: std::string::String,
|
||||||
|
#[serde(default)]
|
||||||
|
data: std::option::Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcErrorObject {
|
||||||
|
/// Returns the remote JSON-RPC application error code.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(&self) -> i64 {
|
||||||
|
return self.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remote human-readable RPC error message.
|
||||||
|
#[must_use]
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
return self.message.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns optional provider-supplied error data.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn data(&self) -> std::option::Option<&serde_json::Value> {
|
||||||
|
return self.data.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for JsonRpcErrorObject {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("JsonRpcErrorObject")
|
||||||
|
.field("code", &self.code)
|
||||||
|
.field("message", &"<redacted>")
|
||||||
|
.field("data", &if self.data.is_some() { "present" } else { "absent" })
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated JSON-RPC 2.0 success response.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct JsonRpcSuccessResponse {
|
||||||
|
id: u64,
|
||||||
|
result: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcSuccessResponse {
|
||||||
|
/// Returns the echoed request identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> u64 {
|
||||||
|
return self.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw JSON result, including JSON `null` when the method legitimately returns it.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn result(&self) -> &serde_json::Value {
|
||||||
|
return &self.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the response and returns its raw JSON result.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_result(self) -> serde_json::Value {
|
||||||
|
return self.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for JsonRpcSuccessResponse {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.debug_struct("JsonRpcSuccessResponse").field("id", &self.id).field("result", &"<omitted>").finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated JSON-RPC 2.0 error response.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct JsonRpcErrorResponse {
|
||||||
|
id: u64,
|
||||||
|
error: crate::JsonRpcErrorObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcErrorResponse {
|
||||||
|
/// Returns the echoed request identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> u64 {
|
||||||
|
return self.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remote JSON-RPC application error payload.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn error(&self) -> &crate::JsonRpcErrorObject {
|
||||||
|
return &self.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated JSON-RPC 2.0 HTTP response preserving success and application-error payloads separately.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum JsonRpcResponse {
|
||||||
|
/// Successful response containing a raw method result.
|
||||||
|
Success(crate::JsonRpcSuccessResponse),
|
||||||
|
/// Application-level JSON-RPC error returned by the remote endpoint.
|
||||||
|
Error(crate::JsonRpcErrorResponse),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcResponse {
|
||||||
|
/// Converts a validated response into the raw success result or a KSP application-error classification.
|
||||||
|
pub fn into_result(self) -> ksp_core_lib::Result<serde_json::Value> {
|
||||||
|
return match self {
|
||||||
|
Self::Success(success) => std::result::Result::Ok(success.into_result()),
|
||||||
|
Self::Error(error_response) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_RPC_APPLICATION_ERROR, "Solana JSON-RPC endpoint returned an application error")
|
||||||
|
.with_context("rpc_code", error_response.error().code().to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses and validates a JSON-RPC HTTP response from UTF-8 JSON text.
|
||||||
|
pub fn parse_json_rpc_response_text(text: &str, expected_id: u64) -> ksp_core_lib::Result<crate::JsonRpcResponse> {
|
||||||
|
let decode_result = serde_json::from_str::<serde_json::Value>(text);
|
||||||
|
let value = match decode_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_JSON_DECODE_FAILED, "cannot decode JSON-RPC response as JSON").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::parse_json_rpc_response_value(value, expected_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a decoded JSON value as one JSON-RPC HTTP response for the expected KSP request identifier.
|
||||||
|
pub fn parse_json_rpc_response_value(value: serde_json::Value, expected_id: u64) -> ksp_core_lib::Result<crate::JsonRpcResponse> {
|
||||||
|
let object = match value.as_object() {
|
||||||
|
std::option::Option::Some(object) => object,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC response must be an object", "response");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let version = match object.get("jsonrpc") {
|
||||||
|
std::option::Option::Some(serde_json::Value::String(version)) => version.as_str(),
|
||||||
|
std::option::Option::Some(_) => {
|
||||||
|
return protocol_error("JSON-RPC version must be a string", "jsonrpc");
|
||||||
|
},
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC response is missing its version", "jsonrpc");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if version != JSON_RPC_VERSION {
|
||||||
|
return protocol_error("JSON-RPC version must be exactly 2.0", "jsonrpc");
|
||||||
|
}
|
||||||
|
let response_id = match object.get("id") {
|
||||||
|
std::option::Option::Some(id) => match id.as_u64() {
|
||||||
|
std::option::Option::Some(id) => id,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC response id must be an unsigned integer", "id");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC response is missing its id", "id");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if response_id != expected_id {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, "JSON-RPC response id does not match the request")
|
||||||
|
.with_context("expected_id", expected_id.to_string())
|
||||||
|
.with_context("actual_id", response_id.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let has_result = object.contains_key("result");
|
||||||
|
let has_error = object.contains_key("error");
|
||||||
|
if has_result == has_error {
|
||||||
|
return protocol_error("JSON-RPC response must contain exactly one of result or error", "response");
|
||||||
|
}
|
||||||
|
if has_result {
|
||||||
|
let result = match object.get("result") {
|
||||||
|
std::option::Option::Some(result) => result.clone(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC result field disappeared during validation", "result");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::JsonRpcResponse::Success(crate::JsonRpcSuccessResponse { id: response_id, result }));
|
||||||
|
}
|
||||||
|
let error_value = match object.get("error") {
|
||||||
|
std::option::Option::Some(error) => error.clone(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC error field disappeared during validation", "error");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let error_decode_result = serde_json::from_value::<crate::JsonRpcErrorObject>(error_value);
|
||||||
|
let error = match error_decode_result {
|
||||||
|
std::result::Result::Ok(error) => error,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, "JSON-RPC error object is invalid")
|
||||||
|
.with_context("field", "error")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::JsonRpcResponse::Error(crate::JsonRpcErrorResponse { id: response_id, error }));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_error(message: &str, field: &str) -> ksp_core_lib::Result<crate::JsonRpcResponse> {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, message).with_context("field", field));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/json_rpc.rs"]
|
||||||
|
mod tests;
|
||||||
145
crates/ksp-onchain-transport-lib/src/lib.rs
Normal file
145
crates/ksp-onchain-transport-lib/src/lib.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||||
|
// version: 6
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! KSP-owned Solana on-chain transport foundation.
|
||||||
|
//!
|
||||||
|
//! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains
|
||||||
|
//! independent from `ksp-config-lib`, Store and Program layers. `ksp-config-lib` now constructs these public settings through its one-way Config ->
|
||||||
|
//! Transport adapter without creating a reverse dependency. Logical endpoint clients, priority-aware pools, bounded admission limits and retry/no-resend policy
|
||||||
|
//! are available. The first typed Solana HTTP canaries execute real
|
||||||
|
//! JSON-RPC requests while the remaining audited methods stay staged by subsequent `0.2.x` releases.
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod constants;
|
||||||
|
mod error;
|
||||||
|
mod executor;
|
||||||
|
mod json_rpc;
|
||||||
|
mod pool;
|
||||||
|
mod resilience;
|
||||||
|
mod rpc_canary;
|
||||||
|
mod rpc_method;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET;
|
||||||
|
|
||||||
|
/// Passive runtime availability reported for one logical HTTP endpoint.
|
||||||
|
pub use self::client::HttpEndpointAvailability;
|
||||||
|
/// Shareable logical HTTP endpoint client owned by KSP Transport.
|
||||||
|
pub use self::client::HttpEndpointClient;
|
||||||
|
/// Safe routing snapshot for one configured endpoint role.
|
||||||
|
pub use self::client::HttpEndpointRoleSnapshot;
|
||||||
|
/// Safe metadata snapshot for one logical HTTP endpoint.
|
||||||
|
pub use self::client::HttpEndpointSnapshot;
|
||||||
|
/// Error code used when no logical endpoint can satisfy a request.
|
||||||
|
pub use self::error::ERROR_CODE_ENDPOINT_SELECTION_FAILED;
|
||||||
|
/// Error code used when an HTTP connection cannot be established.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
|
||||||
|
/// Error code used when an HTTP request fails after connection establishment.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED;
|
||||||
|
/// Error code used when a decoded response cannot satisfy the expected KSP transport contract.
|
||||||
|
pub use self::error::ERROR_CODE_INVALID_RESPONSE;
|
||||||
|
/// Error code used when HTTP transport runtime settings are invalid.
|
||||||
|
pub use self::error::ERROR_CODE_INVALID_SETTINGS;
|
||||||
|
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_DECODE_FAILED;
|
||||||
|
/// Error code used when a JSON-RPC request cannot be encoded.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_ENCODE_FAILED;
|
||||||
|
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID;
|
||||||
|
/// Error code used when a historically documented RPC method has been removed from the targeted runtime.
|
||||||
|
pub use self::error::ERROR_CODE_METHOD_REMOVED;
|
||||||
|
/// Error code used when an endpoint or provider rate-limits a request.
|
||||||
|
pub use self::error::ERROR_CODE_RATE_LIMITED;
|
||||||
|
/// Error code used when a remote endpoint returns an application-level JSON-RPC error.
|
||||||
|
pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR;
|
||||||
|
/// Error code used when a transport deadline expires.
|
||||||
|
pub use self::error::ERROR_CODE_TIMEOUT;
|
||||||
|
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
||||||
|
pub use self::json_rpc::JsonRpcErrorObject;
|
||||||
|
/// Validated JSON-RPC 2.0 error response.
|
||||||
|
pub use self::json_rpc::JsonRpcErrorResponse;
|
||||||
|
/// JSON-RPC 2.0 HTTP request envelope emitted by KSP.
|
||||||
|
pub use self::json_rpc::JsonRpcRequest;
|
||||||
|
/// Validated JSON-RPC 2.0 HTTP response.
|
||||||
|
pub use self::json_rpc::JsonRpcResponse;
|
||||||
|
/// Validated JSON-RPC 2.0 success response.
|
||||||
|
pub use self::json_rpc::JsonRpcSuccessResponse;
|
||||||
|
/// Parses and validates a JSON-RPC HTTP response from UTF-8 JSON text.
|
||||||
|
pub use self::json_rpc::parse_json_rpc_response_text;
|
||||||
|
/// Validates a decoded JSON value as one JSON-RPC HTTP response.
|
||||||
|
pub use self::json_rpc::parse_json_rpc_response_value;
|
||||||
|
/// Result of one logical endpoint selection.
|
||||||
|
pub use self::pool::HttpEndpointSelection;
|
||||||
|
/// Runtime admission permit for one HTTP request.
|
||||||
|
pub use self::pool::HttpRequestPermit;
|
||||||
|
/// Shareable logical HTTP endpoint pool with priority routing, admission limits and bounded deadlines.
|
||||||
|
pub use self::pool::HttpTransportPool;
|
||||||
|
/// Safe snapshot of the logical HTTP endpoint pool.
|
||||||
|
pub use self::pool::HttpTransportPoolSnapshot;
|
||||||
|
/// Dispatch knowledge used to prevent ambiguous automatic resubmission.
|
||||||
|
pub use self::resilience::HttpDispatchState;
|
||||||
|
/// Transport-level cause considered by the bounded retry policy.
|
||||||
|
pub use self::resilience::HttpRetryCause;
|
||||||
|
/// Result of evaluating one bounded transport retry opportunity.
|
||||||
|
pub use self::resilience::HttpRetryDecision;
|
||||||
|
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
|
||||||
|
pub use self::resilience::evaluate_transport_retry;
|
||||||
|
/// Optional typed configuration for the `getBalance` canary.
|
||||||
|
pub use self::rpc_canary::GetBalanceConfig;
|
||||||
|
/// Typed lamport balance returned by the `getBalance` canary.
|
||||||
|
pub use self::rpc_canary::GetBalanceResult;
|
||||||
|
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
|
||||||
|
pub use self::rpc_canary::SolanaCommitment;
|
||||||
|
/// Typed genesis hash returned by the `getGenesisHash` canary.
|
||||||
|
pub use self::rpc_canary::SolanaGenesisHash;
|
||||||
|
/// Typed healthy result returned by the `getHealth` canary.
|
||||||
|
pub use self::rpc_canary::SolanaNodeHealth;
|
||||||
|
/// Typed software-version response returned by the `getVersion` canary.
|
||||||
|
pub use self::rpc_canary::SolanaNodeVersion;
|
||||||
|
/// Typed Solana RPC context used by the initial account canary.
|
||||||
|
pub use self::rpc_canary::SolanaRpcContext;
|
||||||
|
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
|
||||||
|
pub use self::rpc_method::HttpRpcCategory;
|
||||||
|
/// Release that owns typed KSP coverage for one audited HTTP RPC method.
|
||||||
|
pub use self::rpc_method::HttpRpcCoverageRelease;
|
||||||
|
/// Immutable audited descriptor for one Solana HTTP JSON-RPC method.
|
||||||
|
pub use self::rpc_method::HttpRpcMethodDescriptor;
|
||||||
|
/// Documentation lifecycle status of one audited RPC method.
|
||||||
|
pub use self::rpc_method::RpcDocumentationStatus;
|
||||||
|
/// Technical operation kind used to separate reads, simulations and submissions.
|
||||||
|
pub use self::rpc_method::RpcOperationKind;
|
||||||
|
/// Request-form policy attached to a stable RPC method.
|
||||||
|
pub use self::rpc_method::RpcRequestFormStatus;
|
||||||
|
/// Runtime availability status of one audited RPC method.
|
||||||
|
pub use self::rpc_method::RpcRuntimeStatus;
|
||||||
|
/// HTTP transport retry classification attached to an RPC method descriptor.
|
||||||
|
pub use self::rpc_method::TransportRetryClass;
|
||||||
|
/// Returns all current Solana HTTP RPC method descriptors audited for the `0.2.1`–`0.2.4` coverage sequence.
|
||||||
|
pub use self::rpc_method::current_http_rpc_methods;
|
||||||
|
/// Finds a current or historical standard Solana HTTP RPC descriptor by exact method name.
|
||||||
|
pub use self::rpc_method::find_http_rpc_method;
|
||||||
|
/// Returns historically documented deprecated HTTP RPC descriptors retained for compliance history.
|
||||||
|
pub use self::rpc_method::historical_http_rpc_methods;
|
||||||
|
/// Open cluster or network descriptor used by HTTP endpoint settings.
|
||||||
|
pub use self::settings::HttpClusterName;
|
||||||
|
/// Runtime settings for one role declared by an HTTP endpoint.
|
||||||
|
pub use self::settings::HttpEndpointRoleSettings;
|
||||||
|
/// Runtime settings for one named Solana HTTP endpoint.
|
||||||
|
pub use self::settings::HttpEndpointSettings;
|
||||||
|
/// Runtime HTTP endpoint URL with redacted diagnostics.
|
||||||
|
pub use self::settings::HttpEndpointUrl;
|
||||||
|
/// Open provider descriptor used by HTTP endpoint settings.
|
||||||
|
pub use self::settings::HttpProviderName;
|
||||||
|
/// Open request-kind descriptor used by logical endpoint capabilities.
|
||||||
|
pub use self::settings::HttpRequestKind;
|
||||||
|
/// Bounded retry settings owned by the HTTP transport runtime.
|
||||||
|
pub use self::settings::HttpRetrySettings;
|
||||||
|
/// Local limits attached to one logical HTTP endpoint role.
|
||||||
|
pub use self::settings::HttpRoleLimits;
|
||||||
|
/// Open logical endpoint role descriptor.
|
||||||
|
pub use self::settings::HttpRoleName;
|
||||||
|
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
|
||||||
|
pub use self::settings::HttpTransportSettings;
|
||||||
618
crates/ksp-onchain-transport-lib/src/pool.rs
Normal file
618
crates/ksp-onchain-transport-lib/src/pool.rs
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/pool.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
/// Safe snapshot of the logical HTTP endpoint pool.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HttpTransportPoolSnapshot {
|
||||||
|
endpoints: std::vec::Vec<crate::HttpEndpointSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpTransportPoolSnapshot {
|
||||||
|
/// Returns safe endpoint snapshots in configured declaration order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn endpoints(&self) -> &[crate::HttpEndpointSnapshot] {
|
||||||
|
return self.endpoints.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total number of configured logical endpoints.
|
||||||
|
#[must_use]
|
||||||
|
pub fn endpoint_count(&self) -> usize {
|
||||||
|
return self.endpoints.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of endpoints currently eligible for normal routing.
|
||||||
|
#[must_use]
|
||||||
|
pub fn available_endpoint_count(&self) -> usize {
|
||||||
|
return self.endpoints.iter().filter(|endpoint| return endpoint.availability() == crate::HttpEndpointAvailability::Available).count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of one logical endpoint selection.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HttpEndpointSelection {
|
||||||
|
client: crate::HttpEndpointClient,
|
||||||
|
role: crate::HttpRoleName,
|
||||||
|
request_kind: crate::HttpRequestKind,
|
||||||
|
priority: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpEndpointSelection {
|
||||||
|
/// Returns the selected endpoint client.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn client(&self) -> &crate::HttpEndpointClient {
|
||||||
|
return &self.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected endpoint identity.
|
||||||
|
#[must_use]
|
||||||
|
pub fn endpoint_name(&self) -> &str {
|
||||||
|
return self.client.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the matched logical role.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn role(&self) -> &crate::HttpRoleName {
|
||||||
|
return &self.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the matched request-kind capability.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn request_kind(&self) -> &crate::HttpRequestKind {
|
||||||
|
return &self.request_kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected role priority where lower values are preferred.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn priority(&self) -> u32 {
|
||||||
|
return self.priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime admission permit for one HTTP request.
|
||||||
|
///
|
||||||
|
/// The permit reserves configured concurrency capacity and carries the common request deadline. Dropping it releases any semaphore capacity immediately.
|
||||||
|
pub struct HttpRequestPermit {
|
||||||
|
selection: crate::HttpEndpointSelection,
|
||||||
|
deadline: std::time::Instant,
|
||||||
|
role_runtime: std::sync::Arc<crate::resilience::HttpRoleRuntime>,
|
||||||
|
_concurrency_permit: crate::resilience::HttpConcurrencyPermit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpRequestPermit {
|
||||||
|
/// Returns the selected logical endpoint and role.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection(&self) -> &crate::HttpEndpointSelection {
|
||||||
|
return &self.selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected endpoint client.
|
||||||
|
#[must_use]
|
||||||
|
pub fn client(&self) -> &crate::HttpEndpointClient {
|
||||||
|
return self.selection.client();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remaining duration in the common request budget.
|
||||||
|
#[must_use]
|
||||||
|
pub fn remaining_timeout(&self) -> std::time::Duration {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
if now >= self.deadline {
|
||||||
|
return std::time::Duration::ZERO;
|
||||||
|
}
|
||||||
|
return self.deadline.duration_since(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a successful request and clears the passive degraded state for this endpoint role.
|
||||||
|
pub fn record_success(&self) {
|
||||||
|
self.role_runtime.record_success();
|
||||||
|
ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = self.selection.endpoint_name(),
|
||||||
|
role = self.selection.role().as_str(),
|
||||||
|
request_kind = self.selection.request_kind().as_str(),
|
||||||
|
"recorded successful HTTP endpoint observation"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a transport failure and marks the endpoint role degraded until a later success.
|
||||||
|
pub fn record_failure(&self) {
|
||||||
|
self.role_runtime.record_failure();
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = self.selection.endpoint_name(),
|
||||||
|
provider = self.selection.client().provider().as_str(),
|
||||||
|
cluster = self.selection.client().cluster().as_str(),
|
||||||
|
role = self.selection.role().as_str(),
|
||||||
|
request_kind = self.selection.request_kind().as_str(),
|
||||||
|
"HTTP endpoint role marked degraded after transport failure"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records provider rate limiting and applies the role cooldown.
|
||||||
|
///
|
||||||
|
/// A provider delay can extend the configured cooldown but is defensively capped by the Transport runtime before use.
|
||||||
|
pub fn record_rate_limited(&self, provider_retry_after: std::option::Option<std::time::Duration>) -> std::time::Duration {
|
||||||
|
let pause = self.role_runtime.record_rate_limited(provider_retry_after);
|
||||||
|
let cooldown_ms = duration_millis_u64(pause);
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = self.selection.endpoint_name(),
|
||||||
|
provider = self.selection.client().provider().as_str(),
|
||||||
|
cluster = self.selection.client().cluster().as_str(),
|
||||||
|
role = self.selection.role().as_str(),
|
||||||
|
request_kind = self.selection.request_kind().as_str(),
|
||||||
|
cooldown_ms,
|
||||||
|
"HTTP endpoint role entered provider rate-limit cooldown"
|
||||||
|
);
|
||||||
|
return pause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for HttpRequestPermit {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("HttpRequestPermit")
|
||||||
|
.field("selection", &self.selection)
|
||||||
|
.field("remaining_timeout", &self.remaining_timeout())
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shareable logical HTTP endpoint pool with priority routing, admission limits and bounded request deadlines.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpTransportPool {
|
||||||
|
inner: std::sync::Arc<HttpTransportPoolInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HttpTransportPoolInner {
|
||||||
|
clients: std::vec::Vec<crate::HttpEndpointClient>,
|
||||||
|
retry: crate::HttpRetrySettings,
|
||||||
|
notify: std::sync::Arc<tokio::sync::Notify>,
|
||||||
|
request_ids: std::sync::atomic::AtomicU64,
|
||||||
|
cursors: std::sync::Mutex<std::collections::BTreeMap<(std::string::String, std::string::String, u32), usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpTransportPool {
|
||||||
|
/// Builds a logical endpoint pool after validating all Transport-owned runtime settings.
|
||||||
|
pub fn new(settings: crate::HttpTransportSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let validation = settings.validate();
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let notify = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||||
|
let mut clients = std::vec::Vec::with_capacity(settings.endpoints().len());
|
||||||
|
for endpoint in settings.endpoints() {
|
||||||
|
let client_result = crate::HttpEndpointClient::new_with_notify(endpoint.clone(), std::sync::Arc::clone(¬ify));
|
||||||
|
let client = match client_result {
|
||||||
|
std::result::Result::Ok(client) => client,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
clients.push(client);
|
||||||
|
}
|
||||||
|
let pool = Self {
|
||||||
|
inner: std::sync::Arc::new(HttpTransportPoolInner {
|
||||||
|
clients,
|
||||||
|
retry: settings.retry().clone(),
|
||||||
|
notify,
|
||||||
|
request_ids: std::sync::atomic::AtomicU64::new(1),
|
||||||
|
cursors: std::sync::Mutex::new(std::collections::BTreeMap::new()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_count = pool.inner.clients.len(),
|
||||||
|
available_endpoint_count = pool.snapshot().available_endpoint_count(),
|
||||||
|
max_retries = pool.inner.retry.max_retries(),
|
||||||
|
"created logical HTTP endpoint pool"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bounded transport retry settings owned by this pool.
|
||||||
|
#[must_use]
|
||||||
|
pub fn retry_settings(&self) -> &crate::HttpRetrySettings {
|
||||||
|
return &self.inner.retry;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn next_request_id(&self) -> u64 {
|
||||||
|
let id = self.inner.request_ids.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if id == 0 {
|
||||||
|
return self.inner.request_ids.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects an endpoint for one standard audited RPC method without reserving runtime capacity.
|
||||||
|
///
|
||||||
|
/// Request execution should use `acquire_for_method` so rate, cooldown and concurrency limits are enforced.
|
||||||
|
pub fn select_for_method(&self, role: &crate::HttpRoleName, method: &crate::HttpRpcMethodDescriptor) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||||
|
let support = method.ensure_runtime_supported();
|
||||||
|
if let std::result::Result::Err(error) = support {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return self.select_for_request_kind(role, &crate::HttpRequestKind::new(method.request_kind()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects an endpoint for an open request-kind descriptor without reserving runtime capacity.
|
||||||
|
pub fn select_for_request_kind(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||||
|
let candidates = self.static_candidates(role, request_kind);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return selection_failed(role, request_kind);
|
||||||
|
}
|
||||||
|
let best_priority = candidates[0].priority;
|
||||||
|
let mut tier_size = 0_usize;
|
||||||
|
for candidate in &candidates {
|
||||||
|
if candidate.priority != best_priority {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tier_size = tier_size.saturating_add(1);
|
||||||
|
}
|
||||||
|
let selected_position = self.next_position(role, request_kind, best_priority, tier_size);
|
||||||
|
let selected = match candidates.get(selected_position) {
|
||||||
|
std::option::Option::Some(selected) => selected,
|
||||||
|
std::option::Option::None => return selection_failed(role, request_kind),
|
||||||
|
};
|
||||||
|
return self.selection_from_candidate(role, request_kind, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquires runtime capacity for one standard audited RPC method using the common timeout of matching endpoints.
|
||||||
|
pub async fn acquire_for_method(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
method: &crate::HttpRpcMethodDescriptor,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||||
|
let support = method.ensure_runtime_supported();
|
||||||
|
if let std::result::Result::Err(error) = support {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return self.acquire_for_request_kind(role, &crate::HttpRequestKind::new(method.request_kind())).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquires runtime capacity for an open request-kind descriptor using the shortest configured request timeout among matching endpoints as the common
|
||||||
|
/// deadline.
|
||||||
|
pub async fn acquire_for_request_kind(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||||
|
let timeout_result = self.common_request_timeout(role, request_kind);
|
||||||
|
let timeout = match timeout_result {
|
||||||
|
std::result::Result::Ok(timeout) => timeout,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return self.acquire_for_request_kind_with_timeout(role, request_kind, timeout).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquires runtime capacity with an explicit end-to-end admission budget.
|
||||||
|
///
|
||||||
|
/// This is primarily useful when a higher layer already owns a stricter request deadline. A zero timeout is rejected as immediately expired.
|
||||||
|
pub async fn acquire_for_request_kind_with_timeout(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||||
|
if timeout.is_zero() {
|
||||||
|
return request_timeout(role, request_kind, "HTTP request admission deadline expired before selection");
|
||||||
|
}
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let deadline = match now.checked_add(timeout) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return request_timeout(role, request_kind, "HTTP request admission deadline could not be represented"),
|
||||||
|
};
|
||||||
|
return self.acquire_until(role, request_kind, deadline).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a safe pool snapshot without endpoint URLs.
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(&self) -> crate::HttpTransportPoolSnapshot {
|
||||||
|
let endpoints = self.inner.clients.iter().map(|client| return client.snapshot()).collect();
|
||||||
|
return crate::HttpTransportPoolSnapshot { endpoints };
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn acquire_until(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
deadline: std::time::Instant,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||||
|
let candidates = self.runtime_candidates(role, request_kind);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return request_selection_failed(role, request_kind);
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return request_timeout(role, request_kind, "HTTP request admission deadline expired while waiting for endpoint capacity");
|
||||||
|
}
|
||||||
|
let attempt = self.try_candidates(role, request_kind, candidates.as_slice(), now, deadline);
|
||||||
|
match attempt {
|
||||||
|
RuntimeSelectionAttempt::Ready(permit) => return std::result::Result::Ok(permit),
|
||||||
|
RuntimeSelectionAttempt::Blocked { earliest_ready, concurrency_saturated } => {
|
||||||
|
let wait_result = self.wait_for_capacity(earliest_ready, concurrency_saturated, deadline).await;
|
||||||
|
if !wait_result {
|
||||||
|
return request_timeout(role, request_kind, "HTTP request admission deadline expired while waiting for endpoint capacity");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RuntimeSelectionAttempt::Unavailable => return request_selection_failed(role, request_kind),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_candidates(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
candidates: &[RuntimePoolCandidate],
|
||||||
|
now: std::time::Instant,
|
||||||
|
deadline: std::time::Instant,
|
||||||
|
) -> RuntimeSelectionAttempt {
|
||||||
|
let mut earliest_ready: std::option::Option<std::time::Instant> = std::option::Option::None;
|
||||||
|
let mut concurrency_saturated = false;
|
||||||
|
let mut tier_start = 0_usize;
|
||||||
|
while tier_start < candidates.len() {
|
||||||
|
let priority = candidates[tier_start].priority;
|
||||||
|
let mut tier_end = tier_start;
|
||||||
|
while tier_end < candidates.len() && candidates[tier_end].priority == priority {
|
||||||
|
tier_end = tier_end.saturating_add(1);
|
||||||
|
}
|
||||||
|
let tier_size = tier_end.saturating_sub(tier_start);
|
||||||
|
let start_position = self.next_position(role, request_kind, priority, tier_size);
|
||||||
|
let mut offset = 0_usize;
|
||||||
|
while offset < tier_size {
|
||||||
|
let position = tier_start.saturating_add((start_position.saturating_add(offset)) % tier_size);
|
||||||
|
let candidate = &candidates[position];
|
||||||
|
let admission = candidate.runtime.try_acquire(now);
|
||||||
|
match admission {
|
||||||
|
crate::resilience::RoleAdmissionAttempt::Ready(concurrency_permit) => {
|
||||||
|
let selection_result = self.selection_from_runtime_candidate(role, request_kind, candidate);
|
||||||
|
let selection = match selection_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return RuntimeSelectionAttempt::Unavailable,
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = selection.endpoint_name(),
|
||||||
|
role = role.as_str(),
|
||||||
|
request_kind = request_kind.as_str(),
|
||||||
|
priority,
|
||||||
|
remaining_deadline_ms = duration_millis_u64(deadline.saturating_duration_since(now)),
|
||||||
|
"admitted HTTP request through logical endpoint pool"
|
||||||
|
);
|
||||||
|
return RuntimeSelectionAttempt::Ready(crate::HttpRequestPermit {
|
||||||
|
selection,
|
||||||
|
deadline,
|
||||||
|
role_runtime: std::sync::Arc::clone(&candidate.runtime),
|
||||||
|
_concurrency_permit: concurrency_permit,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
crate::resilience::RoleAdmissionAttempt::BlockedUntil(ready_at) => {
|
||||||
|
earliest_ready = earlier_instant(earliest_ready, ready_at);
|
||||||
|
},
|
||||||
|
crate::resilience::RoleAdmissionAttempt::ConcurrencySaturated => {
|
||||||
|
concurrency_saturated = true;
|
||||||
|
},
|
||||||
|
crate::resilience::RoleAdmissionAttempt::Unavailable => {},
|
||||||
|
}
|
||||||
|
offset = offset.saturating_add(1);
|
||||||
|
}
|
||||||
|
tier_start = tier_end;
|
||||||
|
}
|
||||||
|
if earliest_ready.is_none() && !concurrency_saturated {
|
||||||
|
return RuntimeSelectionAttempt::Unavailable;
|
||||||
|
}
|
||||||
|
return RuntimeSelectionAttempt::Blocked { earliest_ready, concurrency_saturated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_capacity(
|
||||||
|
&self,
|
||||||
|
earliest_ready: std::option::Option<std::time::Instant>,
|
||||||
|
concurrency_saturated: bool,
|
||||||
|
deadline: std::time::Instant,
|
||||||
|
) -> bool {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let wake_at = match earliest_ready {
|
||||||
|
std::option::Option::Some(ready_at) => std::cmp::min(ready_at, deadline),
|
||||||
|
std::option::Option::None => deadline,
|
||||||
|
};
|
||||||
|
if concurrency_saturated {
|
||||||
|
tokio::select! {
|
||||||
|
() = self.inner.notify.notified() => {},
|
||||||
|
() = tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)) => {},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)).await;
|
||||||
|
}
|
||||||
|
return std::time::Instant::now() < deadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn common_request_timeout(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
) -> ksp_core_lib::Result<std::time::Duration> {
|
||||||
|
let mut timeout: std::option::Option<std::time::Duration> = std::option::Option::None;
|
||||||
|
for client in &self.inner.clients {
|
||||||
|
if client.matching_role(role, request_kind).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
timeout = match timeout {
|
||||||
|
std::option::Option::Some(current) => std::option::Option::Some(std::cmp::min(current, client.request_timeout())),
|
||||||
|
std::option::Option::None => std::option::Option::Some(client.request_timeout()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return match timeout {
|
||||||
|
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||||
|
std::option::Option::None => selection_failed_duration(role, request_kind),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_candidates(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> std::vec::Vec<PoolCandidate> {
|
||||||
|
let mut candidates = std::vec::Vec::new();
|
||||||
|
for (client_index, client) in self.inner.clients.iter().enumerate() {
|
||||||
|
let matching_role = client.matching_role(role, request_kind);
|
||||||
|
if let std::option::Option::Some(matching_role) = matching_role {
|
||||||
|
candidates.push(PoolCandidate { client_index, priority: matching_role.priority() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort_by_key(|candidate| return candidate.priority);
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_candidates(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> std::vec::Vec<RuntimePoolCandidate> {
|
||||||
|
let mut candidates = std::vec::Vec::new();
|
||||||
|
for (client_index, client) in self.inner.clients.iter().enumerate() {
|
||||||
|
let runtime_match = client.matching_role_runtime(role, request_kind);
|
||||||
|
if let std::option::Option::Some((priority, runtime)) = runtime_match {
|
||||||
|
candidates.push(RuntimePoolCandidate { client_index, priority, runtime });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort_by_key(|candidate| return candidate.priority);
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_from_candidate(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
candidate: &PoolCandidate,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||||
|
let selected_client = self.inner.clients.get(candidate.client_index);
|
||||||
|
let client = match selected_client {
|
||||||
|
std::option::Option::Some(client) => client.clone(),
|
||||||
|
std::option::Option::None => return selection_failed(role, request_kind),
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_name = client.name(),
|
||||||
|
role = role.as_str(),
|
||||||
|
request_kind = request_kind.as_str(),
|
||||||
|
priority = candidate.priority,
|
||||||
|
"selected logical HTTP endpoint without runtime admission"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(crate::HttpEndpointSelection {
|
||||||
|
client,
|
||||||
|
role: role.clone(),
|
||||||
|
request_kind: request_kind.clone(),
|
||||||
|
priority: candidate.priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_from_runtime_candidate(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
request_kind: &crate::HttpRequestKind,
|
||||||
|
candidate: &RuntimePoolCandidate,
|
||||||
|
) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||||
|
let selected_client = self.inner.clients.get(candidate.client_index);
|
||||||
|
let client = match selected_client {
|
||||||
|
std::option::Option::Some(client) => client.clone(),
|
||||||
|
std::option::Option::None => return selection_failed(role, request_kind),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::HttpEndpointSelection {
|
||||||
|
client,
|
||||||
|
role: role.clone(),
|
||||||
|
request_kind: request_kind.clone(),
|
||||||
|
priority: candidate.priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_position(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind, priority: u32, tier_size: usize) -> usize {
|
||||||
|
let key = (role.as_str().to_owned(), request_kind.as_str().to_owned(), priority);
|
||||||
|
let lock_result = self.inner.cursors.lock();
|
||||||
|
let mut cursors = match lock_result {
|
||||||
|
std::result::Result::Ok(cursors) => cursors,
|
||||||
|
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
let cursor = cursors.entry(key).or_insert(0);
|
||||||
|
if tier_size == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let selected = *cursor % tier_size;
|
||||||
|
*cursor = (*cursor).wrapping_add(1);
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for HttpTransportPool {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.debug_struct("HttpTransportPool").field("snapshot", &self.snapshot()).finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct PoolCandidate {
|
||||||
|
client_index: usize,
|
||||||
|
priority: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RuntimePoolCandidate {
|
||||||
|
client_index: usize,
|
||||||
|
priority: u32,
|
||||||
|
runtime: std::sync::Arc<crate::resilience::HttpRoleRuntime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuntimeSelectionAttempt {
|
||||||
|
Ready(crate::HttpRequestPermit),
|
||||||
|
Blocked { earliest_ready: std::option::Option<std::time::Instant>, concurrency_saturated: bool },
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
|
||||||
|
let converted = u64::try_from(duration.as_millis());
|
||||||
|
return match converted {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => u64::MAX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn earlier_instant(current: std::option::Option<std::time::Instant>, candidate: std::time::Instant) -> std::option::Option<std::time::Instant> {
|
||||||
|
return match current {
|
||||||
|
std::option::Option::Some(value) => std::option::Option::Some(std::cmp::min(value, candidate)),
|
||||||
|
std::option::Option::None => std::option::Option::Some(candidate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_failed(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||||
|
return std::result::Result::Err(selection_error(role, request_kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_failed_duration(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result<std::time::Duration> {
|
||||||
|
return std::result::Result::Err(selection_error(role, request_kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_selection_failed(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||||
|
return std::result::Result::Err(selection_error(role, request_kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_error(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENDPOINT_SELECTION_FAILED, "no HTTP endpoint can satisfy the requested role and request kind")
|
||||||
|
.with_context("role", role.as_str())
|
||||||
|
.with_context("request_kind", request_kind.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_timeout(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind, message: &str) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
role = role.as_str(),
|
||||||
|
request_kind = request_kind.as_str(),
|
||||||
|
"HTTP request admission deadline expired"
|
||||||
|
);
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message)
|
||||||
|
.with_context("role", role.as_str())
|
||||||
|
.with_context("request_kind", request_kind.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/pool.rs"]
|
||||||
|
mod tests;
|
||||||
390
crates/ksp-onchain-transport-lib/src/resilience.rs
Normal file
390
crates/ksp-onchain-transport-lib/src/resilience.rs
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/resilience.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
const DEFAULT_RATE_LIMIT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
const MAX_PROVIDER_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Transport-level cause considered by the bounded retry policy.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum HttpRetryCause {
|
||||||
|
/// A connection could not be established and no usable response exists.
|
||||||
|
Connection,
|
||||||
|
/// The request exceeded its transport deadline without a usable response.
|
||||||
|
Timeout,
|
||||||
|
/// The provider returned HTTP 429 or an equivalent transport-level rate-limit signal.
|
||||||
|
RateLimited,
|
||||||
|
/// The provider returned an HTTP status classified by the caller as temporary.
|
||||||
|
TemporaryHttp,
|
||||||
|
/// A generic request failure is not known to be safe to retry automatically.
|
||||||
|
Request,
|
||||||
|
/// A JSON-RPC application error was returned by the provider.
|
||||||
|
RpcApplication,
|
||||||
|
/// The response violated the KSP transport contract.
|
||||||
|
InvalidResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpRetryCause {
|
||||||
|
#[must_use]
|
||||||
|
const fn is_retryable(self) -> bool {
|
||||||
|
return match self {
|
||||||
|
Self::Connection | Self::Timeout | Self::RateLimited | Self::TemporaryHttp => true,
|
||||||
|
Self::Request | Self::RpcApplication | Self::InvalidResponse => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch knowledge used to prevent ambiguous automatic resubmission.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum HttpDispatchState {
|
||||||
|
/// The transport knows that the request was not dispatched to the provider.
|
||||||
|
NotDispatched,
|
||||||
|
/// The transport cannot prove whether a dispatched request was processed remotely.
|
||||||
|
DispatchedAmbiguous,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of evaluating one bounded transport retry opportunity.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum HttpRetryDecision {
|
||||||
|
/// Stop retrying this transport request.
|
||||||
|
Stop,
|
||||||
|
/// Retry after the bounded delay.
|
||||||
|
RetryAfter(std::time::Duration),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpRetryDecision {
|
||||||
|
/// Returns whether the decision authorizes another transport attempt.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn should_retry(self) -> bool {
|
||||||
|
return match self {
|
||||||
|
Self::Stop => false,
|
||||||
|
Self::RetryAfter(_) => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the retry delay when another attempt is authorized.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn delay(self) -> std::option::Option<std::time::Duration> {
|
||||||
|
return match self {
|
||||||
|
Self::Stop => std::option::Option::None,
|
||||||
|
Self::RetryAfter(delay) => std::option::Option::Some(delay),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
|
||||||
|
///
|
||||||
|
/// `completed_retries` counts retries already performed after the initial attempt. Provider `Retry-After` values are defensively bounded to sixty seconds
|
||||||
|
/// before they can extend the local exponential backoff. RPC application errors are never converted into transport retries.
|
||||||
|
#[must_use]
|
||||||
|
pub fn evaluate_transport_retry(
|
||||||
|
method: &crate::HttpRpcMethodDescriptor,
|
||||||
|
settings: &crate::HttpRetrySettings,
|
||||||
|
cause: crate::HttpRetryCause,
|
||||||
|
dispatch_state: crate::HttpDispatchState,
|
||||||
|
completed_retries: u32,
|
||||||
|
provider_retry_after: std::option::Option<std::time::Duration>,
|
||||||
|
) -> crate::HttpRetryDecision {
|
||||||
|
if completed_retries >= settings.max_retries() || !cause.is_retryable() {
|
||||||
|
return crate::HttpRetryDecision::Stop;
|
||||||
|
}
|
||||||
|
if method.transport_retry_class() == crate::TransportRetryClass::NotApplicable {
|
||||||
|
return crate::HttpRetryDecision::Stop;
|
||||||
|
}
|
||||||
|
if method.transport_retry_class() == crate::TransportRetryClass::NeverAfterDispatch && dispatch_state == crate::HttpDispatchState::DispatchedAmbiguous {
|
||||||
|
return crate::HttpRetryDecision::Stop;
|
||||||
|
}
|
||||||
|
let retry_number = completed_retries.saturating_add(1);
|
||||||
|
let mut delay = retry_backoff(settings, retry_number);
|
||||||
|
if cause == crate::HttpRetryCause::RateLimited
|
||||||
|
&& let std::option::Option::Some(provider_delay) = provider_retry_after
|
||||||
|
{
|
||||||
|
let bounded_provider_delay = std::cmp::min(provider_delay, MAX_PROVIDER_RETRY_AFTER);
|
||||||
|
if bounded_provider_delay > delay {
|
||||||
|
delay = bounded_provider_delay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crate::HttpRetryDecision::RetryAfter(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn retry_backoff(settings: &crate::HttpRetrySettings, retry_number: u32) -> std::time::Duration {
|
||||||
|
let mut delay = settings.initial_backoff();
|
||||||
|
if retry_number <= 1 {
|
||||||
|
return std::cmp::min(delay, settings.max_backoff());
|
||||||
|
}
|
||||||
|
let mut step = 1_u32;
|
||||||
|
while step < retry_number {
|
||||||
|
let doubled = match delay.checked_mul(2) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => settings.max_backoff(),
|
||||||
|
};
|
||||||
|
delay = std::cmp::min(doubled, settings.max_backoff());
|
||||||
|
if delay >= settings.max_backoff() {
|
||||||
|
return settings.max_backoff();
|
||||||
|
}
|
||||||
|
step = step.saturating_add(1);
|
||||||
|
}
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct HttpRoleRuntime {
|
||||||
|
limits: crate::HttpRoleLimits,
|
||||||
|
bucket: std::sync::Mutex<std::option::Option<HttpTokenBucketState>>,
|
||||||
|
semaphore: std::option::Option<std::sync::Arc<tokio::sync::Semaphore>>,
|
||||||
|
notify: std::sync::Arc<tokio::sync::Notify>,
|
||||||
|
cooldown_until: std::sync::Mutex<std::option::Option<std::time::Instant>>,
|
||||||
|
degraded: std::sync::atomic::AtomicBool,
|
||||||
|
success_count: std::sync::atomic::AtomicU64,
|
||||||
|
failure_count: std::sync::atomic::AtomicU64,
|
||||||
|
rate_limit_count: std::sync::atomic::AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpRoleRuntime {
|
||||||
|
pub(crate) fn new(settings: &crate::HttpEndpointRoleSettings, notify: std::sync::Arc<tokio::sync::Notify>) -> Self {
|
||||||
|
let bucket = match settings.limits().requests_per_second() {
|
||||||
|
std::option::Option::Some(requests_per_second) => {
|
||||||
|
let burst_capacity = match settings.limits().burst_capacity() {
|
||||||
|
std::option::Option::Some(capacity) => capacity,
|
||||||
|
std::option::Option::None => requests_per_second,
|
||||||
|
};
|
||||||
|
std::option::Option::Some(HttpTokenBucketState::new(requests_per_second.get(), burst_capacity.get(), std::time::Instant::now()))
|
||||||
|
},
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
let semaphore = match settings.limits().max_concurrent_requests() {
|
||||||
|
std::option::Option::Some(max_concurrent) => {
|
||||||
|
std::option::Option::Some(std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.get() as usize)))
|
||||||
|
},
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
return Self {
|
||||||
|
limits: settings.limits().clone(),
|
||||||
|
bucket: std::sync::Mutex::new(bucket),
|
||||||
|
semaphore,
|
||||||
|
notify,
|
||||||
|
cooldown_until: std::sync::Mutex::new(std::option::Option::None),
|
||||||
|
degraded: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
success_count: std::sync::atomic::AtomicU64::new(0),
|
||||||
|
failure_count: std::sync::atomic::AtomicU64::new(0),
|
||||||
|
rate_limit_count: std::sync::atomic::AtomicU64::new(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn availability(&self, now: std::time::Instant) -> crate::HttpEndpointAvailability {
|
||||||
|
if self.cooldown_remaining_at(now).is_some() {
|
||||||
|
return crate::HttpEndpointAvailability::RateLimited;
|
||||||
|
}
|
||||||
|
if self.degraded.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
return crate::HttpEndpointAvailability::Degraded;
|
||||||
|
}
|
||||||
|
return crate::HttpEndpointAvailability::Available;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
|
||||||
|
return self.cooldown_remaining_at(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn max_concurrent_requests(&self) -> std::option::Option<u32> {
|
||||||
|
return self.limits.max_concurrent_requests().map(|value| return value.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn in_flight_requests(&self) -> std::option::Option<u32> {
|
||||||
|
let semaphore = match &self.semaphore {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let maximum = match self.limits.max_concurrent_requests() {
|
||||||
|
std::option::Option::Some(value) => value.get(),
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let available = semaphore.available_permits();
|
||||||
|
let available_u32 = match u32::try_from(available) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => maximum,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(maximum.saturating_sub(available_u32));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn success_count(&self) -> u64 {
|
||||||
|
return self.success_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn failure_count(&self) -> u64 {
|
||||||
|
return self.failure_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rate_limit_count(&self) -> u64 {
|
||||||
|
return self.rate_limit_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn try_acquire(self: &std::sync::Arc<Self>, now: std::time::Instant) -> RoleAdmissionAttempt {
|
||||||
|
if let std::option::Option::Some(remaining) = self.cooldown_remaining_at(now) {
|
||||||
|
let ready_at = match now.checked_add(remaining) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => now,
|
||||||
|
};
|
||||||
|
return RoleAdmissionAttempt::BlockedUntil(ready_at);
|
||||||
|
}
|
||||||
|
let semaphore_permit = match &self.semaphore {
|
||||||
|
std::option::Option::Some(semaphore) => {
|
||||||
|
let permit_result = std::sync::Arc::clone(semaphore).try_acquire_owned();
|
||||||
|
match permit_result {
|
||||||
|
std::result::Result::Ok(permit) => std::option::Option::Some(permit),
|
||||||
|
std::result::Result::Err(tokio::sync::TryAcquireError::NoPermits) => return RoleAdmissionAttempt::ConcurrencySaturated,
|
||||||
|
std::result::Result::Err(tokio::sync::TryAcquireError::Closed) => return RoleAdmissionAttempt::Unavailable,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
let token_result = self.try_consume_token(now);
|
||||||
|
if let std::option::Option::Some(ready_at) = token_result {
|
||||||
|
drop(semaphore_permit);
|
||||||
|
self.notify.notify_one();
|
||||||
|
return RoleAdmissionAttempt::BlockedUntil(ready_at);
|
||||||
|
}
|
||||||
|
return RoleAdmissionAttempt::Ready(HttpConcurrencyPermit { semaphore_permit, notify: std::sync::Arc::clone(&self.notify) });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_success(&self) {
|
||||||
|
self.success_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
self.degraded.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
self.notify.notify_waiters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_failure(&self) {
|
||||||
|
self.failure_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
self.degraded.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
self.notify.notify_waiters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_rate_limited(&self, provider_retry_after: std::option::Option<std::time::Duration>) -> std::time::Duration {
|
||||||
|
self.failure_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
self.rate_limit_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
self.degraded.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let configured_pause = match self.limits.pause_after_rate_limit() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => DEFAULT_RATE_LIMIT_COOLDOWN,
|
||||||
|
};
|
||||||
|
let provider_pause = match provider_retry_after {
|
||||||
|
std::option::Option::Some(value) => std::cmp::min(value, MAX_PROVIDER_RETRY_AFTER),
|
||||||
|
std::option::Option::None => std::time::Duration::ZERO,
|
||||||
|
};
|
||||||
|
let effective_pause = std::cmp::max(configured_pause, provider_pause);
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let candidate = match now.checked_add(effective_pause) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
drop(cooldown_until);
|
||||||
|
self.notify.notify_waiters();
|
||||||
|
return effective_pause;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 try_consume_token(&self, now: std::time::Instant) -> std::option::Option<std::time::Instant> {
|
||||||
|
let lock_result = self.bucket.lock();
|
||||||
|
let mut bucket = match lock_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
let state = match bucket.as_mut() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return state.try_consume_at(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum RoleAdmissionAttempt {
|
||||||
|
Ready(HttpConcurrencyPermit),
|
||||||
|
BlockedUntil(std::time::Instant),
|
||||||
|
ConcurrencySaturated,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct HttpConcurrencyPermit {
|
||||||
|
semaphore_permit: std::option::Option<tokio::sync::OwnedSemaphorePermit>,
|
||||||
|
notify: std::sync::Arc<tokio::sync::Notify>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for HttpConcurrencyPermit {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let permit = self.semaphore_permit.take();
|
||||||
|
drop(permit);
|
||||||
|
self.notify.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct HttpTokenBucketState {
|
||||||
|
available_tokens: f64,
|
||||||
|
requests_per_second: u32,
|
||||||
|
burst_capacity: u32,
|
||||||
|
last_refill: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpTokenBucketState {
|
||||||
|
fn new(requests_per_second: u32, burst_capacity: u32, now: std::time::Instant) -> Self {
|
||||||
|
return Self { available_tokens: f64::from(burst_capacity), requests_per_second, burst_capacity, last_refill: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_consume_at(&mut self, now: std::time::Instant) -> std::option::Option<std::time::Instant> {
|
||||||
|
self.refill_at(now);
|
||||||
|
if self.available_tokens >= 1.0 {
|
||||||
|
self.available_tokens -= 1.0;
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
let missing_tokens = 1.0 - self.available_tokens;
|
||||||
|
let wait_seconds = missing_tokens / f64::from(self.requests_per_second);
|
||||||
|
let wait = std::time::Duration::from_secs_f64(wait_seconds);
|
||||||
|
let ready_at = match now.checked_add(wait) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => now,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(ready_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refill_at(&mut self, now: std::time::Instant) {
|
||||||
|
if now <= self.last_refill {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let elapsed_seconds = now.duration_since(self.last_refill).as_secs_f64();
|
||||||
|
let refill = elapsed_seconds * f64::from(self.requests_per_second);
|
||||||
|
self.available_tokens = (self.available_tokens + refill).min(f64::from(self.burst_capacity));
|
||||||
|
self.last_refill = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/resilience.rs"]
|
||||||
|
mod tests;
|
||||||
301
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
Normal file
301
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/rpc_canary.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum SolanaCommitment {
|
||||||
|
/// Query the most recent processed bank.
|
||||||
|
Processed,
|
||||||
|
/// Query a bank confirmed by cluster vote.
|
||||||
|
Confirmed,
|
||||||
|
/// Query a finalized bank.
|
||||||
|
Finalized,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SolanaCommitment {
|
||||||
|
/// Returns the Solana JSON-RPC commitment string.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
return match self {
|
||||||
|
Self::Processed => "processed",
|
||||||
|
Self::Confirmed => "confirmed",
|
||||||
|
Self::Finalized => "finalized",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional typed configuration for `getBalance`.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct GetBalanceConfig {
|
||||||
|
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||||
|
min_context_slot: std::option::Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GetBalanceConfig {
|
||||||
|
/// Creates an explicit `getBalance` configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, min_context_slot: std::option::Option<u64>) -> Self {
|
||||||
|
return Self { commitment, min_context_slot };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional commitment level.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||||
|
return self.commitment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional minimum context slot.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
|
||||||
|
return self.min_context_slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
return self.commitment.is_none() && self.min_context_slot.is_none();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_json_value(&self) -> serde_json::Value {
|
||||||
|
let mut object = serde_json::Map::new();
|
||||||
|
if let std::option::Option::Some(commitment) = self.commitment {
|
||||||
|
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
|
||||||
|
object.insert("minContextSlot".to_owned(), serde_json::Value::Number(min_context_slot.into()));
|
||||||
|
}
|
||||||
|
return serde_json::Value::Object(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed healthy result returned by the `getHealth` canary.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum SolanaNodeHealth {
|
||||||
|
/// The RPC node returned the stable `"ok"` health result.
|
||||||
|
Healthy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed genesis hash returned by the `getGenesisHash` canary.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct SolanaGenesisHash {
|
||||||
|
value: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SolanaGenesisHash {
|
||||||
|
/// Returns the base58-encoded genesis hash text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed software-version response returned by the `getVersion` canary.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SolanaNodeVersion {
|
||||||
|
solana_core: std::string::String,
|
||||||
|
feature_set: std::option::Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SolanaNodeVersion {
|
||||||
|
/// Returns the node software version string from the `solana-core` field.
|
||||||
|
#[must_use]
|
||||||
|
pub fn solana_core(&self) -> &str {
|
||||||
|
return self.solana_core.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional runtime feature-set identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn feature_set(&self) -> std::option::Option<u32> {
|
||||||
|
return self.feature_set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed Solana RPC context used by the initial account canary.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SolanaRpcContext {
|
||||||
|
slot: u64,
|
||||||
|
api_version: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SolanaRpcContext {
|
||||||
|
/// Returns the context slot reported by the RPC node.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slot(&self) -> u64 {
|
||||||
|
return self.slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional RPC API version reported by the node.
|
||||||
|
#[must_use]
|
||||||
|
pub fn api_version(&self) -> std::option::Option<&str> {
|
||||||
|
return match self.api_version.as_ref() {
|
||||||
|
std::option::Option::Some(value) => std::option::Option::Some(value.as_str()),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed lamport balance returned by the `getBalance` canary.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct GetBalanceResult {
|
||||||
|
context: crate::SolanaRpcContext,
|
||||||
|
value: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GetBalanceResult {
|
||||||
|
/// Returns the Solana response context.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn context(&self) -> &crate::SolanaRpcContext {
|
||||||
|
return &self.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the account balance in lamports.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn value(&self) -> u64 {
|
||||||
|
return self.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::HttpTransportPool {
|
||||||
|
/// Executes the typed `getHealth` foundation canary.
|
||||||
|
pub async fn get_health(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeHealth> {
|
||||||
|
let method_result = canary_descriptor("getHealth");
|
||||||
|
let method = match method_result {
|
||||||
|
std::result::Result::Ok(method) => method,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||||
|
let value = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if value.as_str() == std::option::Option::Some("ok") {
|
||||||
|
return std::result::Result::Ok(crate::SolanaNodeHealth::Healthy);
|
||||||
|
}
|
||||||
|
return invalid_canary_response("getHealth", "result must be exactly the string ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the typed `getGenesisHash` foundation canary.
|
||||||
|
pub async fn get_genesis_hash(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaGenesisHash> {
|
||||||
|
let method_result = canary_descriptor("getGenesisHash");
|
||||||
|
let method = match method_result {
|
||||||
|
std::result::Result::Ok(method) => method,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||||
|
let value = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let hash = match value.as_str() {
|
||||||
|
std::option::Option::Some(hash) => hash,
|
||||||
|
std::option::Option::None => return invalid_canary_response("getGenesisHash", "result must be a string"),
|
||||||
|
};
|
||||||
|
if hash.is_empty() || hash.trim() != hash {
|
||||||
|
return invalid_canary_response("getGenesisHash", "result must be a non-empty trimmed string");
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(crate::SolanaGenesisHash { value: hash.to_owned() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the typed `getVersion` foundation canary.
|
||||||
|
pub async fn get_version(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeVersion> {
|
||||||
|
let method_result = canary_descriptor("getVersion");
|
||||||
|
let method = match method_result {
|
||||||
|
std::result::Result::Ok(method) => method,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||||
|
let value = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let decode_result = serde_json::from_value::<WireNodeVersion>(value);
|
||||||
|
let decoded = match decode_result {
|
||||||
|
std::result::Result::Ok(decoded) => decoded,
|
||||||
|
std::result::Result::Err(error) => return invalid_canary_decode("getVersion", error),
|
||||||
|
};
|
||||||
|
if decoded.solana_core.is_empty() || decoded.solana_core.trim() != decoded.solana_core {
|
||||||
|
return invalid_canary_response("getVersion", "solana-core must be a non-empty trimmed string");
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(crate::SolanaNodeVersion { solana_core: decoded.solana_core, feature_set: decoded.feature_set });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the typed `getBalance` foundation canary.
|
||||||
|
pub async fn get_balance(
|
||||||
|
&self,
|
||||||
|
role: &crate::HttpRoleName,
|
||||||
|
account: &ksp_core_lib::Pubkey,
|
||||||
|
config: std::option::Option<&crate::GetBalanceConfig>,
|
||||||
|
) -> ksp_core_lib::Result<crate::GetBalanceResult> {
|
||||||
|
let method_result = canary_descriptor("getBalance");
|
||||||
|
let method = match method_result {
|
||||||
|
std::result::Result::Ok(method) => method,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut params = std::vec![serde_json::Value::String(account.to_string())];
|
||||||
|
if let std::option::Option::Some(config) = config
|
||||||
|
&& !config.is_empty()
|
||||||
|
{
|
||||||
|
params.push(config.to_json_value());
|
||||||
|
}
|
||||||
|
let result = self.execute_standard_rpc(role, method, params).await;
|
||||||
|
let value = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let decode_result = serde_json::from_value::<WireBalanceResult>(value);
|
||||||
|
let decoded = match decode_result {
|
||||||
|
std::result::Result::Ok(decoded) => decoded,
|
||||||
|
std::result::Result::Err(error) => return invalid_canary_decode("getBalance", error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::GetBalanceResult {
|
||||||
|
context: crate::SolanaRpcContext { slot: decoded.context.slot, api_version: decoded.context.api_version },
|
||||||
|
value: decoded.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct WireNodeVersion {
|
||||||
|
#[serde(rename = "solana-core")]
|
||||||
|
solana_core: std::string::String,
|
||||||
|
#[serde(rename = "feature-set", default)]
|
||||||
|
feature_set: std::option::Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct WireRpcContext {
|
||||||
|
slot: u64,
|
||||||
|
#[serde(rename = "apiVersion", default)]
|
||||||
|
api_version: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct WireBalanceResult {
|
||||||
|
context: WireRpcContext,
|
||||||
|
value: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canary_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
|
||||||
|
let descriptor = crate::find_http_rpc_method(method);
|
||||||
|
return match descriptor {
|
||||||
|
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
|
||||||
|
std::option::Option::None => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed canary descriptor is missing from the audited registry")
|
||||||
|
.with_context("rpc_method", method),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_canary_decode<T>(method: &str, error: serde_json::Error) -> ksp_core_lib::Result<T> {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP canary response has an invalid shape")
|
||||||
|
.with_context("rpc_method", method)
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_canary_response<T>(method: &str, message: &str) -> ksp_core_lib::Result<T> {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/rpc_canary.rs"]
|
||||||
|
mod tests;
|
||||||
1108
crates/ksp-onchain-transport-lib/src/rpc_method.rs
Normal file
1108
crates/ksp-onchain-transport-lib/src/rpc_method.rs
Normal file
File diff suppressed because it is too large
Load Diff
585
crates/ksp-onchain-transport-lib/src/settings.rs
Normal file
585
crates/ksp-onchain-transport-lib/src/settings.rs
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/settings.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
/// Runtime HTTP endpoint URL owned by Transport.
|
||||||
|
///
|
||||||
|
/// The actual URL can contain provider credentials. Its [`std::fmt::Debug`] implementation is intentionally redacted.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct HttpEndpointUrl {
|
||||||
|
value: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpEndpointUrl {
|
||||||
|
/// Parses and validates one HTTP or HTTPS endpoint URL.
|
||||||
|
pub fn parse(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let value = value.into();
|
||||||
|
let parsed_result = reqwest::Url::parse(value.as_str());
|
||||||
|
let parsed = match parsed_result {
|
||||||
|
std::result::Result::Ok(parsed) => parsed,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint URL is invalid")
|
||||||
|
.with_context("field", "endpoints.url")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint URL must use http or https")
|
||||||
|
.with_context("field", "endpoints.url")
|
||||||
|
.with_context("scheme", parsed.scheme()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if parsed.host_str().is_none() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint URL must contain a host").with_context("field", "endpoints.url"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { value });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sensitive runtime URL text.
|
||||||
|
///
|
||||||
|
/// Callers must not write this value to logs, generic diagnostics or snapshots.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for HttpEndpointUrl {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str("HttpEndpointUrl(<redacted>)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open provider descriptor used by HTTP endpoint settings.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct HttpProviderName {
|
||||||
|
value: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpProviderName {
|
||||||
|
/// Creates an open provider descriptor. Validation is performed by [`HttpTransportSettings::validate`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||||
|
return Self { value: value.into() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider descriptor text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open cluster or network descriptor used by HTTP endpoint settings.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct HttpClusterName {
|
||||||
|
value: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClusterName {
|
||||||
|
/// Creates an open cluster descriptor. Validation is performed by [`HttpTransportSettings::validate`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||||
|
return Self { value: value.into() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the cluster descriptor text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open logical endpoint role descriptor.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct HttpRoleName {
|
||||||
|
value: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpRoleName {
|
||||||
|
/// Creates an open role descriptor. Validation is performed by [`HttpTransportSettings::validate`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||||
|
return Self { value: value.into() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the role descriptor text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open request-kind descriptor used by logical endpoint capabilities.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct HttpRequestKind {
|
||||||
|
value: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpRequestKind {
|
||||||
|
/// Creates an open request-kind descriptor. `*` is reserved as the wildcard accepted by all standard request kinds.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||||
|
return Self { value: value.into() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the wildcard request-kind descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn wildcard() -> Self {
|
||||||
|
return Self::new("*");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the request-kind descriptor text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this descriptor is the wildcard capability.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_wildcard(&self) -> bool {
|
||||||
|
return self.value == "*";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local limits attached to one logical HTTP endpoint role.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HttpRoleLimits {
|
||||||
|
requests_per_second: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
burst_capacity: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
max_concurrent_requests: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
pause_after_rate_limit: std::option::Option<std::time::Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpRoleLimits {
|
||||||
|
/// Creates explicit role limits.
|
||||||
|
///
|
||||||
|
/// When RPS is configured and burst capacity is absent, runtime burst defaults to one second of RPS capacity. An absent concurrency limit is
|
||||||
|
/// unbounded by this KSP transport layer. An absent rate-limit cooldown uses the Transport runtime fallback cooldown.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(
|
||||||
|
requests_per_second: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
burst_capacity: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
max_concurrent_requests: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
pause_after_rate_limit: std::option::Option<std::time::Duration>,
|
||||||
|
) -> Self {
|
||||||
|
return Self { requests_per_second, burst_capacity, max_concurrent_requests, pause_after_rate_limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured requests-per-second limit.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn requests_per_second(&self) -> std::option::Option<std::num::NonZeroU32> {
|
||||||
|
return self.requests_per_second;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured token-bucket burst capacity. `None` means the runtime derives capacity from configured RPS.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn burst_capacity(&self) -> std::option::Option<std::num::NonZeroU32> {
|
||||||
|
return self.burst_capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured maximum concurrent request count.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_concurrent_requests(&self) -> std::option::Option<std::num::NonZeroU32> {
|
||||||
|
return self.max_concurrent_requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured cooldown applied after rate limiting. `None` delegates to the Transport runtime fallback cooldown.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pause_after_rate_limit(&self) -> std::option::Option<std::time::Duration> {
|
||||||
|
return self.pause_after_rate_limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded retry settings owned by the HTTP transport runtime.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HttpRetrySettings {
|
||||||
|
max_retries: u32,
|
||||||
|
initial_backoff: std::time::Duration,
|
||||||
|
max_backoff: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpRetrySettings {
|
||||||
|
/// Creates bounded retry settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(max_retries: u32, initial_backoff: std::time::Duration, max_backoff: std::time::Duration) -> Self {
|
||||||
|
return Self { max_retries, initial_backoff, max_backoff };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of retries allowed after the initial attempt.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_retries(&self) -> u32 {
|
||||||
|
return self.max_retries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the initial retry backoff.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn initial_backoff(&self) -> std::time::Duration {
|
||||||
|
return self.initial_backoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum retry backoff.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_backoff(&self) -> std::time::Duration {
|
||||||
|
return self.max_backoff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime settings for one role declared by an HTTP endpoint.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HttpEndpointRoleSettings {
|
||||||
|
role: crate::HttpRoleName,
|
||||||
|
enabled: bool,
|
||||||
|
request_kinds: std::vec::Vec<crate::HttpRequestKind>,
|
||||||
|
priority: u32,
|
||||||
|
limits: crate::HttpRoleLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpEndpointRoleSettings {
|
||||||
|
/// Creates explicit settings for one logical endpoint role.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
role: crate::HttpRoleName,
|
||||||
|
enabled: bool,
|
||||||
|
request_kinds: std::vec::Vec<crate::HttpRequestKind>,
|
||||||
|
priority: u32,
|
||||||
|
limits: crate::HttpRoleLimits,
|
||||||
|
) -> Self {
|
||||||
|
return Self { role, enabled, request_kinds, priority, limits };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the open logical role descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn role(&self) -> &crate::HttpRoleName {
|
||||||
|
return &self.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this role participates in endpoint selection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn enabled(&self) -> bool {
|
||||||
|
return self.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns request kinds supported by this role.
|
||||||
|
#[must_use]
|
||||||
|
pub fn request_kinds(&self) -> &[crate::HttpRequestKind] {
|
||||||
|
return self.request_kinds.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the role priority where lower values are preferred.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn priority(&self) -> u32 {
|
||||||
|
return self.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns local rate, burst and concurrency limits.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn limits(&self) -> &crate::HttpRoleLimits {
|
||||||
|
return &self.limits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime settings for one named Solana HTTP endpoint.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HttpEndpointSettings {
|
||||||
|
name: std::string::String,
|
||||||
|
enabled: bool,
|
||||||
|
provider: crate::HttpProviderName,
|
||||||
|
cluster: crate::HttpClusterName,
|
||||||
|
url: crate::HttpEndpointUrl,
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
request_timeout: std::time::Duration,
|
||||||
|
max_idle_connections_per_host: std::option::Option<usize>,
|
||||||
|
roles: std::vec::Vec<crate::HttpEndpointRoleSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpEndpointSettings {
|
||||||
|
/// Creates explicit runtime settings for one logical HTTP endpoint.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
name: impl std::convert::Into<std::string::String>,
|
||||||
|
enabled: bool,
|
||||||
|
provider: crate::HttpProviderName,
|
||||||
|
cluster: crate::HttpClusterName,
|
||||||
|
url: crate::HttpEndpointUrl,
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
request_timeout: std::time::Duration,
|
||||||
|
max_idle_connections_per_host: std::option::Option<usize>,
|
||||||
|
roles: std::vec::Vec<crate::HttpEndpointRoleSettings>,
|
||||||
|
) -> Self {
|
||||||
|
return Self {
|
||||||
|
name: name.into(),
|
||||||
|
enabled,
|
||||||
|
provider,
|
||||||
|
cluster,
|
||||||
|
url,
|
||||||
|
connect_timeout,
|
||||||
|
request_timeout,
|
||||||
|
max_idle_connections_per_host,
|
||||||
|
roles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the endpoint identity used by selection and safe diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
return self.name.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this endpoint participates in endpoint selection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn enabled(&self) -> bool {
|
||||||
|
return self.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the provider descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provider(&self) -> &crate::HttpProviderName {
|
||||||
|
return &self.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the cluster descriptor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn cluster(&self) -> &crate::HttpClusterName {
|
||||||
|
return &self.cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sensitive endpoint URL wrapper.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn url(&self) -> &crate::HttpEndpointUrl {
|
||||||
|
return &self.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the connection-establishment timeout.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn connect_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.connect_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the end-to-end request timeout used by this endpoint.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn request_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.request_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional per-host idle connection pool limit.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_idle_connections_per_host(&self) -> std::option::Option<usize> {
|
||||||
|
return self.max_idle_connections_per_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns endpoint roles in declaration order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn roles(&self) -> &[crate::HttpEndpointRoleSettings] {
|
||||||
|
return self.roles.as_slice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HttpTransportSettings {
|
||||||
|
endpoints: std::vec::Vec<crate::HttpEndpointSettings>,
|
||||||
|
retry: crate::HttpRetrySettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpTransportSettings {
|
||||||
|
/// Creates complete HTTP transport runtime settings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(endpoints: std::vec::Vec<crate::HttpEndpointSettings>, retry: crate::HttpRetrySettings) -> Self {
|
||||||
|
return Self { endpoints, retry };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns configured endpoints in declaration order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn endpoints(&self) -> &[crate::HttpEndpointSettings] {
|
||||||
|
return self.endpoints.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bounded transport retry settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn retry(&self) -> &crate::HttpRetrySettings {
|
||||||
|
return &self.retry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates structural runtime invariants without reading Config or environment state.
|
||||||
|
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||||
|
let retry_validation = validate_retry(self.retry());
|
||||||
|
if let std::result::Result::Err(error) = retry_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if self.endpoints.is_empty() {
|
||||||
|
return invalid_settings("at least one HTTP endpoint must be configured", "endpoints");
|
||||||
|
}
|
||||||
|
let mut enabled_endpoint_count = 0_usize;
|
||||||
|
for (endpoint_index, endpoint) in self.endpoints.iter().enumerate() {
|
||||||
|
let endpoint_validation = validate_endpoint(endpoint, endpoint_index);
|
||||||
|
if let std::result::Result::Err(error) = endpoint_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if endpoint.enabled() {
|
||||||
|
enabled_endpoint_count += 1;
|
||||||
|
}
|
||||||
|
for previous in &self.endpoints[..endpoint_index] {
|
||||||
|
if previous.name() == endpoint.name() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint names must be unique")
|
||||||
|
.with_context("field", format!("endpoints[{endpoint_index}].name"))
|
||||||
|
.with_context("endpoint_name", endpoint.name()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if enabled_endpoint_count == 0 {
|
||||||
|
return invalid_settings("at least one HTTP endpoint must be enabled", "endpoints.enabled");
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
endpoint_count = self.endpoints.len(),
|
||||||
|
enabled_endpoint_count,
|
||||||
|
"validated HTTP transport settings"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_endpoint_settings(endpoint: &crate::HttpEndpointSettings) -> ksp_core_lib::Result<()> {
|
||||||
|
return validate_endpoint(endpoint, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_retry(retry: &crate::HttpRetrySettings) -> ksp_core_lib::Result<()> {
|
||||||
|
if retry.initial_backoff().is_zero() {
|
||||||
|
return invalid_settings("initial retry backoff must be greater than zero", "retry.initial_backoff");
|
||||||
|
}
|
||||||
|
if retry.max_backoff().is_zero() {
|
||||||
|
return invalid_settings("maximum retry backoff must be greater than zero", "retry.max_backoff");
|
||||||
|
}
|
||||||
|
if retry.max_backoff() < retry.initial_backoff() {
|
||||||
|
return invalid_settings("maximum retry backoff must not be lower than initial retry backoff", "retry.max_backoff");
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_endpoint(endpoint: &crate::HttpEndpointSettings, endpoint_index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
let endpoint_name_validation = validate_descriptor(endpoint.name(), format!("endpoints[{endpoint_index}].name").as_str());
|
||||||
|
if let std::result::Result::Err(error) = endpoint_name_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let provider_validation = validate_descriptor(endpoint.provider().as_str(), format!("endpoints[{endpoint_index}].provider").as_str());
|
||||||
|
if let std::result::Result::Err(error) = provider_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let cluster_validation = validate_descriptor(endpoint.cluster().as_str(), format!("endpoints[{endpoint_index}].cluster").as_str());
|
||||||
|
if let std::result::Result::Err(error) = cluster_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if endpoint.connect_timeout().is_zero() {
|
||||||
|
return invalid_settings("HTTP connect timeout must be greater than zero", format!("endpoints[{endpoint_index}].connect_timeout").as_str());
|
||||||
|
}
|
||||||
|
if endpoint.request_timeout().is_zero() {
|
||||||
|
return invalid_settings("HTTP request timeout must be greater than zero", format!("endpoints[{endpoint_index}].request_timeout").as_str());
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(max_idle) = endpoint.max_idle_connections_per_host()
|
||||||
|
&& max_idle == 0
|
||||||
|
{
|
||||||
|
return invalid_settings(
|
||||||
|
"max idle connections per host must be greater than zero when configured",
|
||||||
|
format!("endpoints[{endpoint_index}].max_idle_connections_per_host").as_str(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if endpoint.roles().is_empty() {
|
||||||
|
return invalid_settings("HTTP endpoint must declare at least one role", format!("endpoints[{endpoint_index}].roles").as_str());
|
||||||
|
}
|
||||||
|
let mut enabled_role_count = 0_usize;
|
||||||
|
for (role_index, role) in endpoint.roles().iter().enumerate() {
|
||||||
|
let role_validation = validate_role(role, endpoint_index, role_index);
|
||||||
|
if let std::result::Result::Err(error) = role_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if role.enabled() {
|
||||||
|
enabled_role_count += 1;
|
||||||
|
}
|
||||||
|
for previous in &endpoint.roles()[..role_index] {
|
||||||
|
if previous.role() == role.role() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint role names must be unique per endpoint")
|
||||||
|
.with_context("field", format!("endpoints[{endpoint_index}].roles[{role_index}].role"))
|
||||||
|
.with_context("endpoint_name", endpoint.name())
|
||||||
|
.with_context("role", role.role().as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endpoint.enabled() && enabled_role_count == 0 {
|
||||||
|
return invalid_settings("enabled HTTP endpoint must expose at least one enabled role", format!("endpoints[{endpoint_index}].roles.enabled").as_str());
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_role(role: &crate::HttpEndpointRoleSettings, endpoint_index: usize, role_index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
let role_validation = validate_descriptor(role.role().as_str(), format!("endpoints[{endpoint_index}].roles[{role_index}].role").as_str());
|
||||||
|
if let std::result::Result::Err(error) = role_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if role.request_kinds().is_empty() {
|
||||||
|
return invalid_settings(
|
||||||
|
"HTTP endpoint role must declare at least one request kind",
|
||||||
|
format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds").as_str(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if role.request_kinds().len() > 1 && role.request_kinds().iter().any(crate::HttpRequestKind::is_wildcard) {
|
||||||
|
return invalid_settings("wildcard request kind must be used alone", format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds").as_str());
|
||||||
|
}
|
||||||
|
for (request_kind_index, request_kind) in role.request_kinds().iter().enumerate() {
|
||||||
|
let request_kind_validation =
|
||||||
|
validate_descriptor(request_kind.as_str(), format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds[{request_kind_index}]").as_str());
|
||||||
|
if let std::result::Result::Err(error) = request_kind_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
for previous in &role.request_kinds()[..request_kind_index] {
|
||||||
|
if previous == request_kind {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP request kinds must be unique per role")
|
||||||
|
.with_context("field", format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds[{request_kind_index}]"))
|
||||||
|
.with_context("request_kind", request_kind.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if role.limits().burst_capacity().is_some() && role.limits().requests_per_second().is_none() {
|
||||||
|
return invalid_settings(
|
||||||
|
"burst capacity requires a requests-per-second limit",
|
||||||
|
format!("endpoints[{endpoint_index}].roles[{role_index}].limits.burst_capacity").as_str(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(pause) = role.limits().pause_after_rate_limit()
|
||||||
|
&& pause.is_zero()
|
||||||
|
{
|
||||||
|
return invalid_settings(
|
||||||
|
"rate-limit cooldown must be greater than zero when configured",
|
||||||
|
format!("endpoints[{endpoint_index}].roles[{role_index}].limits.pause_after_rate_limit").as_str(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_descriptor(value: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return invalid_settings("transport descriptor must not be empty", field);
|
||||||
|
}
|
||||||
|
if value.trim() != value {
|
||||||
|
return invalid_settings("transport descriptor must not contain leading or trailing whitespace", field);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/settings.rs"]
|
||||||
|
mod tests;
|
||||||
173
crates/ksp-onchain-transport-lib/tests/public_api.rs
Normal file
173
crates/ksp-onchain-transport-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_settings_contract_is_constructible_without_config_dependency() {
|
||||||
|
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://api.devnet.solana.com").expect("public URL parser must accept Devnet endpoint");
|
||||||
|
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
|
||||||
|
100,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||||
|
"solana_devnet_public",
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::HttpProviderName::new("solana-public"),
|
||||||
|
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
std::option::Option::Some(8),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
ksp_onchain_transport_lib::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(2)),
|
||||||
|
);
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_json_rpc_contract_round_trips_foundation_shape() {
|
||||||
|
let request = ksp_onchain_transport_lib::JsonRpcRequest::new(1, "getHealth", std::vec![]).expect("public request constructor must succeed");
|
||||||
|
let encoded = request.to_json_string().expect("public request must serialize");
|
||||||
|
assert!(encoded.contains("\"jsonrpc\":\"2.0\""));
|
||||||
|
let response =
|
||||||
|
ksp_onchain_transport_lib::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","id":1}"#, 1).expect("public parser must validate response");
|
||||||
|
let result = response.into_result().expect("success response must return result");
|
||||||
|
assert_eq!(result, serde_json::json!("ok"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_method_registry_exposes_current_and_historical_surfaces() {
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::current_http_rpc_methods().len(), 52);
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::historical_http_rpc_methods().len(), 14);
|
||||||
|
let removed = ksp_onchain_transport_lib::find_http_rpc_method("confirmTransaction").expect("historical method must be discoverable");
|
||||||
|
assert_eq!(removed.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Removed);
|
||||||
|
assert_eq!(removed.ensure_runtime_supported().expect_err("removed method must fail").code(), ksp_onchain_transport_lib::ERROR_CODE_METHOD_REMOVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_error_codes_share_the_core_error_domain() {
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS.domain(), "onchain_transport");
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_RPC_APPLICATION_ERROR.domain(), "onchain_transport");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pool_contract_selects_a_standard_method_without_exposing_url() {
|
||||||
|
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://provider.invalid/rpc?token=SECRET-CANARY")
|
||||||
|
.expect("public URL parser must accept HTTPS endpoint");
|
||||||
|
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![ksp_onchain_transport_lib::HttpRequestKind::new("get_balance")],
|
||||||
|
10,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||||
|
"primary",
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::HttpProviderName::new("provider"),
|
||||||
|
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::option::Option::Some(8),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
ksp_onchain_transport_lib::HttpRetrySettings::new(1, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)),
|
||||||
|
);
|
||||||
|
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(settings).expect("public pool constructor must succeed");
|
||||||
|
let method = ksp_onchain_transport_lib::find_http_rpc_method("getBalance").expect("audited method must exist");
|
||||||
|
let selected = pool.select_for_method(&ksp_onchain_transport_lib::HttpRoleName::new("default"), method).expect("public pool must route audited method");
|
||||||
|
assert_eq!(selected.endpoint_name(), "primary");
|
||||||
|
let rendered = format!("{pool:?}");
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("provider.invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_retry_policy_preserves_no_resend_after_ambiguous_write_dispatch() {
|
||||||
|
let method = ksp_onchain_transport_lib::find_http_rpc_method("sendTransaction").expect("sendTransaction must be audited");
|
||||||
|
let settings = ksp_onchain_transport_lib::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(1));
|
||||||
|
let decision = ksp_onchain_transport_lib::evaluate_transport_retry(
|
||||||
|
method,
|
||||||
|
&settings,
|
||||||
|
ksp_onchain_transport_lib::HttpRetryCause::Timeout,
|
||||||
|
ksp_onchain_transport_lib::HttpDispatchState::DispatchedAmbiguous,
|
||||||
|
0,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert_eq!(decision, ksp_onchain_transport_lib::HttpRetryDecision::Stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn public_async_admission_exposes_bounded_permit_without_endpoint_url() {
|
||||||
|
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://provider.invalid/rpc?token=ASYNC-SECRET-CANARY")
|
||||||
|
.expect("public URL parser must accept HTTPS endpoint");
|
||||||
|
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![ksp_onchain_transport_lib::HttpRequestKind::new("get_balance")],
|
||||||
|
10,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::num::NonZeroU32::new(1),
|
||||||
|
std::option::Option::None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||||
|
"primary",
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::HttpProviderName::new("provider"),
|
||||||
|
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::option::Option::Some(8),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
ksp_onchain_transport_lib::HttpRetrySettings::new(1, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)),
|
||||||
|
);
|
||||||
|
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(settings).expect("public pool constructor must succeed");
|
||||||
|
let method = ksp_onchain_transport_lib::find_http_rpc_method("getBalance").expect("audited method must exist");
|
||||||
|
let permit = pool
|
||||||
|
.acquire_for_method(&ksp_onchain_transport_lib::HttpRoleName::new("default"), method)
|
||||||
|
.await
|
||||||
|
.expect("public async admission must acquire capacity");
|
||||||
|
assert_eq!(permit.selection().endpoint_name(), "primary");
|
||||||
|
assert!(permit.remaining_timeout() > std::time::Duration::ZERO);
|
||||||
|
let rendered = format!("{permit:?}");
|
||||||
|
assert!(!rendered.contains("ASYNC-SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("provider.invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_typed_canary_contracts_are_available_from_crate_root() {
|
||||||
|
let config = ksp_onchain_transport_lib::GetBalanceConfig::new(
|
||||||
|
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||||
|
std::option::Option::Some(42),
|
||||||
|
);
|
||||||
|
assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||||
|
assert_eq!(config.min_context_slot(), std::option::Option::Some(42));
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::SolanaNodeHealth::Healthy, ksp_onchain_transport_lib::SolanaNodeHealth::Healthy);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Release-level completeness canaries for the `0.2.1` HTTP foundation contract.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_registry_partition_matches_the_audited_http_plan() {
|
||||||
|
let mut foundation = 0_usize;
|
||||||
|
let mut accounts_tokens_cluster = 0_usize;
|
||||||
|
let mut transactions = 0_usize;
|
||||||
|
let mut blocks_economics = 0_usize;
|
||||||
|
let mut historical_in_current = 0_usize;
|
||||||
|
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
|
||||||
|
match descriptor.coverage_release() {
|
||||||
|
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_1 => foundation += 1,
|
||||||
|
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2 => accounts_tokens_cluster += 1,
|
||||||
|
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3 => transactions += 1,
|
||||||
|
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_4 => blocks_economics += 1,
|
||||||
|
ksp_onchain_transport_lib::HttpRpcCoverageRelease::Historical => historical_in_current += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::current_http_rpc_methods().len(), 52);
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::historical_http_rpc_methods().len(), 14);
|
||||||
|
assert_eq!(foundation, 4);
|
||||||
|
assert_eq!(accounts_tokens_cluster, 22);
|
||||||
|
assert_eq!(transactions, 11);
|
||||||
|
assert_eq!(blocks_economics, 15);
|
||||||
|
assert_eq!(historical_in_current, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_foundation_canaries_and_historical_statuses_are_exact() {
|
||||||
|
let mut foundation_names = std::vec::Vec::<&str>::new();
|
||||||
|
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
|
||||||
|
if descriptor.coverage_release() == ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_1 {
|
||||||
|
foundation_names.push(descriptor.method());
|
||||||
|
assert_eq!(descriptor.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Supported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foundation_names.sort_unstable();
|
||||||
|
assert_eq!(foundation_names, std::vec!["getBalance", "getGenesisHash", "getHealth", "getVersion"]);
|
||||||
|
for descriptor in ksp_onchain_transport_lib::historical_http_rpc_methods() {
|
||||||
|
assert_eq!(descriptor.documentation_status(), ksp_onchain_transport_lib::RpcDocumentationStatus::Deprecated);
|
||||||
|
assert_eq!(descriptor.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Removed);
|
||||||
|
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::Historical);
|
||||||
|
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NotApplicable);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
crates/ksp-onchain-transport-lib/unit_tests/client.rs
Normal file
62
crates/ksp-onchain-transport-lib/unit_tests/client.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/client.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
fn endpoint(enabled: bool, url_text: &str) -> crate::HttpEndpointSettings {
|
||||||
|
let url = crate::HttpEndpointUrl::parse(url_text).expect("test endpoint URL must parse");
|
||||||
|
let role = crate::HttpEndpointRoleSettings::new(
|
||||||
|
crate::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![crate::HttpRequestKind::wildcard()],
|
||||||
|
10,
|
||||||
|
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
return crate::HttpEndpointSettings::new(
|
||||||
|
"endpoint",
|
||||||
|
enabled,
|
||||||
|
crate::HttpProviderName::new("provider"),
|
||||||
|
crate::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
std::option::Option::Some(4),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_client_snapshot_never_contains_url_or_secret_material() {
|
||||||
|
let client = super::HttpEndpointClient::new(endpoint(true, "https://provider.invalid/rpc?api-key=SECRET-CANARY")).expect("client must build");
|
||||||
|
let snapshot = client.snapshot();
|
||||||
|
let rendered = format!("{snapshot:?} {client:?}");
|
||||||
|
assert_eq!(snapshot.availability(), crate::HttpEndpointAvailability::Available);
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("provider.invalid"));
|
||||||
|
assert!(!rendered.contains("https://"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_endpoint_client_is_visible_but_not_selectable() {
|
||||||
|
let client = super::HttpEndpointClient::new(endpoint(false, "https://api.devnet.solana.com")).expect("disabled client must still build");
|
||||||
|
assert_eq!(client.snapshot().availability(), crate::HttpEndpointAvailability::Disabled);
|
||||||
|
assert!(!client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_client_matches_exact_and_wildcard_capabilities() {
|
||||||
|
let client = super::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
|
||||||
|
assert!(client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")));
|
||||||
|
assert!(!client.supports(&crate::HttpRoleName::new("write"), &crate::HttpRequestKind::new("get_balance")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_role_snapshot_exposes_safe_resilience_state() {
|
||||||
|
let client = super::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
|
||||||
|
let snapshot = client.snapshot();
|
||||||
|
let role = &snapshot.roles()[0];
|
||||||
|
assert_eq!(role.availability(), crate::HttpEndpointAvailability::Available);
|
||||||
|
assert_eq!(role.in_flight_requests(), std::option::Option::None);
|
||||||
|
assert_eq!(role.cooldown_remaining(), std::option::Option::None);
|
||||||
|
assert_eq!(role.success_count(), 0);
|
||||||
|
assert_eq!(role.failure_count(), 0);
|
||||||
|
assert_eq!(role.rate_limit_count(), 0);
|
||||||
|
}
|
||||||
141
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
Normal file
141
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/executor.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
fn pool_for_url(url: &str, request_timeout: std::time::Duration, max_retries: u32) -> crate::HttpTransportPool {
|
||||||
|
let role = crate::HttpEndpointRoleSettings::new(
|
||||||
|
crate::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![crate::HttpRequestKind::wildcard()],
|
||||||
|
10,
|
||||||
|
crate::HttpRoleLimits::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::Some(std::time::Duration::from_millis(1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let endpoint = crate::HttpEndpointSettings::new(
|
||||||
|
"fixture",
|
||||||
|
true,
|
||||||
|
crate::HttpProviderName::new("fixture"),
|
||||||
|
crate::HttpClusterName::new("local"),
|
||||||
|
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
request_timeout,
|
||||||
|
std::option::Option::Some(1),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = crate::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
crate::HttpRetrySettings::new(max_retries, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
|
||||||
|
);
|
||||||
|
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health_method() -> &'static crate::HttpRpcMethodDescriptor {
|
||||||
|
return crate::find_http_rpc_method("getHealth").expect("getHealth descriptor must exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_rate_limit_then_success() -> (std::string::String, std::thread::JoinHandle<usize>) {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||||
|
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let mut count = 0_usize;
|
||||||
|
while count < 2 {
|
||||||
|
let (mut stream, _) = listener.accept().expect("fixture server must accept request");
|
||||||
|
let _ = read_request(&mut stream);
|
||||||
|
let response = if count == 0 {
|
||||||
|
"HTTP/1.1 429 Too Many Requests\r\nRetry-After: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned()
|
||||||
|
} else {
|
||||||
|
let body = include_str!("../fixtures/http/get_health.success.json");
|
||||||
|
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body)
|
||||||
|
};
|
||||||
|
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
||||||
|
count = count.saturating_add(1);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
return (format!("http://{address}"), handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_timeout() -> (std::string::String, std::thread::JoinHandle<()>) {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||||
|
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("fixture server must accept request");
|
||||||
|
let _ = read_request(&mut stream);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
return (format!("http://{address}"), handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_request(stream: &mut std::net::TcpStream) -> std::string::String {
|
||||||
|
let mut bytes = std::vec::Vec::new();
|
||||||
|
let mut buffer = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read");
|
||||||
|
if count == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&buffer[..count]);
|
||||||
|
if request_complete(bytes.as_slice()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_complete(bytes: &[u8]) -> bool {
|
||||||
|
let text = match std::str::from_utf8(bytes) {
|
||||||
|
std::result::Result::Ok(text) => text,
|
||||||
|
std::result::Result::Err(_) => return false,
|
||||||
|
};
|
||||||
|
let header_end = match text.find("\r\n\r\n") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return false,
|
||||||
|
};
|
||||||
|
let mut content_length = 0_usize;
|
||||||
|
for line in text[..header_end].lines() {
|
||||||
|
let (name, value) = match line.split_once(':') {
|
||||||
|
std::option::Option::Some(parts) => parts,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if name.eq_ignore_ascii_case("content-length") {
|
||||||
|
content_length = value.trim().parse::<usize>().expect("content length must parse");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn executor_applies_retry_after_and_retries_http_429_for_retry_safe_method() {
|
||||||
|
let (url, handle) = serve_rate_limit_then_success();
|
||||||
|
let pool = pool_for_url(url.as_str(), std::time::Duration::from_millis(500), 1);
|
||||||
|
let result = pool
|
||||||
|
.execute_standard_rpc(&crate::HttpRoleName::new("default"), health_method(), std::vec::Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("retry-safe request must recover from one 429");
|
||||||
|
assert_eq!(result, serde_json::json!("ok"));
|
||||||
|
assert_eq!(handle.join().expect("fixture server must join"), 2);
|
||||||
|
let snapshot = pool.snapshot();
|
||||||
|
assert_eq!(snapshot.endpoints()[0].roles()[0].rate_limit_count(), 1);
|
||||||
|
assert_eq!(snapshot.endpoints()[0].roles()[0].success_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn executor_maps_reqwest_timeout_to_ksp_timeout_error_without_endpoint_secret_leak() {
|
||||||
|
const SECRET_CANARY: &str = "SECRET-REQWEST-URL-CANARY";
|
||||||
|
let (url, handle) = serve_timeout();
|
||||||
|
let sensitive_url = format!("{url}/rpc?api-key={SECRET_CANARY}");
|
||||||
|
let pool = pool_for_url(sensitive_url.as_str(), std::time::Duration::from_millis(20), 0);
|
||||||
|
let error = pool
|
||||||
|
.execute_standard_rpc(&crate::HttpRoleName::new("default"), health_method(), std::vec::Vec::new())
|
||||||
|
.await
|
||||||
|
.expect_err("timed out request must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT);
|
||||||
|
assert!(!format!("{error:?}").contains(SECRET_CANARY));
|
||||||
|
let source = std::error::Error::source(&error).expect("transport timeout should preserve a sanitized reqwest source");
|
||||||
|
assert!(!format!("{source:?}").contains(SECRET_CANARY));
|
||||||
|
handle.join().expect("fixture server must join");
|
||||||
|
}
|
||||||
115
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
Normal file
115
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_serialization_matches_json_rpc_2_0_shape() {
|
||||||
|
let request = super::JsonRpcRequest::new(7, "getBalance", std::vec![serde_json::json!("Address111"), serde_json::json!({"commitment":"confirmed"})])
|
||||||
|
.expect("valid request must construct");
|
||||||
|
let encoded = request.to_json_string().expect("serializable request must encode");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(encoded.as_str()).expect("encoded request must remain JSON");
|
||||||
|
assert_eq!(value["jsonrpc"], serde_json::json!("2.0"));
|
||||||
|
assert_eq!(value["id"], serde_json::json!(7));
|
||||||
|
assert_eq!(value["method"], serde_json::json!("getBalance"));
|
||||||
|
assert_eq!(value["params"].as_array().expect("params must be an array").len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_rejects_empty_or_untrimmed_method() {
|
||||||
|
assert!(super::JsonRpcRequest::new(1, "", std::vec![]).is_err());
|
||||||
|
assert!(super::JsonRpcRequest::new(1, " getHealth", std::vec![]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_preserves_null_success_result() {
|
||||||
|
let response = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":null,"id":9}"#, 9).expect("null result is a valid success payload");
|
||||||
|
assert!(matches!(&response, super::JsonRpcResponse::Success(_)), "success response must not parse as error");
|
||||||
|
if let super::JsonRpcResponse::Success(success) = response {
|
||||||
|
assert!(success.result().is_null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_preserves_rpc_error_payload() {
|
||||||
|
let response = super::parse_json_rpc_response_text(
|
||||||
|
r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is unhealthy","data":{"numSlotsBehind":12}},"id":4}"#,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
.expect("valid JSON-RPC error envelope must parse");
|
||||||
|
assert!(matches!(&response, super::JsonRpcResponse::Error(_)), "RPC error response must not parse as success");
|
||||||
|
if let super::JsonRpcResponse::Error(error_response) = response {
|
||||||
|
assert_eq!(error_response.error().code(), -32005);
|
||||||
|
assert_eq!(error_response.error().message(), "Node is unhealthy");
|
||||||
|
assert_eq!(error_response.error().data(), std::option::Option::Some(&serde_json::json!({"numSlotsBehind":12})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_rejects_id_mismatch() {
|
||||||
|
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","id":2}"#, 1).expect_err("mismatched id must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_rejects_wrong_protocol_version() {
|
||||||
|
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"1.0","result":"ok","id":1}"#, 1).expect_err("wrong version must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_rejects_both_result_and_error() {
|
||||||
|
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","error":{"code":-1,"message":"bad"},"id":1}"#, 1)
|
||||||
|
.expect_err("mutually exclusive fields must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_rejects_missing_result_and_error() {
|
||||||
|
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","id":1}"#, 1).expect_err("missing outcome must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_distinguishes_invalid_json_from_protocol_error() {
|
||||||
|
let error = super::parse_json_rpc_response_text("not-json", 1).expect_err("invalid JSON must fail decoding");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_DECODE_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rpc_error_maps_to_shared_ksp_error_without_copying_remote_payload_into_context() {
|
||||||
|
let response = super::parse_json_rpc_response_text(
|
||||||
|
r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"SECRET-CANARY","data":{"payload":"SECRET-DATA"}},"id":1}"#,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.expect("valid error envelope must parse");
|
||||||
|
let error = response.into_result().expect_err("RPC application error must map to KSP error");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
||||||
|
let rendered = format!("{error:?}");
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("SECRET-DATA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_debug_omits_parameter_payloads() {
|
||||||
|
let request = super::JsonRpcRequest::new(1, "sendTransaction", std::vec![serde_json::json!("SIGNED-TRANSACTION-SECRET-CANARY")])
|
||||||
|
.expect("test request must construct");
|
||||||
|
let rendered = format!("{request:?}");
|
||||||
|
assert!(rendered.contains("sendTransaction"));
|
||||||
|
assert!(rendered.contains("param_count"));
|
||||||
|
assert!(!rendered.contains("SIGNED-TRANSACTION-SECRET-CANARY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_debug_omits_result_and_remote_error_payloads() {
|
||||||
|
let success = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":{"secret":"RESULT-SECRET-CANARY"},"id":1}"#, 1)
|
||||||
|
.expect("test success response must parse");
|
||||||
|
let success_rendered = format!("{success:?}");
|
||||||
|
assert!(!success_rendered.contains("RESULT-SECRET-CANARY"));
|
||||||
|
let failure = super::parse_json_rpc_response_text(
|
||||||
|
r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"MESSAGE-SECRET-CANARY","data":{"secret":"DATA-SECRET-CANARY"}},"id":2}"#,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.expect("test error response must parse");
|
||||||
|
let failure_rendered = format!("{failure:?}");
|
||||||
|
assert!(!failure_rendered.contains("MESSAGE-SECRET-CANARY"));
|
||||||
|
assert!(!failure_rendered.contains("DATA-SECRET-CANARY"));
|
||||||
|
}
|
||||||
336
crates/ksp-onchain-transport-lib/unit_tests/pool.rs
Normal file
336
crates/ksp-onchain-transport-lib/unit_tests/pool.rs
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/pool.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
fn role(name: &str, priority: u32, request_kinds: std::vec::Vec<crate::HttpRequestKind>) -> crate::HttpEndpointRoleSettings {
|
||||||
|
return crate::HttpEndpointRoleSettings::new(
|
||||||
|
crate::HttpRoleName::new(name),
|
||||||
|
true,
|
||||||
|
request_kinds,
|
||||||
|
priority,
|
||||||
|
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(name: &str, enabled: bool, priority: u32, request_kinds: std::vec::Vec<crate::HttpRequestKind>) -> crate::HttpEndpointSettings {
|
||||||
|
return crate::HttpEndpointSettings::new(
|
||||||
|
name,
|
||||||
|
enabled,
|
||||||
|
crate::HttpProviderName::new("provider"),
|
||||||
|
crate::HttpClusterName::new("devnet"),
|
||||||
|
crate::HttpEndpointUrl::parse(format!("https://{name}.invalid/rpc?token=SECRET-CANARY")).expect("test URL must parse"),
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
std::option::Option::Some(4),
|
||||||
|
std::vec![role("default", priority, request_kinds)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_zero(value: u32) -> std::num::NonZeroU32 {
|
||||||
|
return std::num::NonZeroU32::new(value).expect("test limit must be non-zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limited_endpoint(
|
||||||
|
name: &str,
|
||||||
|
priority: u32,
|
||||||
|
requests_per_second: std::option::Option<u32>,
|
||||||
|
burst_capacity: std::option::Option<u32>,
|
||||||
|
max_concurrent_requests: std::option::Option<u32>,
|
||||||
|
cooldown: std::option::Option<std::time::Duration>,
|
||||||
|
) -> crate::HttpEndpointSettings {
|
||||||
|
let limits = crate::HttpRoleLimits::new(
|
||||||
|
requests_per_second.map(|value| return non_zero(value)),
|
||||||
|
burst_capacity.map(|value| return non_zero(value)),
|
||||||
|
max_concurrent_requests.map(|value| return non_zero(value)),
|
||||||
|
cooldown,
|
||||||
|
);
|
||||||
|
let role = crate::HttpEndpointRoleSettings::new(crate::HttpRoleName::new("default"), true, std::vec![crate::HttpRequestKind::wildcard()], priority, limits);
|
||||||
|
return crate::HttpEndpointSettings::new(
|
||||||
|
name,
|
||||||
|
true,
|
||||||
|
crate::HttpProviderName::new("provider"),
|
||||||
|
crate::HttpClusterName::new("devnet"),
|
||||||
|
crate::HttpEndpointUrl::parse(format!("https://{name}.invalid/rpc?token=SECRET-CANARY")).expect("test URL must parse"),
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
std::option::Option::Some(4),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(endpoints: std::vec::Vec<crate::HttpEndpointSettings>) -> crate::HttpTransportSettings {
|
||||||
|
return crate::HttpTransportSettings::new(
|
||||||
|
endpoints,
|
||||||
|
crate::HttpRetrySettings::new(2, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_prefers_lowest_priority_tier() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||||
|
endpoint("secondary", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
endpoint("primary", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let selection = pool
|
||||||
|
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
|
||||||
|
.expect("selection must succeed");
|
||||||
|
assert_eq!(selection.endpoint_name(), "primary");
|
||||||
|
assert_eq!(selection.priority(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_round_robins_fairly_inside_best_priority_tier() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||||
|
endpoint("one", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
endpoint("two", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let role = crate::HttpRoleName::new("default");
|
||||||
|
let kind = crate::HttpRequestKind::new("get_balance");
|
||||||
|
let first = pool.select_for_request_kind(&role, &kind).expect("first selection must succeed");
|
||||||
|
let second = pool.select_for_request_kind(&role, &kind).expect("second selection must succeed");
|
||||||
|
let third = pool.select_for_request_kind(&role, &kind).expect("third selection must succeed");
|
||||||
|
assert_eq!(first.endpoint_name(), "one");
|
||||||
|
assert_eq!(second.endpoint_name(), "two");
|
||||||
|
assert_eq!(third.endpoint_name(), "one");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_best_priority_endpoint_falls_back_to_next_tier() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||||
|
endpoint("disabled-primary", false, 1, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let selection = pool
|
||||||
|
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
|
||||||
|
.expect("fallback must be selected");
|
||||||
|
assert_eq!(selection.endpoint_name(), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_filters_role_and_capability_before_priority() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||||
|
endpoint("wrong-capability", true, 1, std::vec![crate::HttpRequestKind::new("send_transaction")]),
|
||||||
|
endpoint("matching", true, 50, std::vec![crate::HttpRequestKind::new("get_balance")]),
|
||||||
|
]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let selection = pool
|
||||||
|
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
|
||||||
|
.expect("matching capability must be selected");
|
||||||
|
assert_eq!(selection.endpoint_name(), "matching");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_returns_structured_error_when_no_endpoint_matches() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("read-only", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let error = pool
|
||||||
|
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("send_transaction"))
|
||||||
|
.expect_err("unsupported request kind must fail selection");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_ENDPOINT_SELECTION_FAILED);
|
||||||
|
assert!(!format!("{error:?}").contains("SECRET-CANARY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_method_selection_uses_registry_request_kind() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("balance", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let method = crate::find_http_rpc_method("getBalance").expect("audited method must exist");
|
||||||
|
let selection = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect("standard method must route");
|
||||||
|
assert_eq!(selection.request_kind().as_str(), "get_balance");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_snapshot_is_safe_and_preserves_disabled_endpoints() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||||
|
endpoint("enabled", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
endpoint("disabled", false, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let snapshot = pool.snapshot();
|
||||||
|
let rendered = format!("{snapshot:?} {pool:?}");
|
||||||
|
assert_eq!(snapshot.endpoint_count(), 2);
|
||||||
|
assert_eq!(snapshot.available_endpoint_count(), 1);
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains(".invalid/rpc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_role_is_excluded_before_priority_selection() {
|
||||||
|
let base = endpoint("disabled-role", true, 1, std::vec![crate::HttpRequestKind::wildcard()]);
|
||||||
|
let disabled_role = crate::HttpEndpointRoleSettings::new(
|
||||||
|
crate::HttpRoleName::new("default"),
|
||||||
|
false,
|
||||||
|
std::vec![crate::HttpRequestKind::wildcard()],
|
||||||
|
1,
|
||||||
|
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
let enabled_non_matching_role = crate::HttpEndpointRoleSettings::new(
|
||||||
|
crate::HttpRoleName::new("maintenance"),
|
||||||
|
true,
|
||||||
|
std::vec![crate::HttpRequestKind::wildcard()],
|
||||||
|
1,
|
||||||
|
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
let disabled_role_endpoint = crate::HttpEndpointSettings::new(
|
||||||
|
base.name(),
|
||||||
|
true,
|
||||||
|
base.provider().clone(),
|
||||||
|
base.cluster().clone(),
|
||||||
|
base.url().clone(),
|
||||||
|
base.connect_timeout(),
|
||||||
|
base.request_timeout(),
|
||||||
|
base.max_idle_connections_per_host(),
|
||||||
|
std::vec![disabled_role, enabled_non_matching_role],
|
||||||
|
);
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||||
|
disabled_role_endpoint,
|
||||||
|
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||||
|
]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let selection = pool
|
||||||
|
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
|
||||||
|
.expect("enabled fallback role must be selected");
|
||||||
|
assert_eq!(selection.endpoint_name(), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removed_standard_method_is_rejected_before_endpoint_routing() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("wildcard", true, 10, std::vec![crate::HttpRequestKind::wildcard()],)]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let method = crate::find_http_rpc_method("confirmTransaction").expect("historical method must exist");
|
||||||
|
let error = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect_err("removed standard method must be rejected before routing");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_METHOD_REMOVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_concurrency_saturation_falls_back_to_lower_priority_tier() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||||
|
limited_endpoint("primary", 1, std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None),
|
||||||
|
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None),
|
||||||
|
]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let role = crate::HttpRoleName::new("default");
|
||||||
|
let kind = crate::HttpRequestKind::new("get_balance");
|
||||||
|
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("first request must acquire primary");
|
||||||
|
assert_eq!(first.selection().endpoint_name(), "primary");
|
||||||
|
let second = pool.acquire_for_request_kind(&role, &kind).await.expect("second request must fall back while primary is saturated");
|
||||||
|
assert_eq!(second.selection().endpoint_name(), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_token_bucket_exhaustion_falls_back_without_busy_waiting() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||||
|
limited_endpoint("primary", 1, std::option::Option::Some(1), std::option::Option::Some(1), std::option::Option::None, std::option::Option::None),
|
||||||
|
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||||
|
]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let role = crate::HttpRoleName::new("default");
|
||||||
|
let kind = crate::HttpRequestKind::new("get_balance");
|
||||||
|
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("first request must consume primary token");
|
||||||
|
assert_eq!(first.selection().endpoint_name(), "primary");
|
||||||
|
drop(first);
|
||||||
|
let second = pool.acquire_for_request_kind(&role, &kind).await.expect("fallback must be used while primary token bucket refills");
|
||||||
|
assert_eq!(second.selection().endpoint_name(), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_cooldown_excludes_rate_limited_role_and_uses_fallback() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||||
|
limited_endpoint(
|
||||||
|
"primary",
|
||||||
|
1,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::Some(std::time::Duration::from_millis(50)),
|
||||||
|
),
|
||||||
|
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||||
|
]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let role = crate::HttpRoleName::new("default");
|
||||||
|
let kind = crate::HttpRequestKind::new("get_balance");
|
||||||
|
let primary = pool.acquire_for_request_kind(&role, &kind).await.expect("primary must be acquired");
|
||||||
|
assert_eq!(primary.selection().endpoint_name(), "primary");
|
||||||
|
let pause = primary.record_rate_limited(std::option::Option::None);
|
||||||
|
assert_eq!(pause, std::time::Duration::from_millis(50));
|
||||||
|
drop(primary);
|
||||||
|
let fallback = pool.acquire_for_request_kind(&role, &kind).await.expect("fallback must be selected during primary cooldown");
|
||||||
|
assert_eq!(fallback.selection().endpoint_name(), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admission_waits_for_released_concurrency_without_holding_a_sync_mutex_across_await() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![limited_endpoint(
|
||||||
|
"primary",
|
||||||
|
1,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::Some(1),
|
||||||
|
std::option::Option::None,
|
||||||
|
)]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let role = crate::HttpRoleName::new("default");
|
||||||
|
let kind = crate::HttpRequestKind::new("get_balance");
|
||||||
|
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("first permit must be acquired");
|
||||||
|
let release_task = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
drop(first);
|
||||||
|
});
|
||||||
|
let second = pool
|
||||||
|
.acquire_for_request_kind_with_timeout(&role, &kind, std::time::Duration::from_millis(100))
|
||||||
|
.await
|
||||||
|
.expect("second permit must wake after concurrency release");
|
||||||
|
assert_eq!(second.selection().endpoint_name(), "primary");
|
||||||
|
release_task.await.expect("release task must complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admission_timeout_is_bounded_when_concurrency_never_becomes_available() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![limited_endpoint(
|
||||||
|
"primary",
|
||||||
|
1,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::Some(1),
|
||||||
|
std::option::Option::None,
|
||||||
|
)]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let role = crate::HttpRoleName::new("default");
|
||||||
|
let kind = crate::HttpRequestKind::new("get_balance");
|
||||||
|
let _held = pool.acquire_for_request_kind(&role, &kind).await.expect("first permit must be acquired");
|
||||||
|
let error = pool
|
||||||
|
.acquire_for_request_kind_with_timeout(&role, &kind, std::time::Duration::from_millis(20))
|
||||||
|
.await
|
||||||
|
.expect_err("second permit must time out while concurrency remains saturated");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn passive_health_snapshot_moves_from_degraded_back_to_available_after_success() {
|
||||||
|
let pool = super::HttpTransportPool::new(settings(std::vec![limited_endpoint(
|
||||||
|
"primary",
|
||||||
|
1,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
)]))
|
||||||
|
.expect("pool must build");
|
||||||
|
let role = crate::HttpRoleName::new("default");
|
||||||
|
let kind = crate::HttpRequestKind::new("get_balance");
|
||||||
|
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("request permit must be acquired");
|
||||||
|
first.record_failure();
|
||||||
|
drop(first);
|
||||||
|
let degraded = pool.snapshot();
|
||||||
|
assert_eq!(degraded.endpoints()[0].availability(), crate::HttpEndpointAvailability::Degraded);
|
||||||
|
assert_eq!(degraded.endpoints()[0].roles()[0].failure_count(), 1);
|
||||||
|
let second = pool.acquire_for_request_kind(&role, &kind).await.expect("degraded endpoint remains eligible for passive recovery");
|
||||||
|
second.record_success();
|
||||||
|
drop(second);
|
||||||
|
let recovered = pool.snapshot();
|
||||||
|
assert_eq!(recovered.endpoints()[0].availability(), crate::HttpEndpointAvailability::Available);
|
||||||
|
assert_eq!(recovered.endpoints()[0].roles()[0].success_count(), 1);
|
||||||
|
}
|
||||||
186
crates/ksp-onchain-transport-lib/unit_tests/resilience.rs
Normal file
186
crates/ksp-onchain-transport-lib/unit_tests/resilience.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/resilience.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
fn non_zero(value: u32) -> std::num::NonZeroU32 {
|
||||||
|
return std::num::NonZeroU32::new(value).expect("test limit must be non-zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_settings() -> crate::HttpRetrySettings {
|
||||||
|
return crate::HttpRetrySettings::new(4, std::time::Duration::from_millis(100), std::time::Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method(name: &str) -> &'static crate::HttpRpcMethodDescriptor {
|
||||||
|
return crate::find_http_rpc_method(name).expect("audited test method must exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_limits(
|
||||||
|
requests_per_second: std::option::Option<u32>,
|
||||||
|
burst_capacity: std::option::Option<u32>,
|
||||||
|
max_concurrent_requests: std::option::Option<u32>,
|
||||||
|
cooldown: std::option::Option<std::time::Duration>,
|
||||||
|
) -> crate::HttpEndpointRoleSettings {
|
||||||
|
return crate::HttpEndpointRoleSettings::new(
|
||||||
|
crate::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![crate::HttpRequestKind::wildcard()],
|
||||||
|
10,
|
||||||
|
crate::HttpRoleLimits::new(
|
||||||
|
requests_per_second.map(|value| return non_zero(value)),
|
||||||
|
burst_capacity.map(|value| return non_zero(value)),
|
||||||
|
max_concurrent_requests.map(|value| return non_zero(value)),
|
||||||
|
cooldown,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_backoff_is_exponential_and_bounded() {
|
||||||
|
let settings = retry_settings();
|
||||||
|
assert_eq!(super::retry_backoff(&settings, 1), std::time::Duration::from_millis(100));
|
||||||
|
assert_eq!(super::retry_backoff(&settings, 2), std::time::Duration::from_millis(200));
|
||||||
|
assert_eq!(super::retry_backoff(&settings, 3), std::time::Duration::from_millis(400));
|
||||||
|
assert_eq!(super::retry_backoff(&settings, 4), std::time::Duration::from_millis(500));
|
||||||
|
assert_eq!(super::retry_backoff(&settings, 32), std::time::Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_safe_timeout_is_retried_until_budget_is_exhausted() {
|
||||||
|
let settings = retry_settings();
|
||||||
|
let first = super::evaluate_transport_retry(
|
||||||
|
method("getBalance"),
|
||||||
|
&settings,
|
||||||
|
super::HttpRetryCause::Timeout,
|
||||||
|
super::HttpDispatchState::DispatchedAmbiguous,
|
||||||
|
0,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert_eq!(first, super::HttpRetryDecision::RetryAfter(std::time::Duration::from_millis(100)));
|
||||||
|
let exhausted = super::evaluate_transport_retry(
|
||||||
|
method("getBalance"),
|
||||||
|
&settings,
|
||||||
|
super::HttpRetryCause::Timeout,
|
||||||
|
super::HttpDispatchState::DispatchedAmbiguous,
|
||||||
|
settings.max_retries(),
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert_eq!(exhausted, super::HttpRetryDecision::Stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_submission_never_retries_after_ambiguous_dispatch() {
|
||||||
|
let decision = super::evaluate_transport_retry(
|
||||||
|
method("sendTransaction"),
|
||||||
|
&retry_settings(),
|
||||||
|
super::HttpRetryCause::Connection,
|
||||||
|
super::HttpDispatchState::DispatchedAmbiguous,
|
||||||
|
0,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert_eq!(decision, super::HttpRetryDecision::Stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_submission_can_retry_when_transport_proves_no_dispatch() {
|
||||||
|
let decision = super::evaluate_transport_retry(
|
||||||
|
method("sendTransaction"),
|
||||||
|
&retry_settings(),
|
||||||
|
super::HttpRetryCause::Connection,
|
||||||
|
super::HttpDispatchState::NotDispatched,
|
||||||
|
0,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert!(decision.should_retry());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rpc_application_and_invalid_response_are_not_transport_retries() {
|
||||||
|
for cause in [super::HttpRetryCause::RpcApplication, super::HttpRetryCause::InvalidResponse, super::HttpRetryCause::Request] {
|
||||||
|
let decision = super::evaluate_transport_retry(
|
||||||
|
method("getBalance"),
|
||||||
|
&retry_settings(),
|
||||||
|
cause,
|
||||||
|
super::HttpDispatchState::NotDispatched,
|
||||||
|
0,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert_eq!(decision, super::HttpRetryDecision::Stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_retry_after_can_extend_backoff_but_is_defensively_bounded() {
|
||||||
|
let settings = retry_settings();
|
||||||
|
let extended = super::evaluate_transport_retry(
|
||||||
|
method("getBalance"),
|
||||||
|
&settings,
|
||||||
|
super::HttpRetryCause::RateLimited,
|
||||||
|
super::HttpDispatchState::DispatchedAmbiguous,
|
||||||
|
0,
|
||||||
|
std::option::Option::Some(std::time::Duration::from_secs(3)),
|
||||||
|
);
|
||||||
|
assert_eq!(extended.delay(), std::option::Option::Some(std::time::Duration::from_secs(3)));
|
||||||
|
let bounded = super::evaluate_transport_retry(
|
||||||
|
method("getBalance"),
|
||||||
|
&settings,
|
||||||
|
super::HttpRetryCause::RateLimited,
|
||||||
|
super::HttpDispatchState::DispatchedAmbiguous,
|
||||||
|
0,
|
||||||
|
std::option::Option::Some(std::time::Duration::from_secs(600)),
|
||||||
|
);
|
||||||
|
assert_eq!(bounded.delay(), std::option::Option::Some(std::time::Duration::from_secs(60)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_bucket_consumes_burst_then_refills_from_elapsed_time() {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let mut bucket = super::HttpTokenBucketState::new(2, 2, start);
|
||||||
|
assert!(bucket.try_consume_at(start).is_none());
|
||||||
|
assert!(bucket.try_consume_at(start).is_none());
|
||||||
|
assert!(bucket.try_consume_at(start).is_some());
|
||||||
|
let later = start.checked_add(std::time::Duration::from_millis(500)).expect("test instant must advance");
|
||||||
|
assert!(bucket.try_consume_at(later).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_burst_capacity_defaults_to_one_second_of_rps_capacity() {
|
||||||
|
let role = role_limits(std::option::Option::Some(2), std::option::Option::None, std::option::Option::None, std::option::Option::None);
|
||||||
|
let runtime = std::sync::Arc::new(super::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let first = runtime.try_acquire(now);
|
||||||
|
let second = runtime.try_acquire(now);
|
||||||
|
let third = runtime.try_acquire(now);
|
||||||
|
assert!(matches!(first, super::RoleAdmissionAttempt::Ready(_)));
|
||||||
|
assert!(matches!(second, super::RoleAdmissionAttempt::Ready(_)));
|
||||||
|
assert!(matches!(third, super::RoleAdmissionAttempt::BlockedUntil(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrency_semaphore_releases_capacity_when_permit_is_dropped() {
|
||||||
|
let role = role_limits(std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None);
|
||||||
|
let runtime = std::sync::Arc::new(super::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let first = runtime.try_acquire(now);
|
||||||
|
let held = match first {
|
||||||
|
super::RoleAdmissionAttempt::Ready(permit) => permit,
|
||||||
|
_ => panic!("first concurrency permit must be available"),
|
||||||
|
};
|
||||||
|
assert!(matches!(runtime.try_acquire(now), super::RoleAdmissionAttempt::ConcurrencySaturated));
|
||||||
|
drop(held);
|
||||||
|
assert!(matches!(runtime.try_acquire(now), super::RoleAdmissionAttempt::Ready(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_cooldown_marks_role_and_caps_provider_delay() {
|
||||||
|
let role = role_limits(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::Some(std::time::Duration::from_millis(10)),
|
||||||
|
);
|
||||||
|
let runtime = super::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new()));
|
||||||
|
let pause = runtime.record_rate_limited(std::option::Option::Some(std::time::Duration::from_secs(600)));
|
||||||
|
assert_eq!(pause, std::time::Duration::from_secs(60));
|
||||||
|
assert_eq!(runtime.rate_limit_count(), 1);
|
||||||
|
assert_eq!(runtime.failure_count(), 1);
|
||||||
|
assert_eq!(runtime.availability(std::time::Instant::now()), crate::HttpEndpointAvailability::RateLimited);
|
||||||
|
}
|
||||||
137
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
Normal file
137
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
fn pool_for_url(url: &str) -> crate::HttpTransportPool {
|
||||||
|
let role = crate::HttpEndpointRoleSettings::new(
|
||||||
|
crate::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![crate::HttpRequestKind::wildcard()],
|
||||||
|
10,
|
||||||
|
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
let endpoint = crate::HttpEndpointSettings::new(
|
||||||
|
"fixture",
|
||||||
|
true,
|
||||||
|
crate::HttpProviderName::new("fixture"),
|
||||||
|
crate::HttpClusterName::new("local"),
|
||||||
|
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::option::Option::Some(1),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = crate::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)),
|
||||||
|
);
|
||||||
|
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle<std::string::String>) {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||||
|
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
|
||||||
|
let request = read_request(&mut stream);
|
||||||
|
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
|
||||||
|
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
return (format!("http://{address}"), handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_request(stream: &mut std::net::TcpStream) -> std::string::String {
|
||||||
|
let mut bytes = std::vec::Vec::new();
|
||||||
|
let mut buffer = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read");
|
||||||
|
if count == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&buffer[..count]);
|
||||||
|
if request_complete(bytes.as_slice()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_complete(bytes: &[u8]) -> bool {
|
||||||
|
let text = match std::str::from_utf8(bytes) {
|
||||||
|
std::result::Result::Ok(text) => text,
|
||||||
|
std::result::Result::Err(_) => return false,
|
||||||
|
};
|
||||||
|
let header_end = match text.find("\r\n\r\n") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return false,
|
||||||
|
};
|
||||||
|
let mut content_length = 0_usize;
|
||||||
|
for line in text[..header_end].lines() {
|
||||||
|
let (name, value) = match line.split_once(':') {
|
||||||
|
std::option::Option::Some(parts) => parts,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if name.eq_ignore_ascii_case("content-length") {
|
||||||
|
content_length = value.trim().parse::<usize>().expect("content length must parse");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_body(request: &str) -> serde_json::Value {
|
||||||
|
let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist");
|
||||||
|
return serde_json::from_str(body).expect("fixture request body must be JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn typed_get_health_executes_real_http_fixture() {
|
||||||
|
let (url, handle) = serve_once(include_str!("../fixtures/http/get_health.success.json"));
|
||||||
|
let pool = pool_for_url(url.as_str());
|
||||||
|
let result = pool.get_health(&crate::HttpRoleName::new("default")).await.expect("getHealth fixture must succeed");
|
||||||
|
assert_eq!(result, crate::SolanaNodeHealth::Healthy);
|
||||||
|
let request = handle.join().expect("fixture server must join");
|
||||||
|
let body = request_body(request.as_str());
|
||||||
|
assert_eq!(body["method"], serde_json::json!("getHealth"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn typed_get_genesis_hash_executes_real_http_fixture() {
|
||||||
|
let (url, handle) = serve_once(include_str!("../fixtures/http/get_genesis_hash.success.json"));
|
||||||
|
let pool = pool_for_url(url.as_str());
|
||||||
|
let result = pool.get_genesis_hash(&crate::HttpRoleName::new("default")).await.expect("getGenesisHash fixture must succeed");
|
||||||
|
assert_eq!(result.as_str(), "GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC");
|
||||||
|
let request = handle.join().expect("fixture server must join");
|
||||||
|
assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getGenesisHash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn typed_get_version_executes_real_http_fixture() {
|
||||||
|
let (url, handle) = serve_once(include_str!("../fixtures/http/get_version.success.json"));
|
||||||
|
let pool = pool_for_url(url.as_str());
|
||||||
|
let result = pool.get_version(&crate::HttpRoleName::new("default")).await.expect("getVersion fixture must succeed");
|
||||||
|
assert_eq!(result.solana_core(), "3.1.8");
|
||||||
|
assert_eq!(result.feature_set(), std::option::Option::Some(2_891_131_721));
|
||||||
|
let request = handle.join().expect("fixture server must join");
|
||||||
|
assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getVersion"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn typed_get_balance_executes_real_http_fixture_and_encodes_config() {
|
||||||
|
let (url, handle) = serve_once(include_str!("../fixtures/http/get_balance.success.json"));
|
||||||
|
let pool = pool_for_url(url.as_str());
|
||||||
|
let account = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("system address must parse");
|
||||||
|
let config = crate::GetBalanceConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(123));
|
||||||
|
let result = pool
|
||||||
|
.get_balance(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config))
|
||||||
|
.await
|
||||||
|
.expect("getBalance fixture must succeed");
|
||||||
|
assert_eq!(result.value(), 424_242);
|
||||||
|
assert_eq!(result.context().slot(), 123_456_789);
|
||||||
|
assert_eq!(result.context().api_version(), std::option::Option::Some("3.1.8"));
|
||||||
|
let request = handle.join().expect("fixture server must join");
|
||||||
|
let body = request_body(request.as_str());
|
||||||
|
assert_eq!(body["method"], serde_json::json!("getBalance"));
|
||||||
|
assert_eq!(body["params"][0], serde_json::json!("11111111111111111111111111111111"));
|
||||||
|
assert_eq!(body["params"][1]["commitment"], serde_json::json!("finalized"));
|
||||||
|
assert_eq!(body["params"][1]["minContextSlot"], serde_json::json!(123));
|
||||||
|
}
|
||||||
123
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
Normal file
123
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audited_registry_has_expected_current_and_historical_counts() {
|
||||||
|
assert_eq!(super::current_http_rpc_methods().len(), 52);
|
||||||
|
assert_eq!(super::historical_http_rpc_methods().len(), 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audited_registry_method_names_are_unique() {
|
||||||
|
let mut names = std::collections::BTreeSet::<&str>::new();
|
||||||
|
for descriptor in super::current_http_rpc_methods() {
|
||||||
|
assert!(names.insert(descriptor.method()), "duplicate current method {}", descriptor.method());
|
||||||
|
}
|
||||||
|
for descriptor in super::historical_http_rpc_methods() {
|
||||||
|
assert!(names.insert(descriptor.method()), "duplicate historical method {}", descriptor.method());
|
||||||
|
}
|
||||||
|
assert_eq!(names.len(), 66);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_release_counts_match_recalibrated_matrix() {
|
||||||
|
let mut foundation = 0_usize;
|
||||||
|
let mut accounts_tokens_cluster = 0_usize;
|
||||||
|
let mut transactions = 0_usize;
|
||||||
|
let mut blocks_economics = 0_usize;
|
||||||
|
let mut historical = 0_usize;
|
||||||
|
for descriptor in super::current_http_rpc_methods() {
|
||||||
|
match descriptor.coverage_release() {
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_1 => foundation += 1,
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_2 => accounts_tokens_cluster += 1,
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_3 => transactions += 1,
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_4 => blocks_economics += 1,
|
||||||
|
super::HttpRpcCoverageRelease::Historical => historical += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(historical, 0);
|
||||||
|
assert_eq!(foundation, 4);
|
||||||
|
assert_eq!(accounts_tokens_cluster, 22);
|
||||||
|
assert_eq!(transactions, 11);
|
||||||
|
assert_eq!(blocks_economics, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_canary_assignment_is_exact() {
|
||||||
|
let mut names = std::vec::Vec::<&str>::new();
|
||||||
|
for descriptor in super::current_http_rpc_methods() {
|
||||||
|
if descriptor.coverage_release() == super::HttpRpcCoverageRelease::V0_2_1 {
|
||||||
|
names.push(descriptor.method());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
assert_eq!(names, std::vec!["getBalance", "getGenesisHash", "getHealth", "getVersion"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_methods_are_deprecated_removed_and_not_retryable() {
|
||||||
|
for descriptor in super::historical_http_rpc_methods() {
|
||||||
|
assert_eq!(descriptor.documentation_status(), super::RpcDocumentationStatus::Deprecated);
|
||||||
|
assert_eq!(descriptor.runtime_status(), super::RpcRuntimeStatus::Removed);
|
||||||
|
assert_eq!(descriptor.transport_retry_class(), super::TransportRetryClass::NotApplicable);
|
||||||
|
assert_eq!(descriptor.coverage_release(), super::HttpRpcCoverageRelease::Historical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_transaction_and_get_block_track_deprecated_legacy_request_form() {
|
||||||
|
let get_transaction = super::find_http_rpc_method("getTransaction").expect("getTransaction descriptor must exist");
|
||||||
|
let get_block = super::find_http_rpc_method("getBlock").expect("getBlock descriptor must exist");
|
||||||
|
assert!(get_transaction.request_form_status().has_deprecated_legacy());
|
||||||
|
assert!(get_block.request_form_status().has_deprecated_legacy());
|
||||||
|
let get_balance = super::find_http_rpc_method("getBalance").expect("getBalance descriptor must exist");
|
||||||
|
assert!(!get_balance.request_form_status().has_deprecated_legacy());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_submission_methods_are_never_retry_after_ambiguous_dispatch() {
|
||||||
|
for method in ["sendTransaction", "requestAirdrop"] {
|
||||||
|
let descriptor = super::find_http_rpc_method(method).expect("write descriptor must exist");
|
||||||
|
assert_eq!(descriptor.operation_kind(), super::RpcOperationKind::WriteSubmission);
|
||||||
|
assert_eq!(descriptor.transport_retry_class(), super::TransportRetryClass::NeverAfterDispatch);
|
||||||
|
}
|
||||||
|
let simulation = super::find_http_rpc_method("simulateTransaction").expect("simulation descriptor must exist");
|
||||||
|
assert_eq!(simulation.operation_kind(), super::RpcOperationKind::Simulation);
|
||||||
|
assert_eq!(simulation.transport_retry_class(), super::TransportRetryClass::RetrySafe);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removed_method_support_check_returns_method_removed_error() {
|
||||||
|
let descriptor = super::find_http_rpc_method("confirmTransaction").expect("historical descriptor must exist");
|
||||||
|
let error = descriptor.ensure_runtime_supported().expect_err("removed method must not be callable");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_METHOD_REMOVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_supported_method_passes_runtime_support_check() {
|
||||||
|
let descriptor = super::find_http_rpc_method("getHealth").expect("current descriptor must exist");
|
||||||
|
assert!(descriptor.ensure_runtime_supported().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supported_unstable_descriptor_executes_central_warning_path() {
|
||||||
|
let descriptor = super::HttpRpcMethodDescriptor::new(
|
||||||
|
"experimentalMethod",
|
||||||
|
super::HttpRpcCategory::Cluster,
|
||||||
|
"experimental_method",
|
||||||
|
super::RpcDocumentationStatus::Unstable,
|
||||||
|
super::RpcRuntimeStatus::Supported,
|
||||||
|
super::RpcRequestFormStatus::Stable,
|
||||||
|
super::RpcOperationKind::Read,
|
||||||
|
super::TransportRetryClass::RetrySafe,
|
||||||
|
std::option::Option::None,
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_1,
|
||||||
|
);
|
||||||
|
assert!(descriptor.requires_method_usage_warning());
|
||||||
|
assert!(descriptor.ensure_runtime_supported().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_rejects_unknown_method_without_affecting_raw_provider_extensions() {
|
||||||
|
assert!(super::find_http_rpc_method("providerCustomMethod").is_none());
|
||||||
|
}
|
||||||
207
crates/ksp-onchain-transport-lib/unit_tests/settings.rs
Normal file
207
crates/ksp-onchain-transport-lib/unit_tests/settings.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/settings.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
fn non_zero(value: u32) -> std::num::NonZeroU32 {
|
||||||
|
return std::num::NonZeroU32::new(value).expect("test non-zero value must remain non-zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_settings(url_text: &str) -> super::HttpTransportSettings {
|
||||||
|
let url = super::HttpEndpointUrl::parse(url_text).expect("test URL must be valid");
|
||||||
|
let limits = super::HttpRoleLimits::new(
|
||||||
|
std::option::Option::Some(non_zero(10)),
|
||||||
|
std::option::Option::Some(non_zero(20)),
|
||||||
|
std::option::Option::Some(non_zero(4)),
|
||||||
|
std::option::Option::Some(std::time::Duration::from_millis(500)),
|
||||||
|
);
|
||||||
|
let role = super::HttpEndpointRoleSettings::new(super::HttpRoleName::new("default"), true, std::vec![super::HttpRequestKind::wildcard()], 100, limits);
|
||||||
|
let endpoint = super::HttpEndpointSettings::new(
|
||||||
|
"devnet_public",
|
||||||
|
true,
|
||||||
|
super::HttpProviderName::new("solana-public"),
|
||||||
|
super::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
std::option::Option::Some(8),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
return super::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
super::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_url_accepts_http_and_https() {
|
||||||
|
assert!(super::HttpEndpointUrl::parse("https://api.devnet.solana.com").is_ok());
|
||||||
|
assert!(super::HttpEndpointUrl::parse("http://127.0.0.1:8899").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_url_rejects_non_http_schemes() {
|
||||||
|
let result = super::HttpEndpointUrl::parse("ws://api.devnet.solana.com");
|
||||||
|
let error = result.expect_err("WebSocket URL must not be accepted by HTTP settings");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_url_debug_redacts_secret_material() {
|
||||||
|
let url = super::HttpEndpointUrl::parse("https://provider.invalid/rpc?api-key=SECRET-CANARY").expect("test URL must parse");
|
||||||
|
let rendered = format!("{url:?}");
|
||||||
|
assert!(rendered.contains("<redacted>"));
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("provider.invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_transport_settings_pass_validation() {
|
||||||
|
let settings = valid_settings("https://api.devnet.solana.com");
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_debug_does_not_leak_endpoint_url() {
|
||||||
|
let settings = valid_settings("https://provider.invalid/rpc?api-key=SECRET-CANARY");
|
||||||
|
let rendered = format!("{settings:?}");
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("provider.invalid"));
|
||||||
|
assert!(rendered.contains("HttpEndpointUrl(<redacted>)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_require_one_enabled_endpoint() {
|
||||||
|
let url = super::HttpEndpointUrl::parse("https://api.devnet.solana.com").expect("test URL must parse");
|
||||||
|
let role = super::HttpEndpointRoleSettings::new(
|
||||||
|
super::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![super::HttpRequestKind::wildcard()],
|
||||||
|
100,
|
||||||
|
super::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
let endpoint = super::HttpEndpointSettings::new(
|
||||||
|
"disabled",
|
||||||
|
false,
|
||||||
|
super::HttpProviderName::new("provider"),
|
||||||
|
super::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::option::Option::None,
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
super::HttpRetrySettings::new(1, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
|
||||||
|
);
|
||||||
|
let error = settings.validate().expect_err("all-disabled settings must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_duplicate_endpoint_names() {
|
||||||
|
let first = valid_settings("https://one.invalid");
|
||||||
|
let second = valid_settings("https://two.invalid");
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![first.endpoints()[0].clone(), second.endpoints()[0].clone()], first.retry().clone());
|
||||||
|
let error = settings.validate().expect_err("duplicate endpoint names must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_duplicate_roles() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let endpoint = &base.endpoints()[0];
|
||||||
|
let duplicated_endpoint = super::HttpEndpointSettings::new(
|
||||||
|
endpoint.name(),
|
||||||
|
true,
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.url().clone(),
|
||||||
|
endpoint.connect_timeout(),
|
||||||
|
endpoint.request_timeout(),
|
||||||
|
endpoint.max_idle_connections_per_host(),
|
||||||
|
std::vec![endpoint.roles()[0].clone(), endpoint.roles()[0].clone()],
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![duplicated_endpoint], base.retry().clone());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_wildcard_mixed_with_specific_kind() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let endpoint = &base.endpoints()[0];
|
||||||
|
let role = super::HttpEndpointRoleSettings::new(
|
||||||
|
super::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![super::HttpRequestKind::wildcard(), super::HttpRequestKind::new("get_balance")],
|
||||||
|
100,
|
||||||
|
endpoint.roles()[0].limits().clone(),
|
||||||
|
);
|
||||||
|
let modified_endpoint = super::HttpEndpointSettings::new(
|
||||||
|
endpoint.name(),
|
||||||
|
true,
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.url().clone(),
|
||||||
|
endpoint.connect_timeout(),
|
||||||
|
endpoint.request_timeout(),
|
||||||
|
endpoint.max_idle_connections_per_host(),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_burst_without_rps() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let endpoint = &base.endpoints()[0];
|
||||||
|
let role = super::HttpEndpointRoleSettings::new(
|
||||||
|
super::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![super::HttpRequestKind::wildcard()],
|
||||||
|
100,
|
||||||
|
super::HttpRoleLimits::new(std::option::Option::None, std::option::Option::Some(non_zero(2)), std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
let modified_endpoint = super::HttpEndpointSettings::new(
|
||||||
|
endpoint.name(),
|
||||||
|
true,
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.url().clone(),
|
||||||
|
endpoint.connect_timeout(),
|
||||||
|
endpoint.request_timeout(),
|
||||||
|
endpoint.max_idle_connections_per_host(),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_reversed_retry_backoff() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let settings = super::HttpTransportSettings::new(
|
||||||
|
base.endpoints().to_vec(),
|
||||||
|
super::HttpRetrySettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
|
||||||
|
);
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_zero_request_timeout() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let endpoint = &base.endpoints()[0];
|
||||||
|
let modified_endpoint = super::HttpEndpointSettings::new(
|
||||||
|
endpoint.name(),
|
||||||
|
true,
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.url().clone(),
|
||||||
|
endpoint.connect_timeout(),
|
||||||
|
std::time::Duration::ZERO,
|
||||||
|
endpoint.max_idle_connections_per_host(),
|
||||||
|
endpoint.roles().to_vec(),
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: deltas/0.1.4/pre.004.md -->
|
<!-- file: deltas/0.1.4/pre.004.md -->
|
||||||
<!-- version: 1 -->
|
<!-- version: 2 -->
|
||||||
|
|
||||||
# Delta 0.1.4-pre.004 — squelette Rust/Tauri de `ksp-app-config-desk`
|
# Delta 0.1.4-pre.004 — squelette Rust/Tauri de `ksp-app-config-desk`
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ Le plugin tracing n'est volontairement pas déclaré dans cette tranche ; son co
|
|||||||
La destination de build est fixée dès maintenant à :
|
La destination de build est fixée dès maintenant à :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
||||||
```
|
```
|
||||||
|
|
||||||
`tauri.conf.json` l'utilise comme `build.frontendDist`. `vite.config.ts` utilisera la même valeur comme `build.outDir` à partir de `pre.005`.
|
`tauri.conf.json` l'utilise comme `build.frontendDist`. `vite.config.ts` utilisera la même valeur comme `build.outDir` à partir de `pre.005`.
|
||||||
@@ -255,5 +255,5 @@ Si cette tranche est validée et commitée, `pre.005` introduira le gabarit fron
|
|||||||
- Bootstrap, Font Awesome, SimpleBar et `resize-observer-polyfill` ;
|
- Bootstrap, Font Awesome, SimpleBar et `resize-observer-polyfill` ;
|
||||||
- `tauri-plugin-tracing` + `@fltsci/tauri-plugin-tracing` ;
|
- `tauri-plugin-tracing` + `@fltsci/tauri-plugin-tracing` ;
|
||||||
- Vite strict `1430`, HMR `1431` ;
|
- Vite strict `1430`, HMR `1431` ;
|
||||||
- output `../../builds/khadhroony-solana-project/ksp-app-config-desk/dist` ;
|
- output `../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist` ;
|
||||||
- premier `cargo tauri dev` du shell minimal.
|
- premier `cargo tauri dev` du shell minimal.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: deltas/0.1.4/pre.005.md -->
|
<!-- file: deltas/0.1.4/pre.005.md -->
|
||||||
<!-- version: 1 -->
|
<!-- version: 2 -->
|
||||||
|
|
||||||
# Delta 0.1.4-pre.005 — gabarit frontend Vite/TypeScript/SCSS et tracing Tauri
|
# Delta 0.1.4-pre.005 — gabarit frontend Vite/TypeScript/SCSS et tracing Tauri
|
||||||
|
|
||||||
@@ -167,13 +167,13 @@ Le serveur est configuré ainsi :
|
|||||||
`tauri.conf.json` possédait déjà :
|
`tauri.conf.json` possédait déjà :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
||||||
```
|
```
|
||||||
|
|
||||||
`vite.config.ts` part maintenant de la racine de la crate puis résout **le même chemin contractuel** :
|
`vite.config.ts` part maintenant de la racine de la crate puis résout **le même chemin contractuel** :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
||||||
```
|
```
|
||||||
|
|
||||||
Le `root` Vite reste `frontend/`, mais l'utilisation d'un path absolu résolu depuis la crate évite que `build.outDir` ne soit accidentellement interprété relativement à `frontend/`.
|
Le `root` Vite reste `frontend/`, mais l'utilisation d'un path absolu résolu depuis la crate évite que `build.outDir` ne soit accidentellement interprété relativement à `frontend/`.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
<!-- file: deltas/0.1.4/pre.016-fix.002.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
# 0.1.4-pre.016-fix.002
|
# 0.1.4-pre.016-fix.002
|
||||||
|
|
||||||
## Objet
|
## Objet
|
||||||
|
|||||||
242
deltas/0.2.0/pre.001.md
Normal file
242
deltas/0.2.0/pre.001.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<!-- file: deltas/0.2.0/pre.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.2.0-pre.001
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Release stable attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.1.4
|
||||||
|
```
|
||||||
|
|
||||||
|
L'archive KSP fournie contient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.4"
|
||||||
|
```
|
||||||
|
|
||||||
|
et les quatre fondations stabilisées :
|
||||||
|
|
||||||
|
- `ksp-core-lib` ;
|
||||||
|
- `ksp-logging-lib` ;
|
||||||
|
- `ksp-config-lib` ;
|
||||||
|
- `ksp-app-config-desk`.
|
||||||
|
|
||||||
|
L'archive ne contient pas `.git`; le tag `v0.1.4` n'est donc pas revérifiable localement depuis le zip.
|
||||||
|
|
||||||
|
Le snapshot `khadhroony-bot3` fourni pour audit est l'archive d'échange :
|
||||||
|
|
||||||
|
```text
|
||||||
|
khadhroony-bot3_v0.5.3-pre.005-fix010.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Son `Cargo.toml` porte `workspace.package.version = "0.5.3-pre.5"`.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ouvrir `0.2.0` par la tranche obligatoire de brainstorming, inventaire et méthode d'audit, sans commencer l'implémentation d'une capacité Solana N2.
|
||||||
|
|
||||||
|
Le plan directeur est créé dans :
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/007-V0_2_0_SERIES_PLANNING.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Conformément à `VER-ID-009`, une prerelease non-fix synchronise la version Cargo même lorsque son contenu fonctionnel est documentaire.
|
||||||
|
|
||||||
|
`workspace.package.version` passe donc de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.4
|
||||||
|
```
|
||||||
|
|
||||||
|
à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.0-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
L'identifiant de livraison reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.0-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune nouvelle dépendance n'est ajoutée.
|
||||||
|
|
||||||
|
## Méthode d'audit définie
|
||||||
|
|
||||||
|
Chaque élément bot3 est désormais séparé en :
|
||||||
|
|
||||||
|
- fonctionnalité ;
|
||||||
|
- implémentation historique ;
|
||||||
|
- contrat public ;
|
||||||
|
- dépendance externe ;
|
||||||
|
- convention de projet.
|
||||||
|
|
||||||
|
La décision `reprendre / adapter / refondre / abandonner / ajouter` porte d'abord sur le besoin et le contrat utile, jamais implicitement sur une copie du code.
|
||||||
|
|
||||||
|
La fiche d'audit couvre ownership, dépendances, Config/environnement, Logging, tests/invariants, dette, risques et lot `0.2.x` candidat.
|
||||||
|
|
||||||
|
## Première cartographie bot3
|
||||||
|
|
||||||
|
Le snapshot a été inspecté par domaines.
|
||||||
|
|
||||||
|
### Wallet
|
||||||
|
|
||||||
|
`ks-wallet` expose notamment alias/identité non secrète, wallet temporaire, manager multi-wallet, format `.kswallet` protégé, password redacted, `UnlockedWallet`, création atomique/no-clobber, changement de password, migration legacy, import/export Solana CLI JSON/Base58 et contrôles de collision.
|
||||||
|
|
||||||
|
Les invariants de sécurité et le contrat de capacité de signature sont des candidats forts à la reprise/adaptation.
|
||||||
|
|
||||||
|
Le store JSON legacy `TemporaryWalletStore` et le `lamport_spend_limit` situé dans `WalletPolicy` ne sont pas retenus comme frontières cibles.
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
`ks-onchain-transport` possède une surface HTTP/WS importante : JSON-RPC, pools, rôles/quota, typed standard methods, sessions/subscriptions/reconnect et méthodes techniques d'exécution.
|
||||||
|
|
||||||
|
Deux couplages imposent déjà une refonte de frontière :
|
||||||
|
|
||||||
|
- le public transport consomme directement des types `ks-config` ;
|
||||||
|
- `getTransaction` et le trait RPC minimal dépendent de types `ks-lib`.
|
||||||
|
|
||||||
|
KSP doit produire des modèles transport homogènes possédés par `ksp-onchain-transport-lib`, sans dépendance Program/Store.
|
||||||
|
|
||||||
|
### Interface / Program / Execution
|
||||||
|
|
||||||
|
Bot3 ne possède pas de crate Interface isolée : wire, codecs et protocoles sont dispersés dans `ks-lib`, qui regroupe decoder, executor et materializer.
|
||||||
|
|
||||||
|
Les API `DcApi*` / `ExApi*` contiennent des concepts utiles mais ne correspondent pas au découpage KSP cible. Elles seront utilisées comme inventaire de contrats et de preuves, puis réparties entre `ksp-interface-lib`, `ksp-program-api`, `ksp-program-lib`, `ksp-execution-policy-api` et éventuellement `ksp-execution-lib`.
|
||||||
|
|
||||||
|
### Scénarios/demos
|
||||||
|
|
||||||
|
Le principe de scénarios réutilisables hors Tauri est conservé. En revanche la crate multi-domaines `ks-pipeline-demo-scenarios` et l'application omnibus `kb-app-demo-desktop` ne sont pas des modèles cibles KSP.
|
||||||
|
|
||||||
|
### Off-chain
|
||||||
|
|
||||||
|
Aucune crate générale off-chain n'existe dans le snapshot. Le besoin reste conditionnel et ne sera pas introduit par anticipation.
|
||||||
|
|
||||||
|
## Première matrice
|
||||||
|
|
||||||
|
Le plan `007` contient une première matrice provisoire couvrant Wallet, Transport, Interface, Program, Execution, scenarios/demos et off-chain.
|
||||||
|
|
||||||
|
Aucun numéro `0.2.1+` n'est figé dans cette tranche. Les candidats restent des lots fonctionnels non numérotés.
|
||||||
|
|
||||||
|
## Graphe de dépendances initial
|
||||||
|
|
||||||
|
Le plan confirme notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Wallet ---------------------------+
|
||||||
|
|
|
||||||
|
Interface -> Program API/Program -+-> Execution policy -> Execution
|
||||||
|
|
|
||||||
|
Transport ------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
avec les nuances suivantes :
|
||||||
|
|
||||||
|
- Wallet, Transport et Interface sont largement indépendants au démarrage ;
|
||||||
|
- Program dépend d'Interface pour la première surface wire réelle ;
|
||||||
|
- Execution ne doit être créée que si un cycle réel justifie Program + Policy + Wallet + Transport ;
|
||||||
|
- Store/Materializer/worker restent hors `0.2.x`.
|
||||||
|
|
||||||
|
## Prévision souple de `0.2.0`
|
||||||
|
|
||||||
|
Le plan prévoit initialement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.001 méthode + cartographie initiale
|
||||||
|
pre.002 Wallet
|
||||||
|
pre.003 transport on-chain
|
||||||
|
pre.004 Interface/wire + dépendances
|
||||||
|
pre.005 Program API/Program
|
||||||
|
pre.006 policy/execution
|
||||||
|
pre.007 scenarios/demos/off-chain gaps
|
||||||
|
pre.008 matrice/graphe/découpage candidat
|
||||||
|
pre.009 challenge et dimensionnement des releases 0.2.1+
|
||||||
|
pre.010 clôture/docs/nettoyage/prompt suivant
|
||||||
|
```
|
||||||
|
|
||||||
|
La séquence reste révisable.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/007-V0_2_0_SERIES_PLANNING.md
|
||||||
|
deltas/0.2.0/pre.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Validations exécutées
|
||||||
|
|
||||||
|
- lecture de `ROADMAP.md`, de `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`, des règles KSP et des documents d'architecture Program/Wire/Execution/Scenario ;
|
||||||
|
- inspection du workspace KSP stable `0.1.4` ;
|
||||||
|
- inspection du workspace bot3 fourni et de ses manifests ;
|
||||||
|
- inventaire ciblé de `ks-wallet`, `ks-wallet-demo-scenarios`, `ks-onchain-transport`, `ks-lib`, `ks-pipeline`, `ks-pipeline-demo-scenarios` et `kb-app-demo-desktop` ;
|
||||||
|
- inspection des guides/rapports Wallet bot3 et de la configuration historique Wallet/Transport/Execution ;
|
||||||
|
- contrôle de la présence des surfaces public decoder/executor et des principaux couplages de dépendances ;
|
||||||
|
- vérification documentaire de l'absence de développement N2 dans cette tranche.
|
||||||
|
|
||||||
|
## Validations non exécutées
|
||||||
|
|
||||||
|
Aucune fonctionnalité Rust n'est ajoutée et aucune dépendance n'est modifiée hors signal de version Cargo.
|
||||||
|
|
||||||
|
Les versions upstream des dépendances candidates ne sont pas auditées dans `pre.001` car aucune dépendance n'est introduite. Elles seront vérifiées depuis les sources officielles au moment où une tranche décide réellement de les ajouter.
|
||||||
|
|
||||||
|
Le sandbox de préparation ne fournit pas le binaire `cargo` (`cargo: command not found`). Les validations suivantes n'ont donc pas pu être exécutées ici :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Elles devront être rejouées sur le poste de développement avant commit de `pre.001`.
|
||||||
|
|
||||||
|
L'archive source KSP fournie ne contient pas de métadonnées `.git`. Le commit ne peut donc pas être créé ni vérifié dans ce sandbox. Après application de la livraison sur le checkout Git canonique et validation technique, le commit attendu suit `VER-GIT-001` : `v0.2.0-pre.001`.
|
||||||
|
|
||||||
|
## Décisions prises
|
||||||
|
|
||||||
|
- `0.2.0` reste une release d'audit, pas la première release N2 ;
|
||||||
|
- aucune migration mécanique de bot3 ;
|
||||||
|
- fondations bot3 déjà remplacées par `0.1.x` utilisées uniquement comme référence historique ;
|
||||||
|
- Wallet bot3 considéré comme candidat mature à reprendre/adapter, avec abandon du store JSON legacy comme cible ;
|
||||||
|
- Transport bot3 considéré comme riche fonctionnellement mais nécessitant une nouvelle frontière publique sans `ks-lib` ;
|
||||||
|
- `ks-lib` monolithique non retenu comme architecture cible ;
|
||||||
|
- Interface/wire doit être auditée contrat par contrat ;
|
||||||
|
- Program preparation, policy et execution restent séparés ;
|
||||||
|
- scenarios réutilisables conservés comme méthode, mais spécialisés par domaine ;
|
||||||
|
- off-chain reste `need-driven` ;
|
||||||
|
- aucun numéro `0.2.1+` n'est figé dans `pre.001`.
|
||||||
|
|
||||||
|
## Questions ouvertes
|
||||||
|
|
||||||
|
Les questions structurantes sont conservées dans le plan `007`, notamment :
|
||||||
|
|
||||||
|
- compatibilité exacte du format `.kswallet` bot3 avec KSP ;
|
||||||
|
- primitive signer publique minimale ;
|
||||||
|
- séparation ou non des releases Transport HTTP/WS ;
|
||||||
|
- frontière transport/Core de `getTransaction` ;
|
||||||
|
- stratégie exacte de réexport/réimplémentation des interfaces Solana/SPL/Metaplex ;
|
||||||
|
- choix du premier Program canari ;
|
||||||
|
- nécessité réelle d'Execution dans `0.2.x`.
|
||||||
|
|
||||||
|
La prochaine tranche prévue est l'audit Wallet détaillé.
|
||||||
195
deltas/0.2.0/pre.002.md
Normal file
195
deltas/0.2.0/pre.002.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<!-- file: deltas/0.2.0/pre.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.0-pre.002`
|
||||||
|
|
||||||
|
## Identité
|
||||||
|
|
||||||
|
```text
|
||||||
|
release : 0.2.0
|
||||||
|
prerelease : pre.002
|
||||||
|
identifiant de commit attendu : v0.2.0-pre.002
|
||||||
|
workspace.package.version : 0.2.0-pre.2
|
||||||
|
base : 0.2.0-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette livraison est une **nouvelle tranche planifiée** et non un `pre.001-fix.001` : `pre.001` avait volontairement laissé le découpage `0.2.1+` ouvert. `pre.002` ajoute de nouvelles décisions de planification/architecture issues de l'audit et du brainstorming suivants.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Consolider le plan de série `0.2.x`, fixer la première séquence fonctionnelle, corriger l'ancienne ambiguïté D1/D2 liée au décodage Program, établir la progression `RAW -> CORE -> DECODE -> SPECIALIZED`, formaliser la discipline de sizing d'une release/session et préparer un prompt de démarrage complet pour `0.2.1`.
|
||||||
|
|
||||||
|
Aucune capacité Solana runtime N2 n'est implémentée dans cette tranche.
|
||||||
|
|
||||||
|
## Décisions principales
|
||||||
|
|
||||||
|
### Dimensionnement
|
||||||
|
|
||||||
|
- une prerelease vise environ 15–20 minutes de travail effectif ;
|
||||||
|
- une release concrète doit être entièrement clôturable dans une seule session de chat ;
|
||||||
|
- si `pre.001` révèle un risque de dépassement, la release est scindée avant implémentation fonctionnelle lourde.
|
||||||
|
|
||||||
|
### Début de `0.2.x`
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1 HTTP Solana foundation
|
||||||
|
0.2.2 ksp-wallet-lib / .kspwallet
|
||||||
|
0.2.3 ksp-app-wallet-desk
|
||||||
|
0.2.4 standard WebSocket
|
||||||
|
0.2.5 Helius LaserStream WebSocket
|
||||||
|
0.2.6 Yellowstone gRPC standard foundation
|
||||||
|
0.2.7 off-chain price transport
|
||||||
|
0.2.8 price desk
|
||||||
|
0.2.9 ksp-interface-lib foundation
|
||||||
|
0.2.10 ksp-program-api foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP précède Wallet afin que Wallet Desk puisse être validé avec un solde réseau réel.
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
- `ksp-onchain-transport-lib` possède ses settings publics et ne dépend pas de Config ;
|
||||||
|
- `ksp-config-lib` peut fournir un document standard Transport et un adapter Config -> Transport ;
|
||||||
|
- pools/rôles/priorités/limites HTTP sont retenus ;
|
||||||
|
- toute méthode documentée de la surface normative ciblée doit être inventoriée/implémentée sauf impossibilité documentée ;
|
||||||
|
- une méthode deprecated/obsolete encore fonctionnelle émet un warning KSP à l'utilisation ;
|
||||||
|
- une méthode unstable/experimental émet également un warning KSP ;
|
||||||
|
- WebSocket autorise plusieurs sessions sur une même URL mais n'impose pas encore un pool automatique ;
|
||||||
|
- Helius WS réutilise le moteur standard ;
|
||||||
|
- Yellowstone reste provider-neutral dans sa première version ;
|
||||||
|
- providers avancés/shred streams sont reportés dans IDEAS.
|
||||||
|
|
||||||
|
### Wallet
|
||||||
|
|
||||||
|
- format natif KSP : `.kspwallet` ;
|
||||||
|
- temporary wallet JSON historique abandonné ;
|
||||||
|
- `WalletPolicy` sort de Wallet et relève de la future execution policy ;
|
||||||
|
- import/export reste extensible, formats supplémentaires suivis dans IDEAS.
|
||||||
|
|
||||||
|
### Interface / Program / Policy
|
||||||
|
|
||||||
|
- `ksp-interface-lib` expose sa propre API publique wire ; pas de `ksp-interface-api` séparée actuellement ;
|
||||||
|
- nomenclature confirmée : `ksp-program-api` / `ksp-program-lib` ;
|
||||||
|
- `ksp-execution-policy-api` reste le contrat commun ; petites policies locales dans scenarios/orchestrateurs, libs communes uniquement si réutilisation réelle.
|
||||||
|
|
||||||
|
### Données
|
||||||
|
|
||||||
|
La chaîne durable devient explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
RAW -> CORE -> DECODE -> SPECIALIZED
|
||||||
|
```
|
||||||
|
|
||||||
|
- RAW et CORE ne nécessitent aucun decoder Program ;
|
||||||
|
- CORE est une normalisation générique Solana ;
|
||||||
|
- Program decoding commence à CORE -> DECODE ;
|
||||||
|
- à partir de DECODE, progression verticale groupe par groupe.
|
||||||
|
|
||||||
|
### Groupes Program
|
||||||
|
|
||||||
|
Ordre prioritaire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Solana Core Programs
|
||||||
|
-> SPL token/trading
|
||||||
|
-> token metadata
|
||||||
|
-> Anchor
|
||||||
|
-> Meteora
|
||||||
|
-> Raydium
|
||||||
|
-> Pump
|
||||||
|
-> Orca
|
||||||
|
-> Market Desk V1
|
||||||
|
-> Jupiter/OKX routing
|
||||||
|
-> Market Desk V2
|
||||||
|
-> trading-adjacent
|
||||||
|
-> general decoding
|
||||||
|
```
|
||||||
|
|
||||||
|
Les programmes satellites nécessaires restent dans leur groupe : Meteora vaults avec Meteora, Pump fee avec Pump, etc.
|
||||||
|
|
||||||
|
### Market Desk
|
||||||
|
|
||||||
|
Une première `ksp-app-market-desk` est prévue après les DEX prioritaires pour afficher notamment tokens, pools, liquidity, trades, prix et OHLC. Elle est enrichie après routing avec routes/legs/fees/slippage/quote-execution.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/IDEAS.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/architecture/006-WIRE_AND_PROGRAM.md
|
||||||
|
docs/architecture/007-EXECUTION_AND_POLICY.md
|
||||||
|
docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md
|
||||||
|
docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md
|
||||||
|
docs/architecture/010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/001-V0_0_3_PLAN.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/007-V0_2_0_SERIES_PLANNING.md
|
||||||
|
docs/rules/PROMPT_STRUCTURE.md
|
||||||
|
docs/rules/RULES_DEPENDENCIES.md
|
||||||
|
docs/rules/RULES_KSP.md
|
||||||
|
prompts/000-README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/006-V0_2_1_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le prompt `0.2.1` impose notamment :
|
||||||
|
|
||||||
|
- audit bot3 précis ;
|
||||||
|
- consultation des sources officielles Solana actuelles ;
|
||||||
|
- matrice exhaustive des méthodes HTTP ;
|
||||||
|
- classification stable/deprecated/unstable ;
|
||||||
|
- warnings runtime appropriés ;
|
||||||
|
- ownership Config/Transport ;
|
||||||
|
- pools/rôles/limites ;
|
||||||
|
- tests/canaries ;
|
||||||
|
- gate de sizing avant grosse implémentation ;
|
||||||
|
- préparation du futur plan `docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`.
|
||||||
|
|
||||||
|
## Validations réalisées dans l'environnement d'échange
|
||||||
|
|
||||||
|
- parsing TOML du `Cargo.toml` via Python `tomllib` ;
|
||||||
|
- contrôle de la version Cargo `0.2.0-pre.2` ;
|
||||||
|
- contrôle des en-têtes `<!-- file: ... -->` / `<!-- version: ... -->` des fichiers modifiés ;
|
||||||
|
- contrôle des fences Markdown équilibrées ;
|
||||||
|
- contrôle des liens Markdown locaux des fichiers modifiés ;
|
||||||
|
- recherche de contradictions actives principales (`ksp-program-api-lib`, ancienne dépendance Program dans RAW -> CORE, anciennes chaînes de workers/pipelines figées) ;
|
||||||
|
- annotation explicite du plan historique `0.0.3` pour distinguer ses anciennes décisions des règles désormais actives ;
|
||||||
|
- contrôle de l'archive d'échange et calcul SHA-256.
|
||||||
|
|
||||||
|
## Validations non exécutables dans cet environnement
|
||||||
|
|
||||||
|
Le conteneur d'échange ne fournit ni `cargo` ni `rustc`. Les commandes Rust doivent donc être exécutées après application sur le dépôt canonique :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune de ces commandes n'est déclarée réussie dans ce delta.
|
||||||
|
|
||||||
|
## Commit attendu
|
||||||
|
|
||||||
|
Après application et validations sur le dépôt canonique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.0-pre.002
|
||||||
|
```
|
||||||
|
|
||||||
|
Conformément aux règles KSP, ce commit de prerelease ne reçoit pas de tag Git stable.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Le travail restant de `0.2.0` est volontairement court : audit de cohérence final, correction des écarts documentaires restants, finalisation du prompt `0.2.1`, puis publication `0.2.0-rel.001` lorsque le cadrage est validé.
|
||||||
210
deltas/0.2.0/pre.003.md
Normal file
210
deltas/0.2.0/pre.003.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<!-- file: deltas/0.2.0/pre.003.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# Delta `0.2.0-pre.003`
|
||||||
|
|
||||||
|
## Identité
|
||||||
|
|
||||||
|
```text
|
||||||
|
release : 0.2.0
|
||||||
|
prerelease : pre.003
|
||||||
|
identifiant de commit attendu : v0.2.0-pre.003
|
||||||
|
workspace.package.version : 0.2.0-pre.3
|
||||||
|
base : 0.2.0-pre.002
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.003` est la **dernière prerelease planifiée** de la release de cadrage `0.2.0`. Elle ne développe aucune capacité N2 runtime ; elle réalise l'audit de cohérence final demandé par le plan `007` avant `rel.001`.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Auditer la base Git complète `0.2.0-pre.002`, éliminer les contradictions normatives/documentaires résiduelles, vérifier que les décisions bot3 utiles n'ont pas été perdues, compléter les fiches de releases `0.2.1+`, finaliser le prompt `0.2.1` et fournir une matrice de clôture durable.
|
||||||
|
|
||||||
|
## Écarts détectés et corrigés
|
||||||
|
|
||||||
|
### Collisions d'identifiants normatifs
|
||||||
|
|
||||||
|
`docs/rules/RULES_KSP.md` utilisait deux fois :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP-TRANSPORT-001
|
||||||
|
KSP-DATA-001
|
||||||
|
KSP-DATA-002
|
||||||
|
```
|
||||||
|
|
||||||
|
Correction :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP-TRANSPORT-001 reste : pas de ksp-onchain-transport-api séparée
|
||||||
|
KSP-TRANSPORT-006 devient : couverture documentaire exhaustive + warnings de statut
|
||||||
|
KSP-DATA-001/002 restent : contrats de notifications de données
|
||||||
|
KSP-FLOW-001/002 deviennent : progression RAW/CORE/DECODE/SPECIALIZED + satellites de protocole
|
||||||
|
```
|
||||||
|
|
||||||
|
Un audit automatique des IDs normatifs ne trouve plus de doublon après correction.
|
||||||
|
|
||||||
|
### Replay jobs historiques encore figés
|
||||||
|
|
||||||
|
`KSP-JOB-009` imposait encore :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-job-replay-core
|
||||||
|
ksp-job-replay-generic-materialization
|
||||||
|
ksp-job-replay-domain-projection
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette règle contredisait la progression verticale fixée par `pre.002`.
|
||||||
|
|
||||||
|
La nouvelle règle ne fige plus de jobs DECODE/SPECIALIZED globaux. Un replay Core pourra être introduit avec CORE ; à partir de DECODE, les jobs de replay émergent avec les groupes/capacités réels et réutilisent la même logique que le processing live correspondant.
|
||||||
|
|
||||||
|
Les entrées `IDEAS.md` basées sur `generic-materialization`, `domain-projection` et un type global `DomainProjector` sont requalifiées en conséquence.
|
||||||
|
|
||||||
|
### Diagramme global W1–W4 encore actif
|
||||||
|
|
||||||
|
`docs/architecture/010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md` conservait encore :
|
||||||
|
|
||||||
|
```text
|
||||||
|
W1 -> D1 -> W2 -> D2 -> W3 -> D3 -> W4 -> D4
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce schéma pouvait contredire la décision de `pre.002` en suggérant quatre workers globaux imposés. Il est remplacé par les frontières durables `D1 RAW -> D2 CORE -> D3 DECODE -> D4 SPECIALIZED`, avec workers RAW/CORE horizontaux et workers/processors DECODE/SPECIALIZED introduits need-driven par groupe vertical.
|
||||||
|
|
||||||
|
### TODO Wallet bot3 incomplets dans KSP
|
||||||
|
|
||||||
|
L'audit du TODO/matrice Wallet bot3 montre que KSP avait conservé Solana CLI/Base58/Phantom/Solflare mais avait trop résumé plusieurs reports utiles.
|
||||||
|
|
||||||
|
`IDEAS.md` conserve maintenant explicitement :
|
||||||
|
|
||||||
|
- Solflare Keystore, seulement avec format suffisamment stable/testable ;
|
||||||
|
- Backpack, après caractérisation exacte du wire Solana `Private key` ;
|
||||||
|
- Trust Wallet, après caractérisation du wire Solana exact ;
|
||||||
|
- Base app / ex-Coinbase Wallet, sans synthèse de recovery phrase ;
|
||||||
|
- distinction entre Base app et Coinbase Developer Platform.
|
||||||
|
|
||||||
|
Ces entrées restent des idées/TODO, pas des dépendances ni engagements de `0.2.2`.
|
||||||
|
|
||||||
|
### Fiches de release manquantes
|
||||||
|
|
||||||
|
Le prompt d'ouverture `0.2.0` exigeait pour chaque release `0.2.1+` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
mission
|
||||||
|
périmètre
|
||||||
|
hors-périmètre
|
||||||
|
dépendances
|
||||||
|
critères de clôture
|
||||||
|
estimation souple des prereleases
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.002` avait fixé la séquence mais n'avait pas regroupé ces six dimensions pour chaque release.
|
||||||
|
|
||||||
|
`docs/plans/007-V0_2_0_SERIES_PLANNING.md` contient maintenant une fiche pour `0.2.1` à `0.2.10`.
|
||||||
|
|
||||||
|
## Spot-check HTTP Solana
|
||||||
|
|
||||||
|
Un contrôle externe daté du **2026-08-17** a été effectué uniquement pour valider le sizing et la qualité du prompt `0.2.1` :
|
||||||
|
|
||||||
|
- l'index officiel HTTP Solana observé contient 52 méthodes courantes ;
|
||||||
|
- la section officielle `Deprecated Methods` expose séparément 14 noms ;
|
||||||
|
- l'inventaire bot3 `ks-onchain-transport/src/standard_methods.rs` contient les 52 noms courants observés ;
|
||||||
|
- bot3 ne couvre pas ces 14 anciennes méthodes deprecated comme surface standard ;
|
||||||
|
- l'égalité des noms courants ne garantit pas un contrat équivalent, bot3 distinguant notamment typed adapters et appels raw JSON.
|
||||||
|
|
||||||
|
Ces nombres ne deviennent pas une règle durable. `0.2.1-pre.001` doit refaire l'inventaire depuis la documentation officielle du jour et vérifier la disponibilité runtime des méthodes deprecated/obsolete avant de promettre leur support.
|
||||||
|
|
||||||
|
## Prompt `0.2.1`
|
||||||
|
|
||||||
|
`prompts/006-V0_2_1_START_PROMPT.md` passe en version 2 et est considéré **finalisé côté contenu** pour l'ouverture de `0.2.1` après `v0.2.0` stable.
|
||||||
|
|
||||||
|
Il impose maintenant explicitement :
|
||||||
|
|
||||||
|
- index HTTP courant ;
|
||||||
|
- section officielle `Deprecated Methods` séparée ;
|
||||||
|
- toute surface HTTP unstable/experimental officiellement documentée ;
|
||||||
|
- vérification de disponibilité runtime des méthodes deprecated/obsolete ;
|
||||||
|
- comparaison nom par nom avec bot3 ;
|
||||||
|
- distinction du niveau de contrat bot3 `typed` vs raw/generic ;
|
||||||
|
- gate de sizing avant implémentation lourde.
|
||||||
|
|
||||||
|
## Matrice de clôture ajoutée
|
||||||
|
|
||||||
|
Nouveau document :
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/validation/002-V0_2_0_SERIES_PLANNING.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Il trace les critères du prompt `0.2.0`, les preuves documentaires, les écarts trouvés par `pre.003`, le spot-check HTTP et les conditions permettant de passer à `rel.001`.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/IDEAS.md
|
||||||
|
docs/architecture/000-README.md
|
||||||
|
docs/architecture/010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/007-V0_2_0_SERIES_PLANNING.md
|
||||||
|
docs/rules/RULES_KSP.md
|
||||||
|
docs/validation/000-README.md
|
||||||
|
prompts/000-README.md
|
||||||
|
prompts/006-V0_2_1_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/validation/002-V0_2_0_SERIES_PLANNING.md
|
||||||
|
deltas/0.2.0/pre.003.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation statique réalisée dans l'environnement d'échange
|
||||||
|
|
||||||
|
- parsing TOML de `Cargo.toml` ;
|
||||||
|
- `workspace.package.version = 0.2.0-pre.3` ;
|
||||||
|
- audit d'unicité des IDs normatifs sous `docs/rules/` ;
|
||||||
|
- contrôle des en-têtes `<!-- file: ... -->` / versions des fichiers modifiés ;
|
||||||
|
- contrôle des fences Markdown équilibrées ;
|
||||||
|
- contrôle des liens Markdown locaux, en ignorant les exemples littéraux de syntaxe Markdown ;
|
||||||
|
- scan global des headers Markdown : un ancien delta livré `deltas/0.1.4/pre.016-fix.002.md` ne possède pas le header moderne ; il est volontairement laissé intact afin de ne pas réécrire silencieusement l'historique `0.1.4` ;
|
||||||
|
- recherche des anciennes décisions actives `replay-generic-materialization` / `replay-domain-projection` / `DomainProjector` ;
|
||||||
|
- recherche des diagrammes/contrats pouvant encore imposer mécaniquement un worker global par niveau D1–D4 ;
|
||||||
|
- comparaison ciblée avec les TODO Wallet et l'inventaire HTTP de bot3 ;
|
||||||
|
- spot-check de la documentation officielle Solana actuelle pour HTTP et Deprecated Methods.
|
||||||
|
|
||||||
|
## Validations non exécutables dans cet environnement
|
||||||
|
|
||||||
|
Le conteneur d'échange ne fournit ni `cargo` ni `rustc`.
|
||||||
|
|
||||||
|
Avant commit de `pre.003`, exécuter sur le dépôt canonique :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun build Tauri n'est requis par cette tranche documentaire : aucune application/frontend/runtime Tauri n'est modifié.
|
||||||
|
|
||||||
|
## Commit attendu
|
||||||
|
|
||||||
|
Après application et validations :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.0-pre.003
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun tag Git stable n'est créé pour cette prerelease.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Si les validations de `pre.003` sont propres, la prochaine livraison doit être :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.0-rel.001
|
||||||
|
```
|
||||||
|
|
||||||
|
`rel.001` doit rester minimal : version stable `0.2.0`, statuts documentaires, entrée `CHANGELOG`, delta final, validations globales, commit de release puis tag `v0.2.0`.
|
||||||
118
deltas/0.2.0/rel.001.md
Normal file
118
deltas/0.2.0/rel.001.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<!-- file: deltas/0.2.0/rel.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.0-rel.001` — publication stable du cadrage `0.2.x`
|
||||||
|
|
||||||
|
## Base validée
|
||||||
|
|
||||||
|
La base requise est le commit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
e721464a7c2cbf4c564757061a2a44dd7913facb
|
||||||
|
v0.2.0-pre.003
|
||||||
|
```
|
||||||
|
|
||||||
|
Le user a communiqué le 2026-08-17 les validations suivantes avec succès :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` exécute 241 tests avec succès ; le probe diagnostic d'overhead de `ksp-logging-lib` reste volontairement ignoré. `git diff --check` et `git status --short` ne produisent aucune sortie après le commit `pre.003`.
|
||||||
|
|
||||||
|
## Objet
|
||||||
|
|
||||||
|
Publier sous version stable le cadrage `0.2.0` déjà audité et validé, sans ajouter de capacité N2 ni modifier les décisions architecturales finalisées en `pre.003`.
|
||||||
|
|
||||||
|
## Changements
|
||||||
|
|
||||||
|
- `workspace.package.version` passe de `0.2.0-pre.3` à `0.2.0` ;
|
||||||
|
- `CHANGELOG.md` reçoit l'entrée stable `0.2.0` ;
|
||||||
|
- `ROADMAP.md` marque `0.2.0` et `0.2.0-pre.003` réalisés et enregistre `rel.001` ;
|
||||||
|
- l'index documentaire marque `007-V0_2_0_SERIES_PLANNING.md` comme plan historique clôturé ;
|
||||||
|
- la séquence fonctionnelle enregistre `0.2.0` stable et `0.2.1` comme prochaine release ;
|
||||||
|
- le plan `007` enregistre sa clôture par `rel.001` ;
|
||||||
|
- la matrice `docs/validation/002-V0_2_0_SERIES_PLANNING.md` enregistre les preuves opérateur finales de `pre.003` ;
|
||||||
|
- le prompt `prompts/006-V0_2_1_START_PROMPT.md` reste inchangé et devient le point d'entrée de la prochaine release après création du tag stable.
|
||||||
|
|
||||||
|
## Surface stable publiée
|
||||||
|
|
||||||
|
`0.2.0` stabilise notamment :
|
||||||
|
|
||||||
|
- l'ordre fonctionnel de `0.2.1+` : HTTP Solana -> Wallet -> Wallet Desk -> WebSocket standard -> Helius LaserStream WebSocket -> Yellowstone gRPC standard -> off-chain prix -> app prix -> Interface -> Program API ;
|
||||||
|
- `ksp-onchain-transport-lib` indépendant de `ksp-config-lib`, Store et Program, avec settings publics propres au transport et adapter Config -> Transport ;
|
||||||
|
- la règle de couverture exhaustive des méthodes/opérations documentées pour chaque surface Transport ciblée ;
|
||||||
|
- la conservation des méthodes deprecated/obsolete encore fonctionnelles avec warning KSP, et le warning KSP des méthodes unstable/experimental ;
|
||||||
|
- la progression durable `RAW -> CORE -> DECODE -> SPECIALIZED` ;
|
||||||
|
- RAW et CORE sans decoder Program, complétés horizontalement avec persistence/replay/jobs/workers/apps selon besoin ;
|
||||||
|
- à partir de DECODE, des vertical slices groupe par groupe : wire -> decode -> materialize -> specialized -> prepare -> policy -> execute -> scenario Devnet ;
|
||||||
|
- l'intégration des composants satellites dans leur groupe protocolaire, par exemple Meteora vaults avec Meteora et Pump fees avec Pump ;
|
||||||
|
- la priorité Solana Core -> SPL token/trading -> metadata token -> Anchor -> Meteora/Raydium/Pump/Orca -> Market Desk V1 -> Jupiter/OKX -> Market Desk V2 -> trading-adjacent -> décodage généraliste ;
|
||||||
|
- le gate de sizing : une prerelease vise environ 15–20 minutes et une release concrète doit rester clôturable dans une seule session de chat, sinon elle est redécoupée avant l'implémentation lourde.
|
||||||
|
|
||||||
|
## Hors périmètre
|
||||||
|
|
||||||
|
`0.2.0-rel.001` ne modifie pas :
|
||||||
|
|
||||||
|
- les crates Rust hors signal de version workspace ;
|
||||||
|
- `ksp-app-config-desk` runtime/frontend ;
|
||||||
|
- les versions `package.json` / `tauri.conf.json` de Config Desk ;
|
||||||
|
- les dépendances Cargo ;
|
||||||
|
- les contrats publics existants ;
|
||||||
|
- la configuration runtime ;
|
||||||
|
- le prompt `0.2.1` finalisé par `pre.003`.
|
||||||
|
|
||||||
|
Aucune nouvelle fonctionnalité N2 n'est introduite.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.2.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation de `rel.001`
|
||||||
|
|
||||||
|
Le delta modifie `Cargo.toml` et de la documentation, sans fichier Rust ni frontend/Tauri. Avant publication/tag, exécuter :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `cargo tauri build` n'est requis : la version Tauri de `ksp-app-config-desk` reste `0.1.4` et aucun fichier de l'application n'est modifié par cette publication.
|
||||||
|
|
||||||
|
## Commit et tag
|
||||||
|
|
||||||
|
Après validation de `rel.001` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
commit : v0.2.0-rel.001
|
||||||
|
tag : v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Seul le tag stable `v0.2.0` est attendu.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Après création du tag `v0.2.0`, ouvrir :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/006-V0_2_1_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`0.2.1` commence par audit de la documentation Solana HTTP actuelle, audit du transport bot3, matrice exhaustive des méthodes et gate de sizing avant toute implémentation fonctionnelle lourde.
|
||||||
171
deltas/0.2.1/pre.001-fix.001.md
Normal file
171
deltas/0.2.1/pre.001-fix.001.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.001-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.1-pre.001-fix.001` — recalibrage du split HTTP et fusion possible de sessions
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.001
|
||||||
|
workspace.package.version = "0.2.1-pre.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce correctif est exclusivement documentaire. Il corrige le dimensionnement issu de `pre.001` avant toute implémentation fonctionnelle lourde de `ksp-onchain-transport-lib`.
|
||||||
|
|
||||||
|
Le delta historique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.1/pre.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
reste inchangé et continue de tracer le premier split décidé par `pre.001`.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Réduire le nombre de releases HTTP complémentaires sans diminuer la couverture exhaustive de la surface normative et préciser qu'une même session de chat peut clôturer plusieurs releases successives lorsque leur sizing réel le permet.
|
||||||
|
|
||||||
|
Le correctif ne fusionne jamais les releases elles-mêmes : chaque release conserve son numéro, ses prereleases, son signal de version, ses deltas, ses validations et sa clôture stable propres.
|
||||||
|
|
||||||
|
## Split HTTP corrigé
|
||||||
|
|
||||||
|
Le premier split de `pre.001` était :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1 foundation + 4 canaris
|
||||||
|
0.2.2 Accounts + Tokens
|
||||||
|
0.2.3 Transactions
|
||||||
|
0.2.4 Blocks
|
||||||
|
0.2.5 Cluster
|
||||||
|
0.2.6 Economics + compliance finale
|
||||||
|
```
|
||||||
|
|
||||||
|
Il est remplacé pour l'exécution par :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1 HTTP foundation + 4 canaris
|
||||||
|
0.2.2 Accounts + Tokens + Cluster 22 méthodes
|
||||||
|
0.2.3 Transactions 11 méthodes
|
||||||
|
0.2.4 Blocks + Economics + compliance 15 méthodes
|
||||||
|
```
|
||||||
|
|
||||||
|
La matrice reste exhaustive :
|
||||||
|
|
||||||
|
```text
|
||||||
|
4 + 22 + 11 + 15 = 52 méthodes HTTP courantes
|
||||||
|
```
|
||||||
|
|
||||||
|
Les 14 méthodes historiques de la section Deprecated restent présentes dans la matrice documentaire avec leur statut runtime déjà décidé par `pre.001`.
|
||||||
|
|
||||||
|
Aucune méthode n'est supprimée ou masquée pour faire tenir le planning.
|
||||||
|
|
||||||
|
## Règle de session précisée
|
||||||
|
|
||||||
|
La règle KSP « une release concrète doit pouvoir être ouverte et clôturée dans une seule session » définit une borne maximale, pas une obligation de changer de chat après chaque release.
|
||||||
|
|
||||||
|
Après clôture complète d'une release, la même session peut ouvrir puis clôturer la suivante si :
|
||||||
|
|
||||||
|
- le gate de sizing de la release suivante reste raisonnablement positif ;
|
||||||
|
- la release précédente est réellement clôturée avant l'ouverture de la suivante ;
|
||||||
|
- versions, deltas, validations et critères de clôture restent séparés ;
|
||||||
|
- aucune dette de validation n'est reportée implicitement sur la release suivante.
|
||||||
|
|
||||||
|
Ainsi, `0.2.2`, `0.2.3` et `0.2.4` représentent **trois sessions nominales au maximum**, mais peuvent tenir dans deux sessions, voire être enchaînées plus rapidement si le travail réel le permet.
|
||||||
|
|
||||||
|
## Séquence `0.2.x` recalibrée
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1 HTTP transport foundation + 4 canaris
|
||||||
|
0.2.2 HTTP Accounts + Tokens + Cluster
|
||||||
|
0.2.3 HTTP Transactions
|
||||||
|
0.2.4 HTTP Blocks + Economics + compliance complète
|
||||||
|
0.2.5 wallet foundation (.kspwallet)
|
||||||
|
0.2.6 Wallet Desk
|
||||||
|
0.2.7 standard Solana WebSocket
|
||||||
|
0.2.8 Helius LaserStream WebSocket
|
||||||
|
0.2.9 Yellowstone gRPC standard foundation
|
||||||
|
0.2.10 off-chain price transport
|
||||||
|
0.2.11 price visualization desk
|
||||||
|
0.2.12 interface/wire foundation
|
||||||
|
0.2.13 program-api foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan historique `docs/plans/007-V0_2_0_SERIES_PLANNING.md` reste inchangé : il documente la planification telle qu'elle existait à la clôture de `0.2.0`. Le présent fix et les documents actifs portent la séquence courante.
|
||||||
|
|
||||||
|
## Synchronisation documentaire
|
||||||
|
|
||||||
|
Le correctif met à jour :
|
||||||
|
|
||||||
|
- le roadmap et la séquence fonctionnelle ;
|
||||||
|
- le plan actif `008` et sa matrice méthode -> release ;
|
||||||
|
- les index documentaires ;
|
||||||
|
- le prompt historique `006`, en conservant son rôle de trace d'ouverture ;
|
||||||
|
- l'inventaire courant des composants ;
|
||||||
|
- les références temporelles de `docs/IDEAS.md` devenues obsolètes après renumérotation.
|
||||||
|
|
||||||
|
L'historique est explicite : `pre.001` garde son premier split `0.2.1`–`0.2.6`; ce `fix.001` porte le recalibrage `0.2.1`–`0.2.4`.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
ROADMAP.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/IDEAS.md
|
||||||
|
docs/architecture/004-COMPONENT_INVENTORY.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
prompts/006-V0_2_1_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.1/pre.001-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers volontairement inchangés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
deltas/0.2.1/pre.001.md
|
||||||
|
docs/plans/007-V0_2_0_SERIES_PLANNING.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.001.md` reste la trace immuable de la livraison initiale. Le plan `007` reste l'historique stable de `0.2.0`.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Aucune modification de `Cargo.toml`.
|
||||||
|
|
||||||
|
Le correctif est documentaire uniquement ; `workspace.package.version` reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
L'identifiant de livraison est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.001-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance ajoutée ou modifiée.
|
||||||
|
|
||||||
|
## Validations du correctif
|
||||||
|
|
||||||
|
À la livraison du fix :
|
||||||
|
|
||||||
|
- le delta historique `pre.001.md` doit conserver exactement son contenu ;
|
||||||
|
- le plan `008` doit contenir 52 méthodes courantes réparties exactement en `4 / 22 / 11 / 15` sur `0.2.1 / 0.2.2 / 0.2.3 / 0.2.4` ;
|
||||||
|
- aucune méthode courante ne doit rester affectée à `0.2.5` ou `0.2.6` ;
|
||||||
|
- les 14 entrées Deprecated historiques doivent rester dans la matrice ;
|
||||||
|
- Wallet doit être `0.2.5`, Wallet Desk `0.2.6`, WebSocket `0.2.7`, LaserStream `0.2.8`, Yellowstone `0.2.9` dans les documents actifs ;
|
||||||
|
- l'enchaînement de plusieurs releases dans une même session doit être autorisé uniquement après clôture complète et nouveau sizing positif ;
|
||||||
|
- `Cargo.toml` doit rester à `0.2.1-pre.1` ;
|
||||||
|
- l'archive du fix doit être limitée aux huit fichiers modifiés et au nouveau delta.
|
||||||
|
|
||||||
|
Aucune commande Cargo n'est requise spécifiquement pour ce correctif documentaire. Les validations Rust prévues pour `0.2.1-pre.002` et les frontières globales de release restent inchangées.
|
||||||
151
deltas/0.2.1/pre.001.md
Normal file
151
deltas/0.2.1/pre.001.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.1-pre.001` — audit HTTP, matrice exhaustive, architecture et sizing
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Release stable attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
L'archive KSP fournie porte `workspace.package.version = "0.2.0"` et contient les quatre crates N1 attendues. Elle ne contient pas `.git`; le tag `v0.2.0` n'est donc pas revérifiable localement depuis le zip.
|
||||||
|
|
||||||
|
Archive historique auditée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
khadhroony-bot3_v0.5.3-pre.005-fix010.zip
|
||||||
|
workspace.package.version = "0.5.3-pre.5"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Exécuter la première tranche obligatoire d'audit/conception de `0.2.1` sans grosse implémentation : relecture KSP, audit détaillé du transport bot3, inventaire officiel Solana HTTP actuel, matrice exhaustive, décisions d'architecture et gate de sizing.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Conformément à `VER-ID-009`, la prerelease non-fix synchronise le signal technique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.0 -> 0.2.1-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune crate, source Rust ou dépendance n'est ajoutée dans cette tranche.
|
||||||
|
|
||||||
|
## Résultats principaux
|
||||||
|
|
||||||
|
- index HTTP Solana courant audité : **52 méthodes** ;
|
||||||
|
- navigation Deprecated officielle : **14 méthodes historiques** ;
|
||||||
|
- changelog Agave : endpoints RPC v1 obsolete/deprecated retirés en v2 ; les 14 pages sont donc conservées dans la matrice comme `Deprecated / runtime Removed`, pas comme wrappers callables ;
|
||||||
|
- aucune méthode HTTP courante marquée unstable/experimental trouvée dans l'audit du 2026-08-17 ; les marqueurs Unstable trouvés concernent WebSocket/PubSub ;
|
||||||
|
- `getBlock` et `getTransaction` sont des méthodes courantes stables mais leur deuxième paramètre legacy sous forme de bare encoding string est explicitement deprecated ;
|
||||||
|
- bot3 contient exactement les 52 noms courants, tous `TypedAdapter`, sans manque ni extra ; il n'intègre pas les 14 Deprecated au registre HTTP ;
|
||||||
|
- bot3 doit être refondu pour supprimer Transport -> Config, Transport -> `ks-lib` et `tracing` direct.
|
||||||
|
|
||||||
|
## Gate de sizing
|
||||||
|
|
||||||
|
Réponse pour le périmètre monolithique du prompt `006` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
NON
|
||||||
|
```
|
||||||
|
|
||||||
|
Le scope est scindé immédiatement, sans masquer aucune méthode :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1 foundation + registry exhaustif + pool/resilience/config + 4 canaris
|
||||||
|
0.2.2 Accounts + Tokens
|
||||||
|
0.2.3 Transactions
|
||||||
|
0.2.4 Blocks
|
||||||
|
0.2.5 Cluster
|
||||||
|
0.2.6 Economics + compliance finale HTTP
|
||||||
|
```
|
||||||
|
|
||||||
|
Wallet est décalé à `0.2.7`, Wallet Desk à `0.2.8`, puis les releases suivantes sont renumérotées.
|
||||||
|
|
||||||
|
Réponse pour la `0.2.1` réduite définie par le plan `008` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
OUI, raisonnablement clôturable dans la session.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture décidée
|
||||||
|
|
||||||
|
- settings runtime publics possédés par Transport, utilisant notamment `Duration`;
|
||||||
|
- provider/cluster/role/request kind sous descriptors ouverts/newtypes ;
|
||||||
|
- URL encapsulée avec `Debug` redacted ;
|
||||||
|
- descriptor central séparant statut documentaire, statut runtime, statut de forme de requête, operation kind et retry class ;
|
||||||
|
- pool logique : priorité globale puis round-robin/fallback, avec endpoints disabled exclus ;
|
||||||
|
- RPS/burst/concurrence/cooldown par endpoint/rôle ;
|
||||||
|
- retry transport borné seulement pour opérations retry-safe ; `sendTransaction`/`requestAirdrop` ne sont jamais resend automatiquement après dispatch ambigu ;
|
||||||
|
- erreurs via l'unique `ksp_core_lib::Error` ;
|
||||||
|
- réponses Transport/wire, aucun modèle Store/Program/canonical ;
|
||||||
|
- Config standard IDs candidats `cfg.std.transport` / `schema.std.transport` et adapter Config -> Transport ;
|
||||||
|
- aucune nouvelle variable `.env.example` dans `pre.001`.
|
||||||
|
|
||||||
|
## Dépendances auditées
|
||||||
|
|
||||||
|
`reqwest` courant observé : `0.13.4`; Tokio courant observé : `1.53.1`. Le workspace possède déjà `serde`, `serde_json` et `tokio ^1.53`. `reqwest` sera ajouté seulement à `pre.002` après vérification exacte de ses features. `base64`/`bs58` sont différés jusqu'au premier besoin concret.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
deltas/0.2.1/pre.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
prompts/006-V0_2_1_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Validations exécutées
|
||||||
|
|
||||||
|
- relecture des sources internes obligatoires et des contrats publics Core/Logging/Config ;
|
||||||
|
- inspection du workspace stable fourni ;
|
||||||
|
- inspection du transport/config bot3 fourni ;
|
||||||
|
- comparaison automatisée locale des 52 noms bot3 avec les 52 noms de l'index officiel audité : `missing = []`, `extra = []` ;
|
||||||
|
- consultation de la documentation officielle Solana HTTP et Deprecated ;
|
||||||
|
- consultation de JSON-RPC 2.0 ;
|
||||||
|
- consultation du changelog Agave pour le statut runtime des endpoints deprecated ; aucun POST JSON-RPC live vers un provider n’a été exécuté, les éventuelles divergences provider restent donc à documenter séparément ;
|
||||||
|
- vérification des versions actuelles candidates `reqwest`/`tokio` ;
|
||||||
|
- vérification de l'absence de `.git` dans l'archive KSP.
|
||||||
|
|
||||||
|
## Validations non exécutées
|
||||||
|
|
||||||
|
Le sandbox ne contient pas le binaire `cargo` (`cargo: command not found`). Les commandes suivantes n'ont donc pas été exécutées ici et doivent être rejouées sur le checkout de développement avant commit :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `cargo tree` n'est pertinent avant ajout de la crate/dépendance ; les audits `cargo tree -p ksp-onchain-transport-lib ...` deviennent obligatoires dès `pre.002`.
|
||||||
|
|
||||||
|
L'archive source ne contient pas `.git`; le commit attendu après application/validation suit `VER-GIT-001` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Questions ouvertes
|
||||||
|
|
||||||
|
Aucune question bloquante pour ouvrir `pre.002`. Les détails de nommage Rust peuvent encore être ajustés si une contrainte concrète apparaît, sans modifier l'ownership et les invariants fixés par le plan.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
`0.2.1-pre.002` : créer la crate, fixer les settings/validation, JSON-RPC, descriptors/status, erreurs et base Logging sur le périmètre réduit.
|
||||||
171
deltas/0.2.1/pre.002-fix.001.md
Normal file
171
deltas/0.2.1/pre.002-fix.001.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.002-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.1-pre.002-fix.001` — nettoyage Clippy et documentation des tests
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
```text
|
||||||
|
release : 0.2.1
|
||||||
|
prerelease corrigée : pre.002
|
||||||
|
identifiant de commit attendu : v0.2.1-pre.002-fix.001
|
||||||
|
workspace.package.version : 0.2.1-pre.2.fix.1
|
||||||
|
base : v0.2.1-pre.002
|
||||||
|
```
|
||||||
|
|
||||||
|
Le correctif est ouvert à partir de la validation locale transmise après application de `pre.002`.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger exclusivement les warnings révélés par `cargo clippy --workspace --all-targets` et `cargo test`, sans modifier le périmètre fonctionnel ni les contrats publics introduits par `pre.002` :
|
||||||
|
|
||||||
|
- supprimer trois usages de `assert!(false, ...)` signalés par `clippy::assertions_on_constants` dans les tests unitaires ;
|
||||||
|
- appliquer les deux simplifications `clippy::collapsible_if` dans la validation des settings ;
|
||||||
|
- documenter les deux crates de tests d'intégration afin de satisfaire `missing_docs` ;
|
||||||
|
- conserver inchangés le registre 52 current + 14 historiques, les settings publics, les envelopes JSON-RPC, les codes d'erreur, les descriptors et le firewall de dépendances.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Le correctif touche du Rust de production et de test. Conformément à `VER-ID-007` et `VER-ID-010` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.2 -> 0.2.1-pre.2.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Toutes les crates membres continuent d'hériter `version.workspace = true`.
|
||||||
|
|
||||||
|
## Corrections Rust
|
||||||
|
|
||||||
|
### Validation des settings
|
||||||
|
|
||||||
|
Les deux validations imbriquées suivantes sont exprimées sous forme de let-chain Rust 2024 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
max_idle_connections_per_host = Some(0)
|
||||||
|
pause_after_rate_limit = Some(Duration::ZERO)
|
||||||
|
```
|
||||||
|
|
||||||
|
La sémantique reste strictement identique : les valeurs nulles configurées restent rejetées avec `ERROR_CODE_INVALID_SETTINGS`.
|
||||||
|
|
||||||
|
### Tests JSON-RPC
|
||||||
|
|
||||||
|
Les tests qui validaient la variante attendue avec un `match` et `assert!(false, ...)` utilisent désormais :
|
||||||
|
|
||||||
|
1. une assertion `matches!` sur la variante attendue ;
|
||||||
|
2. un `if let` pour vérifier le payload de cette variante.
|
||||||
|
|
||||||
|
Aucun `panic!`/`unreachable!` explicite n'est introduit.
|
||||||
|
|
||||||
|
### Test de matrice de couverture
|
||||||
|
|
||||||
|
Le cas `HttpRpcCoverageRelease::Historical` rencontré dans `current_http_rpc_methods()` est désormais comptabilisé dans un compteur local puis vérifié avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
historical == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
La preuve reste donc explicite sans assertion constante fausse.
|
||||||
|
|
||||||
|
### Tests d'intégration
|
||||||
|
|
||||||
|
Les targets Cargo :
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/public_api.rs
|
||||||
|
tests/dependency_boundary.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
possèdent maintenant une documentation de crate `//!`, ce qui satisfait le lint workspace `missing_docs` sans masquer le warning global.
|
||||||
|
|
||||||
|
## Graphe de dépendances
|
||||||
|
|
||||||
|
Les quatre commandes `cargo tree` ont été exécutées localement sur `pre.002` avant ce fix.
|
||||||
|
|
||||||
|
Constats :
|
||||||
|
|
||||||
|
- aucune dépendance directe interdite Transport -> Config/Store/Program/tracing n'apparaît ;
|
||||||
|
- `reqwest 0.13.4` reste la dépendance HTTP retenue par `pre.002` ;
|
||||||
|
- `cargo tree -d` ne rapporte comme duplication que `syn 2.0.119` / `syn 3.0.3`, issue des chaînes proc-macro/ICU/Serde et ne justifiant pas une modification du graphe KSP dans ce correctif ;
|
||||||
|
- aucune dépendance n'est ajoutée ou retirée par `fix.001`.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.1/pre.002-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
crates/ksp-onchain-transport-lib/src/settings.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
|
||||||
|
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Validation de `pre.002` ayant déclenché le correctif
|
||||||
|
|
||||||
|
Les commandes suivantes ont été exécutées par le user sur `v0.2.1-pre.002` avant création du fix :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all : exécuté
|
||||||
|
cargo check --workspace : OK
|
||||||
|
cargo clippy --workspace --all-targets : terminé avec warnings
|
||||||
|
cargo test -p ksp-onchain-transport-lib : OK, 40 tests passés
|
||||||
|
cargo test --workspace : OK ; 1 test diagnostic ignoré comme prévu
|
||||||
|
cargo tree -p ksp-onchain-transport-lib : exécuté
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -d : exécuté
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e features : exécuté
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e normal : exécuté
|
||||||
|
```
|
||||||
|
|
||||||
|
Warnings observés et ciblés par ce fix :
|
||||||
|
|
||||||
|
```text
|
||||||
|
3 x clippy::assertions_on_constants
|
||||||
|
2 x clippy::collapsible_if
|
||||||
|
2 x missing documentation for integration-test crate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validations du correctif
|
||||||
|
|
||||||
|
Le sandbox de préparation ne possède pas `cargo`, `rustc` ni `rustfmt`. Les validations Rust du correctif ne peuvent donc pas y être exécutées et ne sont pas déclarées réussies.
|
||||||
|
|
||||||
|
Après application du delta, exécuter dans cet ordre :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Les quatre `cargo tree` ne sont pas obligatoires à répéter pour ce fix puisqu'aucune dépendance n'a changé. Ils peuvent être relancés si une vérification de non-régression du graphe est souhaitée.
|
||||||
|
|
||||||
|
## Contrôles statiques effectués pendant la préparation
|
||||||
|
|
||||||
|
- aucune occurrence restante de `assert!(false` dans la crate Transport ;
|
||||||
|
- les deux blocs signalés `collapsible_if` ont été remplacés ;
|
||||||
|
- les deux tests d'intégration possèdent une rustdoc de crate ;
|
||||||
|
- les headers/version des fichiers modifiés ont été incrémentés ;
|
||||||
|
- `pre.002.md` n'est pas modifié ;
|
||||||
|
- le périmètre public de `pre.002` reste inchangé.
|
||||||
|
|
||||||
|
## Décisions prises
|
||||||
|
|
||||||
|
- ne pas masquer les lints par `#[allow(...)]` ; corriger leur cause ;
|
||||||
|
- ne pas remplacer `assert!(false)` par `panic!()` ou `unreachable!()` ;
|
||||||
|
- ne pas modifier le graphe de dépendances pour le seul doublon `syn 2/3` ;
|
||||||
|
- conserver ce travail sous un vrai `pre.002-fix.001`, conformément à l'historique Git KSP.
|
||||||
|
|
||||||
|
## Questions ouvertes
|
||||||
|
|
||||||
|
Aucune pour ce correctif. Après validation et commit, `0.2.1` peut reprendre avec la prerelease suivante prévue par le plan `008`.
|
||||||
333
deltas/0.2.1/pre.002.md
Normal file
333
deltas/0.2.1/pre.002.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.1-pre.002` — fondation crate/settings/JSON-RPC/descriptors
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
```text
|
||||||
|
release : 0.2.1
|
||||||
|
prerelease : pre.002
|
||||||
|
identifiant de commit attendu : v0.2.1-pre.002
|
||||||
|
workspace.package.version : 0.2.1-pre.2
|
||||||
|
base : v0.2.1-pre.001-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Le user a confirmé avoir commité `v0.2.1-pre.001` puis `v0.2.1-pre.001-fix.001` avant l'ouverture de cette tranche.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Matérialiser la fondation Rust de `ksp-onchain-transport-lib` sans ouvrir encore le client/pool HTTP :
|
||||||
|
|
||||||
|
- ajouter la crate au workspace ;
|
||||||
|
- stabiliser les codes d'erreur Transport ;
|
||||||
|
- posséder les settings runtime publics et leur validation ;
|
||||||
|
- posséder les envelopes JSON-RPC 2.0 HTTP ;
|
||||||
|
- matérialiser le registre central des méthodes/statuts issu de la matrice `pre.001` ;
|
||||||
|
- installer la base de warnings KSP de statut sans dépendance directe à `tracing` ;
|
||||||
|
- ajouter les tests unitaires/intégration correspondant à ces contrats.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Conformément à `VER-ID-009` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.1 -> 0.2.1-pre.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Toutes les crates membres continuent d'hériter `version.workspace = true`.
|
||||||
|
|
||||||
|
## Workspace et dépendances
|
||||||
|
|
||||||
|
Nouveau membre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Dépendances directes de la crate :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-core-lib
|
||||||
|
ksp-logging-lib
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
```
|
||||||
|
|
||||||
|
`reqwest` reste centralisé au workspace :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
reqwest = { version = "^0.13", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `pre.001` avait vérifié le 2026-08-17 la génération `reqwest 0.13` et observé `0.13.4`. Cette tranche n'active volontairement aucune feature TLS/JSON/client : `reqwest` est utilisé uniquement pour `Url::parse` dans la validation de `HttpEndpointUrl`. Conformément à `RUST-DEP-001` / `RUST-DEP-003`, les features réseau et Tokio seront ajoutés seulement lorsque `pre.003` introduira un vrai client HTTP async.
|
||||||
|
|
||||||
|
Aucune dépendance directe vers :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib
|
||||||
|
ksp-store-api
|
||||||
|
ksp-store-lib
|
||||||
|
ksp-program-api
|
||||||
|
ksp-program-lib
|
||||||
|
tracing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contrats publics Transport
|
||||||
|
|
||||||
|
### Settings runtime
|
||||||
|
|
||||||
|
La crate expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpTransportSettings
|
||||||
|
HttpEndpointSettings
|
||||||
|
HttpEndpointRoleSettings
|
||||||
|
HttpRoleLimits
|
||||||
|
HttpRetrySettings
|
||||||
|
HttpEndpointUrl
|
||||||
|
HttpProviderName
|
||||||
|
HttpClusterName
|
||||||
|
HttpRoleName
|
||||||
|
HttpRequestKind
|
||||||
|
```
|
||||||
|
|
||||||
|
Décisions :
|
||||||
|
|
||||||
|
- Transport reste totalement constructible/testable sans Config ;
|
||||||
|
- `provider`, `cluster`, `role` et `request_kind` sont des descriptors ouverts, pas des enums fermées ;
|
||||||
|
- `HttpEndpointUrl` accepte uniquement HTTP/HTTPS, conserve la valeur runtime réelle mais redacted systématiquement son `Debug` ;
|
||||||
|
- `HttpTransportSettings::validate()` vérifie notamment endpoints activés, identités/roles uniques, timeouts positifs, request kinds, wildcard, limites et backoff cohérents ;
|
||||||
|
- `NonZeroU32` empêche structurellement les zéros sur RPS/burst/concurrence lorsqu'ils sont configurés ;
|
||||||
|
- aucun accès direct à `.env` ou `std::env::var*` n'existe dans Transport.
|
||||||
|
|
||||||
|
### Codes d'erreur
|
||||||
|
|
||||||
|
Le domaine unique reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
onchain_transport
|
||||||
|
```
|
||||||
|
|
||||||
|
Codes stabilisés/réservés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
invalid_settings
|
||||||
|
endpoint_selection_failed
|
||||||
|
http_connection_failed
|
||||||
|
http_request_failed
|
||||||
|
timeout
|
||||||
|
rate_limited
|
||||||
|
json_encode_failed
|
||||||
|
json_decode_failed
|
||||||
|
json_rpc_protocol_invalid
|
||||||
|
rpc_application_error
|
||||||
|
method_removed
|
||||||
|
invalid_response
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils utilisent exclusivement `ksp_core_lib::Error` / `ErrorCode`.
|
||||||
|
|
||||||
|
## JSON-RPC HTTP
|
||||||
|
|
||||||
|
La fondation expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
JsonRpcRequest
|
||||||
|
JsonRpcSuccessResponse
|
||||||
|
JsonRpcErrorObject
|
||||||
|
JsonRpcErrorResponse
|
||||||
|
JsonRpcResponse
|
||||||
|
parse_json_rpc_response_text
|
||||||
|
parse_json_rpc_response_value
|
||||||
|
```
|
||||||
|
|
||||||
|
Invariants :
|
||||||
|
|
||||||
|
- request ids KSP numériques `u64` ;
|
||||||
|
- `jsonrpc` exactement `"2.0"` ;
|
||||||
|
- id de réponse exactement égal à celui de la requête ;
|
||||||
|
- présence exclusive de `result` ou `error` ;
|
||||||
|
- JSON `null` conservé comme résultat valide ;
|
||||||
|
- erreur JSON syntaxique distincte d'une violation protocolaire ;
|
||||||
|
- application error RPC préservée dans `JsonRpcResponse::Error`, puis mappable vers `rpc_application_error` ;
|
||||||
|
- le mapping KSP ne copie pas le message/data distant dans le contexte générique ;
|
||||||
|
- `Debug` des requests masque les paramètres ; `Debug` des succès masque le résultat ; `Debug` des erreurs masque message/data distants.
|
||||||
|
|
||||||
|
Ce dernier point empêche un log/debug accidentel d'une transaction, d'un token provider ou d'une réponse massive tout en laissant les getters explicites accessibles au consumer légitime.
|
||||||
|
|
||||||
|
## Registre central des méthodes
|
||||||
|
|
||||||
|
`rpc_method.rs` matérialise exactement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
52 méthodes current
|
||||||
|
14 méthodes historiques Deprecated / Removed
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque descriptor possède :
|
||||||
|
|
||||||
|
```text
|
||||||
|
method
|
||||||
|
category
|
||||||
|
request_kind
|
||||||
|
documentation_status
|
||||||
|
runtime_status
|
||||||
|
request_form_status
|
||||||
|
operation_kind
|
||||||
|
transport_retry_class
|
||||||
|
replacement
|
||||||
|
coverage_release
|
||||||
|
```
|
||||||
|
|
||||||
|
La distribution de couverture est encodée et testée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1 : 4
|
||||||
|
0.2.2 : 22
|
||||||
|
0.2.3 : 11
|
||||||
|
0.2.4 : 15
|
||||||
|
----
|
||||||
|
52 current
|
||||||
|
```
|
||||||
|
|
||||||
|
Cas structurants déjà encodés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
getTransaction -> StableWithDeprecatedLegacy
|
||||||
|
getBlock -> StableWithDeprecatedLegacy
|
||||||
|
sendTransaction -> WriteSubmission / NeverAfterDispatch
|
||||||
|
requestAirdrop -> WriteSubmission / NeverAfterDispatch
|
||||||
|
simulateTransaction -> Simulation / RetrySafe
|
||||||
|
14 historiques -> Deprecated / Removed / NotApplicable
|
||||||
|
```
|
||||||
|
|
||||||
|
`HttpRpcMethodDescriptor::ensure_runtime_supported()` centralise le comportement de statut :
|
||||||
|
|
||||||
|
- Stable + Supported : succès silencieux ;
|
||||||
|
- Deprecated/Unstable + Supported : `warn` via `ksp-logging-lib` ;
|
||||||
|
- Removed : `warn` puis erreur `method_removed`, sans simuler un appel supporté.
|
||||||
|
|
||||||
|
Le target utilisé est toujours le nom Cargo réel via `env!("CARGO_PKG_NAME")`; aucune dépendance directe `tracing` n'est introduite.
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
40 tests Rust sont définis :
|
||||||
|
|
||||||
|
- validation des settings et erreurs structurées ;
|
||||||
|
- HTTP/HTTPS uniquement ;
|
||||||
|
- redaction URL et `Debug` global settings ;
|
||||||
|
- endpoint enabled, doublons endpoint/rôle, wildcard, burst/RPS, timeouts, backoff ;
|
||||||
|
- sérialisation JSON-RPC request ;
|
||||||
|
- parse success/error/null ;
|
||||||
|
- id mismatch, version, `result xor error`, invalid JSON ;
|
||||||
|
- redaction des payloads request/result/error dans `Debug` ;
|
||||||
|
- mapping RPC application error ;
|
||||||
|
- registre 52 + 14 et unicité ;
|
||||||
|
- distribution 4/22/11/15 ;
|
||||||
|
- canaris exacts `getBalance/getGenesisHash/getHealth/getVersion` ;
|
||||||
|
- request forms deprecated de `getTransaction/getBlock` ;
|
||||||
|
- no-resend descriptors write ;
|
||||||
|
- warning path Unstable supporté ;
|
||||||
|
- erreur `method_removed` ;
|
||||||
|
- tests d'intégration de la façade publique ;
|
||||||
|
- canary de firewall des dépendances directes du manifest.
|
||||||
|
|
||||||
|
Les vrais tests unitaires sont physiquement sous `unit_tests/` et rattachés aux modules de production via `#[cfg(test)]` + `#[path = ...]`. `tests/` est réservé aux tests d'intégration publics.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/Cargo.toml
|
||||||
|
crates/ksp-onchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/json_rpc.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/rpc_method.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/settings.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/settings.rs
|
||||||
|
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||||
|
deltas/0.2.1/pre.002.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Validations exécutées dans l'environnement d'échange
|
||||||
|
|
||||||
|
Le conteneur ne fournit ni `cargo`, ni `rustc`, ni `rustfmt`. Les validations exécutables disponibles ont donc été limitées aux contrôles statiques suivants :
|
||||||
|
|
||||||
|
- parsing TOML du `Cargo.toml` racine et du manifest de la nouvelle crate ;
|
||||||
|
- contrôle `workspace.package.version = 0.2.1-pre.2` ;
|
||||||
|
- contrôle présence du nouveau membre workspace ;
|
||||||
|
- contrôle des headers `file:` et newline finale sur tous les nouveaux fichiers ;
|
||||||
|
- scan production : aucun `use`, `?`, `unwrap`, `expect`, `panic!`, `pub mod` ;
|
||||||
|
- scan manifest : aucune dépendance directe Config/Store/Program/`tracing` ;
|
||||||
|
- scan production : aucune lecture directe `std::env::var*` ;
|
||||||
|
- audit heuristique de rustdoc sur les éléments publics ;
|
||||||
|
- comparaison automatique du registre Rust avec la matrice `008` : 52 current identiques, dans le même ordre, et 14 historiques identiques ;
|
||||||
|
- contrôle de distribution de release : `4 / 22 / 11 / 15` ;
|
||||||
|
- contrôle des deux seuls marqueurs `StableWithDeprecatedLegacy` : `getTransaction`, `getBlock` ;
|
||||||
|
- contrôle des fences Markdown et headers des documents modifiés ;
|
||||||
|
- contrôle des lignes Rust > 160 colonnes après formatage manuel : aucune.
|
||||||
|
|
||||||
|
## Validations non exécutées
|
||||||
|
|
||||||
|
Obligatoires sur le checkout de développement avant commit :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
cargo tree -p ksp-onchain-transport-lib
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -d
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e features
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||||
|
```
|
||||||
|
|
||||||
|
Les `cargo tree` sont désormais obligatoires parce que `reqwest` entre réellement dans le graphe à cette tranche. Les doublons significatifs doivent être examinés avant validation du commit.
|
||||||
|
|
||||||
|
Aucun build Tauri n'est requis : aucune application Tauri/frontend n'est modifiée.
|
||||||
|
|
||||||
|
## Décisions prises
|
||||||
|
|
||||||
|
- conserver un identifiant JSON-RPC KSP numérique `u64` pour les requêtes émises ;
|
||||||
|
- préserver les payloads JSON dynamiques via `serde_json::Value` sans introduire de DTO Solana SDK massif ;
|
||||||
|
- redacter les `Debug` wire susceptibles de contenir des secrets/payloads massifs ;
|
||||||
|
- encoder dès maintenant la couverture release et la retry class dans le registre central ;
|
||||||
|
- ne pas activer de features réseau `reqwest` ni Tokio avant leur premier usage réel ;
|
||||||
|
- ne pas introduire Config, Store, Program, Solana SDK haut niveau, base64, bs58, WebSocket ou gRPC.
|
||||||
|
|
||||||
|
## Questions ouvertes / reports explicites
|
||||||
|
|
||||||
|
Aucune question bloquante pour `pre.003`.
|
||||||
|
|
||||||
|
À décider avec l'implémentation réelle de `pre.003` :
|
||||||
|
|
||||||
|
- features `reqwest 0.13` exactes nécessaires au client async/TLS ;
|
||||||
|
- ownership précis du `reqwest::Client` par endpoint logique ;
|
||||||
|
- structures runtime du pool, fairness et snapshots ;
|
||||||
|
- première utilisation effective de Tokio et des primitives `sync` si nécessaires.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.003
|
||||||
|
```
|
||||||
|
|
||||||
|
Mission prévue : endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs, sans encore ouvrir rate-limit/concurrence/retry effectif de `pre.004` au-delà des structures strictement nécessaires.
|
||||||
135
deltas/0.2.1/pre.003-fix.001.md
Normal file
135
deltas/0.2.1/pre.003-fix.001.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.003-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `v0.2.1-pre.003-fix.001`
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.003
|
||||||
|
```
|
||||||
|
|
||||||
|
Version Cargo cible :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.3.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger exclusivement les écarts révélés par la validation locale de `pre.003`, sans modifier le contrat fonctionnel du client/pool HTTP ni la planification de `0.2.1` :
|
||||||
|
|
||||||
|
- satisfaire la règle workspace `clippy::implicit-return` dans trois closures de `pool.rs` ;
|
||||||
|
- corriger le test `disabled_role_is_excluded_before_priority_selection` afin qu'il construise des settings valides tout en vérifiant réellement qu'un rôle disabled n'est pas sélectionnable ;
|
||||||
|
- conserver inchangés la stratégie priority + round-robin, le fallback, les snapshots, la feature TLS `reqwest/rustls` et les frontières de `pre.003`.
|
||||||
|
|
||||||
|
`ROADMAP.md` n'est pas modifié par ce fix : le détail des corrections appartient au delta et ne constitue pas un changement de roadmap.
|
||||||
|
|
||||||
|
## Validation ayant déclenché le fix
|
||||||
|
|
||||||
|
Après application de `pre.003`, les commandes exécutées localement par le user ont donné :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all : exécuté
|
||||||
|
cargo check --workspace : OK
|
||||||
|
cargo clippy --workspace --all-targets : échec, 3 x clippy::implicit-return
|
||||||
|
cargo test -p ksp-onchain-transport-lib : échec, 46 passés / 1 échoué
|
||||||
|
cargo test --workspace : échec sur le même test Transport
|
||||||
|
cargo tree -p ksp-onchain-transport-lib : exécuté
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -d : exécuté
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e features : exécuté
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e normal : exécuté
|
||||||
|
```
|
||||||
|
|
||||||
|
Les trois erreurs Clippy concernaient uniquement des closures dans `pool.rs` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
filter(...)
|
||||||
|
sort_by_key(...)
|
||||||
|
take_while(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le test en échec construisait un endpoint `enabled = true` dont l'unique rôle était `enabled = false`. Cette fixture contredisait la validation Transport déjà définie, qui exige qu'un endpoint activé expose au moins un rôle activé.
|
||||||
|
|
||||||
|
## Corrections
|
||||||
|
|
||||||
|
### `pool.rs`
|
||||||
|
|
||||||
|
Les trois closures signalées utilisent maintenant un `return` explicite, conformément à la configuration Clippy KSP :
|
||||||
|
|
||||||
|
```text
|
||||||
|
filter(|endpoint| return ...)
|
||||||
|
sort_by_key(|candidate| return ...)
|
||||||
|
take_while(|candidate| return ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune sémantique de sélection n'est modifiée.
|
||||||
|
|
||||||
|
### Test du rôle disabled
|
||||||
|
|
||||||
|
La fixture de test conserve désormais sur le premier endpoint :
|
||||||
|
|
||||||
|
- un rôle `default` disabled, de priorité 1 ;
|
||||||
|
- un rôle `maintenance` enabled, également valide pour l'endpoint mais ne correspondant pas au rôle demandé.
|
||||||
|
|
||||||
|
Le pool est donc valide à la construction. Lors d'une sélection sur le rôle `default`, le rôle disabled du premier endpoint doit être ignoré et l'endpoint fallback reste sélectionné.
|
||||||
|
|
||||||
|
Cette forme teste réellement le comportement annoncé sans contourner l'invariant de validation des settings.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Le fix touche du Rust de production et de test :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.3 -> 0.2.1-pre.3.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Toutes les crates membres continuent d'hériter `version.workspace = true`.
|
||||||
|
|
||||||
|
## Graphe de dépendances
|
||||||
|
|
||||||
|
Aucune dépendance ni feature n'est ajoutée, retirée ou modifiée par ce fix.
|
||||||
|
|
||||||
|
Les `cargo tree` exécutés sur `pre.003` montrent notamment :
|
||||||
|
|
||||||
|
- `reqwest 0.13.4` avec la feature `rustls` attendue ;
|
||||||
|
- aucun retour de dépendance Transport vers Config/Store/Program ;
|
||||||
|
- `cargo tree -d` ne signale que `syn 2.x / 3.x`, déjà identifié comme duplication transitive de proc-macros et ne nécessitant pas de modification KSP.
|
||||||
|
|
||||||
|
Il n'est donc pas nécessaire de répéter les quatre `cargo tree` pour ce correctif.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.1/pre.003-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-onchain-transport-lib/src/pool.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/pool.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Validation du fix
|
||||||
|
|
||||||
|
Le sandbox de préparation ne dispose pas de `cargo`/`rustc`. Les validations Rust ne sont donc pas déclarées réussies ici.
|
||||||
|
|
||||||
|
Après application du delta :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Les commandes `cargo tree` n'ont pas à être répétées puisque le graphe de dépendances est inchangé.
|
||||||
196
deltas/0.2.1/pre.003.md
Normal file
196
deltas/0.2.1/pre.003.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.003.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `v0.2.1-pre.003`
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue : `v0.2.1-pre.002-fix.001`, validée localement avant ouverture de cette tranche.
|
||||||
|
|
||||||
|
Version Cargo cible :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Matérialiser la première couche de client/routing HTTP de `ksp-onchain-transport-lib` sans encore exécuter de méthode JSON-RPC :
|
||||||
|
|
||||||
|
- client endpoint logique autour de `reqwest::Client` ;
|
||||||
|
- pool logique KSP ;
|
||||||
|
- matching rôle/capability ;
|
||||||
|
- priorité globale ;
|
||||||
|
- round-robin équitable dans un même tier ;
|
||||||
|
- fallback lorsque les candidats plus prioritaires ne sont pas sélectionnables ;
|
||||||
|
- snapshots sûrs sans URL ;
|
||||||
|
- préparation des états passifs nécessaires à la résilience de `pre.004`.
|
||||||
|
|
||||||
|
Cette tranche corrige également l'usage de `ROADMAP.md` afin de respecter son contrat existant : le ROADMAP décrit l'état synthétique et la planification majeure, pas l'historique des prereleases/fixes.
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### Workspace
|
||||||
|
|
||||||
|
- `workspace.package.version` passe de `0.2.1-pre.2.fix.1` à `0.2.1-pre.3` ;
|
||||||
|
- `reqwest` reste centralisé sous `[workspace.dependencies]`, avec `default-features = false` ;
|
||||||
|
- activation explicite de la feature `rustls`, nécessaire à la construction réelle de clients HTTPS dans cette tranche ;
|
||||||
|
- aucune feature `json` n'est ajoutée : les envelopes JSON-RPC restent possédées par KSP via `serde_json` ;
|
||||||
|
- aucune dépendance Tokio directe n'est ajoutée à Transport dans cette tranche.
|
||||||
|
|
||||||
|
### `HttpEndpointClient`
|
||||||
|
|
||||||
|
Nouveau client endpoint logique :
|
||||||
|
|
||||||
|
- encapsule un `reqwest::Client` partageable ;
|
||||||
|
- applique `connect_timeout`, `request_timeout` et `max_idle_connections_per_host` depuis les settings KSP ;
|
||||||
|
- désactive les redirects automatiques ;
|
||||||
|
- désactive les proxies système/environnement implicites ;
|
||||||
|
- fixe un `User-Agent` KSP `ksp-onchain-transport-lib/<version>` ;
|
||||||
|
- conserve l'URL hors de toute surface `Debug`/snapshot ;
|
||||||
|
- expose uniquement identité, provider, cluster, état et capability matching sûrs.
|
||||||
|
|
||||||
|
### Snapshots et état passif
|
||||||
|
|
||||||
|
Nouveaux contrats publics :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpEndpointAvailability
|
||||||
|
HttpEndpointRoleSnapshot
|
||||||
|
HttpEndpointSnapshot
|
||||||
|
HttpTransportPoolSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Les snapshots ne contiennent jamais l'URL endpoint.
|
||||||
|
|
||||||
|
`HttpEndpointAvailability` réserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Disabled
|
||||||
|
Available
|
||||||
|
Degraded
|
||||||
|
RateLimited
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.003` ne produit activement que `Disabled` et `Available`. Les transitions `Degraded` / `RateLimited` appartiennent à `pre.004` avec cooldown, limiter et observations runtime.
|
||||||
|
|
||||||
|
### `HttpTransportPool`
|
||||||
|
|
||||||
|
Nouveaux contrats publics :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpTransportPool
|
||||||
|
HttpEndpointSelection
|
||||||
|
```
|
||||||
|
|
||||||
|
Algorithme concret de `pre.003` :
|
||||||
|
|
||||||
|
1. valider les settings Transport ;
|
||||||
|
2. construire un client logique par endpoint ;
|
||||||
|
3. exclure les endpoints disabled ;
|
||||||
|
4. rechercher un rôle exact enabled ;
|
||||||
|
5. exiger la capability exacte ou `*` ;
|
||||||
|
6. choisir la plus faible priorité numérique ;
|
||||||
|
7. appliquer un round-robin par couple rôle/request-kind dans ce meilleur tier ;
|
||||||
|
8. utiliser un tier moins prioritaire lorsque les candidats plus prioritaires ne sont pas sélectionnables ;
|
||||||
|
9. retourner `endpoint_selection_failed` si aucun candidat n'existe.
|
||||||
|
|
||||||
|
Le mutex synchrone du pool protège uniquement les curseurs de fairness et ne couvre aucune I/O ni aucun `await` réseau.
|
||||||
|
|
||||||
|
`select_for_method()` dérive le `request_kind` depuis le registre central et appelle `ensure_runtime_supported()` avant sélection ; une méthode historique `Removed` ne peut donc pas être routée comme méthode standard.
|
||||||
|
|
||||||
|
### ROADMAP
|
||||||
|
|
||||||
|
`ROADMAP.md` est ramené à son rôle défini par `FILE_CONTRACTS.md` :
|
||||||
|
|
||||||
|
- suppression des lignes servant de journal `0.2.1-pre.*` / `fix.*` ;
|
||||||
|
- conservation d'un état synthétique de `0.2.0` ;
|
||||||
|
- état synthétique courant de `0.2.1` ;
|
||||||
|
- conservation des releases fonctionnelles `0.2.1+` et de leur planification majeure.
|
||||||
|
|
||||||
|
Aucune règle normative nouvelle n'est nécessaire : cette correction applique le contrat `ROADMAP.md` déjà documenté.
|
||||||
|
|
||||||
|
### Plan `008`
|
||||||
|
|
||||||
|
Le plan est synchronisé avec :
|
||||||
|
|
||||||
|
- la feature TLS réellement retenue ;
|
||||||
|
- la politique client `reqwest` ;
|
||||||
|
- le statut réalisé de `pre.003` ;
|
||||||
|
- l'état exact après la tranche ;
|
||||||
|
- le report explicite des limiteurs/concurrence/cooldown/retry effectifs à `pre.004`.
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
La crate Transport passe de 40 à **53 tests déclarés**.
|
||||||
|
|
||||||
|
Nouvelles preuves :
|
||||||
|
|
||||||
|
- snapshot client sans URL/secret ;
|
||||||
|
- endpoint disabled visible mais non sélectionnable ;
|
||||||
|
- matching rôle/wildcard capability ;
|
||||||
|
- priorité globale ;
|
||||||
|
- round-robin dans le meilleur tier ;
|
||||||
|
- fallback depuis un endpoint plus prioritaire disabled ;
|
||||||
|
- filtrage capability avant priorité ;
|
||||||
|
- erreur structurée lorsque rien ne correspond ;
|
||||||
|
- routing d'une méthode standard via son descriptor ;
|
||||||
|
- snapshot pool sûr conservant les endpoints disabled ;
|
||||||
|
- consommation du nouveau pool depuis l'API publique.
|
||||||
|
|
||||||
|
## Hors périmètre conservé
|
||||||
|
|
||||||
|
Restent à `pre.004` :
|
||||||
|
|
||||||
|
- token bucket RPS/burst ;
|
||||||
|
- semaphore/max concurrent ;
|
||||||
|
- cooldown après `429` ;
|
||||||
|
- deadline effective de sélection/exécution ;
|
||||||
|
- retry/backoff effectif ;
|
||||||
|
- classification des erreurs `reqwest` pendant une requête ;
|
||||||
|
- transitions runtime `Degraded` / `RateLimited`.
|
||||||
|
|
||||||
|
Restent à `pre.005+` :
|
||||||
|
|
||||||
|
- exécution JSON-RPC HTTP ;
|
||||||
|
- méthodes canari typées ;
|
||||||
|
- document Config standard et adapter ;
|
||||||
|
- smoke tests réseau.
|
||||||
|
|
||||||
|
## Sources externes revérifiées
|
||||||
|
|
||||||
|
Pour `reqwest 0.13` :
|
||||||
|
|
||||||
|
- dépôt/documentation officielle `reqwest` : rustls est le backend TLS de référence actuel ;
|
||||||
|
- changelog `reqwest` : en `0.13`, la feature historique `rustls-tls` a été renommée `rustls` ;
|
||||||
|
- `ClientBuilder` officiel : `connect_timeout`, `timeout`, `pool_max_idle_per_host`, `redirect` et `no_proxy` sont disponibles sur le client async retenu.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Non exécutée dans le sandbox de génération : `cargo` et `rustc` n'y sont pas installés.
|
||||||
|
|
||||||
|
À exécuter après application :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-onchain-transport-lib
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -d
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e features
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||||
|
```
|
||||||
|
|
||||||
|
Les quatre `cargo tree` doivent être rejoués dans cette tranche car le feature-set de `reqwest` change avec l'activation de `rustls`.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Tranche suivante prévue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.004
|
||||||
|
```
|
||||||
|
|
||||||
|
Périmètre : RPS/burst, concurrence, cooldown/429, timeout/deadline et retry/backoff borné, avec respect strict de `TransportRetryClass` et interdiction de resend après dispatch ambigu.
|
||||||
248
deltas/0.2.1/pre.004-fix.001.md
Normal file
248
deltas/0.2.1/pre.004-fix.001.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.004-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `v0.2.1-pre.004-fix.001`
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.004
|
||||||
|
```
|
||||||
|
|
||||||
|
Version Cargo cible :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.4.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger la responsabilité Cargo des features externes révélée pendant l'audit de `pre.004`, sans modifier le comportement fonctionnel de la résilience HTTP :
|
||||||
|
|
||||||
|
- conserver au `Cargo.toml` racine la version et les options communes de résolution (`default-features`, etc.) ;
|
||||||
|
- retirer les activations `features = [...]` de `[workspace.dependencies]` ;
|
||||||
|
- activer chaque feature dans le manifeste de la crate KSP qui utilise réellement l'API correspondante ;
|
||||||
|
- distinguer lorsque c'est utile les features de production et celles requises uniquement par les tests ;
|
||||||
|
- formaliser explicitement cette règle sous `DEP-CARGO-*` ;
|
||||||
|
- ajouter une canarie empêchant le retour d'une activation de feature consumer dans `[workspace.dependencies]`.
|
||||||
|
|
||||||
|
Ce fix ne modifie pas `ROADMAP.md` : il corrige un contrat Cargo et sa règle normative, pas la roadmap de `0.2.1`.
|
||||||
|
|
||||||
|
## Validation de `pre.004` ayant précédé le fix
|
||||||
|
|
||||||
|
Après application de `pre.004`, le user a exécuté :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all : exécuté
|
||||||
|
cargo check --workspace : OK
|
||||||
|
cargo clippy --workspace --all-targets : OK
|
||||||
|
cargo test -p ksp-onchain-transport-lib : OK, 72 tests Transport au total
|
||||||
|
cargo test --workspace : OK ; 1 test diagnostic ignoré comme prévu
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e features : exécuté
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e normal : exécuté
|
||||||
|
```
|
||||||
|
|
||||||
|
Le problème n'est donc pas un échec fonctionnel de `pre.004`. L'audit `cargo tree -e features` a cependant rendu visible que les features Tokio déclarées au niveau workspace s'appliquaient au graphe Transport en plus de sa feature locale `sync`, ce qui rendait l'ownership des besoins réels trop global.
|
||||||
|
|
||||||
|
## Décision normative
|
||||||
|
|
||||||
|
La politique KSP retenue est désormais explicite :
|
||||||
|
|
||||||
|
```text
|
||||||
|
[workspace.dependencies]
|
||||||
|
-> version
|
||||||
|
-> default-features et autres options communes de résolution
|
||||||
|
-> aucune activation features = [...]
|
||||||
|
|
||||||
|
crates/<consumer>/Cargo.toml
|
||||||
|
-> dependency.workspace = true
|
||||||
|
-> features = [...] nécessaires à ce consumer
|
||||||
|
```
|
||||||
|
|
||||||
|
Lorsqu'une feature n'est nécessaire qu'aux tests/benchmarks/examples, son activation appartient à la section de dépendances de développement correspondante.
|
||||||
|
|
||||||
|
Cette politique conserve la centralisation des versions sans transformer le feature-set d'une crate en politique implicite pour tous les autres consumers du workspace.
|
||||||
|
|
||||||
|
## Répartition des features
|
||||||
|
|
||||||
|
### `serde`
|
||||||
|
|
||||||
|
Le root conserve uniquement :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
serde = { version = "^1.0" }
|
||||||
|
```
|
||||||
|
|
||||||
|
La feature `derive` est activée dans les trois crates qui utilisent réellement `serde::Serialize` / `serde::Deserialize` dérivés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-app-config-desk
|
||||||
|
ksp-config-lib
|
||||||
|
ksp-onchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
### `reqwest`
|
||||||
|
|
||||||
|
Le root conserve :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
reqwest = { version = "^0.13", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
`ksp-onchain-transport-lib` active localement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
rustls
|
||||||
|
```
|
||||||
|
|
||||||
|
### `tracing` / `tracing-subscriber`
|
||||||
|
|
||||||
|
Le root conserve les versions avec `default-features = false`.
|
||||||
|
|
||||||
|
Seul `ksp-logging-lib`, propriétaire KSP de ces dépendances, active :
|
||||||
|
|
||||||
|
```text
|
||||||
|
tracing : std
|
||||||
|
tracing-subscriber : fmt, json, ansi
|
||||||
|
```
|
||||||
|
|
||||||
|
`tracing-appender` ne possédait aucune feature explicite à déplacer.
|
||||||
|
|
||||||
|
### `chrono`
|
||||||
|
|
||||||
|
Le root conserve uniquement la version avec `default-features = false`.
|
||||||
|
|
||||||
|
`ksp-app-config-desk`, seul consumer direct actuel de `chrono::Utc::now`, active localement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
std, now
|
||||||
|
```
|
||||||
|
|
||||||
|
### `tokio`
|
||||||
|
|
||||||
|
Le root devient :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
tokio = { version = "^1.53", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Les activations sont réparties selon l'usage réel :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-app-config-desk
|
||||||
|
dependencies : time
|
||||||
|
|
||||||
|
ksp-logging-lib
|
||||||
|
dev-dependencies : macros, rt, rt-multi-thread
|
||||||
|
|
||||||
|
ksp-onchain-transport-lib
|
||||||
|
dependencies : macros, sync, time
|
||||||
|
dev-dependencies : rt
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour Transport :
|
||||||
|
|
||||||
|
- `sync` couvre `Notify`, `Semaphore` et `OwnedSemaphorePermit` ;
|
||||||
|
- `time` couvre `sleep_until` / `Instant` ;
|
||||||
|
- `macros` est une dépendance de production car `pool.rs` utilise `tokio::select!` ;
|
||||||
|
- `rt` est requis par `#[tokio::test]` et reste donc activé uniquement côté développement ;
|
||||||
|
- aucun besoin Transport actuel ne justifie `rt-multi-thread`.
|
||||||
|
|
||||||
|
## Règles KSP
|
||||||
|
|
||||||
|
`docs/rules/RULES_DEPENDENCIES.md` passe de la version `11` à `12`.
|
||||||
|
|
||||||
|
`DEP-CARGO-003` précise maintenant que les features d'usage ne sont pas activées sous `[workspace.dependencies]` et appartiennent aux manifests consumers.
|
||||||
|
|
||||||
|
Nouvelle règle `DEP-CARGO-006` : une feature requise uniquement par tests/benchmarks/examples doit être activée dans la section de dépendances de développement appropriée ; l'unification Cargo ne transfère pas cet ownership au root.
|
||||||
|
|
||||||
|
## Canarie Cargo
|
||||||
|
|
||||||
|
`crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs` vérifie désormais aussi :
|
||||||
|
|
||||||
|
- le feature-set direct attendu de `reqwest` et `tokio` pour Transport ;
|
||||||
|
- la séparation du `tokio/rt` de test sous `[dev-dependencies]` ;
|
||||||
|
- l'absence de toute chaîne `features =` dans la section `[workspace.dependencies]` du manifeste racine.
|
||||||
|
|
||||||
|
Cette canarie complète le firewall Transport existant sans introduire de nouvelle dépendance d'audit.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Le fix modifie plusieurs manifests et donc le contrat de build :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.4 -> 0.2.1-pre.4.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Toutes les crates KSP continuent d'hériter `version.workspace = true`.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.1/pre.004-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-app-config-desk/Cargo.toml
|
||||||
|
crates/ksp-config-lib/Cargo.toml
|
||||||
|
crates/ksp-logging-lib/Cargo.toml
|
||||||
|
crates/ksp-onchain-transport-lib/Cargo.toml
|
||||||
|
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
docs/rules/RULES_DEPENDENCIES.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Graphe de dépendances
|
||||||
|
|
||||||
|
Aucune crate externe n'est ajoutée ou retirée. Le fix modifie en revanche volontairement les feature-sets directs par consumer ; les audits Cargo doivent donc être rejoués après application.
|
||||||
|
|
||||||
|
Pour Transport, vérifier au minimum :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo tree -p ksp-onchain-transport-lib
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -d
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e features
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||||
|
```
|
||||||
|
|
||||||
|
Il est également utile de vérifier les consumers dont les features ont été relocalisées :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo tree -p ksp-app-config-desk -e features
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
cargo tree -p ksp-logging-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation du fix
|
||||||
|
|
||||||
|
Le sandbox de préparation ne dispose pas de `cargo`/`rustc`. Les validations Rust du correctif ne sont donc pas déclarées réussies ici.
|
||||||
|
|
||||||
|
Après application :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis exécuter les `cargo tree` indiqués ci-dessus afin de confirmer que chaque crate expose uniquement les features directes qu'elle possède, sous réserve de l'unification transitive normale de Cargo au niveau du build effectif.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Après validation et commit de ce fix, `0.2.1` reprend avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.005
|
||||||
|
```
|
||||||
|
|
||||||
|
Le périmètre fonctionnel prévu de `pre.005` reste inchangé : exécuteur HTTP JSON-RPC réel et quatre méthodes canari typées.
|
||||||
40
deltas/0.2.1/pre.004-fix.002.md
Normal file
40
deltas/0.2.1/pre.004-fix.002.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.004-fix.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `v0.2.1-pre.004-fix.002`
|
||||||
|
|
||||||
|
## Objet
|
||||||
|
|
||||||
|
Corriger la canarie de frontière Cargo introduite par `v0.2.1-pre.004-fix.001` sans modifier la politique de features adoptée par ce fix.
|
||||||
|
|
||||||
|
Le test `workspace_dependency_table_does_not_activate_consumer_features` recherchait naïvement la sous-chaîne `features =` dans `[workspace.dependencies]`. Cette recherche détectait aussi `default-features = false`, alors que `default-features` est précisément une option de résolution commune autorisée au niveau workspace par les règles KSP.
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
- `workspace.package.version` passe de `0.2.1-pre.4.fix.1` à `0.2.1-pre.4.fix.2` ;
|
||||||
|
- la canarie Cargo analyse désormais les champs des tables inline et interdit uniquement la clé exacte `features` dans `[workspace.dependencies]` ;
|
||||||
|
- `default-features` reste explicitement autorisé au niveau workspace ;
|
||||||
|
- suppression de la closure qui déclenchait `clippy::implicit-return` dans cette même canarie ;
|
||||||
|
- aucune modification des features réellement activées dans les manifests membres ;
|
||||||
|
- aucune modification du graphe fonctionnel Transport ;
|
||||||
|
- aucune modification du `ROADMAP.md`.
|
||||||
|
|
||||||
|
## Politique KSP conservée
|
||||||
|
|
||||||
|
La politique introduite par `v0.2.1-pre.004-fix.001` reste inchangée :
|
||||||
|
|
||||||
|
- le workspace centralise les versions et les options communes de résolution telles que `default-features` ;
|
||||||
|
- chaque crate consommatrice active localement les `features = [...]` dont elle a réellement besoin ;
|
||||||
|
- les features requises uniquement par les tests sont placées dans les `dev-dependencies` du consumer concerné.
|
||||||
|
|
||||||
|
## Validation attendue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Les `cargo tree` n'ont pas besoin d'être rejoués pour ce fix : aucune dépendance, aucun `default-features` et aucune activation locale de feature ne changent.
|
||||||
231
deltas/0.2.1/pre.004.md
Normal file
231
deltas/0.2.1/pre.004.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.004.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `v0.2.1-pre.004`
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.003-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette base a été validée localement par le user avec `cargo fmt`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, les tests ciblés Transport et `cargo test --workspace`.
|
||||||
|
|
||||||
|
Version Cargo cible :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Matérialiser la résilience runtime prévue par le plan `008` autour du client/pool HTTP déjà introduit :
|
||||||
|
|
||||||
|
- token bucket RPS/burst par rôle ;
|
||||||
|
- limite de concurrence par semaphore ;
|
||||||
|
- cooldown après rate-limit provider ;
|
||||||
|
- admission async avec deadline commune ;
|
||||||
|
- fallback vers les pairs et tiers moins prioritaires lorsqu'un candidat est temporairement indisponible ;
|
||||||
|
- états passifs `Degraded` / `RateLimited` et snapshots sûrs ;
|
||||||
|
- retry/backoff transport borné ;
|
||||||
|
- interdiction centralisée d'un resend après dispatch ambigu pour les opérations `NeverAfterDispatch`.
|
||||||
|
|
||||||
|
Cette tranche ne réalise toujours pas de POST JSON-RPC ni de méthode Solana typée ; le raccordement réseau appartient à `pre.005`.
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### Workspace et dépendances
|
||||||
|
|
||||||
|
- `workspace.package.version` passe de `0.2.1-pre.3.fix.1` à `0.2.1-pre.4` ;
|
||||||
|
- aucune nouvelle dépendance tierce n'est introduite ;
|
||||||
|
- `ksp-onchain-transport-lib` consomme désormais `tokio.workspace = true` avec la feature locale `sync`, nécessaire au semaphore et à `Notify` ;
|
||||||
|
- les features runtime/time Tokio restent possédées par la déclaration workspace existante ;
|
||||||
|
- le firewall Transport -> Config/Store/Program et l'absence de `tracing` direct restent inchangés.
|
||||||
|
|
||||||
|
### Admission RPS / burst
|
||||||
|
|
||||||
|
Chaque rôle endpoint possède son état runtime indépendant.
|
||||||
|
|
||||||
|
Lorsque `requests_per_second` est configuré :
|
||||||
|
|
||||||
|
- un token bucket est créé au niveau endpoint/rôle ;
|
||||||
|
- `burst_capacity` fixe la capacité maximale ;
|
||||||
|
- si le burst n'est pas explicite, la capacité dérive de la valeur RPS, soit une seconde de capacité ;
|
||||||
|
- les tokens se reforment proportionnellement au temps écoulé ;
|
||||||
|
- une admission sans token disponible expose l'instant de prochaine admissibilité au pool plutôt que de bloquer un thread.
|
||||||
|
|
||||||
|
Sans RPS, le rôle n'est pas limité par le token bucket KSP.
|
||||||
|
|
||||||
|
### Concurrence
|
||||||
|
|
||||||
|
`max_concurrent_requests` est matérialisé par un `tokio::sync::Semaphore` par rôle.
|
||||||
|
|
||||||
|
Le nouveau `HttpRequestPermit` :
|
||||||
|
|
||||||
|
- réserve la capacité de concurrence ;
|
||||||
|
- conserve la sélection endpoint/rôle/request-kind ;
|
||||||
|
- transporte la deadline commune ;
|
||||||
|
- libère automatiquement la capacité à son drop ;
|
||||||
|
- réveille les waiters lorsque de la capacité redevient disponible.
|
||||||
|
|
||||||
|
Un candidat saturé n'empêche pas d'essayer les autres candidats du même tier puis les tiers moins prioritaires.
|
||||||
|
|
||||||
|
### Cooldown et rate-limit
|
||||||
|
|
||||||
|
`HttpRequestPermit::record_rate_limited()` :
|
||||||
|
|
||||||
|
- incrémente les compteurs failure/rate-limit ;
|
||||||
|
- marque le rôle dégradé ;
|
||||||
|
- applique le cooldown configuré par `pause_after_rate_limit` ;
|
||||||
|
- utilise un fallback runtime de 1 seconde lorsque ce cooldown n'est pas configuré ;
|
||||||
|
- accepte un délai provider déjà résolu et le borne à 60 secondes avant de pouvoir prolonger le cooldown local ;
|
||||||
|
- notifie le pool de la nouvelle disponibilité temporelle.
|
||||||
|
|
||||||
|
Le parsing concret du header HTTP `Retry-After` reste au futur exécuteur HTTP de `pre.005`; `pre.004` stabilise le contrat en `Duration`.
|
||||||
|
|
||||||
|
### Admission async et deadline commune
|
||||||
|
|
||||||
|
Nouvelles surfaces publiques :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpRequestPermit
|
||||||
|
HttpTransportPool::acquire_for_method(...)
|
||||||
|
HttpTransportPool::acquire_for_request_kind(...)
|
||||||
|
HttpTransportPool::acquire_for_request_kind_with_timeout(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Algorithme runtime :
|
||||||
|
|
||||||
|
1. filtrer endpoint/rôle/capability comme en `pre.003` ;
|
||||||
|
2. ordonner les candidats par priorité ;
|
||||||
|
3. appliquer le round-robin dans chaque tier ;
|
||||||
|
4. tenter l'admission cooldown + concurrence + token bucket ;
|
||||||
|
5. essayer les pairs puis les tiers inférieurs lorsqu'un candidat est temporairement bloqué ;
|
||||||
|
6. si tous les candidats sont temporairement bloqués, attendre notification ou prochain instant de refill/cooldown ;
|
||||||
|
7. ne jamais dépasser la deadline commune.
|
||||||
|
|
||||||
|
Par défaut, la deadline commune utilise le plus petit `request_timeout` des endpoints structurellement compatibles. Une surcharge permet à une couche supérieure d'imposer un budget plus strict.
|
||||||
|
|
||||||
|
### Retry / backoff / no-resend
|
||||||
|
|
||||||
|
Nouveaux contrats publics :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpRetryCause
|
||||||
|
HttpDispatchState
|
||||||
|
HttpRetryDecision
|
||||||
|
evaluate_transport_retry(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
La policy centralisée :
|
||||||
|
|
||||||
|
- respecte `HttpRetrySettings::max_retries` ;
|
||||||
|
- applique un backoff exponentiel borné entre `initial_backoff` et `max_backoff` ;
|
||||||
|
- autorise seulement les causes transport classées retryables (`Connection`, `Timeout`, `RateLimited`, `TemporaryHttp`) ;
|
||||||
|
- exclut `Request`, `RpcApplication` et `InvalidResponse` du retry automatique ;
|
||||||
|
- respecte `TransportRetryClass::NotApplicable` ;
|
||||||
|
- interdit tout retry d'une méthode `NeverAfterDispatch` lorsque l'état est `DispatchedAmbiguous` ;
|
||||||
|
- autorise encore une tentative si le transport sait explicitement que la requête n'a pas été dispatchée ;
|
||||||
|
- peut prolonger le backoff d'un rate-limit par un délai provider borné à 60 secondes.
|
||||||
|
|
||||||
|
Aucune logique de retry métier/exécution Solana n'est introduite.
|
||||||
|
|
||||||
|
### Santé passive et snapshots
|
||||||
|
|
||||||
|
Les rôles endpoint exposent maintenant de manière sûre :
|
||||||
|
|
||||||
|
- `availability` ;
|
||||||
|
- RPS/burst configurés ;
|
||||||
|
- concurrence maximale et nombre en vol ;
|
||||||
|
- cooldown restant ;
|
||||||
|
- compteurs success/failure/rate-limit.
|
||||||
|
|
||||||
|
Transitions :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Available --failure--> Degraded
|
||||||
|
Available/Degraded --rate-limit--> RateLimited
|
||||||
|
RateLimited --cooldown écoulé--> Degraded
|
||||||
|
Degraded --success--> Available
|
||||||
|
```
|
||||||
|
|
||||||
|
Les snapshots n'exposent toujours aucune URL ni credential provider.
|
||||||
|
|
||||||
|
### ROADMAP et plan
|
||||||
|
|
||||||
|
`ROADMAP.md` reçoit uniquement la mise à jour synthétique normale de l'état de `0.2.1` : la résilience runtime devient acquise et les prochains jalons majeurs restent les 4 canaris, Config standard et la clôture. Aucun historique de prerelease/fix n'y est ajouté.
|
||||||
|
|
||||||
|
Le plan `008` enregistre `pre.004` comme réalisé et décrit l'état précis dans sa section de suivi.
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
La suite Transport passe de **53 à 72 tests déclarés**.
|
||||||
|
|
||||||
|
Les nouveaux tests couvrent notamment :
|
||||||
|
|
||||||
|
- backoff exponentiel et borne maximale ;
|
||||||
|
- budget `max_retries` ;
|
||||||
|
- no-resend après dispatch ambigu ;
|
||||||
|
- retry permis lorsqu'un non-dispatch est prouvé ;
|
||||||
|
- RPC application errors et réponses invalides non retryées ;
|
||||||
|
- extension/cap du délai provider ;
|
||||||
|
- token bucket burst/refill déterministe ;
|
||||||
|
- burst implicite dérivé du RPS ;
|
||||||
|
- libération de semaphore ;
|
||||||
|
- cooldown/rate-limit ;
|
||||||
|
- fallback vers un tier inférieur sur saturation concurrence, RPS ou cooldown ;
|
||||||
|
- réveil après libération de concurrence ;
|
||||||
|
- timeout d'admission borné ;
|
||||||
|
- transition passive degraded -> available ;
|
||||||
|
- snapshot public de résilience ;
|
||||||
|
- admission async depuis l'API publique sans fuite de l'URL ;
|
||||||
|
- consommation publique de la policy de retry.
|
||||||
|
|
||||||
|
## Hors périmètre conservé
|
||||||
|
|
||||||
|
Restent à `pre.005` :
|
||||||
|
|
||||||
|
- exécution HTTP POST JSON-RPC réelle ;
|
||||||
|
- mapping concret des erreurs `reqwest`/HTTP vers `HttpRetryCause` ;
|
||||||
|
- parsing de `Retry-After` HTTP ;
|
||||||
|
- méthodes typées `getHealth`, `getVersion`, `getGenesisHash`, `getBalance` ;
|
||||||
|
- fixtures RPC de ces méthodes.
|
||||||
|
|
||||||
|
Restent à `pre.006+` :
|
||||||
|
|
||||||
|
- document/schema Config standard Transport ;
|
||||||
|
- adapter Config -> settings Transport ;
|
||||||
|
- smoke tests réseau opt-in et documentation finale.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Non exécutée dans le sandbox de génération : `cargo` et `rustc` n'y sont pas installés.
|
||||||
|
|
||||||
|
Après application :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Le graphe de dépendances n'ajoute aucune crate nouvelle, mais la feature locale `tokio/sync` devient directement utilisée par Transport. Pour auditer le feature-set effectif de cette tranche :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e features
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Tranche suivante prévue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.005
|
||||||
|
```
|
||||||
|
|
||||||
|
Périmètre : exécuteur HTTP JSON-RPC réel + quatre méthodes canari typées `getHealth`, `getVersion`, `getGenesisHash`, `getBalance`, avec fixtures déterministes et raccordement des timeouts/429/erreurs transport à la résilience de `pre.004`.
|
||||||
63
deltas/0.2.1/pre.005-fix.001.md
Normal file
63
deltas/0.2.1/pre.005-fix.001.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.005-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# `0.2.1-pre.005-fix.001` — normalisation des targets Logging KSP
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
- base fonctionnelle : `0.2.1-pre.005` ;
|
||||||
|
- validations utilisateur avant correctif : `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport, Core et workspace propres ;
|
||||||
|
- le correctif répond à l'audit manuel du code de logging après cette validation.
|
||||||
|
|
||||||
|
## Problème corrigé
|
||||||
|
|
||||||
|
Transport utilisait `target: env!("CARGO_PKG_NAME")` dans ses émissions `ksp-logging-lib`. Ce mécanisme produit actuellement le bon texte mais ne matérialise pas le target comme contrat d'observabilité possédé par la crate. Config présentait en parallèle un target local dans `environment.rs` et un littéral équivalent dans `persistence.rs`.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
- toute crate KSP comportementale qui émet via `ksp-logging-lib` possède son target principal dans `src/constants.rs` sous `pub(crate) const TRACING_TARGET: &str` ;
|
||||||
|
- le target principal est égal au nom Cargo de la crate ;
|
||||||
|
- les callsites utilisent `crate::TRACING_TARGET` ou un target spécialisé également possédé par `constants.rs` ;
|
||||||
|
- `env!("CARGO_PKG_NAME")` reste autorisé lorsqu'il représente réellement une metadata Cargo, notamment le User-Agent HTTP ;
|
||||||
|
- les règles sont figées par `DEP-LOG-010` et `DEP-LOG-011`.
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
- ajout de `crates/ksp-onchain-transport-lib/src/constants.rs` ;
|
||||||
|
- export crate-private de `TRACING_TARGET` depuis `lib.rs` ;
|
||||||
|
- remplacement des targets `env!("CARGO_PKG_NAME")` dans `settings.rs`, `rpc_method.rs`, `client.rs`, `pool.rs` et `executor.rs` ;
|
||||||
|
- conservation volontaire de `env!("CARGO_PKG_NAME")`/`CARGO_PKG_VERSION` pour le User-Agent HTTP.
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
- ajout de `crates/ksp-config-lib/src/constants.rs` ;
|
||||||
|
- export crate-private de `TRACING_TARGET` depuis `lib.rs` ;
|
||||||
|
- suppression du `LOGGING_TARGET` local de `environment.rs` ;
|
||||||
|
- remplacement du target littéral de `persistence.rs`.
|
||||||
|
|
||||||
|
### Gouvernance
|
||||||
|
|
||||||
|
- ajout de `DEP-LOG-010/011` dans `docs/rules/RULES_DEPENDENCIES.md` ;
|
||||||
|
- ajout de `crates/ksp-core-lib/tests/workspace_logging.rs`, canarie workspace vérifiant l'ownership explicite des targets des crates comportementales et interdisant les targets dérivés de `CARGO_PKG_NAME` ou les littéraux dispersés ;
|
||||||
|
- correction du plan actif `008` pour refléter la feature `reqwest/rustls` locale à Transport et la convention de target explicite ;
|
||||||
|
- aucun changement de `ROADMAP.md`.
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
`workspace.package.version` passe à `0.2.1-pre.5.fix.1` car le correctif modifie du code Rust participant au runtime.
|
||||||
|
|
||||||
|
## Validation requise après application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test -p ksp-config-lib
|
||||||
|
cargo test -p ksp-core-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `cargo tree` supplémentaire n'est requis : le graphe de dépendances et les features ne changent pas.
|
||||||
282
deltas/0.2.1/pre.005.md
Normal file
282
deltas/0.2.1/pre.005.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.005.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `v0.2.1-pre.005`
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.004-fix.002
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette base a été validée localement par le user avec `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib` et `cargo test --workspace`.
|
||||||
|
|
||||||
|
Version Cargo cible :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Matérialiser la première exécution réseau HTTP complète de `ksp-onchain-transport-lib` et les quatre méthodes typées canari assignées à `0.2.1` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
getHealth
|
||||||
|
getVersion
|
||||||
|
getGenesisHash
|
||||||
|
getBalance
|
||||||
|
```
|
||||||
|
|
||||||
|
La tranche doit également corriger l'ownership des canaries de dépendances générales : les contrôles portant sur le workspace entier ne restent pas dans la crate métier Transport et sont centralisés dans la surface de tests de `ksp-core-lib` tant qu'aucun outil d'audit dédié n'existe.
|
||||||
|
|
||||||
|
## Vérification de la surface Solana
|
||||||
|
|
||||||
|
La documentation Solana HTTP officielle a été revérifiée le 17 août 2026 pour les quatre canaris :
|
||||||
|
|
||||||
|
- `getHealth` : aucun paramètre, résultat stable `"ok"` lorsque le node est sain ;
|
||||||
|
- `getGenesisHash` : aucun paramètre, résultat string base58 ;
|
||||||
|
- `getVersion` : aucun paramètre, objet contenant `solana-core` et `feature-set` optionnel/null ;
|
||||||
|
- `getBalance` : pubkey base58 obligatoire, configuration optionnelle `commitment` / `minContextSlot`, réponse `{ context, value }` où `value` est le solde en lamports.
|
||||||
|
|
||||||
|
## Exécuteur HTTP JSON-RPC
|
||||||
|
|
||||||
|
Nouvelle surface publique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpTransportPool::execute_standard_rpc(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
L'exécuteur :
|
||||||
|
|
||||||
|
1. vérifie le descriptor RPC audité et son support runtime ;
|
||||||
|
2. alloue un identifiant JSON-RPC numérique KSP ;
|
||||||
|
3. construit et sérialise `JsonRpcRequest` sans exposer les params dans `Debug` ;
|
||||||
|
4. dérive le request-kind depuis le registre ;
|
||||||
|
5. fixe une deadline commune à partir du plus petit `request_timeout` des endpoints compatibles ;
|
||||||
|
6. acquiert un `HttpRequestPermit`, donc respecte rôles/capabilities/priorité/fairness/RPS/burst/concurrence/cooldown ;
|
||||||
|
7. exécute un POST `application/json` via le `reqwest::Client` privé de l'endpoint ;
|
||||||
|
8. mappe connexion, timeout, HTTP status, JSON invalide, protocole JSON-RPC et RPC application error vers les codes KSP existants ;
|
||||||
|
9. met à jour la santé passive du rôle ;
|
||||||
|
10. applique le retry/backoff centralisé sans dépasser la deadline commune.
|
||||||
|
|
||||||
|
Les retries libèrent le permit précédent puis réacquièrent explicitement capacité RPS/concurrence : ils ne contournent donc pas les limites runtime du pool.
|
||||||
|
|
||||||
|
## HTTP status, `Retry-After` et retry
|
||||||
|
|
||||||
|
Le raccordement réseau de la résilience est désormais effectif :
|
||||||
|
|
||||||
|
- `429` -> `record_rate_limited`, cooldown de rôle et `HttpRetryCause::RateLimited` ;
|
||||||
|
- `Retry-After` sous forme HTTP delta-seconds -> `Duration`, puis borne défensive déjà possédée par la résilience ;
|
||||||
|
- `408`, `500`, `502`, `503`, `504` -> `HttpRetryCause::TemporaryHttp` ;
|
||||||
|
- erreur de connexion reqwest -> `HttpRetryCause::Connection` + `NotDispatched` ;
|
||||||
|
- timeout reqwest -> `HttpRetryCause::Timeout` + `DispatchedAmbiguous` ;
|
||||||
|
- autres erreurs request -> non retryables automatiquement ;
|
||||||
|
- RPC application error et réponse invalide restent hors retry transport.
|
||||||
|
|
||||||
|
Le parsing d'une forme HTTP-date de `Retry-After` n'est pas introduit dans cette tranche : si le header n'est pas un delta-seconds valide, le cooldown local configuré/fallback reste utilisé.
|
||||||
|
|
||||||
|
La règle `NeverAfterDispatch` reste centralisée dans `evaluate_transport_retry()` ; l'exécuteur générique ne la contourne pas et prépare donc correctement les futurs appels write/submission de `0.2.3`.
|
||||||
|
|
||||||
|
## Quatre canaris typés
|
||||||
|
|
||||||
|
### `getHealth`
|
||||||
|
|
||||||
|
Nouvelle méthode :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpTransportPool::get_health(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat public :
|
||||||
|
|
||||||
|
```text
|
||||||
|
SolanaNodeHealth::Healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
Toute autre forme qu'un résultat string exact `"ok"` est classée `invalid_response`.
|
||||||
|
|
||||||
|
### `getGenesisHash`
|
||||||
|
|
||||||
|
Nouvelle méthode :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpTransportPool::get_genesis_hash(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat public :
|
||||||
|
|
||||||
|
```text
|
||||||
|
SolanaGenesisHash
|
||||||
|
```
|
||||||
|
|
||||||
|
Le wrapper exige un string non vide et trimé et n'utilise pas abusivement `Pubkey` pour représenter sémantiquement un hash de genesis.
|
||||||
|
|
||||||
|
### `getVersion`
|
||||||
|
|
||||||
|
Nouvelle méthode :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpTransportPool::get_version(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat public :
|
||||||
|
|
||||||
|
```text
|
||||||
|
SolanaNodeVersion
|
||||||
|
solana_core: String
|
||||||
|
feature_set: Option<u32>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getBalance`
|
||||||
|
|
||||||
|
Nouvelle méthode :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpTransportPool::get_balance(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
L'adresse est reçue sous forme `ksp_core_lib::Pubkey`, conformément à l'ownership Core du type Solana bas niveau.
|
||||||
|
|
||||||
|
Nouveaux contrats :
|
||||||
|
|
||||||
|
```text
|
||||||
|
SolanaCommitment
|
||||||
|
GetBalanceConfig
|
||||||
|
SolanaRpcContext
|
||||||
|
GetBalanceResult
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetBalanceConfig` encode uniquement les champs présents ; un config vide n'ajoute pas un second paramètre inutile.
|
||||||
|
|
||||||
|
## Fixtures déterministes
|
||||||
|
|
||||||
|
Nouvelles fixtures :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json
|
||||||
|
crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json
|
||||||
|
crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json
|
||||||
|
crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests utilisent un serveur TCP local déterministe pour exercer réellement le POST HTTP sans dépendre de Devnet ou d'Internet.
|
||||||
|
|
||||||
|
Ils vérifient notamment :
|
||||||
|
|
||||||
|
- les quatre réponses typées ;
|
||||||
|
- le nom de méthode envoyé ;
|
||||||
|
- les params `getBalance`, dont `commitment` et `minContextSlot` ;
|
||||||
|
- un cycle `429 + Retry-After: 0 -> retry -> succès` ;
|
||||||
|
- le mapping d'un timeout reqwest vers `ERROR_CODE_TIMEOUT`.
|
||||||
|
|
||||||
|
Le smoke réseau réel reste opt-in et appartient toujours à `pre.007`.
|
||||||
|
|
||||||
|
## Centralisation des canaries de dépendances
|
||||||
|
|
||||||
|
Le fichier suivant est supprimé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux contrôles qu'il contenait sont transférés vers :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils continuent de vérifier :
|
||||||
|
|
||||||
|
- absence d'activation `features = [...]` sous `[workspace.dependencies]` ;
|
||||||
|
- maintien de `default-features` au root ;
|
||||||
|
- firewall direct Transport -> pas de Config/Store/Program/`tracing` ;
|
||||||
|
- feature-set direct attendu de `reqwest` et `tokio` dans le manifest Transport.
|
||||||
|
|
||||||
|
Nouvelle règle `DEP-CARGO-007` : les canaries qui inspectent une politique générale du workspace ou plusieurs manifests appartiennent à une surface de gouvernance/fondation, actuellement `ksp-core-lib`, et non à une crate métier spécialisée.
|
||||||
|
|
||||||
|
## ROADMAP et plan
|
||||||
|
|
||||||
|
`ROADMAP.md` reçoit uniquement la mise à jour synthétique normale de la release : exécution HTTP et quatre canaris deviennent acquis ; Config standard et clôture restent à venir.
|
||||||
|
|
||||||
|
Le plan `008` marque `pre.005` réalisé et ajoute son état détaillé. L'historique technique reste dans `deltas/`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Après déplacement des deux canaries workspace hors de Transport et ajout des nouveaux tests :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-onchain-transport-lib : 78 tests Rust déclarés
|
||||||
|
ksp-core-lib : +2 tests d'intégration workspace_dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
La suite Transport reste propriétaire uniquement des tests fonctionnels/API de son domaine ; les canaries workspace générales sont exécutées via Core lors du `cargo test --workspace`.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune nouvelle dépendance externe et aucune nouvelle feature externe ne sont introduites.
|
||||||
|
|
||||||
|
La politique de `pre.004-fix.001/.002` reste inchangée : versions/default-features au root, features d'usage dans chaque crate consommatrice.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||||
|
crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json
|
||||||
|
crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json
|
||||||
|
crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json
|
||||||
|
crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json
|
||||||
|
crates/ksp-onchain-transport-lib/src/executor.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
|
||||||
|
deltas/0.2.1/pre.005.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
crates/ksp-onchain-transport-lib/src/client.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/pool.rs
|
||||||
|
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
docs/rules/RULES_DEPENDENCIES.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Non exécutée dans le sandbox de génération : `cargo` et `rustc` n'y sont pas installés.
|
||||||
|
|
||||||
|
Après application :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test -p ksp-core-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune dépendance/feature n'ayant changé, un `cargo tree` complet n'est pas requis pour cette tranche. Il peut néanmoins être rejoué au checkpoint final `pre.007` comme prévu.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Tranche suivante prévue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.006
|
||||||
|
```
|
||||||
|
|
||||||
|
Périmètre : document/schema/exemple Config standard `std.transport`, enregistrement dans Config, adapter Config -> Transport et tests de sensibilité/provenance/env sans créer de dépendance inverse Transport -> Config.
|
||||||
121
deltas/0.2.1/pre.006-fix.001.md
Normal file
121
deltas/0.2.1/pre.006-fix.001.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.006-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# `0.2.1-pre.006-fix.001` — inventaire Config Desk et routing debug Transport ciblé
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.006
|
||||||
|
```
|
||||||
|
|
||||||
|
La validation utilisateur de `pre.006` a confirmé :
|
||||||
|
|
||||||
|
- `cargo fmt --all` propre ;
|
||||||
|
- `cargo check --workspace` propre ;
|
||||||
|
- `cargo clippy --workspace --all-targets` propre ;
|
||||||
|
- `cargo test -p ksp-config-lib` : 95 unit tests + 5 ownership + 13 public API, propres ;
|
||||||
|
- `cargo test -p ksp-onchain-transport-lib` : 70 unit tests + 8 public API, propres ;
|
||||||
|
- `cargo test -p ksp-core-lib` : 14 unit tests + 3 public API + 2 workspace dependencies + 1 workspace logging, propres ;
|
||||||
|
- `cargo test --workspace` échoue uniquement sur `ksp-app-config-desk::profiles::tests::profile_inventory_exposes_logging_default_and_available_profiles`, qui attend encore un seul document profilé alors que `cfg.std.transport` est désormais le second ;
|
||||||
|
- les audits `cargo tree` Config montrent la dépendance interne attendue `ksp-config-lib -> ksp-onchain-transport-lib`, sans nouvelle dépendance externe volontaire ; le doublon `syn 2/3` reste transitif.
|
||||||
|
|
||||||
|
## Problème 1 — inventaire Profils Config Desk
|
||||||
|
|
||||||
|
Le panneau Profils est volontairement générique et parcourt les documents Config enregistrés qui exposent `default_profile` / `profiles`. Le test historique supposait cependant que seul `cfg.std.logging` possédait ce contrat et imposait `inventory.len() == 1`.
|
||||||
|
|
||||||
|
`pre.006` ajoute légitimement `cfg.std.transport`, donc cette assertion est devenue obsolète alors que l'implémentation runtime se comporte correctement.
|
||||||
|
|
||||||
|
### Correction
|
||||||
|
|
||||||
|
Le test devient `profile_inventory_exposes_registered_standard_profile_documents` et vérifie :
|
||||||
|
|
||||||
|
- présence de `cfg.std.logging` ;
|
||||||
|
- présence de `cfg.std.transport` ;
|
||||||
|
- pour chaque document retourné, `default_profile` est non vide et appartient à `profile_ids`.
|
||||||
|
|
||||||
|
Le test ne fige plus le nombre total de documents profilés, afin que l'ajout futur d'un autre document standard ne provoque pas la même régression artificielle.
|
||||||
|
|
||||||
|
## Problème 2 — verbosité Transport pendant le développement actif
|
||||||
|
|
||||||
|
Le target `ksp-onchain-transport-lib` est désormais explicite et stable. Pendant le développement actif de `0.2.1`, ses événements `debug` doivent pouvoir être inspectés sans relever toute la configuration Logging au niveau `debug`.
|
||||||
|
|
||||||
|
La solution retenue utilise la granularité déjà possédée par `ksp-logging-lib` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
default_filter = warn
|
||||||
|
console.filter.level = info
|
||||||
|
file.all.info = info
|
||||||
|
ksp-config-lib = info
|
||||||
|
ksp-logging-lib = info
|
||||||
|
ksp-app-config-desk = info
|
||||||
|
ksp-onchain-transport-lib = debug
|
||||||
|
file.onchain_transport.debug = debug / target ksp-onchain-transport-lib uniquement
|
||||||
|
```
|
||||||
|
|
||||||
|
Nouveau sink canonique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
output_id = file.onchain_transport.debug
|
||||||
|
path = transport/onchain/ksp-onchain-transport-debug.log
|
||||||
|
level = debug
|
||||||
|
targets = [ksp-onchain-transport-lib]
|
||||||
|
domains = [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `target_filter` Transport à `debug` autorise ces événements au niveau du takeover global. Les filtres de sortie maintiennent cependant la console et `file.all.info` à `info`; les événements Transport `debug` sont donc persistés uniquement dans le fichier dédié.
|
||||||
|
|
||||||
|
Aucun profil global `debug` n'est ajouté : cette option produirait inutilement davantage d'événements pour les autres crates.
|
||||||
|
|
||||||
|
## Règle Logging
|
||||||
|
|
||||||
|
Ajout de `DEP-LOG-012` : lorsqu'une seule crate/target nécessite temporairement `debug`/`trace`, KSP privilégie un `target_filter` ciblé et un sink dédié plutôt qu'un relèvement global du profil.
|
||||||
|
|
||||||
|
Cette règle complète `KSP-APP-031` : la verbosité élevée reste temporaire pendant le développement/correctif de la crate concernée. La tranche finale `pre.007` doit réévaluer le niveau Transport et le ramener à `info`/`warn` avant la stable, sauf justification opératoire explicite.
|
||||||
|
|
||||||
|
Le plan actif `008` est synchronisé avec cette décision.
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
`workspace.package.version` passe à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.6.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fix touche un test Rust participant au workspace et la configuration runtime Logging canonique ; le signal Cargo est donc incrémenté.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
config/std.logging.json
|
||||||
|
crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
docs/rules/RULES_DEPENDENCIES.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.1/pre.006-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`ROADMAP.md`, `pre.006.md` et les deltas historiques restent inchangés.
|
||||||
|
|
||||||
|
## Validation requise après application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-app-config-desk
|
||||||
|
cargo test -p ksp-config-lib
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test -p ksp-core-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `cargo tree` supplémentaire n'est requis pour ce fix : aucune dépendance ni feature n'est modifiée.
|
||||||
155
deltas/0.2.1/pre.006-fix.002.md
Normal file
155
deltas/0.2.1/pre.006-fix.002.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.006-fix.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# `0.2.1-pre.006-fix.002` — audit complet workspace, source `reqwest` sûre et conformité des fichiers courants
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.006-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
La validation utilisateur de `pre.006-fix.001` est entièrement propre :
|
||||||
|
|
||||||
|
- `cargo fmt --all` ;
|
||||||
|
- `cargo check --workspace` ;
|
||||||
|
- `cargo clippy --workspace --all-targets` ;
|
||||||
|
- `cargo test -p ksp-app-config-desk` ;
|
||||||
|
- `cargo test -p ksp-config-lib` ;
|
||||||
|
- `cargo test -p ksp-onchain-transport-lib` ;
|
||||||
|
- `cargo test -p ksp-core-lib` ;
|
||||||
|
- `cargo test --workspace`.
|
||||||
|
|
||||||
|
Le présent fix est déclenché non par une régression de compilation/test, mais par l'audit complet de la copie zippée du workspace demandé avant `pre.007`.
|
||||||
|
|
||||||
|
## Audit complet du workspace
|
||||||
|
|
||||||
|
La copie complète auditée contient 377 fichiers et les cinq crates actuelles. L'audit a revérifié :
|
||||||
|
|
||||||
|
- workspace/Cargo, versions, lints, features et frontières de dépendances ;
|
||||||
|
- règles Rust de production, visibilité, erreurs, imports et organisation des tests ;
|
||||||
|
- ownership Config/Logging/Transport et sensibilité des diagnostics ;
|
||||||
|
- surface HTTP/JSON-RPC, pool, admission, retry/no-resend et canaris typés ;
|
||||||
|
- configuration standard Logging/Transport, schemas, exemples et `.env.example` ;
|
||||||
|
- Tauri/frontend, commandes, dialogues natifs, persistence navigateur et contrats build ;
|
||||||
|
- headers/version de fichiers, fins de ligne, lockfiles/artefacts générés ;
|
||||||
|
- documentation normative, plans actifs, ROADMAP/CHANGELOG et liens relatifs.
|
||||||
|
|
||||||
|
Les contrôles transversaux ne révèlent pas de nouvelle dérive de dépendances/features, de lecture d'environnement hors Config, de tracing direct chez les consumers, de `unsafe`, `unwrap`/`expect`/`panic`/`?` en production, de `pub mod`, de dialogue navigateur natif ou de schéma JSON invalide.
|
||||||
|
|
||||||
|
## Correction 1 — neutralisation des URLs dans les sources `reqwest`
|
||||||
|
|
||||||
|
`HttpEndpointUrl` masque déjà la valeur dans son `Debug`, mais le chemin d'erreur HTTP attachait directement une `reqwest::Error` à `ksp_core_lib::Error::source`.
|
||||||
|
|
||||||
|
Une erreur `reqwest` peut conserver l'URL de requête. Une URL provider pouvant porter une API key dans la query ou un autre segment, la chaîne `Debug`/`source` de l'erreur KSP pouvait donc contourner la redaction du wrapper `HttpEndpointUrl`.
|
||||||
|
|
||||||
|
### Correction
|
||||||
|
|
||||||
|
Toutes les `reqwest::Error` actuellement attachées par Transport sont neutralisées avant `with_source` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.with_source(error.without_url())
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela couvre :
|
||||||
|
|
||||||
|
- l'échec de construction du client `reqwest` ;
|
||||||
|
- les erreurs d'envoi/réception remappées par `map_reqwest_error`.
|
||||||
|
|
||||||
|
La canarie timeout existante utilise désormais une URL locale portant `SECRET-REQWEST-URL-CANARY` dans sa query et vérifie que ni le `Debug` de `ksp_core_lib::Error` ni celui de sa source ne contient le secret.
|
||||||
|
|
||||||
|
Ajout de `RUST-ERR-007` : une erreur externe n'est attachée à `ksp_core_lib::Error` qu'après vérification/neutralisation de toute donnée sensible potentiellement exposée par sa chaîne `Debug`/`source`.
|
||||||
|
|
||||||
|
## Correction 2 — rustdoc Transport devenu obsolète
|
||||||
|
|
||||||
|
Le crate-root Transport indiquait encore que Config « pourrait plus tard » construire les settings Transport par un adapter unidirectionnel.
|
||||||
|
|
||||||
|
Depuis `pre.006`, cet adapter existe réellement dans `ksp-config-lib`. Le rustdoc décrit maintenant l'état courant : Config construit les settings via l'adapter Config -> Transport, sans dépendance inverse.
|
||||||
|
|
||||||
|
## Correction 3 — `GEN-FILE-005`
|
||||||
|
|
||||||
|
L'audit a trouvé neuf fichiers courants code/config/build/test sans fin de ligne finale alors que leur format le permet :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/examples/std.logging.example.json
|
||||||
|
config/schemas/std.logging.schema.json
|
||||||
|
crates/ksp-app-config-desk/capabilities/default.json
|
||||||
|
crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
|
||||||
|
crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
|
||||||
|
crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
|
||||||
|
crates/ksp-app-config-desk/frontend/sass/_variables.scss
|
||||||
|
crates/ksp-app-config-desk/tsconfig.json
|
||||||
|
crates/ksp-config-lib/unit_tests/fixtures/examples/composite.example.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils se terminent désormais par exactement une fin de ligne. Les quatre fichiers SCSS possédant un header de version passent de `version: 1` à `version: 2` conformément à `GEN-FILE-004`.
|
||||||
|
|
||||||
|
Deux occurrences historiques ne sont volontairement pas réécrites dans ce fix :
|
||||||
|
|
||||||
|
- `deltas/0.1.4/pre.015-fix.002.md` : delta historique immuable ;
|
||||||
|
- `docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md` : écart documentaire historique, sans impact runtime, laissé à la politique de nettoyage documentaire de `pre.007` plutôt que réécrit dans un fix code.
|
||||||
|
|
||||||
|
## Dette documentaire réservée à `pre.007`
|
||||||
|
|
||||||
|
L'audit a aussi identifié des écarts sans impact code/config/build. Ils ne justifient pas d'élargir ce fix et seront traités dans la tranche documentaire finale :
|
||||||
|
|
||||||
|
- `docs/rules/FILE_CONTRACTS.md` contient encore `FILE-GEN-003` formulée comme si les artefacts Tauri `bindings/`/`gen/` n'étaient pas encore apparus, alors que `KSP-APP-023` définit désormais leur convention et `.gitignore` les couvre ;
|
||||||
|
- `crates/ksp-config-lib/README.md` parle encore de la « future `ksp-app-config-desk` », alors que l'application est stable depuis `0.1.4` ;
|
||||||
|
- la documentation Transport dédiée `README.md`/`USAGE.md`, l'audit final de complétude, le smoke opt-in, le prompt `0.2.2` et les mises à jour finales ROADMAP/CHANGELOG restent les livrables prévus de `pre.007`.
|
||||||
|
|
||||||
|
La présence de bindings TS-RS générés dans la copie complète n'est pas interprétée comme une violation de versionnement : l'archive fournie ne contient pas les métadonnées Git et `.gitignore` exclut déjà `bindings/` et `gen/`. Aucun lockfile, `target/`, `node_modules/` ou `dist/` n'est présent dans la copie auditée.
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
`workspace.package.version` passe à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.6.fix.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fix touche du code Rust, un test, des fichiers Config/build courants et des règles associées ; le signal Cargo est donc incrémenté.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
config/examples/std.logging.example.json
|
||||||
|
config/schemas/std.logging.schema.json
|
||||||
|
crates/ksp-app-config-desk/capabilities/default.json
|
||||||
|
crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
|
||||||
|
crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
|
||||||
|
crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
|
||||||
|
crates/ksp-app-config-desk/frontend/sass/_variables.scss
|
||||||
|
crates/ksp-app-config-desk/tsconfig.json
|
||||||
|
crates/ksp-config-lib/unit_tests/fixtures/examples/composite.example.json
|
||||||
|
crates/ksp-onchain-transport-lib/src/client.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
docs/rules/RULES_RUST.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.2.1/pre.006-fix.002.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`ROADMAP.md`, `CHANGELOG.md`, `pre.006.md`, `pre.006-fix.001.md` et tous les deltas historiques restent inchangés.
|
||||||
|
|
||||||
|
## Validation requise après application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test -p ksp-app-config-desk
|
||||||
|
cargo test -p ksp-config-lib
|
||||||
|
cargo test -p ksp-core-lib
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `cargo tree` supplémentaire n'est requis : aucune dépendance ni feature n'est modifiée par ce fix.
|
||||||
263
deltas/0.2.1/pre.006.md
Normal file
263
deltas/0.2.1/pre.006.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.006.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `v0.2.1-pre.006`
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.005-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette base a été validée localement par le user avec `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, les tests Transport, Config, Core et `cargo test --workspace`. Les canaries workspace de dépendances et de targets Logging sont également propres.
|
||||||
|
|
||||||
|
Version Cargo cible :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.6
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Matérialiser la frontière de configuration standard de `ksp-onchain-transport-lib` sans inverser l'ownership :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config document / environment -> ksp-config-lib adapter -> HttpTransportSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
Transport ne lit toujours ni Config, ni `.env`, ni `KSP_*` / `KSPB_*`.
|
||||||
|
|
||||||
|
## Nouveau document standard Transport
|
||||||
|
|
||||||
|
Nouvelles ressources gérées :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.transport -> config/std.transport.json
|
||||||
|
schema.std.transport -> config/schemas/std.transport.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Le document standard possède :
|
||||||
|
|
||||||
|
- `format_version = 1` ;
|
||||||
|
- `retry` au niveau global ;
|
||||||
|
- `default_profile` autonome ;
|
||||||
|
- `profiles[]` contenant les endpoints propres au profil ;
|
||||||
|
- endpoints avec identité/provider/cluster/URL/timeouts/idle-pool ;
|
||||||
|
- rôles avec capabilities/request kinds, priorité et limites RPS/burst/concurrence/cooldown.
|
||||||
|
|
||||||
|
Le profil par défaut committé est `devnet_public`. Le document fournit également `mainnet_public`.
|
||||||
|
|
||||||
|
Les URLs publiques utilisent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL
|
||||||
|
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
avec fallback vers les endpoints publics Solana correspondants.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
`config/schemas/std.transport.schema.json` :
|
||||||
|
|
||||||
|
- Draft 2020-12 ;
|
||||||
|
- `additionalProperties = false` aux frontières structurées ;
|
||||||
|
- `format_version` fixé à `1` ;
|
||||||
|
- invariants numériques positifs pour timeouts/limites ;
|
||||||
|
- `request_kinds` non vide et wildcard `*` exclusif ;
|
||||||
|
- profils et endpoints non vides ;
|
||||||
|
- descriptors non vides sans whitespace de bord.
|
||||||
|
|
||||||
|
Le schema protège la forme source ; la validation finale des invariants runtime reste possédée par `HttpTransportSettings::validate()`.
|
||||||
|
|
||||||
|
## Exemple provider-neutral
|
||||||
|
|
||||||
|
`config/examples/std.transport.example.json` démontre un profil `mainnet_mixed` avec :
|
||||||
|
|
||||||
|
- endpoint public de fallback ;
|
||||||
|
- endpoint privé prioritaire ;
|
||||||
|
- URL privée provenant de `KSP_SECRET_SOLANA_HTTP_URL`.
|
||||||
|
|
||||||
|
Aucun credential réel n'est committé.
|
||||||
|
|
||||||
|
`.env.example` inventorie les deux URLs publiques et documente l'URL privée optionnelle sous forme commentée.
|
||||||
|
|
||||||
|
## Registry Config
|
||||||
|
|
||||||
|
Nouveaux contrats :
|
||||||
|
|
||||||
|
```text
|
||||||
|
FILE_ID_STD_TRANSPORT
|
||||||
|
FILE_ID_SCHEMA_STD_TRANSPORT
|
||||||
|
DEFAULT_STD_TRANSPORT_FILENAME
|
||||||
|
DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME
|
||||||
|
```
|
||||||
|
|
||||||
|
`ConfigFileRegistry::defaults()` contient désormais cinq descriptors ordonnés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging
|
||||||
|
cfg.std.transport
|
||||||
|
schema.composite
|
||||||
|
schema.std.logging
|
||||||
|
schema.std.transport
|
||||||
|
```
|
||||||
|
|
||||||
|
Le document Transport référence explicitement son schema.
|
||||||
|
|
||||||
|
## Adapter Config -> Transport
|
||||||
|
|
||||||
|
Nouvelle surface publique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ResolvedTransportConfig
|
||||||
|
ConfigDocumentEngine::load_resolved_transport_config(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
L'adapter :
|
||||||
|
|
||||||
|
1. charge et valide `cfg.std.transport` ;
|
||||||
|
2. sélectionne le `default_profile` ou le profil explicite ;
|
||||||
|
3. résout les placeholders via `ConfigEnvironment` ;
|
||||||
|
4. préserve valeur réelle, valeur sûre, sensibilité et provenance JSON Pointer ;
|
||||||
|
5. décode le contrat effectif Config ;
|
||||||
|
6. convertit les scalaires `*_ms` en `std::time::Duration` ;
|
||||||
|
7. construit les newtypes/roles/limits/endpoints Transport ;
|
||||||
|
8. parse les URLs via `HttpEndpointUrl` ;
|
||||||
|
9. construit `HttpTransportSettings` ;
|
||||||
|
10. délègue la validation structurelle finale à Transport.
|
||||||
|
|
||||||
|
La dépendance est donc :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-onchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Le firewall inverse reste inchangé : Transport ne dépend pas de Config.
|
||||||
|
|
||||||
|
## Secrets, safe view et provenance
|
||||||
|
|
||||||
|
Contrairement au document Logging, le document Transport peut légitimement consommer une valeur `Secret` pour une URL endpoint complète.
|
||||||
|
|
||||||
|
`ResolvedTransportConfig` :
|
||||||
|
|
||||||
|
- expose `effective()` pour conserver le réel/safe/provenance ;
|
||||||
|
- expose `settings()` / `into_settings()` au runtime légitime ;
|
||||||
|
- n'imprime dans `Debug` que la projection `ResolvedConfigJson` sûre ;
|
||||||
|
- ne copie pas l'URL réelle dans le contexte d'une erreur d'adaptation.
|
||||||
|
|
||||||
|
Un échec Transport est projeté vers `ERROR_CODE_EFFECTIVE_CONFIG_INVALID` avec uniquement le domaine/code Transport et les identités non sensibles utiles.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Nouveau module unitaire `unit_tests/transport.rs` :
|
||||||
|
|
||||||
|
- mapping complet des scalaires Config vers le runtime Transport ;
|
||||||
|
- validation des profils committés `devnet_public` / `mainnet_public` ;
|
||||||
|
- provenance top-level `Global` pour `retry` et `Profile` pour `endpoints` ;
|
||||||
|
- URL `KSP_SECRET_*` disponible au runtime mais redacted dans la safe view et `Debug` ;
|
||||||
|
- precedence process > `.env` ;
|
||||||
|
- provenance JSON Pointer de l'URL endpoint ;
|
||||||
|
- URL secrète invalide -> `ERROR_CODE_EFFECTIVE_CONFIG_INVALID` sans fuite du canary.
|
||||||
|
|
||||||
|
Le registry gagne un test dédié au document/schema Transport et la surface publique Config gagne un canary de disponibilité de l'adapter.
|
||||||
|
|
||||||
|
La surface Config déclare désormais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
95 tests unitaires
|
||||||
|
18 tests d'intégration
|
||||||
|
113 tests au total
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ownership et documentation
|
||||||
|
|
||||||
|
`DEP-TRANSPORT-005` précise maintenant explicitement que `ksp-config-lib` peut dépendre des crates Transport pour posséder les adapters Config -> runtime settings, jamais l'inverse.
|
||||||
|
|
||||||
|
Le README/USAGE/TODO de Config est synchronisé avec `std.transport`. Le ROADMAP reçoit uniquement la mise à jour synthétique normale de l'état `0.2.1`; il ne journalise pas les détails du delta.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Nouvelle dépendance interne de `ksp-config-lib` :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune nouvelle dépendance externe et aucune nouvelle feature externe.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/examples/std.transport.example.json
|
||||||
|
config/schemas/std.transport.schema.json
|
||||||
|
config/std.transport.json
|
||||||
|
crates/ksp-config-lib/src/transport.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
|
||||||
|
crates/ksp-config-lib/unit_tests/transport.rs
|
||||||
|
deltas/0.2.1/pre.006.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env.example
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
crates/ksp-config-lib/Cargo.toml
|
||||||
|
crates/ksp-config-lib/README.md
|
||||||
|
crates/ksp-config-lib/TODO.md
|
||||||
|
crates/ksp-config-lib/USAGE.md
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/src/registry.rs
|
||||||
|
crates/ksp-config-lib/tests/ownership.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/registry.rs
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
docs/rules/RULES_DEPENDENCIES.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation de génération
|
||||||
|
|
||||||
|
Effectuée dans le sandbox :
|
||||||
|
|
||||||
|
- parsing TOML des manifests modifiés ;
|
||||||
|
- parsing JSON des nouvelles ressources ;
|
||||||
|
- validation Draft 2020-12 des deux documents Transport et de la fixture contre le nouveau schema via l'implémentation Python `jsonschema` disponible ;
|
||||||
|
- contrôle des lignes Rust/TOML modifiées <= 160 colonnes ;
|
||||||
|
- contrôle des EOF et headers ;
|
||||||
|
- scan statique des interdits KSP sur le nouveau code de production ;
|
||||||
|
- contrôle de l'inventaire des variables `KSP_*` dans `.env.example` ;
|
||||||
|
- contrôle différentiel des fichiers livrés.
|
||||||
|
|
||||||
|
Non exécutée dans le sandbox : validation Cargo/Rust, les binaires `cargo`, `rustc` et `rustfmt` n'étant pas disponibles.
|
||||||
|
|
||||||
|
Après application :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
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
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
cargo tree -p ksp-config-lib -e normal
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `cargo tree` Config est requis cette fois : la dépendance interne Config -> Transport modifie réellement le graphe de la crate Config, même sans nouvelle dépendance externe.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Tranche suivante prévue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.007
|
||||||
|
```
|
||||||
|
|
||||||
|
Périmètre : completeness/canaries finales, smoke réseau opt-in, audit `cargo tree`, README/USAGE Transport, documentation de clôture, prompt `0.2.2` et préparation de `rel.001`.
|
||||||
310
deltas/0.2.1/pre.007.md
Normal file
310
deltas/0.2.1/pre.007.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.007.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `v0.2.1-pre.007`
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.006-fix.002
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette base a été validée localement par l'opérateur le 2026-08-17 avec :
|
||||||
|
|
||||||
|
- `cargo fmt --all` ;
|
||||||
|
- `cargo check --workspace` ;
|
||||||
|
- `cargo clippy --workspace --all-targets` ;
|
||||||
|
- `cargo test -p ksp-onchain-transport-lib` ;
|
||||||
|
- `cargo test -p ksp-app-config-desk` ;
|
||||||
|
- `cargo test -p ksp-config-lib` ;
|
||||||
|
- `cargo test -p ksp-core-lib` ;
|
||||||
|
- `cargo test --workspace`.
|
||||||
|
|
||||||
|
Le test Transport renforcé contre la fuite d'URL dans les sources `reqwest::Error` passe également sur cette base.
|
||||||
|
|
||||||
|
Version Cargo cible :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.7
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Fermer la dernière prerelease fonctionnelle de `0.2.1` sans rouvrir le périmètre HTTP :
|
||||||
|
|
||||||
|
- revérifier la matrice officielle Solana ;
|
||||||
|
- figer la complétude `52 + 14` et la partition `4 / 22 / 11 / 15` par des canaries publiques ;
|
||||||
|
- fournir un smoke Devnet opt-in de bout en bout Config -> Transport ;
|
||||||
|
- produire le README et le guide d'utilisation durables de Transport ;
|
||||||
|
- remettre la baseline Logging Transport à `info` avant stable ;
|
||||||
|
- absorber les écarts documentaires réservés par l'audit complet de `pre.006-fix.002` ;
|
||||||
|
- produire la matrice de validation finale et le prompt de démarrage `0.2.2` ;
|
||||||
|
- préparer un `rel.001` strictement publicationnel.
|
||||||
|
|
||||||
|
Aucune cinquième méthode HTTP typée n'est ajoutée dans cette tranche.
|
||||||
|
|
||||||
|
## Revérification officielle HTTP Solana
|
||||||
|
|
||||||
|
La documentation officielle Solana a été revérifiée le 2026-08-17 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://solana.com/docs/rpc/http
|
||||||
|
https://solana.com/docs/rpc/deprecated/confirmtransaction
|
||||||
|
```
|
||||||
|
|
||||||
|
Le résultat reste compatible avec la matrice acquise :
|
||||||
|
|
||||||
|
```text
|
||||||
|
52 méthodes HTTP courantes
|
||||||
|
14 méthodes historiques Deprecated
|
||||||
|
```
|
||||||
|
|
||||||
|
L'index courant confirme également le transport JSON-RPC 2.0 sur HTTP `POST` avec `Content-Type: application/json`.
|
||||||
|
|
||||||
|
Les 14 méthodes historiques restent représentées dans KSP comme `Deprecated / Removed / Historical` et ne sont pas simulées comme appelables.
|
||||||
|
|
||||||
|
## Canaries de complétude de release
|
||||||
|
|
||||||
|
Nouveau test public :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Il fige depuis l'API publique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
current == 52
|
||||||
|
historical == 14
|
||||||
|
coverage == 4 / 22 / 11 / 15
|
||||||
|
V0_2_1 == getBalance/getGenesisHash/getHealth/getVersion
|
||||||
|
historical => Deprecated + Removed + Historical + NotApplicable
|
||||||
|
```
|
||||||
|
|
||||||
|
La candidate déclare désormais **80 tests Transport**.
|
||||||
|
|
||||||
|
La surface raw/générique `execute_standard_rpc()` reste disponible mais ne compte pas comme couverture typée des 48 méthodes reportées à `0.2.2`–`0.2.4`.
|
||||||
|
|
||||||
|
## Smoke Devnet opt-in Config -> Transport
|
||||||
|
|
||||||
|
Nouveau test :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Il est volontairement :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[ignore = "opt-in live Solana Devnet smoke; performs external network requests"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Le chemin validé est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConfigFileRegistry::defaults()
|
||||||
|
-> cfg.std.transport
|
||||||
|
-> profile devnet_public
|
||||||
|
-> ConfigEnvironment
|
||||||
|
-> ResolvedTransportConfig
|
||||||
|
-> HttpTransportSettings
|
||||||
|
-> HttpTransportPool
|
||||||
|
-> getHealth
|
||||||
|
-> getGenesisHash
|
||||||
|
-> getVersion
|
||||||
|
-> getBalance(System Program)
|
||||||
|
```
|
||||||
|
|
||||||
|
Transport ne lit donc toujours pas directement l'environnement.
|
||||||
|
|
||||||
|
Le test Config nécessite Tokio uniquement comme dev-dependency local :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
La candidate déclare **114 tests Config**, dont cet unique smoke Devnet `ignored`.
|
||||||
|
|
||||||
|
Exécution opt-in :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Un incident ou rate-limit du RPC public Devnet reste un signal externe à analyser ; les gates reproductibles restent les tests déterministes par défaut.
|
||||||
|
|
||||||
|
## Logging Transport avant stable
|
||||||
|
|
||||||
|
Le sink dédié introduit pendant le développement est conservé, mais sa baseline revient de `debug` à `info` conformément à la politique de clôture :
|
||||||
|
|
||||||
|
```text
|
||||||
|
output_id : file.onchain_transport.info
|
||||||
|
path : transport/onchain/ksp-onchain-transport.log
|
||||||
|
target : ksp-onchain-transport-lib
|
||||||
|
level : info
|
||||||
|
```
|
||||||
|
|
||||||
|
La console et le fichier général restent à `info`. Un niveau `debug`/`trace` ciblé peut être réactivé temporairement via Config lors d'un futur développement, puis doit être refermé avant publication stable.
|
||||||
|
|
||||||
|
## Documentation durable Transport
|
||||||
|
|
||||||
|
Nouveaux documents :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/README.md
|
||||||
|
crates/ksp-onchain-transport-lib/USAGE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils documentent :
|
||||||
|
|
||||||
|
- responsabilités et firewall de dépendances ;
|
||||||
|
- registre `52 current + 14 historical` ;
|
||||||
|
- différence raw/générique vs couverture typée ;
|
||||||
|
- quatre canaris `0.2.1` ;
|
||||||
|
- pool/routing/admission/résilience ;
|
||||||
|
- retry/no-resend ;
|
||||||
|
- sécurité des URLs et sources `reqwest` ;
|
||||||
|
- target Logging explicite ;
|
||||||
|
- construction directe ou via Config ;
|
||||||
|
- snapshots et smoke Devnet opt-in.
|
||||||
|
|
||||||
|
## Normalisation documentaire issue de l'audit complet
|
||||||
|
|
||||||
|
Les écarts purement documentaires réservés par `pre.006-fix.002` sont absorbés ici :
|
||||||
|
|
||||||
|
- `FILE-GEN-003` décrit désormais `bindings/` et `gen/` comme artefacts générés/reconstructibles ignorés par défaut, et non comme convention encore future ;
|
||||||
|
- `crates/ksp-config-lib/README.md` ne présente plus `ksp-app-config-desk` comme future ;
|
||||||
|
- l'inventaire des composants décrit `0.2.1` comme foundation HTTP + registre `52+14` + quatre wrappers canari, et non comme surface typed HTTP complète ;
|
||||||
|
- les index docs/plans/validation/prompts sont synchronisés avec les nouveaux livrables ;
|
||||||
|
- le ROADMAP conserve uniquement l'état synthétique de la candidate et reste `[/]` jusqu'à la publication stable.
|
||||||
|
|
||||||
|
Les prompts et deltas historiques consommés restent immuables même lorsqu'ils décrivent l'état de leur époque.
|
||||||
|
|
||||||
|
## Matrice de validation finale
|
||||||
|
|
||||||
|
Nouveau document :
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Il regroupe les critères de clôture, les preuves acquises, les commandes Cargo/cargo-tree attendues et les conditions de passage à `rel.001`.
|
||||||
|
|
||||||
|
## Prompt de reprise `0.2.2`
|
||||||
|
|
||||||
|
Nouveau prompt :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/007-V0_2_2_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Il ouvre `0.2.2 — HTTP Accounts + Tokens + Cluster` par un nouveau `pre.001` d'audit/brainstorming/sizing et conserve la partition nominale actuelle de 22 méthodes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Accounts : 5
|
||||||
|
Tokens : 5
|
||||||
|
Cluster : 12
|
||||||
|
```
|
||||||
|
|
||||||
|
Il exige une revérification officielle du jour avant toute implémentation lourde et rappelle que l'appel raw ne vaut jamais couverture typée.
|
||||||
|
|
||||||
|
## Préparation de `rel.001`
|
||||||
|
|
||||||
|
`CHANGELOG.md` n'est volontairement pas modifié dans cette prerelease. Si la candidate passe les validations opérateur, `rel.001` doit rester minimal :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version -> 0.2.1
|
||||||
|
ROADMAP : 0.2.1 -> [X]
|
||||||
|
CHANGELOG : synthèse stable 0.2.1
|
||||||
|
plan 008 / validation 003 : statut clôturé + preuves opérateur
|
||||||
|
deltas/0.2.1/rel.001.md
|
||||||
|
commit v0.2.1-rel.001
|
||||||
|
tag v0.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune nouvelle fonctionnalité HTTP ne doit être introduite dans `rel.001`.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
|
||||||
|
crates/ksp-onchain-transport-lib/README.md
|
||||||
|
crates/ksp-onchain-transport-lib/USAGE.md
|
||||||
|
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||||
|
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
|
||||||
|
prompts/007-V0_2_2_START_PROMPT.md
|
||||||
|
deltas/0.2.1/pre.007.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
config/std.logging.json
|
||||||
|
crates/ksp-config-lib/Cargo.toml
|
||||||
|
crates/ksp-config-lib/README.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/architecture/004-COMPONENT_INVENTORY.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
docs/rules/FILE_CONTRACTS.md
|
||||||
|
docs/validation/000-README.md
|
||||||
|
prompts/000-README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun ancien delta et aucun `CHANGELOG.md` ne sont modifiés.
|
||||||
|
|
||||||
|
## Validation statique de génération
|
||||||
|
|
||||||
|
Effectuée dans le sandbox :
|
||||||
|
|
||||||
|
- comparaison différentielle avec `pre.006-fix.002` ;
|
||||||
|
- parsing de tous les TOML et JSON du workspace ;
|
||||||
|
- validation Draft 2020-12 de `std.logging.json`, `std.transport.json` et de l'exemple Transport contre leurs schemas ;
|
||||||
|
- contrôle des headers et incréments de version des fichiers modifiés ;
|
||||||
|
- contrôle de l'unique fin de ligne des fichiers livrés ;
|
||||||
|
- contrôle des lignes Rust/TOML <= 160 colonnes ;
|
||||||
|
- contrôle des fences Markdown et des liens Markdown relatifs des fichiers modifiés ;
|
||||||
|
- confirmation que les anciens deltas sont byte-for-byte inchangés ;
|
||||||
|
- contrôle de la baseline Logging Transport `info` ;
|
||||||
|
- comptage statique de 80 tests Transport et 114 tests Config, dont un smoke Config `ignored` ;
|
||||||
|
- contrôle des tableaux de registre Rust `[52]` et `[14]`.
|
||||||
|
|
||||||
|
Non exécutées dans le sandbox : `cargo`, `rustc` et `rustfmt` n'y sont pas disponibles. Aucune réussite Cargo de `pre.007` n'est donc déclarée avant validation opérateur.
|
||||||
|
|
||||||
|
## Validation requise après application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test -p ksp-config-lib
|
||||||
|
cargo test -p ksp-core-lib
|
||||||
|
cargo test -p ksp-app-config-desk
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
cargo tree -p ksp-onchain-transport-lib
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -d
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e features
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||||
|
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
cargo tree -p ksp-config-lib -e normal
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis, de manière opt-in :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Les `cargo tree` Config sont requis car `pre.007` ajoute un dev-dependency Tokio local au smoke live. Si les validations déterministes et les audits de graphe sont propres, la tranche suivante est `0.2.1-rel.001`.
|
||||||
161
deltas/0.2.1/rel.001.md
Normal file
161
deltas/0.2.1/rel.001.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<!-- file: deltas/0.2.1/rel.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `v0.2.1-rel.001`
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Base attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-pre.007
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.007` a été validée localement par l'opérateur le 2026-08-17 avec :
|
||||||
|
|
||||||
|
- `cargo fmt --all` ;
|
||||||
|
- `cargo check --workspace` ;
|
||||||
|
- `cargo clippy --workspace --all-targets` ;
|
||||||
|
- `cargo test -p ksp-onchain-transport-lib` ;
|
||||||
|
- `cargo test -p ksp-config-lib` ;
|
||||||
|
- `cargo test -p ksp-core-lib` ;
|
||||||
|
- `cargo test -p ksp-app-config-desk` ;
|
||||||
|
- `cargo test --workspace` ;
|
||||||
|
- les graphes `cargo tree`, `cargo tree -d`, `cargo tree -e features` et `cargo tree -e normal` pour Transport et Config ;
|
||||||
|
- le smoke Devnet opt-in `cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture`, avec `1 passed; 0 failed`.
|
||||||
|
|
||||||
|
Version Cargo cible :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Publier `0.2.1 — HTTP Solana foundation` sans ajouter de capacité fonctionnelle après la candidate `pre.007`.
|
||||||
|
|
||||||
|
`rel.001` est strictement publicationnelle :
|
||||||
|
|
||||||
|
- passage du workspace à la version stable `0.2.1` ;
|
||||||
|
- clôture `[X]` de `0.2.1` dans le ROADMAP ;
|
||||||
|
- ajout de l'entrée stable `0.2.1` au CHANGELOG ;
|
||||||
|
- clôture du plan HTTP et de sa matrice de validation avec les preuves opérateur ;
|
||||||
|
- synchronisation des index/plans généraux ;
|
||||||
|
- conservation explicite du TODO d'ownership du smoke cross-crates.
|
||||||
|
|
||||||
|
Aucun fichier Rust de production, aucune API publique, aucune configuration runtime, aucune dépendance et aucune feature Cargo ne changent dans cette livraison.
|
||||||
|
|
||||||
|
## Surface stable publiée
|
||||||
|
|
||||||
|
`0.2.1` stabilise `ksp-onchain-transport-lib` avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
JSON-RPC 2.0 sur HTTP POST
|
||||||
|
52 méthodes HTTP courantes auditées
|
||||||
|
14 méthodes historiques Deprecated / Removed
|
||||||
|
4 wrappers typés : getBalance/getGenesisHash/getHealth/getVersion
|
||||||
|
partition typed restante : 22 / 11 / 15 sur 0.2.2–0.2.4
|
||||||
|
pool rôles/capabilities/priorités/fairness
|
||||||
|
RPS/burst/concurrence/cooldown
|
||||||
|
deadline commune + timeout/retry/backoff
|
||||||
|
no-resend après dispatch ambigu
|
||||||
|
429/Retry-After + statuts temporaires
|
||||||
|
std.transport + schema + example
|
||||||
|
adapter ksp-config-lib -> ksp-onchain-transport-lib
|
||||||
|
redaction URL/provider credentials
|
||||||
|
neutralisation des URLs contenues dans reqwest::Error
|
||||||
|
sink Logging Transport dédié à info
|
||||||
|
fixtures HTTP déterministes
|
||||||
|
canaries de complétude
|
||||||
|
smoke Devnet opt-in
|
||||||
|
README/USAGE Transport
|
||||||
|
```
|
||||||
|
|
||||||
|
La surface raw/générique reste distincte de la couverture typée : elle ne vaut pas implémentation des 48 wrappers reportés.
|
||||||
|
|
||||||
|
## Validation de la candidate
|
||||||
|
|
||||||
|
Les tests de `pre.007` ont confirmé notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Transport : 70 unit + 8 public API + 2 release completeness = 80
|
||||||
|
Config : 95 unit + 5 ownership + 13 public API + 1 smoke ignored = 114
|
||||||
|
Core : tests unit/public/workspace canaries propres
|
||||||
|
Config Desk : tests unit/desktop/public propres
|
||||||
|
workspace : propre, hors probes explicitement ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
Le smoke Devnet a ensuite été lancé explicitement et a atteint les quatre canaris foundation via le profil `devnet_public` committé.
|
||||||
|
|
||||||
|
Les graphes Cargo confirment la frontière voulue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-onchain-transport-lib
|
||||||
|
ksp-onchain-transport-lib -X-> ksp-config-lib
|
||||||
|
Transport -X-> Store/Program/tracing direct
|
||||||
|
```
|
||||||
|
|
||||||
|
Les features directes restent activées localement dans les crates consommatrices. Les doublons observés par `cargo tree -d` sur les graphes inspectés se limitent à `syn` 2.x/3.x dans les chaînes transitive/proc-macro ; aucune stack HTTP/Tokio KSP concurrente n'est introduite.
|
||||||
|
|
||||||
|
## TODO ownership des smoke tests cross-crates
|
||||||
|
|
||||||
|
Le fichier actuel :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
reste temporairement sous Config parce que la direction de dépendance existante permet d'y composer Config -> Transport sans violer le firewall Transport -> Config.
|
||||||
|
|
||||||
|
Cette localisation est une **exception transitoire** et ne devient jamais une convention KSP :
|
||||||
|
|
||||||
|
- `ksp-config-lib` ne doit pas devenir la destination générale des smoke tests ;
|
||||||
|
- cela inclut explicitement les futurs scénarios `Config + autre crate` ;
|
||||||
|
- les tests déterministes de mapping/validation Config restent dans Config ;
|
||||||
|
- un smoke autonome d'une crate peut rester dans cette crate s'il n'exige pas de composition interdite ;
|
||||||
|
- les smokes cross-crates doivent appartenir à une future surface dédiée d'intégration/orchestration/demo ;
|
||||||
|
- le smoke `Config -> Transport -> Devnet` devra migrer vers cette surface lorsqu'elle existera.
|
||||||
|
|
||||||
|
## Documentation de clôture
|
||||||
|
|
||||||
|
Mises à jour :
|
||||||
|
|
||||||
|
```text
|
||||||
|
CHANGELOG.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
|
||||||
|
ROADMAP.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le prompt de reprise `prompts/007-V0_2_2_START_PROMPT.md` était déjà livré en `pre.007` et reste inchangé ; il attend comme base le tag stable `v0.2.1`.
|
||||||
|
|
||||||
|
## Commit et tag
|
||||||
|
|
||||||
|
Identifiant de commit attendu :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1-rel.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Le tag stable ne doit être créé qu'après application et validation de ce delta :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Le tag porte uniquement la publication stable ; aucun tag prerelease/fix n'est requis.
|
||||||
|
|
||||||
|
## Validation finale après application
|
||||||
|
|
||||||
|
Comme `rel.001` ne modifie aucun code de production et ne change que la version workspace et la documentation, la gate finale reste :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Après succès : commit `v0.2.1-rel.001`, puis création du tag `v0.2.1`.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/000-README.md -->
|
<!-- file: docs/000-README.md -->
|
||||||
<!-- version: 16 -->
|
<!-- version: 24 -->
|
||||||
|
|
||||||
# Documentation KSP
|
# Documentation KSP
|
||||||
|
|
||||||
@@ -38,10 +38,14 @@ docs/
|
|||||||
│ ├── 003-V0_1_1_CORE_FOUNDATION_PLAN.md
|
│ ├── 003-V0_1_1_CORE_FOUNDATION_PLAN.md
|
||||||
│ ├── 004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
│ ├── 004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||||
│ ├── 005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
│ ├── 005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
│ └── 006-V0_1_4_CONFIG_DESKTOP_PLAN.md
|
│ ├── 006-V0_1_4_CONFIG_DESKTOP_PLAN.md
|
||||||
|
│ ├── 007-V0_2_0_SERIES_PLANNING.md
|
||||||
|
│ └── 008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
├── validation/
|
├── validation/
|
||||||
│ ├── 000-README.md
|
│ ├── 000-README.md
|
||||||
│ └── 001-V0_1_4_CONFIG_DESKTOP.md
|
│ ├── 001-V0_1_4_CONFIG_DESKTOP.md
|
||||||
|
│ ├── 002-V0_2_0_SERIES_PLANNING.md
|
||||||
|
│ └── 003-V0_2_1_ONCHAIN_HTTP.md
|
||||||
└── rules/
|
└── rules/
|
||||||
├── FILE_CONTRACTS.md
|
├── FILE_CONTRACTS.md
|
||||||
├── PROMPT_STRUCTURE.md
|
├── PROMPT_STRUCTURE.md
|
||||||
@@ -58,7 +62,7 @@ D'autres sous-répertoires seront ajoutés uniquement lorsque leur rôle aura é
|
|||||||
|
|
||||||
## Documents de planification
|
## Documents de planification
|
||||||
|
|
||||||
Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La prochaine session est `0.2.0-pre.001`, ouverte par [`../prompts/005-V0_2_0_START_PROMPT.md`](../prompts/005-V0_2_0_START_PROMPT.md).
|
Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La release stable `0.2.1 — HTTP Solana foundation` a été ouverte par [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md). Son gate de sizing et sa matrice exhaustive sont conservés dans [`plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md), avec la validation finale [`validation/003-V0_2_1_ONCHAIN_HTTP.md`](validation/003-V0_2_1_ONCHAIN_HTTP.md), README/USAGE Transport et le smoke Devnet opt-in de composition Config -> Transport. Le prompt [`../prompts/007-V0_2_2_START_PROMPT.md`](../prompts/007-V0_2_2_START_PROMPT.md) devient le point d'entrée de `0.2.2`.
|
||||||
|
|
||||||
`IDEAS.md` conserve les pistes et questions qui ne sont pas encore des engagements du roadmap ni des décisions architecturales.
|
`IDEAS.md` conserve les pistes et questions qui ne sont pas encore des engagements du roadmap ni des décisions architecturales.
|
||||||
|
|
||||||
|
|||||||
101
docs/IDEAS.md
101
docs/IDEAS.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/IDEAS.md -->
|
<!-- file: docs/IDEAS.md -->
|
||||||
<!-- version: 15 -->
|
<!-- version: 18 -->
|
||||||
|
|
||||||
# Idées à explorer
|
# Idées à explorer
|
||||||
|
|
||||||
@@ -70,6 +70,14 @@ Définir avec les premières APIs réelles les conventions de nommage des traits
|
|||||||
|
|
||||||
Définir avec les premières crates fonctionnelles les conventions d'arborescence, façades `lib.rs`, modules API et réexports publics.
|
Définir avec les premières crates fonctionnelles les conventions d'arborescence, façades `lib.rs`, modules API et réexports publics.
|
||||||
|
|
||||||
|
### API Interface séparée
|
||||||
|
|
||||||
|
**Status :** Rejetée pour l'instant
|
||||||
|
|
||||||
|
`ksp-interface-lib` expose sa propre API publique wire afin que des crates Program externes puissent expérimenter contre les mêmes contrats que les implementations officielles.
|
||||||
|
|
||||||
|
Ne créer `ksp-interface-api` que si un futur problème réel de graphe de dépendances, de poids d'implémentation ou de publication démontre qu'un contrat séparé est nécessaire. La symétrie avec `ksp-program-api` n'est pas une justification suffisante.
|
||||||
|
|
||||||
## Transport
|
## Transport
|
||||||
|
|
||||||
### Modèles homogènes on-chain
|
### Modèles homogènes on-chain
|
||||||
@@ -94,17 +102,44 @@ Réévaluer seulement si les premières implémentations montrent une duplicatio
|
|||||||
|
|
||||||
`ksp-offchain-transport-lib` regroupe metadata, prix, quotes, routage et autres accès externes afin d'éviter une explosion de crates. Il n'est pas nécessaire de leur inventer une API métier commune.
|
`ksp-offchain-transport-lib` regroupe metadata, prix, quotes, routage et autres accès externes afin d'éviter une explosion de crates. Il n'est pas nécessaire de leur inventer une API métier commune.
|
||||||
|
|
||||||
|
### Pool automatique de sessions WebSocket
|
||||||
|
|
||||||
|
**Status :** À explorer après `0.2.7`
|
||||||
|
|
||||||
|
Le contrat WebSocket doit autoriser plusieurs sessions physiques sur une même URL, chaque session portant plusieurs subscriptions.
|
||||||
|
|
||||||
|
Ne pas implémenter automatiquement un scheduler/pool de sessions tant qu'un besoin réel de distribution de charge, quotas provider, isolation de flux ou reconnexion indépendante ne le justifie pas.
|
||||||
|
|
||||||
|
### Providers Yellowstone avancés
|
||||||
|
|
||||||
|
**Status :** À explorer après la fondation standard
|
||||||
|
|
||||||
|
Candidats à intégrer ultérieurement par adapters/capabilities sans dupliquer le client Yellowstone générique :
|
||||||
|
|
||||||
|
- Helius LaserStream gRPC ;
|
||||||
|
- Triton One / Dragon's Mouth ;
|
||||||
|
- ERPC ;
|
||||||
|
- Chainstack ;
|
||||||
|
- Shyft ;
|
||||||
|
- autres providers compatibles réellement utiles.
|
||||||
|
|
||||||
|
Le choix dépendra des capacités, quotas, replay, authentification, prix et besoins opérationnels au moment de leur introduction.
|
||||||
|
|
||||||
|
### Streaming pré-exécution / shreds
|
||||||
|
|
||||||
|
**Status :** À explorer plus tard
|
||||||
|
|
||||||
|
Conserver comme pistes séparées les offres pré-exécution/shred/deshred (Helius Shred Delivery, Triton/Yellowstone deshred, Shyft RabbitStream ou équivalents). Leur sémantique n'est pas identique à un flux exécuté Yellowstone standard et elles ne doivent pas être ajoutées comme simples aliases provider sans audit.
|
||||||
|
|
||||||
## Workers et jobs
|
## Workers et jobs
|
||||||
|
|
||||||
### Workers de processing
|
### Workers de processing
|
||||||
|
|
||||||
**Status :** Retenue
|
**Status :** Retenue, granularité révisée
|
||||||
|
|
||||||
Après `ksp-worker-raw-retriever`, les responsabilités de processing actuellement prévues sont séparées :
|
RAW et CORE peuvent disposer de workers dédiés à la fin de leur couche respective.
|
||||||
|
|
||||||
- `ksp-worker-core-processor` ;
|
À partir de DECODE, ne pas figer à l'avance une chaîne globale `generic-materializer -> domain-projector` pour tout Solana : la granularité des workers/processors doit émerger des vertical slices Program réels et réutiliser les mêmes transformations que les jobs de replay correspondants.
|
||||||
- `ksp-worker-generic-materializer` ;
|
|
||||||
- `ksp-worker-domain-projector` (nom provisoire).
|
|
||||||
|
|
||||||
### Worker control
|
### Worker control
|
||||||
|
|
||||||
@@ -145,6 +180,28 @@ Le modèle de sécurité, la frontière Rust/WebAssembly/native et le stockage d
|
|||||||
|
|
||||||
Un wallet web/online utilisant les contrats KSP est envisagé. La gestion des secrets et le modèle de confiance devront être traités comme une question architecturale majeure avant développement.
|
Un wallet web/online utilisant les contrats KSP est envisagé. La gestion des secrets et le modèle de confiance devront être traités comme une question architecturale majeure avant développement.
|
||||||
|
|
||||||
|
### Formats Wallet import/export supplémentaires
|
||||||
|
|
||||||
|
**Status :** À explorer avec `0.2.5` et après
|
||||||
|
|
||||||
|
Le format natif KSP est `.kspwallet`. L'architecture d'import/export doit rester extensible, mais seules les conversions réellement nécessaires sont implémentées immédiatement.
|
||||||
|
|
||||||
|
Formats/cibles à inventorier et prioriser selon usage réel :
|
||||||
|
|
||||||
|
- Solana CLI keypair JSON ;
|
||||||
|
- keypair Base58 complet lorsque pertinent ;
|
||||||
|
- Phantom, en privilégiant le wire Solana générique réellement documenté plutôt qu'un codec de marque inutile ;
|
||||||
|
- Solflare, y compris réévaluation du keystore protégé seulement si son format public devient suffisamment stable pour un round-trip testé ;
|
||||||
|
- Backpack : caractériser le wire Solana exact de l'import `Private key` avant tout codec/alias dédié ;
|
||||||
|
- Trust Wallet : caractériser le wire Solana exact d'import/export avant implémentation ;
|
||||||
|
- Base app / ex-Coinbase Wallet : ne jamais synthétiser une recovery phrase depuis une keypair arbitraire ; réévaluer uniquement si un import direct de keypair Solana est officiellement spécifié ;
|
||||||
|
- distinguer Coinbase Developer Platform d'un wallet utilisateur Base/Coinbase si son API d'import/export est étudiée ;
|
||||||
|
- autres wallets logiciels Solana ;
|
||||||
|
- hardware wallets / standards de dérivation si un besoin apparaît ;
|
||||||
|
- migrations depuis formats historiques KSP/bot uniquement si utiles aux utilisateurs réels.
|
||||||
|
|
||||||
|
Chaque format doit être étudié côté sécurité, round-trip, secret/public, dépendances et compatibilité avant engagement.
|
||||||
|
|
||||||
## Pipelines
|
## Pipelines
|
||||||
|
|
||||||
### Pas de pipeline monolithique
|
### Pas de pipeline monolithique
|
||||||
@@ -234,9 +291,9 @@ Les processing outcomes par processor/version/capability constituent la vérité
|
|||||||
|
|
||||||
### Jobs de replay
|
### Jobs de replay
|
||||||
|
|
||||||
**Status :** Transférée vers une décision/règle
|
**Status :** Requalifiée par `0.2.0-pre.003`
|
||||||
|
|
||||||
Trois jobs distincts sont retenus : `ksp-job-replay-core`, `ksp-job-replay-generic-materialization` et `ksp-job-replay-domain-projection`. Ils réutilisent les pipelines spécialisés correspondants.
|
L'ancienne liste figée `ksp-job-replay-core` / `ksp-job-replay-generic-materialization` / `ksp-job-replay-domain-projection` n'est plus une décision KSP. La frontière `RAW -> CORE` pourra introduire un replay Core lorsque CORE sera ouverte. À partir de DECODE, les jobs de replay doivent émerger avec les groupes/capacités verticaux réels et réutiliser la même logique que le processing live correspondant, sans imposer un materializer/projector global à tout Solana.
|
||||||
|
|
||||||
### Notification backend de référence
|
### Notification backend de référence
|
||||||
|
|
||||||
@@ -258,11 +315,11 @@ Définir le schéma SQL, la durée/renouvellement de lease et la technique Postg
|
|||||||
|
|
||||||
Fixer les noms/types exacts et distinguer Produced, NoOutput, NotApplicable, Unsupported et failure déterministe sans transformer des situations normales en erreurs.
|
Fixer les noms/types exacts et distinguer Produced, NoOutput, NotApplicable, Unsupported et failure déterministe sans transformer des situations normales en erreurs.
|
||||||
|
|
||||||
### Contexte stateful des projectors
|
### Contexte stateful des projections SPECIALIZED
|
||||||
|
|
||||||
**Status :** À explorer avec la première projection nécessitant un état existant
|
**Status :** À explorer avec la première projection nécessitant un état existant
|
||||||
|
|
||||||
Le Store est interrogé par le pipeline/worker puis le contexte est injecté au `DomainProjector`. Définir comment le projector décrit les données de contexte nécessaires sans dépendre du backend.
|
Le Store est interrogé par la couche de composition/pipeline/worker puis le contexte est injecté dans l'implémentation de projection/materialization spécialisée concernée. Définir comment cette capacité décrit les données de contexte nécessaires sans dépendre du backend, sans imposer un type global `DomainProjector`.
|
||||||
|
|
||||||
### Job pause/resume
|
### Job pause/resume
|
||||||
|
|
||||||
@@ -276,6 +333,24 @@ Checkpoint/restart est nécessaire pour backfill/replay. Déterminer si pause/re
|
|||||||
|
|
||||||
Backlog count, oldest pending age, processing rate et failure rate doivent être observables. Décider plus tard si health/status + logging suffisent ou si une API/metrics exporter dédiée devient nécessaire.
|
Backlog count, oldest pending age, processing rate et failure rate doivent être observables. Décider plus tard si health/status + logging suffisent ou si une API/metrics exporter dédiée devient nécessaire.
|
||||||
|
|
||||||
|
### Market Desk évolutive
|
||||||
|
|
||||||
|
**Status :** Transférée au roadmap
|
||||||
|
|
||||||
|
Introduire une première `ksp-app-market-desk` après les groupes DEX prioritaires Meteora/Raydium/Pump/Orca, puis l'enrichir après Jupiter/OKX.
|
||||||
|
|
||||||
|
Pistes futures au-delà de la V1/V2 :
|
||||||
|
|
||||||
|
- profondeur/market microstructure si les sources le permettent ;
|
||||||
|
- indicateurs dérivés ;
|
||||||
|
- alertes/anomalies ;
|
||||||
|
- overlays de risk ;
|
||||||
|
- outputs XGBoost/ML ;
|
||||||
|
- comparaison de providers/latence ;
|
||||||
|
- vues replay historiques.
|
||||||
|
|
||||||
|
Ces extensions restent séparées de l'application de trading opérationnel tant que leur responsabilité est l'observation/analyse.
|
||||||
|
|
||||||
## Applications et orchestration futures
|
## Applications et orchestration futures
|
||||||
|
|
||||||
### Application globale de contrôle/exploitation
|
### Application globale de contrôle/exploitation
|
||||||
@@ -318,8 +393,8 @@ Si cette capacité devient utile, l’intégration doit être conçue dans la pi
|
|||||||
|
|
||||||
### Numérotation fine après `0.1.x`
|
### Numérotation fine après `0.1.x`
|
||||||
|
|
||||||
**Status :** À décider à l'approche de chaque série
|
**Status :** Transférée au roadmap pour le début de `0.2.x`
|
||||||
|
|
||||||
`0.1.1` et `0.1.2` sont fixées ; `0.1.3` / `0.1.4` constituent la séquence par défaut sous réserve d'une éventuelle scission de Config.
|
`0.2.0-pre.002` avait fixé le premier séquencement concret. `0.2.1-pre.001-fix.001` le recalibre désormais sur `0.2.1 -> 0.2.13`, sous réserve du gate de dimensionnement de chaque `pre.001` et avec possibilité d'enchaîner plusieurs releases complètement clôturées dans une même session lorsque le sizing le permet.
|
||||||
|
|
||||||
Pour `0.2.x+`, ne pas attribuer prématurément un numéro précis à chaque composant. L'ordre candidat est documenté dans `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md` et sera converti en releases concrètes lorsque les dépendances et premiers cas d'usage de la série seront connus.
|
Les séries après RAW/CORE ne sont volontairement pas numérotées programme par programme à ce stade : la règle est de redécouper chaque vertical slice selon sa taille réelle et de ne jamais ouvrir une release qui ne peut pas être clôturée dans sa session.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/architecture/000-README.md -->
|
<!-- file: docs/architecture/000-README.md -->
|
||||||
<!-- version: 9 -->
|
<!-- version: 10 -->
|
||||||
|
|
||||||
# Architecture KSP
|
# Architecture KSP
|
||||||
|
|
||||||
@@ -26,6 +26,6 @@ Ils ne remplacent ni `ROADMAP.md`, ni les plans de version, ni les deltas.
|
|||||||
7. [`007-EXECUTION_AND_POLICY.md`](007-EXECUTION_AND_POLICY.md) — policy multi-checkpoints, orchestration transactionnelle, wallet/transport, retry, approval externe et résultat d'exécution ;
|
7. [`007-EXECUTION_AND_POLICY.md`](007-EXECUTION_AND_POLICY.md) — policy multi-checkpoints, orchestration transactionnelle, wallet/transport, retry, approval externe et résultat d'exécution ;
|
||||||
8. [`008-DATA_MATERIALIZATION_AND_STORE.md`](008-DATA_MATERIALIZATION_AND_STORE.md) — niveaux durables D1–D4, Materialization, Store PostgreSQL de référence, provenance, idempotence, replay et notifications de données persistées ;
|
8. [`008-DATA_MATERIALIZATION_AND_STORE.md`](008-DATA_MATERIALIZATION_AND_STORE.md) — niveaux durables D1–D4, Materialization, Store PostgreSQL de référence, provenance, idempotence, replay et notifications de données persistées ;
|
||||||
9. [`009-ACQUISITION_WORKERS_AND_JOBS.md`](009-ACQUISITION_WORKERS_AND_JOBS.md) — pipelines spécialisés, workers live, jobs de backfill/replay, backlog, claim/lease, reprise, concurrence et mécanisme de notification de référence ;
|
9. [`009-ACQUISITION_WORKERS_AND_JOBS.md`](009-ACQUISITION_WORKERS_AND_JOBS.md) — pipelines spécialisés, workers live, jobs de backfill/replay, backlog, claim/lease, reprise, concurrence et mécanisme de notification de référence ;
|
||||||
10. [`010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md`](010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md) — apps spécialisées, workers autonomes, control plane, scenarios réutilisables, demos desktop et frontières IPC/orchestration.
|
10. [`010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md`](010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md) — apps spécialisées, workers autonomes et need-driven, control plane, scenarios réutilisables, demos desktop et frontières IPC/orchestration.
|
||||||
|
|
||||||
`004-COMPONENT_INVENTORY.md` et `005-DEPENDENCY_GRAPH.md` sont maintenus ensemble : une évolution du graphe qui change le propriétaire d'une responsabilité doit corriger l'inventaire au lieu de laisser deux descriptions contradictoires.
|
`004-COMPONENT_INVENTORY.md` et `005-DEPENDENCY_GRAPH.md` sont maintenus ensemble : une évolution du graphe qui change le propriétaire d'une responsabilité doit corriger l'inventaire au lieu de laisser deux descriptions contradictoires.
|
||||||
|
|||||||
@@ -1,202 +1,201 @@
|
|||||||
<!-- file: docs/architecture/002-LAYERS_AND_DEPENDENCIES.md -->
|
<!-- file: docs/architecture/002-LAYERS_AND_DEPENDENCIES.md -->
|
||||||
<!-- version: 5 -->
|
<!-- version: 6 -->
|
||||||
|
|
||||||
# Couches et dépendances KSP
|
# Couches et dépendances KSP
|
||||||
|
|
||||||
## Rôle des niveaux
|
## Rôle des niveaux architecturaux
|
||||||
|
|
||||||
Les niveaux N1 à N4 servent à raisonner sur les responsabilités, la stabilité et le sens des dépendances. Ils ne constituent pas une chaîne d'appels obligatoire.
|
Les niveaux architecturaux N1–N4 décrivent les familles de composants du projet. Ils ne doivent pas être confondus avec les niveaux durables D1–D4.
|
||||||
|
|
||||||
Les niveaux durables de données utilisent une nomenclature distincte **D1 à D4** afin de ne jamais être confondus avec les couches architecturales N1 à N4 : D1 Raw, D2 Core canonique, D3 journal de matérialisation générique et D4 projections spécialisées.
|
### N1 — Fondations communes
|
||||||
|
|
||||||
Une couche supérieure peut dépendre directement d'une bibliothèque KSP plus basse lorsque cette bibliothèque est exactement la propriétaire de la capacité recherchée.
|
- `ksp-core-lib` ;
|
||||||
|
- `ksp-logging-lib` ;
|
||||||
|
- `ksp-config-lib` ;
|
||||||
|
- premières règles/outils transversaux.
|
||||||
|
|
||||||
## N1 — Fondations
|
### N2 — Capacités Solana réutilisables
|
||||||
|
|
||||||
N1 contient les contrats et services transversaux qui doivent rester bas dans le graphe de dépendances.
|
|
||||||
|
|
||||||
Positionnement actuellement retenu :
|
|
||||||
|
|
||||||
- `ksp-core-lib` — primitives et contrats fondamentaux réellement transversaux, type d'erreur commun KSP et responsabilité autrefois séparée des identifiants de programmes ;
|
|
||||||
- `ksp-interface-lib` — façade KSP des interfaces/wire on-chain nécessaires aux autres bibliothèques ;
|
|
||||||
- `ksp-config-lib` — contrats, chargement/résolution et manipulation autorisée de la configuration et des profils ;
|
|
||||||
- `ksp-logging-lib` — façade commune de logging/tracing, propriétaire de l'initialisation et des dépendances directes `tracing`, `tracing-appender` et `tracing-subscriber`.
|
|
||||||
|
|
||||||
`ksp-logging-lib` peut dépendre de `ksp-core-lib` pour le contrat commun `Error` / `Result`. La relation inverse n'est pas requise : `ksp-core-lib` reste sans dépendance logging tant qu'aucun besoin réel ne la justifie.
|
|
||||||
|
|
||||||
Les crates KSP contenant du comportement/runtime peuvent dépendre directement de `ksp-logging-lib` afin de produire des logs structurés aux niveaux `error`, `warn`, `info`, `debug` et `trace`. Les crates `*-api` purement déclaratives n'ajoutent pas cette dépendance sans comportement réel à logger.
|
|
||||||
|
|
||||||
Une bibliothèque N1 ne doit pas dépendre d'une fonctionnalité métier située dans une couche supérieure.
|
|
||||||
|
|
||||||
## N2 — Capacités Solana
|
|
||||||
|
|
||||||
N2 regroupe les capacités réutilisables opérant sur Solana au-dessus des fondations.
|
|
||||||
|
|
||||||
Le nom `ksp-program-lib` est retenu pour la bibliothèque propriétaire du traitement des programmes : décodage et préparation technique d'opérations via `ProgramExecutionPreparer`. Elle doit s'appuyer sur `ksp-interface-lib` plutôt que faire porter les contrats wire aux applications.
|
|
||||||
|
|
||||||
Les autres responsabilités N2 candidates comprennent notamment :
|
|
||||||
|
|
||||||
- `ksp-onchain-transport-lib` ;
|
- `ksp-onchain-transport-lib` ;
|
||||||
- `ksp-offchain-transport-lib` lorsqu'un premier besoin réel justifiera son implémentation ;
|
- `ksp-offchain-transport-lib` ;
|
||||||
- `ksp-wallet-lib`.
|
- `ksp-wallet-lib` ;
|
||||||
|
- `ksp-interface-lib` ;
|
||||||
|
- `ksp-program-api` puis implementations Program ;
|
||||||
|
- `ksp-execution-policy-api` et orchestration d'exécution lorsqu'un vertical slice réel le justifie.
|
||||||
|
|
||||||
Le transport off-chain est général : il ne se limite pas aux metadata et pourra servir à des ressources telles que metadata externes, prix de référence, services de routage ou autres données hors blockchain.
|
### N3 — Données, jobs, workers et processing
|
||||||
|
|
||||||
## N3 — Données et orchestration réutilisable
|
- `ksp-store-api` / `ksp-store-lib` ;
|
||||||
|
- `ksp-materializer-api` / implementations lorsque DECODE s'ouvre ;
|
||||||
|
- `ksp-job-api` et jobs ;
|
||||||
|
- `ksp-worker-api` et workers ;
|
||||||
|
- processors/pipelines spécialisés réellement réutilisés.
|
||||||
|
|
||||||
N3 doit accueillir les responsabilités qui interprètent, persistent ou orchestrent des capacités inférieures, notamment :
|
### N4 — Exécutables
|
||||||
|
|
||||||
- matérialisation ;
|
- applications desk ;
|
||||||
- stockage ;
|
- services workers ;
|
||||||
- replay/reconstruction ;
|
- outils/jobs exécutables ;
|
||||||
- pipelines ;
|
- demos/scenarios ;
|
||||||
- scénarios réutilisables ;
|
- future orchestration globale.
|
||||||
- contrôle/orchestration de workers lorsqu'il sera introduit.
|
|
||||||
|
|
||||||
### W1
|
## Chaîne durable indépendante des niveaux N1–N4
|
||||||
|
|
||||||
W1 est un worker d'acquisition live/quasi-live uniquement.
|
La chaîne de données canonique est :
|
||||||
|
|
||||||
Il consomme au minimum les contrats de configuration, transport on-chain et stockage nécessaires pour :
|
|
||||||
|
|
||||||
1. écouter les sources configurées ;
|
|
||||||
2. rapatrier les données ;
|
|
||||||
3. les persister sous forme raw ;
|
|
||||||
4. notifier qu'une nouvelle information raw est disponible.
|
|
||||||
|
|
||||||
W1 :
|
|
||||||
|
|
||||||
- ne décode pas ;
|
|
||||||
- ne matérialise pas ;
|
|
||||||
- ne réalise pas de replay ;
|
|
||||||
- ne décide pas de l'utilisation métier des données collectées ;
|
|
||||||
- doit pouvoir faire évoluer à chaud ce qu'il écoute, rapatrie ou stocke selon les mécanismes de configuration/commande qui seront définis.
|
|
||||||
|
|
||||||
Les notifications W1 servent de wake-up. Les workers/jobs downstream reconstruisent leur backlog depuis le Store et appliquent les pipelines spécialisés correspondant aux frontières D1 -> D2 -> D3 -> D4.
|
|
||||||
|
|
||||||
### Workers de processing futurs
|
|
||||||
|
|
||||||
Le processing continu n'est plus modélisé comme un unique W2. Il sépare `ksp-worker-core-processor`, `ksp-worker-generic-materializer` et `ksp-worker-domain-projector` afin de respecter les frontières durables D1 Raw -> D2 Core -> D3 journal de matérialisation générique -> D4 projections de domaine. Le lifecycle, backlog, claim/lease et replay sont détaillés dans `009-ACQUISITION_WORKERS_AND_JOBS.md`.
|
|
||||||
|
|
||||||
## N4 — Exécutables
|
|
||||||
|
|
||||||
N4 contient les applications, demos et workers.
|
|
||||||
|
|
||||||
### Dépendances directes autorisées
|
|
||||||
|
|
||||||
N4 n'est pas obligé de traverser N3 puis N2 pour atteindre N1.
|
|
||||||
|
|
||||||
Exemples valides :
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ksp-app-config-desk
|
D1 RAW
|
||||||
└── ksp-config-lib
|
-> D2 CORE
|
||||||
|
-> D3 DECODE
|
||||||
ksp-app-store-desk
|
-> D4 SPECIALIZED
|
||||||
├── ksp-store-lib
|
|
||||||
└── ksp-config-lib
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Une application de configuration n'a aucune raison de dépendre de couches Solana qui ne participent pas à sa fonction.
|
Aliases fonctionnels :
|
||||||
|
|
||||||
Une application de store peut utiliser `ksp-config-lib` pour sélectionner/résoudre un profil et `ksp-store-lib` pour valider, initialiser ou reconstruire le stockage. Elle ne doit pas réimplémenter ces opérations ni appeler directement le backend propriétaire.
|
```text
|
||||||
|
RAW -> CORE -> DECODE -> SPECIALIZED
|
||||||
|
```
|
||||||
|
|
||||||
## Applications et demos
|
RAW et CORE sont indépendants du décodage Program.
|
||||||
|
|
||||||
Une application ou demo :
|
CORE est une normalisation générique de Solana : structure des blocs, transactions, messages, comptes, instructions/CPI brutes, logs/meta et relations fondamentales.
|
||||||
|
|
||||||
- recueille et présente les données ;
|
Le premier decoder Program intervient seulement à `CORE -> DECODE`.
|
||||||
- effectue les conversions/validations strictement liées à son interface ;
|
|
||||||
- sélectionne les options, profils et scénarios autorisés ;
|
|
||||||
- appelle les bibliothèques KSP propriétaires ;
|
|
||||||
- affiche ou exporte les résultats.
|
|
||||||
|
|
||||||
Elle ne doit pas réécrire :
|
## Progression par couche
|
||||||
|
|
||||||
- le décodage ;
|
### RAW et CORE
|
||||||
- la logique protocolaire ;
|
|
||||||
- les PDA et layouts ;
|
|
||||||
- la construction d'instructions ;
|
|
||||||
- la matérialisation ;
|
|
||||||
- les opérations de stockage ;
|
|
||||||
- les scénarios réutilisables ;
|
|
||||||
- les autres opérations appartenant à une bibliothèque inférieure.
|
|
||||||
|
|
||||||
Lorsqu'une bibliothèque de scénarios possède déjà un workflow de démonstration, l'application demo doit l'appeler.
|
Ces deux couches sont construites horizontalement.
|
||||||
|
|
||||||
Les demos/scénarios doivent rester séparés par responsabilité fonctionnelle cohérente. Un regroupement n'est autorisé que lorsque plusieurs interfaces représentent réellement le même domaine fonctionnel.
|
À la fin de chaque couche, KSP ajoute les composants d'exploitation nécessaires : persistence, replay/backfill, worker/service et application de contrôle lorsque utiles.
|
||||||
|
|
||||||
Exemples actuellement retenus :
|
### DECODE et SPECIALIZED
|
||||||
|
|
||||||
- Memo : demo/scénarios séparés ;
|
À partir du décodage, KSP progresse verticalement par groupe fonctionnel :
|
||||||
- SPL Token classique : demo/scénarios séparés ;
|
|
||||||
- Associated Token Account : demo/scénarios séparés ;
|
|
||||||
- Token-2022 : demo/scénarios séparés ;
|
|
||||||
- metadata d'assets/tokens : Metaplex Token Metadata et Token-2022 Metadata peuvent partager une même famille de demo/scénarios ;
|
|
||||||
- Solana Program Metadata (SPM) reste séparé car il n'appartient pas à la même catégorie fonctionnelle.
|
|
||||||
|
|
||||||
## Workers
|
```text
|
||||||
|
wire
|
||||||
|
-> decode
|
||||||
|
-> materialize
|
||||||
|
-> specialized projection si utile
|
||||||
|
-> execution preparation
|
||||||
|
-> policy
|
||||||
|
-> execute
|
||||||
|
-> scenarios
|
||||||
|
```
|
||||||
|
|
||||||
Les workers sont différents des interfaces utilisateur. Ils peuvent contenir l'orchestration runtime strictement nécessaire à leur responsabilité.
|
Cela évite de développer tous les decoders avant les matérialisations et toutes les executions.
|
||||||
|
|
||||||
Un worker doit néanmoins consommer les bibliothèques KSP propriétaires des contrats Solana et ne doit pas dépendre directement de crates Solana/protocoles externes.
|
## Séparation maximale des dépendances
|
||||||
|
|
||||||
Les premiers managers de workers sont spécialisés et séparés. Une application globale est un produit futur, tandis qu'un orchestrateur commun reste une abstraction à réévaluer seulement lorsqu'un besoin opérationnel concret le justifie.
|
Chaque composant possède son contrat et reçoit explicitement les données nécessaires.
|
||||||
|
|
||||||
|
Exemples structurants :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-onchain-transport-lib -X-> ksp-config-lib
|
||||||
|
ksp-onchain-transport-lib -X-> ksp-store-api
|
||||||
|
ksp-wallet-lib -X-> ksp-onchain-transport-lib
|
||||||
|
ksp-wallet-lib -X-> execution policy
|
||||||
|
ksp-interface-lib -X-> ksp-program-api
|
||||||
|
ksp-execution-lib -X-> ksp-program-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
La composition supérieure relie les composants.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`ksp-config-lib` est l'unique propriétaire des documents Config, `.env` et variables KSP/KSPB.
|
||||||
|
|
||||||
|
Un composant ne dépend pas de Config pour être utilisable. Il expose des settings publics.
|
||||||
|
|
||||||
|
Config peut fournir un document standard et un adapter vers ces settings lorsque cela devient utile :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> public settings du composant
|
||||||
|
```
|
||||||
|
|
||||||
|
sans dépendance inverse.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
`ksp-logging-lib` reste l'unique façade KSP de tracing runtime.
|
||||||
|
|
||||||
|
Les crates comportant du comportement runtime peuvent en dépendre. Les crates `*-api` purement déclaratives n'ajoutent cette dépendance que si elles ont réellement un comportement à logger.
|
||||||
|
|
||||||
|
## Applications
|
||||||
|
|
||||||
|
Les applications Tauri restent minces :
|
||||||
|
|
||||||
|
- DTOs applicatifs ;
|
||||||
|
- composition de services KSP ;
|
||||||
|
- lifecycle fenêtre/UI ;
|
||||||
|
- instrumentation frontend ;
|
||||||
|
- aucun déplacement de logique de transport, Wallet, Config, Program, Store ou Materializer dans Tauri.
|
||||||
|
|
||||||
|
Des applications spécialisées sont ajoutées au fur et à mesure pour valider les couches : Config Desk, Wallet Desk, Price Desk, backfill/RAW tooling, CORE tooling puis Market Desk.
|
||||||
|
|
||||||
|
## Workers et jobs
|
||||||
|
|
||||||
|
Un worker est un service continu/autonome ; un job est borné/terminable.
|
||||||
|
|
||||||
|
Ils utilisent des APIs lifecycle distinctes et ne s'appellent pas entre eux pour transférer les payloads du data plane.
|
||||||
|
|
||||||
|
Le Store reste le point durable de synchronisation entre couches de processing.
|
||||||
|
|
||||||
## Firewall des dépendances externes
|
## Firewall des dépendances externes
|
||||||
|
|
||||||
Les exécutables KSP dépendent des bibliothèques KSP pour les capacités Solana.
|
Les exécutables et couches supérieures n'importent pas directement les crates Solana/protocoles métier lorsqu'une façade KSP existe ou est prévue.
|
||||||
|
|
||||||
```text
|
Exceptions bas niveau explicitement autorisées restent limitées aux primitives stables décidées par les règles KSP.
|
||||||
application / demo / worker
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
bibliothèques KSP
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
primitives externes explicitement autorisées
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Solana
|
|
||||||
```
|
|
||||||
|
|
||||||
Une dépendance externe Solana ou protocolaire doit être possédée par la bibliothèque KSP la plus basse et la plus cohérente avec sa responsabilité.
|
`ksp-interface-lib` concentre les interfaces/wires officielles ou compatibles afin d'éviter les doublons de générations et les dépendances protocolaires dans les couches supérieures.
|
||||||
|
|
||||||
## Sens des dépendances
|
|
||||||
|
|
||||||
Le principe recherché est :
|
|
||||||
|
|
||||||
```text
|
|
||||||
N4 ───────► N3 / N2 / N1
|
|
||||||
N3 ───────► N2 / N1
|
|
||||||
N2 ───────► N1
|
|
||||||
N1 ───────► fondations externes explicitement autorisées
|
|
||||||
```
|
|
||||||
|
|
||||||
Il n'est pas permis d'introduire une dépendance vers une couche supérieure pour résoudre localement un problème. Si cela semble nécessaire, le classement des responsabilités doit être réexaminé.
|
|
||||||
|
|
||||||
## Principe contract-first
|
## Principe contract-first
|
||||||
|
|
||||||
Les contrats minimaux entre couches doivent être définis suffisamment tôt pour que les composants futurs puissent se construire contre une frontière KSP stable, même lorsque l'implémentation complète arrive dans une version ultérieure.
|
Une crate `*-api` est créée uniquement lorsque l'extension externe/backend/lifecycle exige un contrat séparé.
|
||||||
|
|
||||||
Ce principe s'applique notamment aux futurs :
|
Cas décidés :
|
||||||
|
|
||||||
- decoders ;
|
```text
|
||||||
- `ProgramExecutionPreparer` / constructeurs d'opérations ;
|
ksp-program-api
|
||||||
- materializers ;
|
ksp-materializer-api
|
||||||
- store/repositories ;
|
ksp-store-api
|
||||||
- transports ;
|
ksp-worker-api
|
||||||
- scénarios ;
|
ksp-job-api
|
||||||
- notifications et contrôle des workers ;
|
ksp-execution-policy-api
|
||||||
- orchestrateur.
|
```
|
||||||
|
|
||||||
Il ne signifie pas qu'il faut implémenter prématurément toutes les fonctionnalités. Les interfaces/traits peuvent évoluer légèrement lorsque l'expérience révèle un besoin réel, mais une dépendance entre couches ne doit pas être remplacée par un couplage ad hoc sous prétexte que le contrat final n'existe pas encore.
|
`ksp-interface-lib`, Wallet et transports conservent pour l'instant leurs APIs publiques dans leur bibliothèque d'implémentation.
|
||||||
|
|
||||||
|
## Groupes Program prioritaires
|
||||||
|
|
||||||
|
Après RAW/CORE :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Solana Core Programs
|
||||||
|
-> SPL token/trading
|
||||||
|
-> token metadata
|
||||||
|
-> Anchor
|
||||||
|
-> Meteora
|
||||||
|
-> Raydium
|
||||||
|
-> Pump
|
||||||
|
-> Orca
|
||||||
|
-> Market Desk V1
|
||||||
|
-> Jupiter/OKX routing
|
||||||
|
-> Market Desk V2
|
||||||
|
-> trading-adjacent
|
||||||
|
-> general decoding
|
||||||
|
```
|
||||||
|
|
||||||
|
Un satellite nécessaire à un protocole reste dans son groupe : Pump fee avec Pump, Meteora vault avec Meteora, etc.
|
||||||
|
|
||||||
## Questions encore ouvertes
|
## Questions encore ouvertes
|
||||||
|
|
||||||
- représentation interne exacte du type d'erreur commun KSP, à traiter dès `0.1.1-pre.001` ;
|
- forme exacte des settings publics Transport ;
|
||||||
- types publics précis de `ksp-program-api` et format ouvert/persistable des résultats décodés ;
|
- nécessité future d'un pool automatique de sessions WebSocket ;
|
||||||
- méthode de conformité wire contre les projets externes ;
|
- split éventuel d'une API Interface séparée uniquement si un vrai besoin apparaît ;
|
||||||
|
- contrats Rust exacts de Program/Materializer/Store ;
|
||||||
- mécanisme IPC du premier manager de worker autonome ;
|
- mécanisme IPC du premier manager de worker autonome ;
|
||||||
- besoins de contexte des futurs `DomainProjector` stateful ;
|
- granularité future des workers DECODE/SPECIALIZED par groupe.
|
||||||
- nécessité réelle d'un orchestrateur commun lorsque plusieurs services/managers existeront.
|
|
||||||
|
|||||||
@@ -1,30 +1,17 @@
|
|||||||
<!-- file: docs/architecture/003-COMPONENT_CONTRACTS.md -->
|
<!-- file: docs/architecture/003-COMPONENT_CONTRACTS.md -->
|
||||||
<!-- version: 11 -->
|
<!-- version: 6 -->
|
||||||
|
|
||||||
# Contrats initiaux des composants KSP
|
# Contrats initiaux des composants KSP
|
||||||
|
|
||||||
## Objet
|
## Objet
|
||||||
|
|
||||||
Ce document enregistre les frontières déjà suffisamment claires pour guider la planification. Il ne définit pas encore les API Rust finales.
|
Ce document synthétise les responsabilités des composants KSP. Les types Rust exacts restent définis au moment de leur première implémentation réelle.
|
||||||
|
|
||||||
Le principe commun est de définir tôt les contrats nécessaires entre composants, puis d'enrichir les implémentations lorsque le besoin réel apparaît.
|
|
||||||
|
|
||||||
L'inventaire détaillé courant est maintenu dans [`004-COMPONENT_INVENTORY.md`](004-COMPONENT_INVENTORY.md), le graphe autorisé/interdit dans [`005-DEPENDENCY_GRAPH.md`](005-DEPENDENCY_GRAPH.md), Wire/Program dans [`006-WIRE_AND_PROGRAM.md`](006-WIRE_AND_PROGRAM.md) Execution/Policy dans [`007-EXECUTION_AND_POLICY.md`](007-EXECUTION_AND_POLICY.md), Data/Materialization/Store dans [`008-DATA_MATERIALIZATION_AND_STORE.md`](008-DATA_MATERIALIZATION_AND_STORE.md) Acquisition/Workers/Jobs dans [`009-ACQUISITION_WORKERS_AND_JOBS.md`](009-ACQUISITION_WORKERS_AND_JOBS.md) et Apps/Services/Scenarios/Control dans [`010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md`](010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md).
|
|
||||||
|
|
||||||
## Convention API / implémentation
|
## Convention API / implémentation
|
||||||
|
|
||||||
Lorsqu'un domaine doit exposer des contrats publics pouvant être implémentés indépendamment, KSP peut séparer :
|
Une crate de contrats extensibles se nomme `ksp-<domain>-api`. Une implémentation réutilisable se nomme `ksp-<role>-lib`.
|
||||||
|
|
||||||
```text
|
Couples explicitement retenus :
|
||||||
ksp-<domain>-api
|
|
||||||
ksp-<domain>-lib
|
|
||||||
```
|
|
||||||
|
|
||||||
La crate `ksp-<domain>-api` est techniquement une bibliothèque Rust mais ne porte volontairement pas le suffixe `-lib`.
|
|
||||||
|
|
||||||
Cette séparation n'est pas automatique. Elle est utilisée lorsqu'une vraie frontière d'extension/backend/lifecycle la justifie.
|
|
||||||
|
|
||||||
Couples retenus :
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ksp-program-api / ksp-program-lib
|
ksp-program-api / ksp-program-lib
|
||||||
@@ -32,185 +19,192 @@ ksp-materializer-api / ksp-materializer-lib
|
|||||||
ksp-store-api / ksp-store-lib
|
ksp-store-api / ksp-store-lib
|
||||||
```
|
```
|
||||||
|
|
||||||
APIs lifecycle retenues :
|
Lifecycle APIs séparées :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ksp-worker-api
|
ksp-worker-api
|
||||||
ksp-job-api
|
ksp-job-api
|
||||||
|
ksp-execution-policy-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Aucune API commune worker+job n'est prévue.
|
Une crate `*-api` n'est jamais créée uniquement pour la symétrie des noms.
|
||||||
|
|
||||||
## Execution policy
|
## Core
|
||||||
|
|
||||||
`ksp-execution-policy-api` est un contrat public de décision séparé de `ksp-program-lib`, du wallet, du transport et de l'UI.
|
`ksp-core-lib` porte les primitives transversales réellement fondamentales, dont `Error`/`Result` et le registre KSP des Program IDs fondamentaux.
|
||||||
|
|
||||||
Une exécution réelle via `ksp-execution-lib` reçoit explicitement une policy ; aucun fallback permissif implicite n'est prévu.
|
Il ne devient pas une crate de modèles métier ou de transport.
|
||||||
|
|
||||||
La policy peut être évaluée à plusieurs checkpoints afin d'intégrer des informations obtenues pendant le cycle, notamment le résultat de simulation.
|
|
||||||
|
|
||||||
Une policy peut autoriser, refuser ou imposer des requirements ; elle ne réalise pas elle-même la simulation, la signature, le réseau ou une interaction Tauri.
|
|
||||||
|
|
||||||
Les implémentations appartiennent aux crates de contexte appropriées : scenario Devnet, future bibliothèque d'application générale, future policy trading, etc.
|
|
||||||
|
|
||||||
Aucune `ksp-execution-policy-lib` générique n'est prévue sans logique réellement commune.
|
|
||||||
|
|
||||||
## Execution orchestration
|
|
||||||
|
|
||||||
`ksp-execution-lib` consomme fondamentalement `PreparedProgramExecution` conforme à `ksp-program-api` et ne dépend pas de `ksp-program-lib`.
|
|
||||||
|
|
||||||
Il orchestre :
|
|
||||||
|
|
||||||
- policy checkpoints ;
|
|
||||||
- assemblage message/transaction ;
|
|
||||||
- simulation via `ksp-onchain-transport-lib` ;
|
|
||||||
- résolution des signers et signature via `ksp-wallet-lib` ;
|
|
||||||
- submission ;
|
|
||||||
- confirmation ;
|
|
||||||
- retry d'exécution lorsque celui-ci change le lifecycle ;
|
|
||||||
- suspension/reprise lorsqu'une approbation externe est requise.
|
|
||||||
|
|
||||||
Le wallet et le provider/réseau sont sélectionnés/fournis par la composition supérieure ; `ksp-execution-lib` les utilise sans définir une policy de sélection implicite.
|
|
||||||
|
|
||||||
Le retry d'un appel réseau identique reste une responsabilité transport, distincte du retry d'exécution nécessitant reconstruction/resimulation/resignature.
|
|
||||||
|
|
||||||
`ksp-execution-lib` ne persiste pas automatiquement son résultat et ne dépend pas du store.
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
`ksp-logging-lib` est la façade KSP unique pour le logging/tracing runtime. Elle importe/initialise directement `tracing`, `tracing-appender` et `tracing-subscriber` et peut dépendre de `ksp-core-lib` pour `Error` / `Result`.
|
`ksp-logging-lib` est la façade unique KSP de tracing runtime.
|
||||||
|
|
||||||
Les crates comportementales KSP utilisent sa façade pour leurs événements `error`, `warn`, `info`, `debug`, `trace` et pour leurs spans sync/async. Elles n'émettent pas leurs propres logs via une dépendance directe à la stack tracing.
|
Les composants runtime émettent leurs événements via cette façade. Les targets tiers sont silencieux par défaut et les informations utiles sont réémises sous le target du composant KSP propriétaire.
|
||||||
|
|
||||||
Chaque émission KSP indique un target correspondant au nom Cargo de la crate propriétaire ; `domain`, `component` et les autres champs structurés décrivent les subdivisions fonctionnelles sans multiplier les targets.
|
## Config
|
||||||
|
|
||||||
Le subscriber KSP rend les targets tiers silencieux par défaut. Lorsqu'une information provenant d'une dépendance externe est nécessaire, la crate KSP qui possède l'opération la réémet explicitement sous son propre target ; Logging ne renomme pas les événements tiers.
|
`ksp-config-lib` est l'unique propriétaire des documents Config, `.env`, variables KSP/KSPB et persistence Config.
|
||||||
|
|
||||||
Logging possède ses `LoggingSettings`, ses writers/guards et son lifecycle. Le subscriber global est installé une fois, puis la configuration peut être rechargée à chaud via la façade KSP sans dépendance vers Config.
|
Les composants exposent leurs settings publics ; Config peut fournir un document standard et un adapter vers ces settings sans créer de dépendance inverse.
|
||||||
|
|
||||||
`ksp-core-lib` n'a pas de dépendance logging requise. Les crates `*-api` purement déclaratives restent sans logging par défaut.
|
|
||||||
|
|
||||||
Une application Tauri peut exceptionnellement avoir une dépendance/framework tracing imposée par un plugin, sans définir une politique parallèle à `ksp-logging-lib`; les événements KSP restent émis via la façade KSP.
|
|
||||||
|
|
||||||
## Frontière materializer / store
|
|
||||||
|
|
||||||
Les niveaux durables utilisent D1 Raw, D2 Core canonique, D3 journal de matérialisation générique et D4 projections spécialisées.
|
|
||||||
|
|
||||||
`ksp-materializer-api` peut dépendre de `ksp-program-api` pour consommer des contrats Core ouverts. Il doit pouvoir distinguer conceptuellement une matérialisation générique D2 -> D3 et une projection spécialisée D3 -> D4.
|
|
||||||
|
|
||||||
`ksp-materializer-lib` reste une bibliothèque de transformation et ne dépend ni de `ksp-store-api` ni de `ksp-store-lib`.
|
|
||||||
|
|
||||||
`ksp-store-api` possède les DTO/contrats persistants et reste indépendant des APIs Program/Materializer. Les workers/jobs de processing convertissent explicitement entre modèles runtime et modèles persistants.
|
|
||||||
|
|
||||||
D3 est un journal durable obligatoire ; D4 reste plus évolutif. Cette séparation évite d'introduire un `ksp-data-api` monolithique uniquement pour partager des modèles entre couches.
|
|
||||||
|
|
||||||
## Transport on-chain
|
## Transport on-chain
|
||||||
|
|
||||||
Aucune `ksp-onchain-transport-api` séparée n'est prévue.
|
Aucune `ksp-onchain-transport-api` séparée n'est prévue.
|
||||||
|
|
||||||
`ksp-onchain-transport-lib` regroupe les transports/providers on-chain et expose des modèles de sortie homogènes par catégorie de données.
|
`ksp-onchain-transport-lib` possède :
|
||||||
|
|
||||||
Il ne dépend pas de `ksp-store-api`.
|
- settings runtime publics ;
|
||||||
|
- endpoints/providers/clusters ;
|
||||||
|
- pools/rôles/capabilities ;
|
||||||
|
- HTTP JSON-RPC ;
|
||||||
|
- WebSocket ;
|
||||||
|
- Yellowstone gRPC et futurs adapters provider lorsque introduits ;
|
||||||
|
- modèles homogènes par catégorie de donnée ;
|
||||||
|
- observabilité transport.
|
||||||
|
|
||||||
Les modèles de transport doivent cependant être conçus pour une conversion explicite et simple vers les modèles raw persistants du store, sans décodage protocolaire.
|
Il ne dépend pas de Config, Store ou Program.
|
||||||
|
|
||||||
|
Pour toute surface normative ciblée, toutes les méthodes documentées sont inventoriées/implémentées sauf impossibilité documentée. Les méthodes deprecated/obsolete encore fonctionnelles et unstable/experimental émettent un warning KSP à l'utilisation.
|
||||||
|
|
||||||
## Transport off-chain
|
## Transport off-chain
|
||||||
|
|
||||||
Aucune `ksp-offchain-transport-api` commune n'est prévue.
|
Aucune `ksp-offchain-transport-api` commune n'est prévue.
|
||||||
|
|
||||||
La crate est volontairement hétérogène : metadata, prix, quotes, routage et autres ressources externes peuvent avoir des modules/APIs distincts à l'intérieur d'une seule crate afin d'éviter une prolifération de crates artificielles.
|
`ksp-offchain-transport-lib` peut contenir plusieurs modules/APIs distincts : prix, metadata HTTP/IPFS/Arweave, quotes et autres accès externes. La première surface engagée est le prix SOL/USD et SOL/EUR.
|
||||||
|
|
||||||
## Wallet
|
## Wallet
|
||||||
|
|
||||||
Aucune `ksp-wallet-api` n'est prévue.
|
Aucune `ksp-wallet-api` séparée n'est prévue.
|
||||||
|
|
||||||
`ksp-wallet-lib` possède le format wallet KSP et les capacités de lecture/protection/import/export/pubkey/secret/signature nécessaires à ses consommateurs.
|
`ksp-wallet-lib` possède le format `.kspwallet`, le secret protégé, l'identité publique, signature, import/export et conversions utiles.
|
||||||
|
|
||||||
## Store et notifications de données
|
Il ne possède pas `WalletPolicy` ni les règles d'autorisation d'exécution.
|
||||||
|
|
||||||
`ksp-store-api` reste la frontière backend-agnostic. `ksp-store-lib` contient PostgreSQL comme implémentation de référence.
|
Le wallet temporaire JSON historique n'est pas migré.
|
||||||
|
|
||||||
Les notifications de données persistées sont normalisées indépendamment de leur producteur. W1, un job de backfill, un import ou un replay utilisent le même contrat pour signaler le même type de donnée.
|
## Interface / wire
|
||||||
|
|
||||||
Une notification est seulement un signal de réveil : le Store et les marqueurs d'idempotence/backlog restent la source de vérité. La publication suit l'ordre `persist -> commit -> notify`.
|
`ksp-interface-lib` est la façade wire officielle KSP et expose aussi une API publique wire réutilisable par `ksp-program-lib` et les extensions Program externes.
|
||||||
|
|
||||||
Le contrat de notification est distinct de son transport concret.
|
Aucune `ksp-interface-api` séparée n'est retenue actuellement.
|
||||||
|
|
||||||
|
La crate sélectionne entre réexport contrôlé, wrapper ou implémentation wire compatible selon stabilité, ownership et graphe de dépendances des interfaces externes.
|
||||||
|
|
||||||
|
## Program
|
||||||
|
|
||||||
|
`ksp-program-api` porte les contrats extensibles de Program : descriptors/capabilities, decoders, outputs et préparation d'exécution lorsque ces contrats sont démontrés.
|
||||||
|
|
||||||
|
`ksp-program-lib` porte les implementations officielles et dépend de `ksp-program-api`.
|
||||||
|
|
||||||
|
Une crate externe peut implémenter `ksp-program-api` sans dépendre de `ksp-program-lib`.
|
||||||
|
|
||||||
|
## Execution policy
|
||||||
|
|
||||||
|
`ksp-execution-policy-api` est le contrat commun de décision/safety.
|
||||||
|
|
||||||
|
Une policy décide ; elle ne signe pas, n'envoie pas et ne possède ni Wallet ni Transport.
|
||||||
|
|
||||||
|
Une petite policy de scenario/orchestrateur peut être implémentée localement. Des bibliothèques communes sont créées uniquement si une réutilisation réelle apparaît.
|
||||||
|
|
||||||
|
## Execution orchestration
|
||||||
|
|
||||||
|
`ksp-execution-lib` est introduit lorsque le premier vertical slice réel nécessite une orchestration stable entre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-program-api
|
||||||
|
ksp-execution-policy-api
|
||||||
|
ksp-wallet-lib
|
||||||
|
ksp-onchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Il ne dépend pas de `ksp-program-lib` afin d'accepter des implementations Program externes.
|
||||||
|
|
||||||
|
## Store et niveaux durables
|
||||||
|
|
||||||
|
La chaîne durable est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
D1 RAW
|
||||||
|
-> D2 CORE
|
||||||
|
-> D3 DECODE
|
||||||
|
-> D4 SPECIALIZED
|
||||||
|
```
|
||||||
|
|
||||||
|
### RAW
|
||||||
|
|
||||||
|
Acquisition replayable + provenance, sans décodage Program.
|
||||||
|
|
||||||
|
### CORE
|
||||||
|
|
||||||
|
Normalisation générique Solana, sans décodage Program.
|
||||||
|
|
||||||
|
### DECODE
|
||||||
|
|
||||||
|
Interprétation Program/protocole puis matérialisation générique/journal durable.
|
||||||
|
|
||||||
|
### SPECIALIZED
|
||||||
|
|
||||||
|
Projections queryables de domaine : token, metadata, pools, trades, OHLC, routes, etc.
|
||||||
|
|
||||||
|
`ksp-store-api` possède les contrats backend-agnostic. `ksp-store-lib` fournit PostgreSQL comme backend officiel.
|
||||||
|
|
||||||
|
La première Store release est RAW-only ; les couches suivantes sont ajoutées quand elles sont réellement ouvertes.
|
||||||
|
|
||||||
|
## Materializer
|
||||||
|
|
||||||
|
`ksp-materializer-api`/`ksp-materializer-lib` sont introduits avec le premier besoin DECODE réel, pas avant.
|
||||||
|
|
||||||
|
Program et Materializer restent indépendants du backend Store ; les composants de composition convertissent leurs outputs vers les DTO persistants.
|
||||||
|
|
||||||
## Workers
|
## Workers
|
||||||
|
|
||||||
`ksp-worker-api` est une lifecycle API pour services continus/live.
|
`ksp-worker-api` est la lifecycle API des services continus.
|
||||||
|
|
||||||
`ksp-worker-control-lib` est une implémentation réutilisable de gouvernance pouvant être consommée d'abord par les applications manager spécialisées, puis éventuellement par un futur orchestrateur ou une future application globale.
|
RAW et CORE peuvent recevoir leurs workers à la fin de leur couche respective.
|
||||||
|
|
||||||
Workers retenus :
|
Les workers DECODE/SPECIALIZED sont introduits avec les groupes Program réels, afin de ne pas créer une orchestration générique vide avant les processors.
|
||||||
|
|
||||||
```text
|
|
||||||
ksp-worker-raw-retriever
|
|
||||||
ksp-worker-core-processor
|
|
||||||
ksp-worker-generic-materializer
|
|
||||||
ksp-worker-domain-projector
|
|
||||||
```
|
|
||||||
|
|
||||||
Le dernier nom reste provisoire.
|
|
||||||
|
|
||||||
Chaque worker doit pouvoir fonctionner comme service/processus indépendant afin d'être arrêté, redémarré ou mis à jour sans imposer l'arrêt des autres. La direction de packaging préférée est un package `ksp-worker-*` avec cible bibliothèque réutilisable et binaire autonome mince.
|
|
||||||
|
|
||||||
Les workers de processing utilisent le Store comme source de vérité du backlog, peuvent être réveillés par notification, et doivent pouvoir reprendre après crash. Le raw retriever possède en plus une capacité de hot reconfiguration de sa sélection d'acquisition.
|
|
||||||
|
|
||||||
## Jobs
|
## Jobs
|
||||||
|
|
||||||
`ksp-job-api` est une lifecycle API distincte pour travaux déclenchés et terminables.
|
`ksp-job-api` est la lifecycle API des travaux déclenchés/terminables.
|
||||||
|
|
||||||
Jobs de données retenus :
|
Le premier job retenu est le backfill RAW.
|
||||||
|
|
||||||
|
Les jobs de replay suivent ensuite les frontières durables ouvertes : RAW -> CORE, CORE -> DECODE, DECODE -> SPECIALIZED.
|
||||||
|
|
||||||
|
Aucune `ksp-job-control-lib` n'est prévue sans duplication concrète.
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
Les scenarios restent dans `ksp-scenario-<domain>-lib` et sont appelables sans desktop.
|
||||||
|
|
||||||
|
Ils composent les Program implementations, policy, Wallet, transport et execution nécessaires à leur vertical slice.
|
||||||
|
|
||||||
|
L'application demo correspondante reste une UI mince.
|
||||||
|
|
||||||
|
## Applications
|
||||||
|
|
||||||
|
KSP privilégie des applications spécialisées servant à valider/exploiter une capacité réelle :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ksp-job-backfill
|
ksp-app-config-desk
|
||||||
ksp-job-replay-core
|
ksp-app-wallet-desk
|
||||||
ksp-job-replay-generic-materialization
|
price desk
|
||||||
ksp-job-replay-domain-projection
|
backfill/raw tooling
|
||||||
|
CORE tooling
|
||||||
|
ksp-app-market-desk
|
||||||
```
|
```
|
||||||
|
|
||||||
Les trois jobs de replay correspondent exactement aux frontières D1 -> D2, D2 -> D3 et D3 -> D4.
|
Une application globale reste future.
|
||||||
|
|
||||||
Aucune `ksp-job-control-lib` n'est prévue actuellement. D'autres jobs pourront apparaître pour metadata, quotes ou autres besoins ponctuels.
|
## Progression verticale Program
|
||||||
|
|
||||||
## Scénarios
|
À partir de DECODE :
|
||||||
|
|
||||||
Les scénarios restent dans des crates spécialisées `ksp-scenario-<domain>-lib`.
|
|
||||||
|
|
||||||
`ksp-scenario-api` n'est pas retenu actuellement : `docs/rules/SCENARIO_CONVENTION.md` porte la norme commune tant qu'un vrai contrat Rust réutilisable n'a pas émergé.
|
|
||||||
|
|
||||||
Les demos desktop de scénario suivent provisoirement la forme :
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ksp-app-scenario-<domain>-<environment>-desk-demo
|
wire -> decode -> materialize -> specialized -> prepare -> policy -> execute -> scenario
|
||||||
```
|
```
|
||||||
|
|
||||||
et réutilisent la crate de scénario correspondante.
|
Ordre prioritaire actuel : Solana Core Programs, SPL token/trading, token metadata, Anchor, Meteora, Raydium, Pump, Orca, routing, trading-adjacent, puis décodage généraliste.
|
||||||
|
|
||||||
## Applications, services et control plane
|
Un satellite nécessaire reste dans son groupe protocolaire.
|
||||||
|
|
||||||
Les applications spécialisées sont développées avant toute application globale.
|
|
||||||
|
|
||||||
Les apps restent des interfaces/compositions et ne réimplémentent pas les workflows des bibliothèques/services sous-jacents.
|
|
||||||
|
|
||||||
Les workers sont des services autonomes et ne communiquent pas directement leurs données entre eux. D1–D4 constituent le data plane ; `ksp-worker-api`, `ksp-worker-control-lib`, `ksp-job-api` et le futur IPC constituent le control plane.
|
|
||||||
|
|
||||||
Aucun `ksp-ipc-api` générique ni `ksp-orchestrator-lib` n'est retenu comme crate actuelle.
|
|
||||||
|
|
||||||
Une future application globale est conservée comme idée produit, pas comme tâche du roadmap présent.
|
|
||||||
|
|
||||||
## Pipelines
|
|
||||||
|
|
||||||
Aucun `ksp-pipeline-lib` monolithique.
|
|
||||||
|
|
||||||
Quatre pipelines spécialisés sont maintenant retenus parce qu'ils évitent de dupliquer une même frontière entre worker live et job :
|
|
||||||
|
|
||||||
```text
|
|
||||||
ksp-pipeline-raw-ingestion-lib
|
|
||||||
ksp-pipeline-core-processing-lib
|
|
||||||
ksp-pipeline-generic-materialization-lib
|
|
||||||
ksp-pipeline-domain-projection-lib
|
|
||||||
```
|
|
||||||
|
|
||||||
Ils dépendent des APIs de domaine nécessaires, pas des implémentations officielles `ksp-store-lib`, `ksp-program-lib` ou `ksp-materializer-lib`. Les workers/jobs réalisent cette composition.
|
|
||||||
|
|||||||
@@ -1,344 +1,153 @@
|
|||||||
<!-- file: docs/architecture/004-COMPONENT_INVENTORY.md -->
|
<!-- file: docs/architecture/004-COMPONENT_INVENTORY.md -->
|
||||||
<!-- version: 10 -->
|
<!-- version: 8 -->
|
||||||
|
|
||||||
# Inventaire initial des composants KSP
|
# Inventaire initial des composants KSP
|
||||||
|
|
||||||
## Objet
|
## Objet
|
||||||
|
|
||||||
Ce document constitue le premier inventaire architectural de `0.0.3-pre.002`.
|
Ce document maintient l'inventaire synthétique des composants retenus ou pressentis. Les numéros de release précis restent soumis au sizing de chaque session.
|
||||||
|
|
||||||
Il répond principalement à la question : **quel composant possède quelle responsabilité ?**
|
|
||||||
|
|
||||||
Le premier inventaire a été produit en `0.0.3-pre.002`. `pre.003` l'a corrigé à partir du graphe, puis `pre.004` a détaillé Wire/Program dans [`006-WIRE_AND_PROGRAM.md`](006-WIRE_AND_PROGRAM.md), `pre.005` Execution/Policy, `pre.006` les niveaux durables/Materialization/Store, `pre.007` l'exploitation workers/jobs/pipelines, `pre.008` Apps/Services/Scenarios/Control, puis `pre.009` le séquencement des premières releases fonctionnelles. La séquence détaillée est dans `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`. Les détails de types Rust restent révisables avec les premières implémentations.
|
|
||||||
|
|
||||||
## Statuts
|
## Statuts
|
||||||
|
|
||||||
- **Retenu** — composant ou responsabilité considérée nécessaire dans la trajectoire actuelle ;
|
- `Stable` — implémenté et publié ;
|
||||||
- **Candidat fort** — composant très probable mais dont la frontière exacte doit encore être validée ;
|
- `Retenu` — composant/contrat décidé ;
|
||||||
- **Futur retenu** — responsabilité acquise mais implémentation différée ;
|
- `Pressenti` — direction décidée mais périmètre exact à confirmer ;
|
||||||
- **À la demande** — ne doit être créé que lorsqu'un premier besoin concret le justifie ;
|
- `À la demande` — créé seulement au premier besoin réel ;
|
||||||
- **Non retenu actuellement** — idée volontairement non créée ; elle peut être réévaluée si l'usage réel change.
|
- `Non retenu` — explicitement écarté pour l'instant.
|
||||||
|
|
||||||
## Inventaire synthétique
|
## Inventaire synthétique
|
||||||
|
|
||||||
| Domaine | Composant | Nature | Niveau provisoire | Statut | Première série envisagée | Responsabilité principale |
|
| Domaine | Composant | Type | Statut | Première cible actuelle | Mission |
|
||||||
|----------------------------------|---------------------------------------------|-------------------------|-------------------|-----------------------------------|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|-------------------------|------------------------------------------|--------------------|--------------|---------------------------------|----------------------------------------------------------|
|
||||||
| Core | `ksp-core-lib` | lib | N1 | Retenu | `0.1.1` | `Error` commun, Program IDs, primitives/contrats réellement transversaux |
|
| Core | `ksp-core-lib` | lib | Stable | `0.1.1` | Error/Result, Program IDs et primitives fondamentales |
|
||||||
| Configuration | `ksp-config-lib` | lib | N1 | Retenu | `0.1.3` par défaut | documents de configuration, profils, résolution, modifications autorisées |
|
| Logging | `ksp-logging-lib` | lib | Stable | `0.1.2` | façade unique tracing KSP |
|
||||||
| Logging | `ksp-logging-lib` | lib | N1 | Retenu | `0.1.2` | façade unique `tracing`/appender/subscriber, initialisation et logging structuré KSP ; peut dépendre de core pour Error/Result |
|
| Config | `ksp-config-lib` | lib | Stable | `0.1.3` | documents, profils, env et persistence Config |
|
||||||
| Config desktop | `ksp-app-config-desk` | app | N4 | Retenu | `0.1.4` par défaut | app spécialisée Tauri validant chargement, profils, édition, sauvegarde, validation et diagnostics Config |
|
| Config Desk | `ksp-app-config-desk` | app | Stable | `0.1.4` | validation/management Config |
|
||||||
| Wire Solana | `ksp-interface-lib` | lib | N1 | Retenu | `0.2.x` | façade wire on-chain, réexports contrôlés et réimplémentations compatibles |
|
| On-chain HTTP | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.1` | foundation HTTP, registry 52+14, pools/rôles, 4 canaris |
|
||||||
| Program API | `ksp-program-api` | API | N2 contrat | Retenu | `0.2.x` | contrats publics ouverts de décodage, préparation d'exécution, descriptors et registry |
|
| Wallet | `ksp-wallet-lib` | lib | Retenu | `0.2.5` | `.kspwallet`, secrets, signature, import/export |
|
||||||
| Program impl. | `ksp-program-lib` | lib | N2 | Retenu | `0.2.x+` | decoders et `ProgramExecutionPreparer` officiels organisés par domaine/programme/capacité |
|
| Wallet Desk | `ksp-app-wallet-desk` | app | Retenu | `0.2.6` | Wallet + Config composite + HTTP/balance |
|
||||||
| Program extension | `ksp-program-<name>-lib` | lib externe/optionnelle | N2 | À la demande | dès besoin | implémentation externe de `ksp-program-api` pour un Program ID non encore intégré officiellement |
|
| Standard WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.7` | WebSocket Solana complet, sessions/subscriptions |
|
||||||
| Execution policy | `ksp-execution-policy-api` | API | N3 contrat | Retenu | premier besoin d'exécution réelle | policy obligatoire, multi-checkpoints, décision/requirements sans wallet/réseau/UI |
|
| Helius WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.8` | LaserStream WebSocket comme extension du moteur standard |
|
||||||
| Execution orchestration | `ksp-execution-lib` | lib | N3 | Retenu | premier besoin d'exécution réelle | consomme `PreparedProgramExecution`; orchestre policy/simulation/signature/submission/confirmation/retry sans dépendre de `ksp-program-lib` ou du store |
|
| Yellowstone | `ksp-onchain-transport-lib` | lib | Pressenti | `0.2.9` | client gRPC standard/provider-neutral |
|
||||||
| Transport on-chain | `ksp-onchain-transport-lib` | lib | N2 | Retenu | `0.2.x` | RPC/WS/providers et modèles de transport homogènes, sans dépendance store |
|
| Off-chain price | `ksp-offchain-transport-lib` | lib | Retenu | `0.2.10` | première abstraction/provider de prix SOL/USD, SOL/EUR |
|
||||||
| Transport off-chain | `ksp-offchain-transport-lib` | lib | N2 | Retenu, implémentation différable | premier besoin réel | accès metadata, prix, quotes, routage et autres ressources hors blockchain |
|
| Price Desk | nom à fixer | app | Retenu | `0.2.11` | visualisation/validation des prix |
|
||||||
| Wallet | `ksp-wallet-lib` | lib | N2 | Retenu | `0.2.x` | format wallet KSP, lecture/protection/import/export, pubkey, secret/signature |
|
| Wire | `ksp-interface-lib` | lib | Retenu | `0.2.12` | façade wire officielle + API publique wire |
|
||||||
| Materializer API | `ksp-materializer-api` | API | N3 contrat | Retenu | `0.3.x` | contrats publics/extensibles de matérialisation |
|
| Program API | `ksp-program-api` | API | Retenu | `0.2.13` | contrats extensibles Program |
|
||||||
| Materializer impl. | `ksp-materializer-lib` | lib | N3 | Retenu | `0.3.x+` | materializers officiels KSP |
|
| Program impl. | `ksp-program-lib` | lib | Retenu | vertical slices ultérieurs | implementations Program officielles |
|
||||||
| Store API | `ksp-store-api` | API | N3 contrat | Retenu | `0.3.x` | contrats backend-agnostic, modèles persistants et notifications de données persistées |
|
| Program extension | `ksp-program-<name>-lib` | lib externe | À la demande | dès besoin | implementation externe de `ksp-program-api` |
|
||||||
| Store impl. | `ksp-store-lib` | lib | N3 | Retenu | `0.3.x` | PostgreSQL de référence, migrations, repositories, queries, backlog/replay et notification backend |
|
| Store API | `ksp-store-api` | API | Retenu | `0.3.1` | contrats persistence backend-agnostic, RAW d'abord |
|
||||||
| Worker lifecycle | `ksp-worker-api` | API | N3 contrat | Retenu | `0.3.x` | lifecycle commun des services continus |
|
| Store PostgreSQL | `ksp-store-lib` | lib | Retenu | `0.3.1` | backend PostgreSQL officiel, RAW d'abord |
|
||||||
| Worker control | `ksp-worker-control-lib` | lib | N3 | Retenu | `0.3.x+` | gouvernance réutilisable de services workers autonomes pour apps spécialisées puis futurs managers/orchestrateurs |
|
| Job lifecycle | `ksp-job-api` | API | Retenu | `0.3.3` | lifecycle des jobs terminables |
|
||||||
| Live raw acquisition | `ksp-worker-raw-retriever` | worker | N4 | Retenu | `0.3.x` | acquisition live/quasi-live -> raw persisté -> notification data |
|
| Backfill | `ksp-job-backfill` | job/lib à préciser | Retenu | `0.3.3` | acquisition historique vers RAW |
|
||||||
| Raw -> Core | `ksp-worker-core-processor` | worker | N4 | Futur retenu | `0.6.x` | transformer le raw persisté en Core canonique |
|
| Backfill Desk | nom à fixer | app | Retenu | `0.3.4` | contrôle/inspection du backfill RAW |
|
||||||
| Core -> generic mat. | `ksp-worker-generic-materializer` | worker | N4 | Futur retenu | `0.6.x` | produire la matérialisation/journal générique depuis le Core |
|
| Worker lifecycle | `ksp-worker-api` | API | Retenu | fin couche RAW | lifecycle des services continus |
|
||||||
| Domain projection | `ksp-worker-domain-projector` | worker | N4 | Futur retenu, nom provisoire | `0.6.x` | matérialiser/classer/stocker les projections spécialisées par domaine |
|
| RAW worker | `ksp-worker-raw-retriever` ou nom révisé | worker | Retenu | fin couche RAW | acquisition live vers RAW |
|
||||||
| Job lifecycle | `ksp-job-api` | API | N3 contrat | Retenu | `0.3.x` | lifecycle/progression commun des travaux déclenchés et terminables |
|
| CORE processor | nom à fixer | processor/lib | Retenu | couche CORE | normalisation Solana générique RAW -> CORE |
|
||||||
| Historical backfill | `ksp-job-backfill` | job | N4 | Retenu | `0.3.x` | acquisition historique avec pagination, progression, checkpoint/reprise |
|
| CORE worker | nom à fixer | worker | Retenu | fin couche CORE | backlog RAW -> CORE continu |
|
||||||
| Core replay | `ksp-job-replay-core` | job | N4 | Retenu | `0.3.x/0.6.x` | replay borné D1 -> D2 via le pipeline Core |
|
| Materializer API | `ksp-materializer-api` | API | Retenu | premier groupe DECODE | contrats extensibles matérialisation |
|
||||||
| Generic materialization replay | `ksp-job-replay-generic-materialization` | job | N4 | Retenu | `0.3.x/0.6.x` | replay borné D2 -> D3 via le pipeline générique |
|
| Materializer impl. | `ksp-materializer-lib` | lib | Retenu | premier groupe DECODE | implementations officielles communes |
|
||||||
| Domain projection replay | `ksp-job-replay-domain-projection` | job | N4 | Retenu | `0.3.x/0.6.x` | replay borné D3 -> D4 via le pipeline de projection |
|
| Execution policy | `ksp-execution-policy-api` | API | Retenu | premier vrai besoin execution | décision/safety multi-contexte |
|
||||||
| Other jobs | `ksp-job-<role>` | job | N4 | À la demande | `0.4.x+` | metadata, quotes et autres travaux ponctuels/historiques |
|
| Execution orchestration | `ksp-execution-lib` | lib | Retenu | premier vrai cycle execution | Program + policy + Wallet + transport |
|
||||||
| Scenarios | `ksp-scenario-<domain>-lib` | lib | N3 | Retenu | `0.4.x+` | scénarios de validation spécialisés par domaine |
|
| Scenarios | `ksp-scenario-<domain>-lib` | lib | Retenu | vertical slices | validation métier/devnet par groupe |
|
||||||
| Scenario common API | `ksp-scenario-api` | API | — | Non retenu actuellement | — | préférer une norme de scénario souple plutôt qu'un trait commun contraignant |
|
| Scenario API | `ksp-scenario-api` | API | Non retenu | — | norme souple avant trait commun |
|
||||||
| Scenario demo apps | `ksp-app-scenario-<domain>-<env>-desk-demo` | app | N4 | Retenu | `0.4.x+` | interface desktop appelant la crate scénario correspondante |
|
| Market Desk | `ksp-app-market-desk` | app | Pressenti | après Meteora/Raydium/Pump/Orca | tokens, pools, trades, liquidity, price, OHLC |
|
||||||
| Raw ingestion pipeline | `ksp-pipeline-raw-ingestion-lib` | lib | N3 | Retenu | `0.3.x` | conversion/persistence transport model -> D1 partagée par worker live et backfill |
|
| Trading Intelligence | noms à définir | libs/jobs | Futur | après données stables | features/signaux/anomalies/ML |
|
||||||
| Core processing pipeline | `ksp-pipeline-core-processing-lib` | lib | N3 | Retenu | `0.3.x/0.6.x` | logique D1 -> D2 partagée par worker et replay, basée sur `ksp-program-api` |
|
|
||||||
| Generic materialization pipeline | `ksp-pipeline-generic-materialization-lib` | lib | N3 | Retenu | `0.3.x/0.6.x` | logique D2 -> D3 partagée par worker et replay, basée sur `ksp-materializer-api` |
|
|
||||||
| Domain projection pipeline | `ksp-pipeline-domain-projection-lib` | lib | N3 | Retenu | `0.3.x/0.6.x` | logique D3 -> D4 partagée par worker et replay, basée sur `ksp-materializer-api` |
|
|
||||||
| Trading Intelligence | noms à définir | API/libs/jobs | N3+ | Futur retenu | `0.7.x` | statistiques, features, signaux, anomalies, backtests, ML |
|
|
||||||
| Trading operation/app | noms à définir | libs/apps | N3/N4 | Futur retenu | après Trading Intelligence | politique/automatisation de trading et application monoposte |
|
|
||||||
| Solana explorer | noms à définir | libs/apps | N3/N4 | Futur retenu | futur | exploration générale Solana |
|
|
||||||
| DEX explorer | noms à définir | libs/apps | N3/N4 | Futur retenu | futur | exploration/analyse DEX |
|
|
||||||
|
|
||||||
## APIs séparées retenues
|
## Contrats séparés retenus
|
||||||
|
|
||||||
Les couples suivants ont une justification d'extensibilité suffisante :
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ksp-program-api -> ksp-program-lib
|
ksp-program-api
|
||||||
ksp-materializer-api -> ksp-materializer-lib
|
ksp-materializer-api
|
||||||
ksp-store-api -> ksp-store-lib
|
ksp-store-api
|
||||||
|
ksp-worker-api
|
||||||
|
ksp-job-api
|
||||||
|
ksp-execution-policy-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Les APIs lifecycle sont également séparées :
|
Pas de crates séparées actuellement pour :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ksp-worker-api -> workers continus
|
ksp-interface-api
|
||||||
ksp-job-api -> jobs terminables
|
ksp-wallet-api
|
||||||
|
ksp-onchain-transport-api
|
||||||
|
ksp-offchain-transport-api
|
||||||
|
ksp-scenario-api
|
||||||
|
ksp-job-control-lib
|
||||||
|
ksp-data-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Aucune API commune worker+job n'est prévue.
|
## Transport
|
||||||
|
|
||||||
## Execution policy et orchestration
|
`ksp-onchain-transport-lib` doit couvrir l'intégralité des opérations documentées de la surface ciblée par chaque release. `0.2.1` stabilise la foundation HTTP et quatre wrappers typés canari ; la couverture typée des 48 autres méthodes courantes reste explicitement répartie sur `0.2.2`–`0.2.4`. Les statuts deprecated/obsolete encore fonctionnels et unstable/experimental restent exposés avec warning runtime KSP.
|
||||||
|
|
||||||
La frontière détaillée est définie dans [`007-EXECUTION_AND_POLICY.md`](007-EXECUTION_AND_POLICY.md).
|
La Config standard Transport appartient à `ksp-config-lib`, qui adapte vers les settings publics du transport ; le transport ne dépend jamais de Config.
|
||||||
|
|
||||||
Principes retenus :
|
Les WebSockets supportent plusieurs sessions pour un même endpoint URL, mais un pool/scheduler automatique n'est créé qu'après besoin démontré.
|
||||||
|
|
||||||
```text
|
Les providers Yellowstone spécifiques restent des extensions futures ; le contrat standard est provider-neutral.
|
||||||
PreparedProgramExecution
|
|
||||||
|
|
|
||||||
v
|
|
||||||
ksp-execution-lib
|
|
||||||
| | |
|
|
||||||
policy wallet onchain transport
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ksp-execution-lib` dépend de `ksp-program-api`, pas de `ksp-program-lib` ;
|
|
||||||
- une policy est explicitement fournie pour toute exécution réelle ;
|
|
||||||
- la policy peut être évaluée à plusieurs checkpoints ;
|
|
||||||
- une policy décide/contraint mais n'exécute pas de réseau/signature/UI ;
|
|
||||||
- Program constraints, options caller et policy constraints restent trois sources distinctes ;
|
|
||||||
- wallet/signers et transport/provider sont fournis par la composition supérieure ;
|
|
||||||
- simulation/submission/status sont des primitives transport orchestrées par execution ;
|
|
||||||
- signature est une capacité wallet orchestrée par execution ;
|
|
||||||
- retry réseau identique et retry de lifecycle d'exécution restent distincts ;
|
|
||||||
- une approbation externe peut suspendre/reprendre l'exécution sans faire dépendre la policy de l'UI ;
|
|
||||||
- aucun accès Store depuis `ksp-execution-lib`.
|
|
||||||
|
|
||||||
`ksp-logging-lib` est consommé transversalement par les crates runtime qui doivent logger, sans imposer une dépendance aux crates `*-api` déclaratives ni à `ksp-core-lib`.
|
|
||||||
|
|
||||||
## Transport on-chain
|
|
||||||
|
|
||||||
Aucune crate `ksp-onchain-transport-api` n'est prévue.
|
|
||||||
|
|
||||||
`ksp-onchain-transport-lib` peut contenir plusieurs familles hétérogènes :
|
|
||||||
|
|
||||||
- HTTP RPC ;
|
|
||||||
- WebSocket RPC ;
|
|
||||||
- Helius avancé ;
|
|
||||||
- Yellowstone ;
|
|
||||||
- autres providers/transports futurs.
|
|
||||||
|
|
||||||
Les adapters providers doivent toutefois faire sortir de la crate des **modèles de transport homogènes par catégorie de donnée**, afin que les consommateurs n'aient pas à comprendre chaque réponse propriétaire.
|
|
||||||
|
|
||||||
Ces modèles :
|
|
||||||
|
|
||||||
- restent indépendants de `ksp-store-api` ;
|
|
||||||
- conservent les informations raw/provenance nécessaires ;
|
|
||||||
- ne réalisent aucun décodage métier/protocolaire ;
|
|
||||||
- doivent être facilement et explicitement convertibles par `ksp-worker-raw-retriever` vers les modèles raw persistants de `ksp-store-api`.
|
|
||||||
|
|
||||||
Les principes de cette frontière sont désormais fixés : transport et Store restent indépendants, et la conversion explicite transport -> D1 appartient au pipeline/composant de composition. Les DTO exacts seront définis avec les premières implémentations Transport/Store.
|
|
||||||
|
|
||||||
## Transport off-chain
|
|
||||||
|
|
||||||
Aucune crate `ksp-offchain-transport-api` ni trait global `OffchainTransport` n'est prévu.
|
|
||||||
|
|
||||||
`ksp-offchain-transport-lib` regroupe volontairement des accès hétérogènes afin d'éviter une explosion de petites crates. Metadata externes, quotes, prix hors blockchain ou routage peuvent conserver des APIs/modules spécialisés à l'intérieur de cette crate sans prétendre partager une abstraction métier commune.
|
|
||||||
|
|
||||||
## Wallet
|
## Wallet
|
||||||
|
|
||||||
Aucune crate `ksp-wallet-api` n'est prévue.
|
Le format natif est `.kspwallet`.
|
||||||
|
|
||||||
`ksp-wallet-lib` est propriétaire du format wallet KSP, des opérations de protection/import/export et de l'accès contrôlé aux capacités pubkey/secret/signature nécessaires aux couches supérieures.
|
Les anciens temporary wallets JSON ne sont pas migrés.
|
||||||
|
|
||||||
Les applications futures utilisant ce wallet restent des consommateurs de `ksp-wallet-lib`, pas des implémentations alternatives du contrat wallet.
|
`WalletPolicy` est exclu du Wallet et relève de l'execution policy.
|
||||||
|
|
||||||
## Workers
|
Import/export reste extensible ; les formats supplémentaires sont suivis dans `docs/IDEAS.md`.
|
||||||
|
|
||||||
### `ksp-worker-api`
|
## Data plane
|
||||||
|
|
||||||
`ksp-worker-api` est une **lifecycle API de services continus**.
|
|
||||||
|
|
||||||
Elle pourra porter des contrats communs comme identité, état, health, start/stop/shutdown et événements de lifecycle. Une capacité optionnelle comme la reconfiguration à chaud ne doit devenir universelle que si plusieurs workers la partagent réellement.
|
|
||||||
|
|
||||||
Elle ne contient aucun concept de progression/checkpoint terminal propre aux jobs.
|
|
||||||
|
|
||||||
### `ksp-worker-control-lib`
|
|
||||||
|
|
||||||
`ksp-worker-control-lib` est une implémentation réutilisable de gouvernance au-dessus de `ksp-worker-api`.
|
|
||||||
|
|
||||||
Elle doit pouvoir être consommée par :
|
|
||||||
|
|
||||||
- une application desktop manager spécialisée ;
|
|
||||||
- une future application globale ;
|
|
||||||
- un éventuel orchestrateur commun si un besoin opérationnel concret le justifie ;
|
|
||||||
- des tools/tests lorsque pertinent.
|
|
||||||
|
|
||||||
Les applications restent des interfaces et ne réimplémentent pas elles-mêmes registre, dispatch start/stop, agrégation d'état ou reconfiguration générique.
|
|
||||||
|
|
||||||
### Packaging des workers
|
|
||||||
|
|
||||||
Chaque package `ksp-worker-<role>` doit pouvoir fournir :
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
library target
|
RAW -> CORE -> DECODE -> SPECIALIZED
|
||||||
-> logique/runtime service réutilisable
|
|
||||||
|
|
||||||
binary target
|
|
||||||
-> bootstrap autonome mince
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Cette direction permet de tester/réutiliser la logique sans perdre la propriété « service indépendant ».
|
- RAW : acquisition replayable ;
|
||||||
|
- CORE : normalisation blockchain générique sans decoder Program ;
|
||||||
|
- DECODE : interpretation Program + matérialisation générique/journal ;
|
||||||
|
- SPECIALIZED : projections queryables de domaine.
|
||||||
|
|
||||||
Le binaire autonome n'est pas une seconde implémentation du worker.
|
## Progression des processors
|
||||||
|
|
||||||
### Workers de processing
|
RAW et CORE sont complétés couche par couche avec jobs/workers/apps utiles.
|
||||||
|
|
||||||
Le modèle initial d'un unique W2 est remplacé par une chaîne de responsabilités plus étroites :
|
À partir de DECODE, progression verticale par groupe :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
raw persisted
|
wire -> decode -> materialize -> specialized -> prepare -> policy -> execute -> scenario
|
||||||
|
|
|
||||||
v
|
|
||||||
ksp-worker-core-processor
|
|
||||||
|
|
|
||||||
v
|
|
||||||
canonical Core
|
|
||||||
|
|
|
||||||
v
|
|
||||||
ksp-worker-generic-materializer
|
|
||||||
|
|
|
||||||
v
|
|
||||||
generic materialization/journal
|
|
||||||
|
|
|
||||||
v
|
|
||||||
ksp-worker-domain-projector
|
|
||||||
|
|
|
||||||
v
|
|
||||||
domain projections
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Le nom `ksp-worker-domain-projector` est explicitement provisoire jusqu'à ce que les contrats de matérialisation spécialisée soient définis.
|
Groupes prioritaires :
|
||||||
|
|
||||||
## Jobs
|
|
||||||
|
|
||||||
`ksp-job-api` est une **lifecycle API de travaux déclenchés et terminables**.
|
|
||||||
|
|
||||||
Elle peut porter identité, état, progression, cancel, résultat et, lorsque pertinent, pause/resume/checkpoint.
|
|
||||||
|
|
||||||
Aucune `ksp-job-control-lib` n'est prévue actuellement. Le fait que plusieurs jobs implémentent `ksp-job-api` suffit tant qu'aucune duplication concrète de gouvernance ne justifie une bibliothèque commune.
|
|
||||||
|
|
||||||
## Notifications de données
|
|
||||||
|
|
||||||
Les notifications de données restent indépendantes du lifecycle des workers et des jobs.
|
|
||||||
|
|
||||||
Le même type de donnée persistée doit produire le même contrat de notification quelle que soit son origine :
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
worker live -----\
|
Solana Core Programs
|
||||||
job backfill -----+--> même notification de donnée
|
SPL token/trading
|
||||||
import -----------/
|
token metadata
|
||||||
|
Anchor
|
||||||
|
Meteora
|
||||||
|
Raydium
|
||||||
|
Pump
|
||||||
|
Orca
|
||||||
|
Market Desk V1
|
||||||
|
Jupiter/OKX routing
|
||||||
|
Market Desk V2
|
||||||
|
trading-adjacent
|
||||||
|
general decoding
|
||||||
```
|
```
|
||||||
|
|
||||||
`ksp-store-api` reste le propriétaire candidat de ces références/notifications lorsqu'elles signifient qu'une donnée persistée est disponible.
|
Meteora vaults, Pump fees et autres satellites nécessaires restent dans leur groupe.
|
||||||
|
|
||||||
Le transport de notification reste distinct du contrat de donnée.
|
## Applications spécialisées
|
||||||
|
|
||||||
## Scénarios et demos
|
Les applications servent de validations/exploitations réelles sans absorber la logique des bibliothèques.
|
||||||
|
|
||||||
Aucune crate monolithique de scénarios n'est prévue.
|
Market Desk est progressive : V1 après les DEX prioritaires, puis enrichissement routing après Jupiter/OKX.
|
||||||
|
|
||||||
Les scénarios vivent dans des crates spécialisées :
|
## Questions restantes
|
||||||
|
|
||||||
```text
|
- noms exacts de Price Desk et Backfill Desk ;
|
||||||
ksp-scenario-memo-lib
|
- surface exacte Yellowstone après audit normatif de `0.2.9-pre.001` ;
|
||||||
ksp-scenario-token-lib
|
- nécessité future d'un pool automatique WS ;
|
||||||
ksp-scenario-ata-lib
|
- types exacts `ksp-program-api`/`ksp-materializer-api`/`ksp-store-api` ;
|
||||||
ksp-scenario-token-2022-lib
|
- nom/packaging précis du premier RAW worker et du CORE normalizer ;
|
||||||
ksp-scenario-metadata-lib
|
- granularité des workers DECODE/SPECIALIZED par groupe.
|
||||||
ksp-scenario-spm-lib
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
`ksp-scenario-api` n'est pas retenu actuellement. La norme commune est documentée dans `docs/rules/SCENARIO_CONVENTION.md` et peut évoluer avec les premières implémentations.
|
|
||||||
|
|
||||||
Les applications de démonstration correspondantes suivent la forme :
|
|
||||||
|
|
||||||
```text
|
|
||||||
ksp-app-scenario-<domain>-<environment>-desk-demo
|
|
||||||
```
|
|
||||||
|
|
||||||
Exemples :
|
|
||||||
|
|
||||||
```text
|
|
||||||
ksp-app-scenario-memo-devnet-desk-demo
|
|
||||||
ksp-app-scenario-token-2022-devnet-desk-demo
|
|
||||||
ksp-app-scenario-metadata-devnet-desk-demo
|
|
||||||
```
|
|
||||||
|
|
||||||
Chaque application appelle la crate `ksp-scenario-<domain>-lib` correspondante et ne duplique pas son scénario. La crate scenario doit également pouvoir être appelée hors desktop.
|
|
||||||
|
|
||||||
## Applications spécialisées et control plane
|
|
||||||
|
|
||||||
Les applications spécialisées sont prioritaires sur une future application globale.
|
|
||||||
|
|
||||||
Une app worker spécialisée passe par `ksp-worker-control-lib` / `ksp-worker-api` et ne manipule pas directement l'état interne du worker dans le Store.
|
|
||||||
|
|
||||||
Le data plane est D1–D4. Le control plane porte start/stop/status/health/reconfigure et reste séparé des payloads de processing.
|
|
||||||
|
|
||||||
Les workers doivent être exploitables comme services autonomes. Le mécanisme IPC exact reste à définir à la première implémentation manager/service ; aucun `ksp-ipc-api` générique n'est créé maintenant.
|
|
||||||
|
|
||||||
Une future application globale est conservée dans `docs/IDEAS.md` seulement.
|
|
||||||
|
|
||||||
## Pipelines
|
|
||||||
|
|
||||||
`ksp-pipeline-lib` reste rejeté.
|
|
||||||
|
|
||||||
Le premier besoin concret de réutilisation est maintenant identifié : worker live et job de replay/backfill doivent partager la même logique d'une frontière durable.
|
|
||||||
|
|
||||||
Pipelines retenus :
|
|
||||||
|
|
||||||
```text
|
|
||||||
ksp-pipeline-raw-ingestion-lib
|
|
||||||
ksp-pipeline-core-processing-lib
|
|
||||||
ksp-pipeline-generic-materialization-lib
|
|
||||||
ksp-pipeline-domain-projection-lib
|
|
||||||
```
|
|
||||||
|
|
||||||
Les pipelines sont backend/implementation agnostic autant que possible : ils dépendent des APIs (`ksp-store-api`, `ksp-program-api`, `ksp-materializer-api`) et reçoivent les implémentations concrètes par composition depuis worker/job.
|
|
||||||
|
|
||||||
## Modèle opérationnel workers/jobs
|
|
||||||
|
|
||||||
Le backlog de processing est défini par les inputs applicables moins les processing outcomes terminaux du processor/version/capability cible.
|
|
||||||
|
|
||||||
L'absence d'output n'est pas une preuve d'absence de traitement.
|
|
||||||
|
|
||||||
KSP retient :
|
|
||||||
|
|
||||||
```text
|
|
||||||
notification wake-up + periodic polling
|
|
||||||
Store backlog = source de vérité
|
|
||||||
claim/lease = ownership temporaire
|
|
||||||
at-least-once + idempotence = sémantique de traitement
|
|
||||||
```
|
|
||||||
|
|
||||||
Le raw retriever expose une hot reconfiguration avec distinction desired/effective configuration.
|
|
||||||
|
|
||||||
Les jobs de replay restent distincts des workers continus et réutilisent les mêmes pipelines.
|
|
||||||
|
|
||||||
## Corrections issues de `pre.003`
|
|
||||||
|
|
||||||
Le graphe confirme les principes suivants :
|
|
||||||
|
|
||||||
- Program et Materializer restent indépendants du store et des I/O réseau ;
|
|
||||||
- `ksp-execution-policy-api` et `ksp-execution-lib` sont retenus ;
|
|
||||||
- `ksp-execution-lib` consomme `ksp-program-api`, pas l'implémentation officielle `ksp-program-lib` ;
|
|
||||||
- `ksp-materializer-api` peut dépendre de `ksp-program-api`, mais ni Materializer API/lib ni Store API/lib ne se dépendent mutuellement ;
|
|
||||||
- les workers/jobs spécialisés réalisent les conversions explicites entre modèles de transport, processing et persistence ;
|
|
||||||
- aucun `ksp-data-api` global n'est introduit ;
|
|
||||||
- `ksp-worker-control-lib` est retenu comme gouvernance workers réutilisable ; aucune `ksp-job-control-lib` n'est retenue.
|
|
||||||
|
|
||||||
## Questions restantes après la fondation
|
|
||||||
|
|
||||||
- types Rust exacts des contrats ouverts de `ksp-program-api` ;
|
|
||||||
- DTO exacts des modèles transport et D1/D2/D3/D4 avec les premières implémentations concernées ;
|
|
||||||
- schémas SQL, indexes, claims/leases et pagination du Store PostgreSQL ;
|
|
||||||
- première implémentation manager/service : mécanisme IPC et proxy distant Worker ;
|
|
||||||
- contexte requis par les projectors stateful ;
|
|
||||||
- futur : orchestrateur commun seulement si un besoin opérationnel concret le justifie ; l'application globale reste un produit futur après validation des apps spécialisées.
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user