35 Commits

Author SHA1 Message Date
fff69ead9c v0.2.2-rel.001 2026-08-18 10:14:43 +02:00
c93a833bac v0.2.2-pre.007-fix.002 2026-08-18 10:04:18 +02:00
05e0e96083 v0.2.2-pre.007-fix.001 2026-08-18 09:56:55 +02:00
b57f796187 v0.2.2-pre.007 2026-08-18 09:48:11 +02:00
ecfb9500eb v0.2.2-pre.006-fix.001 2026-08-18 09:37:03 +02:00
e14ce1c36f v0.2.2-pre.006 2026-08-18 08:27:42 +02:00
83cb861e54 v0.2.2-pre.005 2026-08-18 08:17:10 +02:00
1d1bc6a4d6 v0.2.2-pre.004 2026-08-18 08:09:20 +02:00
c4636ac8b9 v0.2.2-pre.003-fix.001 2026-08-18 07:53:17 +02:00
f15448ff6e v0.2.2-pre.003 2026-08-18 07:31:07 +02:00
bec1f2ec08 v0.2.2-pre.002-fix.002 2026-08-18 07:20:44 +02:00
f625ee5979 v0.2.2-pre.002-fix.001 2026-08-18 07:17:49 +02:00
e68f073505 v0.2.2-pre.002 2026-08-18 07:15:59 +02:00
bffb4f9a31 v0.2.2-pre.001-fix.001 2026-08-18 06:46:59 +02:00
9059a2dc45 v0.2.2-pre.001 2026-08-18 06:41:41 +02:00
6c3ecf1f18 v0.2.1-rel.001 2026-08-17 22:50:56 +02:00
79b67f8eae v0.2.1-pre.007 2026-08-17 22:43:07 +02:00
e0a7ac0bf8 v0.2.1-pre.006-fix.002 2026-08-17 21:41:20 +02:00
598474438b v0.2.1-pre.006-fix.001 2026-08-17 21:16:26 +02:00
f5d98c4e69 v0.2.1-pre.006 2026-08-17 21:03:51 +02:00
14bcbf2cfb v0.2.1-pre.005-fix.001 2026-08-17 20:40:08 +02:00
ac1b1033c4 v0.2.1-pre.005 2026-08-17 20:27:44 +02:00
bc71fba289 v0.2.1-pre.004-fix.002 2026-08-17 19:47:24 +02:00
9c1568ee1c v0.2.1-pre.004-fix.001 2026-08-17 19:40:12 +02:00
c1cea6e813 v0.2.1-pre.004 2026-08-17 19:26:26 +02:00
ed978179d8 v0.2.1-pre.003-fix.001 2026-08-17 18:59:01 +02:00
babe7d9f2b v0.2.1-pre.003 2026-08-17 18:55:14 +02:00
d3fc0c6d69 v0.2.1-pre.002-fix.001 2026-08-17 18:46:52 +02:00
0cff0406ab v0.2.1-pre.002 2026-08-17 18:02:18 +02:00
d98d152f08 v0.2.1-pre.001-fix.001 2026-08-17 17:36:35 +02:00
624202c363 0.2.1-pre.1 2026-08-17 17:20:41 +02:00
24cf2c5a11 v0.2.0-rel.001 2026-08-17 16:21:04 +02:00
e721464a7c v0.2.0-pre.003 2026-08-17 16:05:58 +02:00
259bff6707 v0.2.0-pre.002 2026-08-17 13:33:37 +02:00
7d40de3249 v0.2.0-pre.1 2026-08-17 10:48:02 +02:00
190 changed files with 24472 additions and 2949 deletions

View File

@@ -1,10 +1,22 @@
# file: .env.example
# version: 2
# version: 3
# 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.
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.
KSP_DESK_SPLASH_MINIMUM_MS=1200

View File

@@ -1,10 +1,22 @@
<!-- file: CHANGELOG.md -->
<!-- version: 3 -->
<!-- version: 6 -->
# 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/`.
## 0.2.2 — HTTP Accounts + Tokens + Cluster — 2026-08-18
`0.2.2` complète la surface HTTP typée de `ksp-onchain-transport-lib` avec 22 wrappers supplémentaires : 5 Accounts, 5 Tokens et 12 Cluster, portant la couverture typée totale à 26 méthodes avec les quatre canaris foundation de `0.2.1`. La release stabilise les DTOs/configs wire communs, préserve les formes Account encodées/`jsonParsed`, les `null` et champs optionnels, les selectors Token exclusifs, les contextes RPC, les structures Cluster/leader/vote et les limites locales auditées (`getMultipleAccounts <= 100`, `getProgramAccounts <= 4` filtres, `memcmp` raw <= 128 octets, `getSlotLeaders` entre 1 et 5000). Les 22 descriptors restent `Read / RetrySafe` et passent exclusivement par le flux central `descriptor -> execute_standard_rpc -> pool/admission -> executor HTTP`. La release n'ajoute aucune dépendance externe, ne crée aucun client HTTP parallèle et conserve le firewall Transport -> Config/Store/Program. Elle ajoute un smoke Devnet pur à Transport avec settings programmatiques couvrant Accounts/Tokens/Cluster, conserve séparément le smoke historique Config -> Transport comme exception transitoire, et valide les deux explicitement avant publication. Les canaries figent toujours 52 méthodes HTTP courantes, 14 historiques Deprecated/Removed et la partition typed `4 / 22 / 11 / 15`. Le prompt `prompts/008-V0_2_3_START_PROMPT.md` ouvre `0.2.3 — HTTP Transactions`.
## 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` 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 daudit de `khadhroony-bot3` et de planification du reste de `0.2.x`.

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 92
# version: 119
[workspace]
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]
version = "0.1.4"
version = "0.2.2"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
@@ -15,15 +15,16 @@ publish = false
[workspace.dependencies]
fs2 = { version = "^0.4" }
serde = { version = "^1.0", features = ["derive"] }
serde = { version = "^1.0" }
serde_json = { version = "^1.0" }
jsonschema = { version = "^0.49", default-features = false }
reqwest = { version = "^0.13", default-features = false }
solana-pubkey = { version = "^4.3", default-features = false }
tracing = { version = "^0.1", default-features = false, features = ["std"] }
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] }
tracing = { version = "^0.1", default-features = false }
tracing-subscriber = { version = "^0.3", default-features = false }
tracing-appender = { version = "^0.2", default-features = false }
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time"] }
chrono = { version = "^0.4", default-features = false, features = ["std", "now"] }
tokio = { version = "^1.53", default-features = false }
chrono = { version = "^0.4", default-features = false }
tauri = { version = "^2.11" }
tauri-build = { version = "^2.6" }
tauri-plugin-tracing = { version = "^0.3" }

View File

@@ -1,11 +1,9 @@
<!-- file: ROADMAP.md -->
<!-- version: 20 -->
<!-- version: 37 -->
# 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.
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.
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.
## Légende
@@ -15,6 +13,14 @@ Les décisions architecturales négatives ou de prudence n'apparaissent pas comm
- `[C]` — annulé ;
- `[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
- [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
### 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.1.2`Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`.
- [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.
- [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.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`.
`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 daudit 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.
- [X] `0.2.2` — HTTP Accounts + Tokens + Cluster : 22 wrappers typés (5 Accounts + 5 Tokens + 12 Cluster), canaries de complétude 52+14, smoke Devnet Transport pur et smoke historique Config -> Transport validés, documentation durable et prompt `0.2.3` publiés stables.
- [ ] `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.
- [ ] Introduire `ksp-wallet-lib` et `ksp-app-wallet-desk`.
- [ ] Développer la première surface utile de `ksp-interface-lib`.
- [ ] 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.
- **RAW** et **CORE** ne nécessitent aucun décodage Program.
- À 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.
- À 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.
## 0.3.x — Données, stockage et acquisition raw
## 0.3.x — RAW / acquisition persistée
- [ ] Introduire `ksp-materializer-api` / `ksp-materializer-lib`.
- [ ] Introduire `ksp-store-api` / `ksp-store-lib` avec PostgreSQL de référence.
- [ ] Établir les niveaux durables D1 Raw, D2 Core, D3 journal de matérialisation générique et D4 projections de domaine.
- [ ] Garantir des replays indépendants D1 -> D2, D2 -> D3 et D3 -> D4.
- [ ] Introduire `ksp-app-store-desk`.
- [ ] 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.3.1` Introduire `ksp-store-api` + `ksp-store-lib` avec PostgreSQL de référence et **modèles/persistence RAW uniquement**.
- [ ] `0.3.2` — Étendre `ksp-interface-lib` avec les wires génériques nécessaires aux acquisitions et à la future normalisation CORE.
- [ ] `0.3.3` — Introduire `ksp-job-api` et un job de backfill historique concret.
- [ ] `0.3.4` — Introduire une application spécialisée de backfill/inspection RAW.
- [ ] 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.
## 0.4.x — Baseline Solana, SPL et metadata
## Série CORE suivante
- [ ] Ajouter progressivement les decoders et `ProgramExecutionPreparer` Core/SPL nécessaires.
- [ ] Ajouter Token, Token-2022, ATA et metadata utiles.
- [ ] Introduire `ksp-offchain-transport-lib` au plus tard au premier besoin externe.
- [ ] Ajouter materializers et jobs ponctuels nécessaires.
- [ ] Ajouter les crates `ksp-scenario-<domain>-lib` spécialisées.
- [ ] 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é.
- [ ] Définir la persistence CORE canonique Solana générique.
- [ ] Implémenter `RAW -> CORE` sans decoder Program : blocs, slots, signatures, transactions/messages, comptes, instructions/CPI brutes, logs/meta et relations structurelles.
- [ ] Ajouter replay/backfill RAW -> CORE.
- [ ] Ajouter worker/service CORE.
- [ ] Ajouter l'application de contrôle/inspection CORE utile.
## 0.5.x — Anchor et protocoles trading
## Séries DECODE/SPECIALIZED/EXECUTION — progression verticale
- [ ] Introduire Anchor.
- [ ] É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.
### Priorité 1 — Solana Core Programs
## 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.
- [ ] 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.
### Priorité 2 — SPL token/trading
## 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.
- [ ] Features et datasets historiques.
- [ ] Signaux et risque.
### Priorité 3 — Metadata token
- [ ] 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.
- [ ] Patterns/anomalies.
- [ ] XGBoost puis autres modèles lorsque les contrats sont stables.
## 0.8.x et suivantes — Trading opérationnel et expansion produits
- [ ] 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.
- [ ] XGBoost puis autres modèles lorsque les données et 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.
- [ ] Étudier plus tard les autres applications Wallet : mobile, extensions navigateur et web.

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

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

View File

@@ -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",
"enabled": true,
@@ -70,6 +87,10 @@
{
"target_prefix": "ksp-app-config-desk",
"level": "info"
},
{
"target_prefix": "ksp-onchain-transport-lib",
"level": "info"
}
]
}

69
config/std.transport.json Normal file
View 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
}
}
]
}
]
}
]
}

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-app-config-desk/Cargo.toml
# version: 7
# version: 8
[package]
name = "ksp-app-config-desk"
@@ -26,12 +26,12 @@ fs2.workspace = true
ksp-config-lib = { path = "../ksp-config-lib" }
ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" }
serde.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
tauri.workspace = true
tauri-plugin-tracing.workspace = true
chrono.workspace = true
tokio.workspace = true
chrono = { workspace = true, features = ["std", "now"] }
tokio = { workspace = true, features = ["time"] }
ts-rs.workspace = true
[lints]

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/README.md -->
<!-- version: 23 -->
<!-- version: 24 -->
# `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 :
```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.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
<!-- version: 23 -->
<!-- version: 24 -->
# Utilisation de `ksp-app-config-desk`
@@ -66,7 +66,7 @@ npm run build
Vite construit les pages `main.html` et `splash.html` vers :
```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`.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
// version: 1
// version: 2
// Pulse 5.3.8
// Bootswatch

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
// version: 1
// version: 2
//@use '@fortawesome/fontawesome-free/scss/variables' with (
// // customizing $font-path - make sure it points to where your webfonts are stored in your project

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
// version: 1
// version: 2
/* Rtl support */
[data-simplebar] {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/frontend/sass/_variables.scss
// version: 1
// version: 2
// Pulse 5.3.8
// Bootswatch

View File

@@ -7,7 +7,7 @@
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1430",
"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": {
"windows": [

View File

@@ -1,20 +1,26 @@
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
// version: 2
// version: 3
#[test]
fn profile_inventory_exposes_logging_default_and_available_profiles() {
fn profile_inventory_exposes_registered_standard_profile_documents() {
let management = fixture_management();
assert!(management.is_ok(), "fixture management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let inventory = super::inventory_from_management(&management);
assert!(inventory.is_ok(), "profile inventory should resolve: {inventory:?}");
if let std::result::Result::Ok(inventory) = inventory {
assert_eq!(inventory.len(), 1);
assert_eq!(inventory[0].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_LOGGING;
}));
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;
}));
}
}
}
}

View File

@@ -8,7 +8,7 @@ import { defineConfig, normalizePath } from "vite";
const appRoot = fileURLToPath(new URL(".", import.meta.url));
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;
export default defineConfig({

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-config-lib/Cargo.toml
# version: 3
# version: 6
[package]
name = "ksp-config-lib"
@@ -10,9 +10,13 @@ repository.workspace = true
[dependencies]
ksp-core-lib = { path = "../ksp-core-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
jsonschema.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
[lints]
workspace = true

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/README.md -->
<!-- version: 3 -->
<!-- version: 5 -->
# ksp-config-lib
@@ -23,6 +23,7 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
- la classification `Public`, `Internal`, `Secret` ;
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
- l'adapter du document 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` ;
- 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`.
@@ -32,9 +33,11 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
Le registre par défaut connaît :
```text
cfg.std.logging -> config/std.logging.json
schema.std.logging -> config/schemas/std.logging.schema.json
schema.composite -> config/schemas/composite.schema.json
cfg.std.logging -> config/std.logging.json
cfg.std.transport -> config/std.transport.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.
@@ -59,15 +62,15 @@ Les autres crates et applications KSP ne doivent pas :
- parser ou écrire directement `.env` ;
- ouvrir directement les documents Config connus par leur filename physique ;
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
- reconstruire elles-mêmes la configuration Logging 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`.
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
@@ -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.
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
- [`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` ;
- [`../../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.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/TODO.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# 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.
## 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
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 ;
- 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 ;

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/USAGE.md -->
<!-- version: 4 -->
<!-- version: 5 -->
# Utilisation de ksp-config-lib
@@ -29,6 +29,7 @@ Les arguments compris par Config sont :
--cfgpath=/path/to/config
--schemapath=/path/to/schemas
--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.
@@ -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.
### 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
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, §78 |
| Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 |
| Logging effectif | `ResolvedLoggingConfig` | §4 |
| Transport effectif | `ResolvedTransportConfig` | §4.1 |
| Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §67 |
| 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 |
@@ -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`.

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

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/environment.rs
// version: 4
// version: 5
/// Default local environment file read by Config from the process launch directory.
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.
pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example";
const LOGGING_TARGET: &str = "ksp-config-lib";
const LOGGING_DOMAIN: &str = "config.environment";
/// 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) {
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 {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/lib.rs
// version: 10
// version: 12
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![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
//! 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
//! mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
//! provenance. Standard Logging and HTTP Transport documents map explicitly to their runtime settings contracts, while the management surface provides
//! typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
mod bootstrap;
mod composite;
mod constants;
mod document;
mod environment;
mod error;
@@ -22,6 +23,9 @@ mod persistence;
mod profile;
mod registry;
mod sensitivity;
mod transport;
pub(crate) use self::constants::TRACING_TARGET;
/// Bootstrap argument used to replace the configuration document root.
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;
/// Default physical filename for the standard Logging JSON Schema document.
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
/// Default physical filename for the standard HTTP 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.
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
/// Logical file identifier for the standard Logging JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
/// Logical file identifier for the standard Logging configuration document.
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.
pub use self::sensitivity::ConfigSensitivity;
/// Provenance segment participating in one resolved Config value.
@@ -157,3 +169,5 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
pub use self::sensitivity::ResolvedConfigJson;
/// One resolved Config string preserving real/safe representations and provenance.
pub use self::sensitivity::ResolvedConfigText;
/// Effective standard HTTP Transport configuration mapped to `ksp_onchain_transport_lib::HttpTransportSettings`.
pub use self::transport::ResolvedTransportConfig;

View File

@@ -1,5 +1,5 @@
// 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);
@@ -99,7 +99,7 @@ fn cleanup_temporary_file(path: &std::path::Path) {
&& error.kind() != std::io::ErrorKind::NotFound
{
ksp_logging_lib::warn!(
target: "ksp-config-lib",
target: crate::TRACING_TARGET,
domain = "config.persistence",
path = %path.to_string_lossy(),
error = %error,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/registry.rs
// version: 4
// version: 5
/// Bootstrap argument used to replace a known Config filename mapping.
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";
/// Logical file identifier for the standard Logging JSON Schema document.
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
/// Logical file identifier for the standard HTTP 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.
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
/// Default physical filename for the standard Logging configuration document.
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
/// Default physical filename for the standard Logging JSON Schema document.
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
/// Default physical filename for the standard HTTP 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.
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::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 =
ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None);
let composite_schema = match composite_schema {
std::result::Result::Ok(value) => value,
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.

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

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/ownership.rs
// version: 2
// version: 3
//! 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::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());
}
}

View File

@@ -1,8 +1,8 @@
// 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
//! management contracts.
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
//! Logging/Transport adapters and management contracts.
#[test]
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:?}");
if let std::result::Result::Ok(registry) = registry {
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].kind(), ksp_config_lib::ConfigFileKind::Config);
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
let schema_file_id = descriptors[0].schema_file_id();
assert!(schema_file_id.is_some(), "public descriptor inventory should preserve schema association");
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[1].kind(), ksp_config_lib::ConfigFileKind::Config);
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
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 {
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);
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");
}

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

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

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/registry.rs
// version: 4
// version: 5
#[test]
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:?}");
if let std::result::Result::Ok(registry) = registry {
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].kind(), super::ConfigFileKind::Config);
assert_eq!(descriptors[0].filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_FILENAME));
let schema_file_id = descriptors[0].schema_file_id();
assert!(schema_file_id.is_some(), "logging descriptor should expose its validation schema");
if let std::option::Option::Some(schema_file_id) = schema_file_id {
let logging_schema_file_id = descriptors[0].schema_file_id();
assert!(logging_schema_file_id.is_some(), "logging descriptor should expose its validation schema");
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!(descriptors[1].file_id().as_str(), super::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[1].kind(), super::ConfigFileKind::Schema);
assert_eq!(descriptors[2].file_id().as_str(), super::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[1].file_id().as_str(), super::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[1].kind(), super::ConfigFileKind::Config);
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[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]
fn resolve_path_uses_descriptor_kind_to_select_bootstrap_root() {
let registry = super::ConfigFileRegistry::defaults();

View 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("../..");
}

View 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\"] }"));
}

View 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());
}
}

View File

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

View 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

View File

@@ -0,0 +1,141 @@
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
<!-- version: 5 -->
# `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.
La surface typée stable après `0.2.2` contient **26 méthodes** :
```text
0.2.1 foundation : 4
0.2.2 Accounts : 5
0.2.2 Tokens : 5
0.2.2 Cluster : 12
```
Les quatre canaris foundation restent `getBalance`, `getGenesisHash`, `getHealth` et `getVersion`. `0.2.2` ajoute les 22 wrappers Accounts/Tokens/Cluster affectés à `HttpRpcCoverageRelease::V0_2_2`. Les 26 méthodes Transactions/Blocks/Economics encore affectées à `0.2.3``0.2.4` peuvent déjà passer par l'exécuteur JSON-RPC standard générique lorsqu'un consumer fournit explicitement descriptor et paramètres JSON, mais cette surface raw/générique **ne vaut pas couverture typée**.
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.
Deux smokes Devnet opt-in sont séparés par responsabilité :
```text
Transport pur : settings programmatiques -> HttpTransportPool
-> getAccountInfo/getTokenAccountsByOwner/getEpochInfo/getVoteAccounts
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
-> getHealth/getGenesisHash/getVersion/getBalance
```
Le smoke Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
Les deux sont `ignored` par défaut. Le smoke Transport appartient durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée.
## Documentation
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw, smokes et inspection runtime ;
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — foundation HTTP stable ;
- [`../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — extension typed Accounts/Tokens/Cluster ;
- [`../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée `0.2.2` ;
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP.

View File

@@ -0,0 +1,174 @@
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
<!-- version: 5 -->
# 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 quatre canaris `0.2.1` restent disponibles. `0.2.2` ajoute les wrappers typés Accounts, Tokens et Cluster. Exemples représentatifs :
```rust
let account = pool
.get_account_info(&role, &ksp_core_lib::PRGIDPK_SOLANA_SYSTEM, None)
.await;
let epoch = pool.get_epoch_info(&role, None).await;
let vote_accounts = pool.get_vote_accounts(&role, None).await;
```
La surface stable `0.2.2` contient 26 wrappers typés au total : 4 foundation + 5 Accounts + 5 Tokens + 12 Cluster. Les DTOs Transport conservent les `null`, options et formes wire : données Account encodées/`jsonParsed`, `TokenAmount.uiAmount`, contexte RPC, nodes, epoch, leader schedule et vote accounts. Aucun décodage Program/SPL métier n'est effectué ici.
## 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. Smokes Devnet opt-in
Le smoke **Transport pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif de `0.2.2` :
```bash
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Il appelle `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo` et `getVoteAccounts`. La branche Token suit la forme Devnet documentée : owner Pubkey ordinaire de l'exemple officiel, selector `programId` avec l'ID canonique du programme SPL Token, puis config explicite `commitment: finalized` + `encoding: jsonParsed`. Une réponse vide reste acceptable : le smoke valide ainsi la route Token sans dépendre de la persistance d'un mint ou d'un token account Devnet particulier.
Le smoke historique de **composition Config -> Transport** reste également disponible :
```bash
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
```
Il valide le profil committé `devnet_public` et les quatre canaris foundation. Il reste transitoirement hébergé dans Config : les futurs smokes cross-crates ne doivent pas faire de Config leur destination générale et devront migrer vers une surface d'intégration/orchestration dédiée lorsqu'elle existera.
Les endpoints publics Solana sont rate-limités et non destinés à la production. Un échec réseau externe n'est pas assimilé automatiquement à une régression locale ; les fixtures HTTP locales restent les gates reproductibles.

View File

@@ -0,0 +1,5 @@
[
{"lamports":1,"data":"3MN5","owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"space":null},
{"lamports":2,"data":["KLUv/Q==","base64+zstd"],"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":1,"space":4},
{"lamports":3,"data":{"program":"spl-token","parsed":{"type":"account","info":{"state":"initialized"}},"space":165},"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":2,"space":165}
]

View File

@@ -0,0 +1 @@
{"pubkey":"11111111111111111111111111111111","featureSet":123,"gossip":"127.0.0.1:8001","pubsub":null,"rpc":"127.0.0.1:8899","serveRepair":"127.0.0.1:8003","shredVersion":456,"tpu":"127.0.0.1:8004","tpuForwards":null,"tpuForwardsQuic":"127.0.0.1:8006","tpuQuic":"127.0.0.1:8005","tpuVote":null,"tvu":"127.0.0.1:8002","version":"4.2.1","clientId":"Agave"}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000003},"value":{"data":["","base64"],"executable":false,"lamports":1,"owner":"not-a-pubkey","rentEpoch":0,"space":0}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000002},"value":null},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000001},"value":{"data":["AQIDBA==","base64"],"executable":false,"lamports":2039280,"owner":"11111111111111111111111111111111","rentEpoch":18446744073709551615,"space":4}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"3.1.8","slot":123456789},"value":424242},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"pubkey":"not-a-pubkey","rpc":"127.0.0.1:8899"}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"pubkey":"11111111111111111111111111111111","featureSet":3073396398,"gossip":"127.0.0.1:8001","pubsub":null,"rpc":"127.0.0.1:8899","serveRepair":"127.0.0.1:8004","shredVersion":50093,"tpu":"127.0.0.1:8003","tpuForwards":"127.0.0.1:8004","tpuForwardsQuic":"127.0.0.1:8006","tpuQuic":"127.0.0.1:8009","tpuVote":"127.0.0.1:8005","tvu":"127.0.0.1:8000","version":"4.2.1","clientId":"Agave"},{"pubkey":"Stake11111111111111111111111111111111111111"}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"absoluteSlot":430000001,"blockHeight":429900000,"epoch":995,"slotIndex":12345,"slotsInEpoch":432000,"transactionCount":null},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"firstNormalEpoch":0,"firstNormalSlot":0,"leaderScheduleSlotOffset":432000,"slotsPerEpoch":432000,"warmup":false},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC","id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"ok","id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32008,"message":"No snapshot"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"full":429990000,"incremental":null},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"identity":"invalid-identity"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"identity":"ComputeBudget111111111111111111111111111111"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000005},"value":[{"address":"invalid-address","lamports":1}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000004},"value":[{"address":"11111111111111111111111111111111","lamports":999999999},{"address":"ComputeBudget111111111111111111111111111111","lamports":888888888}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":null,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"11111111111111111111111111111111":[0,2,4],"ComputeBudget111111111111111111111111111111":[1,3]} ,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":430000010,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":430000011,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":890880,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000006},"value":[{"data":["","base64"],"executable":false,"lamports":10,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":0},null,{"data":{"program":"system","parsed":{"type":"nonce"},"space":80},"executable":false,"lamports":20,"owner":"11111111111111111111111111111111","rentEpoch":1,"space":80}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000007},"value":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":430000020,"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"not-a-pubkey","id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"ComputeBudget111111111111111111111111111111","id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":["11111111111111111111111111111111","not-a-pubkey"],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":["11111111111111111111111111111111","ComputeBudget111111111111111111111111111111"],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param: could not find account"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000001},"value":{"amount":"18446744073709551615","decimals":9,"uiAmount":null,"uiAmountString":"18446744073.709551615"}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000002},"value":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":{"program":"spl-token","parsed":{"type":"account","info":{"tokenAmount":{"amount":"1","decimals":1,"uiAmount":0.1,"uiAmountString":"0.1"}}},"space":165},"executable":false,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":9,"space":165}}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"slot":420000004},"value":[{"pubkey":"not-a-pubkey","account":{"data":["","base64"],"executable":false,"lamports":1,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":0,"space":165}}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":null,"slot":420000003},"value":[{"pubkey":"Stake11111111111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":10,"space":165}}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"slot":420000006},"value":[{"address":"invalid-address","amount":"1","decimals":0,"uiAmount":1.0,"uiAmountString":"1"}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000005},"value":[{"address":"11111111111111111111111111111111","amount":"9000","decimals":2,"uiAmount":90.0,"uiAmountString":"90"},{"address":"ComputeBudget111111111111111111111111111111","amount":"8000","decimals":2,"uiAmount":80.0,"uiAmountString":"80"}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000007},"value":{"amount":"1000000000000000000000000","decimals":6,"uiAmount":1000000000000000000.0,"uiAmountString":"1000000000000000000"}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"solana-core":"3.1.8","feature-set":2891131721},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"not-a-pubkey","nodePubkey":"11111111111111111111111111111111","activatedStake":1,"commission":5,"epochVoteAccount":true,"epochCredits":[],"lastVote":2,"rootSlot":1}],"delinquent":[]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"11111111111111111111111111111111","nodePubkey":"ComputeBudget111111111111111111111111111111","activatedStake":424242,"commission":8,"inflationRewardsCommissionBps":750,"epochVoteAccount":true,"epochCredits":[[700,100,90],[701,115,100]],"lastVote":999,"rootSlot":990}],"delinquent":[{"votePubkey":"ComputeBudget111111111111111111111111111111","nodePubkey":"11111111111111111111111111111111","activatedStake":1,"commission":5,"epochVoteAccount":false,"epochCredits":[],"lastVote":0,"rootSlot":0}]},"id":1}

View File

@@ -0,0 +1 @@
{"amount":"18446744073709551615","decimals":9,"uiAmount":null,"uiAmountString":"18446744073.709551615"}

View File

@@ -0,0 +1 @@
{"votePubkey":"11111111111111111111111111111111","nodePubkey":"11111111111111111111111111111111","activatedStake":424242,"commission":8,"inflationRewardsCommissionBps":750,"epochVoteAccount":true,"epochCredits":[[700,100,90],[701,115,100]],"lastVote":999,"rootSlot":990}

View 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(&notify))));
}
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;

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

View File

@@ -0,0 +1,29 @@
// file: crates/ksp-onchain-transport-lib/src/error.rs
// version: 2
/// 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");
/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract.
pub const ERROR_CODE_INVALID_RPC_PARAMETERS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_rpc_parameters");

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

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

View File

@@ -0,0 +1,219 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 11
#![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 four typed Solana HTTP foundation canaries plus all 22 typed `0.2.2` Accounts, Tokens and Cluster wrappers execute real JSON-RPC
//! requests through the shared transport path while the `0.2.3` and `0.2.4` audited families remain staged.
mod client;
mod constants;
mod error;
mod executor;
mod json_rpc;
mod pool;
mod resilience;
mod rpc_accounts;
mod rpc_canary;
mod rpc_cluster;
mod rpc_common;
mod rpc_method;
mod rpc_tokens;
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 typed Solana RPC parameters violate a locally enforceable method contract.
pub use self::error::ERROR_CODE_INVALID_RPC_PARAMETERS;
/// 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;
/// Typed transport-level Solana account without Program/SPL decoding.
pub use self::rpc_accounts::SolanaAccount;
/// Address and lamport balance returned by `getLargestAccounts`.
pub use self::rpc_accounts::SolanaAccountBalance;
/// Wire-preserving account data returned by Solana HTTP account methods.
pub use self::rpc_accounts::SolanaAccountData;
/// Account-data encoding accepted by Solana HTTP account methods.
pub use self::rpc_accounts::SolanaAccountEncoding;
/// Shared account configuration used by account-info and token-account list methods.
pub use self::rpc_accounts::SolanaAccountInfoConfig;
/// Byte range requested from account data without decoding it locally.
pub use self::rpc_accounts::SolanaDataSliceConfig;
/// One public key plus its account returned by account-list RPC methods.
pub use self::rpc_accounts::SolanaKeyedAccount;
/// Optional configuration for `getLargestAccounts`.
pub use self::rpc_accounts::SolanaLargestAccountsConfig;
/// Filter accepted by `getLargestAccounts`.
pub use self::rpc_accounts::SolanaLargestAccountsFilter;
/// Bytes used by a `memcmp` program-account filter.
pub use self::rpc_accounts::SolanaMemcmpBytes;
/// One `memcmp` filter applied to account data.
pub use self::rpc_accounts::SolanaMemcmpFilter;
/// Parsed account payload returned by the RPC node for `jsonParsed` account data.
pub use self::rpc_accounts::SolanaParsedAccountData;
/// Filter accepted by the current `getProgramAccounts` implementation.
pub use self::rpc_accounts::SolanaProgramAccountFilter;
/// Configuration for `getProgramAccounts`.
pub use self::rpc_accounts::SolanaProgramAccountsConfig;
/// Result union returned by `getProgramAccounts` with or without an RPC context.
pub use self::rpc_accounts::SolanaProgramAccountsResult;
/// 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;
/// 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;
/// Contact information returned for one cluster node.
pub use self::rpc_cluster::SolanaClusterNode;
/// Epoch-credit history entry returned by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaEpochCredits;
/// Epoch information returned by `getEpochInfo`.
pub use self::rpc_cluster::SolanaEpochInfo;
/// Epoch schedule returned by `getEpochSchedule`.
pub use self::rpc_cluster::SolanaEpochSchedule;
/// Leader schedule mapping validator identities to relative epoch slot indices.
pub use self::rpc_cluster::SolanaLeaderSchedule;
/// Optional configuration accepted by `getLeaderSchedule`.
pub use self::rpc_cluster::SolanaLeaderScheduleConfig;
/// Typed parameter overload for `getLeaderSchedule`.
pub use self::rpc_cluster::SolanaLeaderScheduleRequest;
/// Highest full and optional incremental snapshot slots returned by `getHighestSnapshotSlot`.
pub use self::rpc_cluster::SolanaSnapshotSlotInfo;
/// One validator vote-account record returned by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountInfo;
/// Current and delinquent validator vote-account groups returned by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountStatus;
/// Configuration accepted by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountsConfig;
/// Commitment level accepted by typed Solana HTTP RPC adapters.
pub use self::rpc_common::SolanaCommitment;
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
pub use self::rpc_common::SolanaCommitmentConfig;
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
pub use self::rpc_common::SolanaContextConfig;
/// Typed Solana RPC context shared by contextual HTTP responses.
pub use self::rpc_common::SolanaRpcContext;
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
pub use self::rpc_common::SolanaRpcResponse;
/// Decodes one private serde wire type into the shared Transport error domain for typed RPC adapters.
pub(crate) use self::rpc_common::decode_wire_json;
/// Parses a base58 public key without echoing its wire value into diagnostics for typed RPC adapters.
pub(crate) use self::rpc_common::parse_wire_pubkey;
/// 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;
/// Token-account balance entry returned by `getTokenLargestAccounts`.
pub use self::rpc_tokens::SolanaTokenAccountBalance;
/// Exclusive selector accepted by token-account list RPC methods.
pub use self::rpc_tokens::SolanaTokenAccountSelector;
/// Token amount returned by Solana HTTP token RPC methods.
pub use self::rpc_tokens::SolanaTokenAmount;
/// 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;

View 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(&notify));
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;

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

View File

@@ -0,0 +1,991 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
// version: 4
/// Account-data encoding accepted by Solana HTTP account methods.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaAccountEncoding {
/// Legacy binary/base58 request encoding.
Binary,
/// Base58 text encoding.
Base58,
/// Base64 text encoding.
Base64,
/// Parsed JSON representation when the RPC node has a parser for the account owner.
JsonParsed,
/// Base64 text containing zstd-compressed bytes.
Base64Zstd,
}
impl SolanaAccountEncoding {
/// Returns the Solana JSON-RPC encoding string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Binary => "binary",
Self::Base58 => "base58",
Self::Base64 => "base64",
Self::JsonParsed => "jsonParsed",
Self::Base64Zstd => "base64+zstd",
};
}
fn from_wire(value: &str) -> std::option::Option<Self> {
return match value {
"binary" => std::option::Option::Some(Self::Binary),
"base58" => std::option::Option::Some(Self::Base58),
"base64" => std::option::Option::Some(Self::Base64),
"jsonParsed" => std::option::Option::Some(Self::JsonParsed),
"base64+zstd" => std::option::Option::Some(Self::Base64Zstd),
_ => std::option::Option::None,
};
}
}
/// Byte range requested from account data without decoding it locally.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SolanaDataSliceConfig {
offset: usize,
length: usize,
}
impl SolanaDataSliceConfig {
/// Creates an account-data slice configuration.
#[must_use]
pub const fn new(offset: usize, length: usize) -> Self {
return Self { offset, length };
}
/// Returns the byte offset.
#[must_use]
pub const fn offset(&self) -> usize {
return self.offset;
}
/// Returns the requested byte length.
#[must_use]
pub const fn length(&self) -> usize {
return self.length;
}
fn to_json_value(self) -> serde_json::Value {
return serde_json::json!({"offset": self.offset, "length": self.length});
}
}
/// Shared account configuration used by account-info and token-account list methods.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaAccountInfoConfig {
encoding: std::option::Option<crate::SolanaAccountEncoding>,
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
context: crate::SolanaContextConfig,
}
impl SolanaAccountInfoConfig {
/// Creates an explicit account-info configuration.
#[must_use]
pub const fn new(
encoding: std::option::Option<crate::SolanaAccountEncoding>,
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
commitment: std::option::Option<crate::SolanaCommitment>,
min_context_slot: std::option::Option<u64>,
) -> Self {
return Self { encoding, data_slice, context: crate::SolanaContextConfig::new(commitment, min_context_slot) };
}
/// Returns the optional account-data encoding.
#[must_use]
pub const fn encoding(&self) -> std::option::Option<crate::SolanaAccountEncoding> {
return self.encoding;
}
/// Returns the optional account-data slice.
#[must_use]
pub const fn data_slice(&self) -> std::option::Option<crate::SolanaDataSliceConfig> {
return self.data_slice;
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.context.commitment();
}
/// Returns the optional minimum context slot.
#[must_use]
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
return self.context.min_context_slot();
}
pub(crate) fn is_empty(&self) -> bool {
return self.encoding.is_none() && self.data_slice.is_none() && self.commitment().is_none() && self.min_context_slot().is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let context_value = self.context.to_json_value();
let mut object = match context_value {
serde_json::Value::Object(object) => object,
_ => serde_json::Map::new(),
};
if let std::option::Option::Some(encoding) = self.encoding {
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
}
if let std::option::Option::Some(data_slice) = self.data_slice {
object.insert("dataSlice".to_owned(), data_slice.to_json_value());
}
return serde_json::Value::Object(object);
}
}
/// Filter accepted by `getLargestAccounts`.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaLargestAccountsFilter {
/// Return only circulating accounts.
Circulating,
/// Return only non-circulating accounts.
NonCirculating,
}
impl SolanaLargestAccountsFilter {
fn as_str(self) -> &'static str {
return match self {
Self::Circulating => "circulating",
Self::NonCirculating => "nonCirculating",
};
}
}
/// Optional configuration for `getLargestAccounts`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaLargestAccountsConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
filter: std::option::Option<crate::SolanaLargestAccountsFilter>,
sort_results: std::option::Option<bool>,
}
impl SolanaLargestAccountsConfig {
/// Creates a largest-accounts configuration.
#[must_use]
pub const fn new(
commitment: std::option::Option<crate::SolanaCommitment>,
filter: std::option::Option<crate::SolanaLargestAccountsFilter>,
sort_results: std::option::Option<bool>,
) -> Self {
return Self { commitment, filter, sort_results };
}
/// Returns the optional commitment.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional circulating-account filter.
#[must_use]
pub const fn filter(&self) -> std::option::Option<crate::SolanaLargestAccountsFilter> {
return self.filter;
}
/// Returns the optional server-side result-sorting request.
#[must_use]
pub const fn sort_results(&self) -> std::option::Option<bool> {
return self.sort_results;
}
fn is_empty(&self) -> bool {
return self.commitment.is_none() && self.filter.is_none() && self.sort_results.is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) 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(filter) = self.filter {
object.insert("filter".to_owned(), serde_json::Value::String(filter.as_str().to_owned()));
}
if let std::option::Option::Some(sort_results) = self.sort_results {
object.insert("sortResults".to_owned(), serde_json::Value::Bool(sort_results));
}
return serde_json::Value::Object(object);
}
}
/// Bytes used by a `memcmp` program-account filter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaMemcmpBytes {
/// Base58-encoded bytes.
Base58(std::string::String),
/// Base64-encoded bytes.
Base64(std::string::String),
/// Raw byte array.
Bytes(std::vec::Vec<u8>),
}
/// One `memcmp` filter applied to account data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaMemcmpFilter {
offset: usize,
bytes: crate::SolanaMemcmpBytes,
}
impl SolanaMemcmpFilter {
/// Creates a `memcmp` filter without locally decoding encoded string data.
#[must_use]
pub fn new(offset: usize, bytes: crate::SolanaMemcmpBytes) -> Self {
return Self { offset, bytes };
}
/// Returns the account-data byte offset.
#[must_use]
pub const fn offset(&self) -> usize {
return self.offset;
}
/// Returns the encoded or raw bytes.
#[must_use]
pub const fn bytes(&self) -> &crate::SolanaMemcmpBytes {
return &self.bytes;
}
fn to_json_value(&self) -> serde_json::Value {
return match &self.bytes {
crate::SolanaMemcmpBytes::Base58(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base58"}),
crate::SolanaMemcmpBytes::Base64(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base64"}),
crate::SolanaMemcmpBytes::Bytes(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "bytes"}),
};
}
}
/// Filter accepted by the current `getProgramAccounts` implementation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaProgramAccountFilter {
/// Require an exact account data size.
DataSize(u64),
/// Compare bytes at one account-data offset.
Memcmp(crate::SolanaMemcmpFilter),
/// Require a valid SPL Token account-state layout according to the RPC implementation.
TokenAccountState,
}
impl SolanaProgramAccountFilter {
fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::DataSize(size) => serde_json::json!({"dataSize": size}),
Self::Memcmp(filter) => serde_json::json!({"memcmp": filter.to_json_value()}),
Self::TokenAccountState => serde_json::Value::String("tokenAccountState".to_owned()),
};
}
}
/// Configuration for `getProgramAccounts` built from the shared account config plus program filters.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaProgramAccountsConfig {
account_config: crate::SolanaAccountInfoConfig,
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
with_context: std::option::Option<bool>,
sort_results: std::option::Option<bool>,
}
impl SolanaProgramAccountsConfig {
/// Creates a program-accounts configuration.
#[must_use]
pub fn new(
account_config: crate::SolanaAccountInfoConfig,
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
with_context: std::option::Option<bool>,
sort_results: std::option::Option<bool>,
) -> Self {
return Self { account_config, filters, with_context, sort_results };
}
/// Returns the shared account configuration.
#[must_use]
pub const fn account_config(&self) -> &crate::SolanaAccountInfoConfig {
return &self.account_config;
}
/// Returns the ordered program-account filters.
#[must_use]
pub fn filters(&self) -> &[crate::SolanaProgramAccountFilter] {
return self.filters.as_slice();
}
/// Returns the optional context-wrapper request.
#[must_use]
pub const fn with_context(&self) -> std::option::Option<bool> {
return self.with_context;
}
/// Returns the optional server-side sorting request.
#[must_use]
pub const fn sort_results(&self) -> std::option::Option<bool> {
return self.sort_results;
}
fn is_empty(&self) -> bool {
return self.account_config.is_empty() && self.filters.is_empty() && self.with_context.is_none() && self.sort_results.is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let account_value = self.account_config.to_json_value();
let mut object = match account_value {
serde_json::Value::Object(object) => object,
_ => serde_json::Map::new(),
};
if !self.filters.is_empty() {
let values = self.filters.iter().map(crate::SolanaProgramAccountFilter::to_json_value).collect::<std::vec::Vec<_>>();
object.insert("filters".to_owned(), serde_json::Value::Array(values));
}
if let std::option::Option::Some(with_context) = self.with_context {
object.insert("withContext".to_owned(), serde_json::Value::Bool(with_context));
}
if let std::option::Option::Some(sort_results) = self.sort_results {
object.insert("sortResults".to_owned(), serde_json::Value::Bool(sort_results));
}
return serde_json::Value::Object(object);
}
}
/// Parsed account payload returned by the RPC node for `jsonParsed` account data.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaParsedAccountData {
program: std::string::String,
parsed: serde_json::Value,
space: u64,
}
impl SolanaParsedAccountData {
/// Returns the parser/program label reported by the RPC node.
#[must_use]
pub fn program(&self) -> &str {
return self.program.as_str();
}
/// Returns the parsed JSON payload without converting it to a Program/SPL domain model.
#[must_use]
pub const fn parsed(&self) -> &serde_json::Value {
return &self.parsed;
}
/// Returns the account-data space reported inside the parsed payload.
#[must_use]
pub const fn space(&self) -> u64 {
return self.space;
}
}
/// Wire-preserving account data returned by Solana HTTP account methods.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaAccountData {
/// Legacy single-string binary form retained for backwards compatibility.
LegacyBinary(std::string::String),
/// Encoded tuple `[data, encoding]`.
Encoded {
/// Encoded account bytes.
data: std::string::String,
/// Encoding label returned by the RPC node.
encoding: crate::SolanaAccountEncoding,
},
/// Parsed JSON object returned by the RPC node.
JsonParsed(crate::SolanaParsedAccountData),
}
impl SolanaAccountData {
fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccountData>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return match wire {
WireAccountData::LegacyBinary(value) => std::result::Result::Ok(Self::LegacyBinary(value)),
WireAccountData::JsonParsed(value) => {
std::result::Result::Ok(Self::JsonParsed(crate::SolanaParsedAccountData { program: value.program, parsed: value.parsed, space: value.space }))
},
WireAccountData::Encoded((data, encoding)) => {
let parsed = crate::SolanaAccountEncoding::from_wire(encoding.as_str());
let encoding = match parsed {
std::option::Option::Some(encoding) => encoding,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "account data tuple uses an unknown encoding")
.with_context("rpc_method", method),
);
},
};
if encoding == crate::SolanaAccountEncoding::Binary || encoding == crate::SolanaAccountEncoding::JsonParsed {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "account data tuple uses an invalid tuple encoding")
.with_context("rpc_method", method),
);
}
std::result::Result::Ok(Self::Encoded { data, encoding })
},
};
}
}
/// Typed transport-level Solana account without Program/SPL decoding.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaAccount {
lamports: u64,
data: crate::SolanaAccountData,
owner: ksp_core_lib::Pubkey,
executable: bool,
rent_epoch: u64,
space: std::option::Option<u64>,
}
impl SolanaAccount {
/// Returns the account balance in lamports.
#[must_use]
pub const fn lamports(&self) -> u64 {
return self.lamports;
}
/// Returns the wire-preserving account data.
#[must_use]
pub const fn data(&self) -> &crate::SolanaAccountData {
return &self.data;
}
/// Returns the account owner program public key.
#[must_use]
pub const fn owner(&self) -> &ksp_core_lib::Pubkey {
return &self.owner;
}
/// Returns whether the account is executable.
#[must_use]
pub const fn executable(&self) -> bool {
return self.executable;
}
/// Returns the rent epoch reported by the RPC node.
#[must_use]
pub const fn rent_epoch(&self) -> u64 {
return self.rent_epoch;
}
/// Returns the optional account data-space field.
#[must_use]
pub const fn space(&self) -> std::option::Option<u64> {
return self.space;
}
/// Decodes one account DTO from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccount>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let owner = crate::parse_wire_pubkey(method, "owner", wire.owner.as_str());
let owner = match owner {
std::result::Result::Ok(owner) => owner,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let data = crate::SolanaAccountData::decode_wire(method, wire.data);
let data = match data {
std::result::Result::Ok(data) => data,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self {
lamports: wire.lamports,
data,
owner,
executable: wire.executable,
rent_epoch: wire.rent_epoch,
space: wire.space,
});
}
}
/// One public key plus its account returned by account-list RPC methods.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaKeyedAccount {
pubkey: ksp_core_lib::Pubkey,
account: crate::SolanaAccount,
}
impl SolanaKeyedAccount {
/// Returns the account public key.
#[must_use]
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.pubkey;
}
/// Returns the account payload.
#[must_use]
pub const fn account(&self) -> &crate::SolanaAccount {
return &self.account;
}
/// Decodes one keyed account from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireKeyedAccount>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pubkey = crate::parse_wire_pubkey(method, "pubkey", wire.pubkey.as_str());
let pubkey = match pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let account = crate::SolanaAccount::decode_wire(method, wire.account);
let account = match account {
std::result::Result::Ok(account) => account,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { pubkey, account });
}
}
/// Address and lamport balance returned by `getLargestAccounts`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaAccountBalance {
address: ksp_core_lib::Pubkey,
lamports: u64,
}
impl SolanaAccountBalance {
/// Returns the account address.
#[must_use]
pub const fn address(&self) -> &ksp_core_lib::Pubkey {
return &self.address;
}
/// Returns the balance in lamports.
#[must_use]
pub const fn lamports(&self) -> u64 {
return self.lamports;
}
/// Decodes one account-balance entry from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccountBalance>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let address = crate::parse_wire_pubkey(method, "address", wire.address.as_str());
return match address {
std::result::Result::Ok(address) => std::result::Result::Ok(Self { address, lamports: wire.lamports }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Result union returned by `getProgramAccounts` with or without an RPC context.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaProgramAccountsResult {
/// Bare account list returned when `withContext` is false or absent.
Accounts(std::vec::Vec<crate::SolanaKeyedAccount>),
/// Contextual account list returned when `withContext` is true.
Context(crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>),
}
const MAX_MULTIPLE_ACCOUNTS: usize = 100;
const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4;
const MAX_MEMCMP_BYTES: usize = 128;
impl crate::HttpTransportPool {
/// Executes typed `getAccountInfo` through the common KSP HTTP transport path.
pub async fn get_account_info(
&self,
role: &crate::HttpRoleName,
account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::option::Option<crate::SolanaAccount>>> {
let method_result = account_descriptor("getAccountInfo");
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),
};
return decode_account_info_response("getAccountInfo", value);
}
/// Executes typed `getLargestAccounts` through the common KSP HTTP transport path.
pub async fn get_largest_accounts(
&self,
role: &crate::HttpRoleName,
config: std::option::Option<&crate::SolanaLargestAccountsConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaAccountBalance>>> {
let method_result = account_descriptor("getLargestAccounts");
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::Vec::new();
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),
};
return decode_largest_accounts_response("getLargestAccounts", value);
}
/// Executes typed `getMinimumBalanceForRentExemption` through the common KSP HTTP transport path.
pub async fn get_minimum_balance_for_rent_exemption(
&self,
role: &crate::HttpRoleName,
data_len: usize,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<u64> {
let method_result = account_descriptor("getMinimumBalanceForRentExemption");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let data_len_value = serde_json::to_value(data_len);
let data_len_value = match data_len_value {
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_ENCODE_FAILED, "rent-exemption data length could not be encoded")
.with_context("rpc_method", "getMinimumBalanceForRentExemption")
.with_source(error),
);
},
};
let mut params = std::vec![data_len_value];
if let std::option::Option::Some(config) = config
&& config.commitment().is_some()
{
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 decoded = crate::decode_wire_json::<u64>("getMinimumBalanceForRentExemption", value);
return match decoded {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getMultipleAccounts` through the common KSP HTTP transport path.
pub async fn get_multiple_accounts(
&self,
role: &crate::HttpRoleName,
accounts: &[ksp_core_lib::Pubkey],
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<std::option::Option<crate::SolanaAccount>>>> {
if accounts.len() > MAX_MULTIPLE_ACCOUNTS {
return invalid_account_parameters("getMultipleAccounts", "getMultipleAccounts accepts at most 100 public keys", "account_count", accounts.len());
}
let method_result = account_descriptor("getMultipleAccounts");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let addresses = accounts.iter().map(std::string::ToString::to_string).map(serde_json::Value::String).collect::<std::vec::Vec<_>>();
let mut params = std::vec![serde_json::Value::Array(addresses)];
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),
};
return decode_multiple_accounts_response("getMultipleAccounts", value, accounts.len());
}
/// Executes typed `getProgramAccounts` through the common KSP HTTP transport path.
pub async fn get_program_accounts(
&self,
role: &crate::HttpRoleName,
program_id: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaProgramAccountsConfig>,
) -> ksp_core_lib::Result<crate::SolanaProgramAccountsResult> {
if let std::option::Option::Some(config) = config {
let validation = validate_program_account_filters(config.filters());
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
}
let method_result = account_descriptor("getProgramAccounts");
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(program_id.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),
};
return decode_program_accounts_response("getProgramAccounts", value);
}
}
fn account_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)
if descriptor.category() == crate::HttpRpcCategory::Accounts && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
{
std::result::Result::Ok(descriptor)
},
_ => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Accounts descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
fn invalid_account_parameters<T>(method: &str, message: &str, field: &'static str, value: usize) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message)
.with_context("rpc_method", method)
.with_context(field, value.to_string()),
);
}
fn validate_program_account_filters(filters: &[crate::SolanaProgramAccountFilter]) -> ksp_core_lib::Result<()> {
if filters.len() > MAX_PROGRAM_ACCOUNT_FILTERS {
return invalid_account_parameters(
"getProgramAccounts",
"getProgramAccounts accepts at most 4 filters on the targeted Agave runtime",
"filter_count",
filters.len(),
);
}
for filter in filters {
if let crate::SolanaProgramAccountFilter::Memcmp(memcmp) = filter
&& let crate::SolanaMemcmpBytes::Bytes(bytes) = memcmp.bytes()
&& bytes.len() > MAX_MEMCMP_BYTES
{
return invalid_account_parameters(
"getProgramAccounts",
"raw getProgramAccounts memcmp data accepts at most 128 bytes",
"memcmp_byte_count",
bytes.len(),
);
}
}
return std::result::Result::Ok(());
}
fn decode_account_info_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::option::Option<crate::SolanaAccount>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::option::Option<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let account = match wire.value {
std::option::Option::Some(value) => {
let account = crate::SolanaAccount::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => std::option::Option::Some(account),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
std::option::Option::None => std::option::Option::None,
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, account));
}
fn decode_largest_accounts_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaAccountBalance>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut values = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
let decoded = crate::SolanaAccountBalance::decode_wire(method, value);
match decoded {
std::result::Result::Ok(decoded) => values.push(decoded),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values));
}
fn decode_multiple_accounts_response(
method: &str,
value: serde_json::Value,
expected_count: usize,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<std::option::Option<crate::SolanaAccount>>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<std::option::Option<serde_json::Value>>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut values = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
match value {
std::option::Option::Some(value) => {
let decoded = crate::SolanaAccount::decode_wire(method, value);
match decoded {
std::result::Result::Ok(decoded) => values.push(std::option::Option::Some(decoded)),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
std::option::Option::None => values.push(std::option::Option::None),
}
}
if values.len() != expected_count {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "getMultipleAccounts result count does not match the requested account count")
.with_context("rpc_method", method)
.with_context("expected_count", expected_count.to_string())
.with_context("actual_count", values.len().to_string()),
);
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values));
}
fn decode_program_accounts_response(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaProgramAccountsResult> {
let decoded = crate::decode_wire_json::<WireProgramAccountsResult>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return match wire {
WireProgramAccountsResult::Accounts(values) => {
let decoded = decode_keyed_accounts(method, values);
match decoded {
std::result::Result::Ok(values) => std::result::Result::Ok(crate::SolanaProgramAccountsResult::Accounts(values)),
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
WireProgramAccountsResult::Context(wire) => {
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let values = decode_keyed_accounts(method, wire.value);
match values {
std::result::Result::Ok(values) => {
std::result::Result::Ok(crate::SolanaProgramAccountsResult::Context(crate::SolanaRpcResponse::new(context, values)))
},
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
};
}
fn decode_keyed_accounts(method: &str, values: std::vec::Vec<serde_json::Value>) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaKeyedAccount>> {
let mut decoded_values = std::vec::Vec::with_capacity(values.len());
for value in values {
let decoded = crate::SolanaKeyedAccount::decode_wire(method, value);
match decoded {
std::result::Result::Ok(decoded) => decoded_values.push(decoded),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(decoded_values);
}
#[derive(serde::Deserialize)]
struct WireRpcResponse<T> {
context: serde_json::Value,
value: T,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireProgramAccountsResult {
Context(WireRpcResponse<std::vec::Vec<serde_json::Value>>),
Accounts(std::vec::Vec<serde_json::Value>),
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireAccountData {
LegacyBinary(std::string::String),
JsonParsed(WireParsedAccountData),
Encoded((std::string::String, std::string::String)),
}
#[derive(serde::Deserialize)]
struct WireParsedAccountData {
program: std::string::String,
parsed: serde_json::Value,
space: u64,
}
#[derive(serde::Deserialize)]
struct WireAccount {
lamports: u64,
data: serde_json::Value,
owner: std::string::String,
executable: bool,
#[serde(rename = "rentEpoch")]
rent_epoch: u64,
#[serde(default)]
space: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
struct WireKeyedAccount {
pubkey: std::string::String,
account: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct WireAccountBalance {
address: std::string::String,
lamports: u64,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_accounts.rs"]
mod tests;

View File

@@ -0,0 +1,242 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_canary.rs
// version: 2
/// Optional typed configuration for `getBalance` retained for the `0.2.1` public canary contract.
#[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 {
return crate::SolanaContextConfig::new(self.commitment, self.min_context_slot).to_json_value();
}
}
/// 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 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),
};
let context = crate::SolanaRpcContext::decode_wire("getBalance", decoded.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::GetBalanceResult { context, 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 WireBalanceResult {
context: serde_json::Value,
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;

View File

@@ -0,0 +1,943 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
// version: 4
const MAX_GET_SLOT_LEADERS: u64 = 5_000;
/// Contact information returned for one cluster node.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaClusterNode {
pubkey: ksp_core_lib::Pubkey,
feature_set: std::option::Option<u32>,
gossip: std::option::Option<std::string::String>,
pubsub: std::option::Option<std::string::String>,
rpc: std::option::Option<std::string::String>,
serve_repair: std::option::Option<std::string::String>,
shred_version: std::option::Option<u16>,
tpu: std::option::Option<std::string::String>,
tpu_forwards: std::option::Option<std::string::String>,
tpu_forwards_quic: std::option::Option<std::string::String>,
tpu_quic: std::option::Option<std::string::String>,
tpu_vote: std::option::Option<std::string::String>,
tvu: std::option::Option<std::string::String>,
version: std::option::Option<std::string::String>,
client_id: std::option::Option<std::string::String>,
}
impl SolanaClusterNode {
/// Returns the node identity public key.
#[must_use]
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.pubkey;
}
/// Returns the optional feature-set identifier.
#[must_use]
pub const fn feature_set(&self) -> std::option::Option<u32> {
return self.feature_set;
}
/// Returns the optional gossip endpoint.
#[must_use]
pub fn gossip(&self) -> std::option::Option<&str> {
return self.gossip.as_deref();
}
/// Returns the optional PubSub endpoint.
#[must_use]
pub fn pubsub(&self) -> std::option::Option<&str> {
return self.pubsub.as_deref();
}
/// Returns the optional JSON-RPC endpoint.
#[must_use]
pub fn rpc(&self) -> std::option::Option<&str> {
return self.rpc.as_deref();
}
/// Returns the optional repair endpoint.
#[must_use]
pub fn serve_repair(&self) -> std::option::Option<&str> {
return self.serve_repair.as_deref();
}
/// Returns the optional shred version.
#[must_use]
pub const fn shred_version(&self) -> std::option::Option<u16> {
return self.shred_version;
}
/// Returns the optional TPU endpoint.
#[must_use]
pub fn tpu(&self) -> std::option::Option<&str> {
return self.tpu.as_deref();
}
/// Returns the optional TPU forwards endpoint.
#[must_use]
pub fn tpu_forwards(&self) -> std::option::Option<&str> {
return self.tpu_forwards.as_deref();
}
/// Returns the optional TPU forwards QUIC endpoint.
#[must_use]
pub fn tpu_forwards_quic(&self) -> std::option::Option<&str> {
return self.tpu_forwards_quic.as_deref();
}
/// Returns the optional TPU QUIC endpoint.
#[must_use]
pub fn tpu_quic(&self) -> std::option::Option<&str> {
return self.tpu_quic.as_deref();
}
/// Returns the optional TPU vote endpoint.
#[must_use]
pub fn tpu_vote(&self) -> std::option::Option<&str> {
return self.tpu_vote.as_deref();
}
/// Returns the optional TVU endpoint.
#[must_use]
pub fn tvu(&self) -> std::option::Option<&str> {
return self.tvu.as_deref();
}
/// Returns the optional software-version string.
#[must_use]
pub fn version(&self) -> std::option::Option<&str> {
return self.version.as_deref();
}
/// Returns the optional Agave client identifier extension.
#[must_use]
pub fn client_id(&self) -> std::option::Option<&str> {
return self.client_id.as_deref();
}
/// Decodes one cluster-node contact record from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireClusterNode>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pubkey = crate::parse_wire_pubkey(method, "pubkey", wire.pubkey.as_str());
let pubkey = match pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self {
pubkey,
feature_set: wire.feature_set,
gossip: wire.gossip,
pubsub: wire.pubsub,
rpc: wire.rpc,
serve_repair: wire.serve_repair,
shred_version: wire.shred_version,
tpu: wire.tpu,
tpu_forwards: wire.tpu_forwards,
tpu_forwards_quic: wire.tpu_forwards_quic,
tpu_quic: wire.tpu_quic,
tpu_vote: wire.tpu_vote,
tvu: wire.tvu,
version: wire.version,
client_id: wire.client_id,
});
}
}
/// Epoch information returned by `getEpochInfo`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaEpochInfo {
absolute_slot: u64,
block_height: u64,
epoch: u64,
slot_index: u64,
slots_in_epoch: u64,
transaction_count: std::option::Option<u64>,
}
impl SolanaEpochInfo {
/// Returns the absolute slot.
#[must_use]
pub const fn absolute_slot(&self) -> u64 {
return self.absolute_slot;
}
/// Returns the block height.
#[must_use]
pub const fn block_height(&self) -> u64 {
return self.block_height;
}
/// Returns the epoch number.
#[must_use]
pub const fn epoch(&self) -> u64 {
return self.epoch;
}
/// Returns the slot index within the epoch.
#[must_use]
pub const fn slot_index(&self) -> u64 {
return self.slot_index;
}
/// Returns the number of slots in the epoch.
#[must_use]
pub const fn slots_in_epoch(&self) -> u64 {
return self.slots_in_epoch;
}
/// Returns the nullable transaction count.
#[must_use]
pub const fn transaction_count(&self) -> std::option::Option<u64> {
return self.transaction_count;
}
/// Decodes epoch information from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireEpochInfo>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
absolute_slot: wire.absolute_slot,
block_height: wire.block_height,
epoch: wire.epoch,
slot_index: wire.slot_index,
slots_in_epoch: wire.slots_in_epoch,
transaction_count: wire.transaction_count,
}),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Epoch schedule returned by `getEpochSchedule`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaEpochSchedule {
first_normal_epoch: u64,
first_normal_slot: u64,
leader_schedule_slot_offset: u64,
slots_per_epoch: u64,
warmup: bool,
}
impl SolanaEpochSchedule {
/// Returns the first normal epoch.
#[must_use]
pub const fn first_normal_epoch(&self) -> u64 {
return self.first_normal_epoch;
}
/// Returns the first normal slot.
#[must_use]
pub const fn first_normal_slot(&self) -> u64 {
return self.first_normal_slot;
}
/// Returns the leader-schedule slot offset.
#[must_use]
pub const fn leader_schedule_slot_offset(&self) -> u64 {
return self.leader_schedule_slot_offset;
}
/// Returns the number of slots per epoch.
#[must_use]
pub const fn slots_per_epoch(&self) -> u64 {
return self.slots_per_epoch;
}
/// Returns whether epoch warmup is enabled.
#[must_use]
pub const fn warmup(&self) -> bool {
return self.warmup;
}
/// Decodes an epoch schedule from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireEpochSchedule>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
first_normal_epoch: wire.first_normal_epoch,
first_normal_slot: wire.first_normal_slot,
leader_schedule_slot_offset: wire.leader_schedule_slot_offset,
slots_per_epoch: wire.slots_per_epoch,
warmup: wire.warmup,
}),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Highest full and optional incremental snapshot slots returned by `getHighestSnapshotSlot`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaSnapshotSlotInfo {
full: u64,
incremental: std::option::Option<u64>,
}
impl SolanaSnapshotSlotInfo {
/// Returns the highest full snapshot slot.
#[must_use]
pub const fn full(&self) -> u64 {
return self.full;
}
/// Returns the optional highest incremental snapshot slot.
#[must_use]
pub const fn incremental(&self) -> std::option::Option<u64> {
return self.incremental;
}
/// Decodes snapshot-slot information from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireSnapshotSlotInfo>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self { full: wire.full, incremental: wire.incremental }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Optional configuration accepted by `getLeaderSchedule`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaLeaderScheduleConfig {
identity: std::option::Option<ksp_core_lib::Pubkey>,
commitment: std::option::Option<crate::SolanaCommitment>,
}
impl SolanaLeaderScheduleConfig {
/// Creates a leader-schedule configuration.
#[must_use]
pub const fn new(identity: std::option::Option<ksp_core_lib::Pubkey>, commitment: std::option::Option<crate::SolanaCommitment>) -> Self {
return Self { identity, commitment };
}
/// Returns the optional validator identity filter.
#[must_use]
pub const fn identity(&self) -> std::option::Option<&ksp_core_lib::Pubkey> {
return self.identity.as_ref();
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
pub(crate) const fn is_empty(&self) -> bool {
return self.identity.is_none() && self.commitment.is_none();
}
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(identity) = self.identity.as_ref() {
object.insert("identity".to_owned(), serde_json::Value::String(identity.to_string()));
}
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
return serde_json::Value::Object(object);
}
}
/// Typed parameter overload for `getLeaderSchedule`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaLeaderScheduleRequest {
/// Query the current epoch, optionally with a config object.
CurrentEpoch(std::option::Option<crate::SolanaLeaderScheduleConfig>),
/// Query the epoch containing one slot, optionally with a config object.
Slot {
/// Slot whose epoch should be queried.
slot: u64,
/// Optional leader-schedule config sent as the second positional parameter.
config: std::option::Option<crate::SolanaLeaderScheduleConfig>,
},
}
impl Default for SolanaLeaderScheduleRequest {
fn default() -> Self {
return Self::CurrentEpoch(std::option::Option::None);
}
}
impl SolanaLeaderScheduleRequest {
/// Serializes the typed overload to the exact positional JSON-RPC params.
#[must_use]
pub(crate) fn to_json_params(&self) -> std::vec::Vec<serde_json::Value> {
return match self {
Self::CurrentEpoch(std::option::Option::None) => std::vec::Vec::new(),
Self::CurrentEpoch(std::option::Option::Some(config)) if config.is_empty() => std::vec::Vec::new(),
Self::CurrentEpoch(std::option::Option::Some(config)) => std::vec![config.to_json_value()],
Self::Slot { slot, config: std::option::Option::None } => std::vec![serde_json::json!(slot)],
Self::Slot { slot, config: std::option::Option::Some(config) } if config.is_empty() => std::vec![serde_json::json!(slot)],
Self::Slot { slot, config: std::option::Option::Some(config) } => std::vec![serde_json::json!(slot), config.to_json_value()],
};
}
}
/// Leader schedule mapping validator identities to relative epoch slot indices.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaLeaderSchedule {
entries: std::collections::BTreeMap<ksp_core_lib::Pubkey, std::vec::Vec<usize>>,
}
impl SolanaLeaderSchedule {
/// Returns the complete leader schedule map.
#[must_use]
pub const fn entries(&self) -> &std::collections::BTreeMap<ksp_core_lib::Pubkey, std::vec::Vec<usize>> {
return &self.entries;
}
/// Decodes a leader schedule map from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<std::collections::BTreeMap<std::string::String, std::vec::Vec<usize>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut entries = std::collections::BTreeMap::new();
for (identity, slots) in wire {
let pubkey = crate::parse_wire_pubkey(method, "leader_identity", identity.as_str());
let pubkey = match pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
entries.insert(pubkey, slots);
}
return std::result::Result::Ok(Self { entries });
}
}
/// Configuration accepted by `getVoteAccounts`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaVoteAccountsConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
vote_pubkey: std::option::Option<ksp_core_lib::Pubkey>,
keep_unstaked_delinquents: std::option::Option<bool>,
delinquent_slot_distance: std::option::Option<u64>,
}
impl SolanaVoteAccountsConfig {
/// Creates a vote-accounts configuration.
#[must_use]
pub const fn new(
commitment: std::option::Option<crate::SolanaCommitment>,
vote_pubkey: std::option::Option<ksp_core_lib::Pubkey>,
keep_unstaked_delinquents: std::option::Option<bool>,
delinquent_slot_distance: std::option::Option<u64>,
) -> Self {
return Self { commitment, vote_pubkey, keep_unstaked_delinquents, delinquent_slot_distance };
}
/// Returns the optional commitment.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional vote-account public key filter.
#[must_use]
pub const fn vote_pubkey(&self) -> std::option::Option<&ksp_core_lib::Pubkey> {
return self.vote_pubkey.as_ref();
}
/// Returns whether unstaked delinquent validators should be kept.
#[must_use]
pub const fn keep_unstaked_delinquents(&self) -> std::option::Option<bool> {
return self.keep_unstaked_delinquents;
}
/// Returns the optional delinquent slot distance.
#[must_use]
pub const fn delinquent_slot_distance(&self) -> std::option::Option<u64> {
return self.delinquent_slot_distance;
}
pub(crate) const fn is_empty(&self) -> bool {
return self.commitment.is_none() && self.vote_pubkey.is_none() && self.keep_unstaked_delinquents.is_none() && self.delinquent_slot_distance.is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) 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(vote_pubkey) = self.vote_pubkey.as_ref() {
object.insert("votePubkey".to_owned(), serde_json::Value::String(vote_pubkey.to_string()));
}
if let std::option::Option::Some(value) = self.keep_unstaked_delinquents {
object.insert("keepUnstakedDelinquents".to_owned(), serde_json::Value::Bool(value));
}
if let std::option::Option::Some(value) = self.delinquent_slot_distance {
object.insert("delinquentSlotDistance".to_owned(), serde_json::Value::Number(value.into()));
}
return serde_json::Value::Object(object);
}
}
/// One epoch-credit history entry returned by `getVoteAccounts`.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SolanaEpochCredits {
epoch: u64,
credits: u64,
previous_credits: u64,
}
impl SolanaEpochCredits {
/// Returns the epoch number.
#[must_use]
pub const fn epoch(&self) -> u64 {
return self.epoch;
}
/// Returns cumulative credits at the end of the epoch.
#[must_use]
pub const fn credits(&self) -> u64 {
return self.credits;
}
/// Returns cumulative credits before the epoch.
#[must_use]
pub const fn previous_credits(&self) -> u64 {
return self.previous_credits;
}
}
/// One validator vote-account record returned by `getVoteAccounts`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaVoteAccountInfo {
vote_pubkey: ksp_core_lib::Pubkey,
node_pubkey: ksp_core_lib::Pubkey,
activated_stake: u64,
commission: u8,
inflation_rewards_commission_bps: std::option::Option<u16>,
epoch_vote_account: bool,
epoch_credits: std::vec::Vec<crate::SolanaEpochCredits>,
last_vote: u64,
root_slot: u64,
}
impl SolanaVoteAccountInfo {
/// Returns the vote account public key.
#[must_use]
pub const fn vote_pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.vote_pubkey;
}
/// Returns the validator identity public key.
#[must_use]
pub const fn node_pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.node_pubkey;
}
/// Returns the activated stake in lamports.
#[must_use]
pub const fn activated_stake(&self) -> u64 {
return self.activated_stake;
}
/// Returns the legacy/effective percentage commission field.
#[must_use]
pub const fn commission(&self) -> u8 {
return self.commission;
}
/// Returns the optional raw inflation-rewards commission in basis points.
#[must_use]
pub const fn inflation_rewards_commission_bps(&self) -> std::option::Option<u16> {
return self.inflation_rewards_commission_bps;
}
/// Returns whether the vote account is staked for the current epoch.
#[must_use]
pub const fn epoch_vote_account(&self) -> bool {
return self.epoch_vote_account;
}
/// Returns the bounded RPC epoch-credit history.
#[must_use]
pub fn epoch_credits(&self) -> &[crate::SolanaEpochCredits] {
return self.epoch_credits.as_slice();
}
/// Returns the latest voted slot or zero when no vote exists.
#[must_use]
pub const fn last_vote(&self) -> u64 {
return self.last_vote;
}
/// Returns the current root slot or zero when no root exists.
#[must_use]
pub const fn root_slot(&self) -> u64 {
return self.root_slot;
}
/// Decodes one vote-account record from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireVoteAccountInfo>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let vote_pubkey = crate::parse_wire_pubkey(method, "votePubkey", wire.vote_pubkey.as_str());
let vote_pubkey = match vote_pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let node_pubkey = crate::parse_wire_pubkey(method, "nodePubkey", wire.node_pubkey.as_str());
let node_pubkey = match node_pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut epoch_credits = std::vec::Vec::with_capacity(wire.epoch_credits.len());
for entry in wire.epoch_credits {
epoch_credits.push(crate::SolanaEpochCredits { epoch: entry[0], credits: entry[1], previous_credits: entry[2] });
}
return std::result::Result::Ok(Self {
vote_pubkey,
node_pubkey,
activated_stake: wire.activated_stake,
commission: wire.commission,
inflation_rewards_commission_bps: wire.inflation_rewards_commission_bps,
epoch_vote_account: wire.epoch_vote_account,
epoch_credits,
last_vote: wire.last_vote,
root_slot: wire.root_slot,
});
}
}
/// Current and delinquent validator vote-account groups returned by `getVoteAccounts`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaVoteAccountStatus {
current: std::vec::Vec<crate::SolanaVoteAccountInfo>,
delinquent: std::vec::Vec<crate::SolanaVoteAccountInfo>,
}
impl SolanaVoteAccountStatus {
/// Returns current vote accounts.
#[must_use]
pub fn current(&self) -> &[crate::SolanaVoteAccountInfo] {
return self.current.as_slice();
}
/// Returns delinquent vote accounts.
#[must_use]
pub fn delinquent(&self) -> &[crate::SolanaVoteAccountInfo] {
return self.delinquent.as_slice();
}
/// Decodes the complete vote-account status response from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireVoteAccountStatus>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut current = std::vec::Vec::with_capacity(wire.current.len());
for value in wire.current {
let decoded = crate::SolanaVoteAccountInfo::decode_wire(method, value);
match decoded {
std::result::Result::Ok(info) => current.push(info),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
let mut delinquent = std::vec::Vec::with_capacity(wire.delinquent.len());
for value in wire.delinquent {
let decoded = crate::SolanaVoteAccountInfo::decode_wire(method, value);
match decoded {
std::result::Result::Ok(info) => delinquent.push(info),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(Self { current, delinquent });
}
}
#[derive(serde::Deserialize)]
struct WireIdentity {
identity: std::string::String,
}
impl crate::HttpTransportPool {
/// Executes typed `getClusterNodes` through the common KSP HTTP transport path.
pub async fn get_cluster_nodes(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaClusterNode>> {
let value = self.execute_cluster_rpc("getClusterNodes", role, std::vec::Vec::new()).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decoded = crate::decode_wire_json::<std::vec::Vec<serde_json::Value>>("getClusterNodes", value);
let values = match decoded {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut nodes = std::vec::Vec::with_capacity(values.len());
for value in values {
let node = crate::SolanaClusterNode::decode_wire("getClusterNodes", value);
match node {
std::result::Result::Ok(node) => nodes.push(node),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(nodes);
}
/// Executes typed `getEpochInfo` through the common KSP HTTP transport path.
pub async fn get_epoch_info(
&self,
role: &crate::HttpRoleName,
config: std::option::Option<&crate::SolanaContextConfig>,
) -> ksp_core_lib::Result<crate::SolanaEpochInfo> {
let mut params = std::vec::Vec::new();
push_context_config(&mut params, config);
let value = self.execute_cluster_rpc("getEpochInfo", role, params).await;
return match value {
std::result::Result::Ok(value) => crate::SolanaEpochInfo::decode_wire("getEpochInfo", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getEpochSchedule` through the common KSP HTTP transport path.
pub async fn get_epoch_schedule(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaEpochSchedule> {
let value = self.execute_cluster_rpc("getEpochSchedule", role, std::vec::Vec::new()).await;
return match value {
std::result::Result::Ok(value) => crate::SolanaEpochSchedule::decode_wire("getEpochSchedule", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getHighestSnapshotSlot` through the common KSP HTTP transport path.
pub async fn get_highest_snapshot_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaSnapshotSlotInfo> {
let value = self.execute_cluster_rpc("getHighestSnapshotSlot", role, std::vec::Vec::new()).await;
return match value {
std::result::Result::Ok(value) => crate::SolanaSnapshotSlotInfo::decode_wire("getHighestSnapshotSlot", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getIdentity` through the common KSP HTTP transport path.
pub async fn get_identity(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
let value = self.execute_cluster_rpc("getIdentity", role, std::vec::Vec::new()).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decoded = crate::decode_wire_json::<WireIdentity>("getIdentity", value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::parse_wire_pubkey("getIdentity", "identity", wire.identity.as_str());
}
/// Executes typed `getMaxRetransmitSlot` through the common KSP HTTP transport path.
pub async fn get_max_retransmit_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<u64> {
return self.get_cluster_simple_slot("getMaxRetransmitSlot", role).await;
}
/// Executes typed `getMaxShredInsertSlot` through the common KSP HTTP transport path.
pub async fn get_max_shred_insert_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<u64> {
return self.get_cluster_simple_slot("getMaxShredInsertSlot", role).await;
}
/// Executes typed `getLeaderSchedule` through the common KSP HTTP transport path.
pub async fn get_leader_schedule(
&self,
role: &crate::HttpRoleName,
request: &crate::SolanaLeaderScheduleRequest,
) -> ksp_core_lib::Result<std::option::Option<crate::SolanaLeaderSchedule>> {
let value = self.execute_cluster_rpc("getLeaderSchedule", role, request.to_json_params()).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if value.is_null() {
return std::result::Result::Ok(std::option::Option::None);
}
let schedule = crate::SolanaLeaderSchedule::decode_wire("getLeaderSchedule", value);
return match schedule {
std::result::Result::Ok(schedule) => std::result::Result::Ok(std::option::Option::Some(schedule)),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getSlot` through the common KSP HTTP transport path.
pub async fn get_slot(&self, role: &crate::HttpRoleName, config: std::option::Option<&crate::SolanaContextConfig>) -> ksp_core_lib::Result<u64> {
let mut params = std::vec::Vec::new();
push_context_config(&mut params, config);
let value = self.execute_cluster_rpc("getSlot", role, params).await;
return match value {
std::result::Result::Ok(value) => crate::decode_wire_json::<u64>("getSlot", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getSlotLeader` through the common KSP HTTP transport path.
pub async fn get_slot_leader(
&self,
role: &crate::HttpRoleName,
config: std::option::Option<&crate::SolanaContextConfig>,
) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
let mut params = std::vec::Vec::new();
push_context_config(&mut params, config);
let value = self.execute_cluster_rpc("getSlotLeader", role, params).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decoded = crate::decode_wire_json::<std::string::String>("getSlotLeader", value);
let leader = match decoded {
std::result::Result::Ok(leader) => leader,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::parse_wire_pubkey("getSlotLeader", "leader", leader.as_str());
}
/// Executes typed `getSlotLeaders` through the common KSP HTTP transport path.
pub async fn get_slot_leaders(&self, role: &crate::HttpRoleName, start_slot: u64, limit: u64) -> ksp_core_lib::Result<std::vec::Vec<ksp_core_lib::Pubkey>> {
if limit == 0 || limit > MAX_GET_SLOT_LEADERS {
return invalid_cluster_parameters("getSlotLeaders", "getSlotLeaders limit must be between 1 and 5000", "limit", limit);
}
let params = std::vec![serde_json::json!(start_slot), serde_json::json!(limit)];
let value = self.execute_cluster_rpc("getSlotLeaders", role, params).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_pubkey_list("getSlotLeaders", "leader", value);
}
/// Executes typed `getVoteAccounts` through the common KSP HTTP transport path.
pub async fn get_vote_accounts(
&self,
role: &crate::HttpRoleName,
config: std::option::Option<&crate::SolanaVoteAccountsConfig>,
) -> ksp_core_lib::Result<crate::SolanaVoteAccountStatus> {
let mut params = std::vec::Vec::new();
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let value = self.execute_cluster_rpc("getVoteAccounts", role, params).await;
return match value {
std::result::Result::Ok(value) => crate::SolanaVoteAccountStatus::decode_wire("getVoteAccounts", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
async fn get_cluster_simple_slot(&self, method_name: &'static str, role: &crate::HttpRoleName) -> ksp_core_lib::Result<u64> {
let value = self.execute_cluster_rpc(method_name, role, std::vec::Vec::new()).await;
return match value {
std::result::Result::Ok(value) => crate::decode_wire_json::<u64>(method_name, value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
async fn execute_cluster_rpc(
&self,
method_name: &'static str,
role: &crate::HttpRoleName,
params: std::vec::Vec<serde_json::Value>,
) -> ksp_core_lib::Result<serde_json::Value> {
let method = cluster_descriptor(method_name);
let method = match method {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return self.execute_standard_rpc(role, method, params).await;
}
}
fn push_context_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaContextConfig>) {
if let std::option::Option::Some(config) = config
&& (config.commitment().is_some() || config.min_context_slot().is_some())
{
params.push((*config).to_json_value());
}
return;
}
fn decode_pubkey_list(method: &str, field: &'static str, value: serde_json::Value) -> ksp_core_lib::Result<std::vec::Vec<ksp_core_lib::Pubkey>> {
let decoded = crate::decode_wire_json::<std::vec::Vec<std::string::String>>(method, value);
let values = match decoded {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut pubkeys = std::vec::Vec::with_capacity(values.len());
for value in values {
let pubkey = crate::parse_wire_pubkey(method, field, value.as_str());
match pubkey {
std::result::Result::Ok(pubkey) => pubkeys.push(pubkey),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(pubkeys);
}
fn invalid_cluster_parameters<T>(method: &str, message: &str, field: &'static str, value: u64) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message)
.with_context("rpc_method", method)
.with_context(field, value.to_string()),
);
}
fn cluster_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)
if descriptor.category() == crate::HttpRpcCategory::Cluster && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
{
std::result::Result::Ok(descriptor)
},
_ => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Cluster descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireClusterNode {
pubkey: std::string::String,
#[serde(default)]
feature_set: std::option::Option<u32>,
#[serde(default)]
gossip: std::option::Option<std::string::String>,
#[serde(default)]
pubsub: std::option::Option<std::string::String>,
#[serde(default)]
rpc: std::option::Option<std::string::String>,
#[serde(default)]
serve_repair: std::option::Option<std::string::String>,
#[serde(default)]
shred_version: std::option::Option<u16>,
#[serde(default)]
tpu: std::option::Option<std::string::String>,
#[serde(default)]
tpu_forwards: std::option::Option<std::string::String>,
#[serde(default)]
tpu_forwards_quic: std::option::Option<std::string::String>,
#[serde(default)]
tpu_quic: std::option::Option<std::string::String>,
#[serde(default)]
tpu_vote: std::option::Option<std::string::String>,
#[serde(default)]
tvu: std::option::Option<std::string::String>,
#[serde(default)]
version: std::option::Option<std::string::String>,
#[serde(default)]
client_id: std::option::Option<std::string::String>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireEpochInfo {
absolute_slot: u64,
block_height: u64,
epoch: u64,
slot_index: u64,
slots_in_epoch: u64,
transaction_count: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireEpochSchedule {
first_normal_epoch: u64,
first_normal_slot: u64,
leader_schedule_slot_offset: u64,
slots_per_epoch: u64,
warmup: bool,
}
#[derive(serde::Deserialize)]
struct WireSnapshotSlotInfo {
full: u64,
incremental: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireVoteAccountInfo {
vote_pubkey: std::string::String,
node_pubkey: std::string::String,
activated_stake: u64,
commission: u8,
#[serde(default)]
inflation_rewards_commission_bps: std::option::Option<u16>,
epoch_vote_account: bool,
epoch_credits: std::vec::Vec<[u64; 3]>,
last_vote: u64,
root_slot: u64,
}
#[derive(serde::Deserialize)]
struct WireVoteAccountStatus {
current: std::vec::Vec<serde_json::Value>,
delinquent: std::vec::Vec<serde_json::Value>,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_cluster.rs"]
mod tests;

View File

@@ -0,0 +1,193 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
// version: 4
/// Commitment level accepted by typed Solana HTTP RPC 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 commitment-only configuration shared by typed Solana HTTP RPC methods.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaCommitmentConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
}
impl SolanaCommitmentConfig {
/// Creates an explicit commitment-only configuration.
#[must_use]
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>) -> Self {
return Self { commitment };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) 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()));
}
return serde_json::Value::Object(object);
}
}
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaContextConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
min_context_slot: std::option::Option<u64>,
}
impl SolanaContextConfig {
/// Creates an explicit context-aware RPC 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;
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) 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 Solana RPC context shared by contextual HTTP responses.
#[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,
};
}
/// Decodes one RPC context from a parsed JSON value for typed RPC adapters.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = decode_wire_json::<WireRpcContext>(method, value);
let context = match decoded {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { slot: context.slot, api_version: context.api_version });
}
}
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaRpcResponse<T> {
context: crate::SolanaRpcContext,
value: T,
}
impl<T> SolanaRpcResponse<T> {
/// Returns the Solana response context.
#[must_use]
pub const fn context(&self) -> &crate::SolanaRpcContext {
return &self.context;
}
/// Returns the typed response value.
#[must_use]
pub const fn value(&self) -> &T {
return &self.value;
}
/// Creates a contextual response after wire decoding and validation.
#[must_use]
pub(crate) const fn new(context: crate::SolanaRpcContext, value: T) -> Self {
return Self { context, value };
}
}
/// Decodes one private serde wire type and maps shape failures to the shared Transport error domain.
pub(crate) fn decode_wire_json<T: serde::de::DeserializeOwned>(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<T> {
let decoded = serde_json::from_value::<T>(value);
return match decoded {
std::result::Result::Ok(decoded) => std::result::Result::Ok(decoded),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response has an invalid wire shape")
.with_context("rpc_method", method)
.with_source(error),
),
};
}
/// Parses a base58 public key from one wire field without echoing its value into diagnostics.
pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
let parsed = value.parse::<ksp_core_lib::Pubkey>();
return match parsed {
std::result::Result::Ok(pubkey) => std::result::Result::Ok(pubkey),
std::result::Result::Err(_) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response contains an invalid public key")
.with_context("rpc_method", method)
.with_context("field", field),
),
};
}
#[derive(serde::Deserialize)]
struct WireRpcContext {
slot: u64,
#[serde(rename = "apiVersion", default)]
api_version: std::option::Option<std::string::String>,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_common.rs"]
mod tests;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,354 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
// version: 3
/// Exclusive selector accepted by token-account list RPC methods.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaTokenAccountSelector {
/// Select token accounts for one mint.
Mint(ksp_core_lib::Pubkey),
/// Select token accounts owned by one token program.
ProgramId(ksp_core_lib::Pubkey),
}
impl SolanaTokenAccountSelector {
/// Serializes the exclusive selector to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::Mint(pubkey) => serde_json::json!({"mint": pubkey.to_string()}),
Self::ProgramId(pubkey) => serde_json::json!({"programId": pubkey.to_string()}),
};
}
}
/// Token amount returned by Solana HTTP token RPC methods.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaTokenAmount {
amount: std::string::String,
decimals: u8,
ui_amount: std::option::Option<f64>,
ui_amount_string: std::string::String,
}
impl SolanaTokenAmount {
/// Returns the integer token amount as an exact decimal string.
#[must_use]
pub fn amount(&self) -> &str {
return self.amount.as_str();
}
/// Returns the mint decimal precision.
#[must_use]
pub const fn decimals(&self) -> u8 {
return self.decimals;
}
/// Returns the nullable floating-point UI amount exactly as provided by RPC.
#[must_use]
pub const fn ui_amount(&self) -> std::option::Option<f64> {
return self.ui_amount;
}
/// Returns the exact UI amount string provided by RPC.
#[must_use]
pub fn ui_amount_string(&self) -> &str {
return self.ui_amount_string.as_str();
}
/// Decodes one token amount from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireTokenAmount>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
amount: wire.amount,
decimals: wire.decimals,
ui_amount: wire.ui_amount,
ui_amount_string: wire.ui_amount_string,
}),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Token-account balance entry returned by `getTokenLargestAccounts`.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaTokenAccountBalance {
address: ksp_core_lib::Pubkey,
amount: crate::SolanaTokenAmount,
}
impl SolanaTokenAccountBalance {
/// Returns the token account address.
#[must_use]
pub const fn address(&self) -> &ksp_core_lib::Pubkey {
return &self.address;
}
/// Returns the token amount fields.
#[must_use]
pub const fn amount(&self) -> &crate::SolanaTokenAmount {
return &self.amount;
}
/// Decodes one token-account balance entry from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireTokenAccountBalance>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let address = crate::parse_wire_pubkey(method, "address", wire.address.as_str());
let address = match address {
std::result::Result::Ok(address) => address,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let amount = crate::SolanaTokenAmount {
amount: wire.amount,
decimals: wire.decimals,
ui_amount: wire.ui_amount,
ui_amount_string: wire.ui_amount_string,
};
return std::result::Result::Ok(Self { address, amount });
}
}
#[derive(serde::Deserialize)]
struct WireTokenAmount {
amount: std::string::String,
decimals: u8,
#[serde(rename = "uiAmount")]
ui_amount: std::option::Option<f64>,
#[serde(rename = "uiAmountString")]
ui_amount_string: std::string::String,
}
#[derive(serde::Deserialize)]
struct WireTokenAccountBalance {
address: std::string::String,
amount: std::string::String,
decimals: u8,
#[serde(rename = "uiAmount")]
ui_amount: std::option::Option<f64>,
#[serde(rename = "uiAmountString")]
ui_amount_string: std::string::String,
}
#[derive(serde::Deserialize)]
struct WireRpcResponse<T> {
context: serde_json::Value,
value: T,
}
impl crate::HttpTransportPool {
/// Executes typed `getTokenAccountBalance` through the common KSP HTTP transport path.
pub async fn get_token_account_balance(
&self,
role: &crate::HttpRoleName,
token_account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
let method_result = token_descriptor("getTokenAccountBalance");
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(token_account.to_string())];
push_commitment_config(&mut params, config);
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),
};
return decode_token_amount_response("getTokenAccountBalance", value);
}
/// Executes typed `getTokenAccountsByDelegate` through the common KSP HTTP transport path.
pub async fn get_token_accounts_by_delegate(
&self,
role: &crate::HttpRoleName,
delegate: &ksp_core_lib::Pubkey,
selector: &crate::SolanaTokenAccountSelector,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
return self.get_token_accounts_list("getTokenAccountsByDelegate", role, delegate, selector, config).await;
}
/// Executes typed `getTokenAccountsByOwner` through the common KSP HTTP transport path.
pub async fn get_token_accounts_by_owner(
&self,
role: &crate::HttpRoleName,
owner: &ksp_core_lib::Pubkey,
selector: &crate::SolanaTokenAccountSelector,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
return self.get_token_accounts_list("getTokenAccountsByOwner", role, owner, selector, config).await;
}
/// Executes typed `getTokenLargestAccounts` through the common KSP HTTP transport path.
pub async fn get_token_largest_accounts(
&self,
role: &crate::HttpRoleName,
mint: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaTokenAccountBalance>>> {
let method_result = token_descriptor("getTokenLargestAccounts");
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(mint.to_string())];
push_commitment_config(&mut params, config);
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),
};
return decode_token_account_balances_response("getTokenLargestAccounts", value);
}
/// Executes typed `getTokenSupply` through the common KSP HTTP transport path.
pub async fn get_token_supply(
&self,
role: &crate::HttpRoleName,
mint: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
let method_result = token_descriptor("getTokenSupply");
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(mint.to_string())];
push_commitment_config(&mut params, config);
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),
};
return decode_token_amount_response("getTokenSupply", value);
}
async fn get_token_accounts_list(
&self,
method_name: &'static str,
role: &crate::HttpRoleName,
address: &ksp_core_lib::Pubkey,
selector: &crate::SolanaTokenAccountSelector,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
let method_result = token_descriptor(method_name);
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(address.to_string()), selector.to_json_value()];
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),
};
return decode_keyed_accounts_response(method_name, value);
}
}
fn token_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)
if descriptor.category() == crate::HttpRpcCategory::Tokens && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
{
std::result::Result::Ok(descriptor)
},
_ => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Tokens descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
fn push_commitment_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaCommitmentConfig>) {
if let std::option::Option::Some(config) = config
&& config.commitment().is_some()
{
params.push(config.to_json_value());
}
return;
}
fn decode_token_amount_response(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<serde_json::Value>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let amount = crate::SolanaTokenAmount::decode_wire(method, wire.value);
let amount = match amount {
std::result::Result::Ok(amount) => amount,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, amount));
}
fn decode_keyed_accounts_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut accounts = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
let account = crate::SolanaKeyedAccount::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => accounts.push(account),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, accounts));
}
fn decode_token_account_balances_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaTokenAccountBalance>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut accounts = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
let account = crate::SolanaTokenAccountBalance::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => accounts.push(account),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, accounts));
}
#[cfg(test)]
#[path = "../unit_tests/rpc_tokens.rs"]
mod tests;

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

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