Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c3ecf1f18 | |||
| 79b67f8eae | |||
| e0a7ac0bf8 | |||
| 598474438b | |||
| f5d98c4e69 | |||
| 14bcbf2cfb | |||
| ac1b1033c4 | |||
| bc71fba289 | |||
| 9c1568ee1c | |||
| c1cea6e813 | |||
| ed978179d8 | |||
| babe7d9f2b | |||
| d3fc0c6d69 | |||
| 0cff0406ab | |||
| d98d152f08 | |||
| 624202c363 |
14
.env.example
14
.env.example
@@ -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
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 4 -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# 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.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.
|
||||
|
||||
17
Cargo.toml
17
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 95
|
||||
# version: 109
|
||||
|
||||
[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.2.0"
|
||||
version = "0.2.1"
|
||||
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" }
|
||||
|
||||
33
ROADMAP.md
33
ROADMAP.md
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 24 -->
|
||||
<!-- version: 34 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -17,6 +17,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -39,24 +40,24 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
|
||||
### Cadrage
|
||||
|
||||
- [X] `0.2.0` — Audit bot3, ordre de `0.2.x`, architecture durable et prompt `0.2.1` stabilisés par `0.2.0-rel.001`.
|
||||
- [X] `0.2.0-pre.001` — Méthode d'audit, cartographie initiale et matrice provisoire.
|
||||
- [X] `0.2.0-pre.002` — Fixer l'ordre fonctionnel, la discipline de sizing, le pipeline RAW/CORE/DECODE/SPECIALIZED et préparer le prompt `0.2.1`.
|
||||
- [X] `0.2.0-pre.003` — Audit de cohérence final : règles résiduelles supersédées corrigées, fiches `0.2.1+` complétées, TODO bot3 utiles préservés et prompt `0.2.1` finalisé.
|
||||
- [X] `0.2.0-rel.001` — Publication stable du cadrage `0.2.x`; prochaine release : `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation`.
|
||||
- [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`.
|
||||
|
||||
### Releases fonctionnelles décidées/pressenties
|
||||
|
||||
- [ ] `0.2.1` — Introduire `ksp-onchain-transport-lib` avec HTTP Solana/JSON-RPC, settings publics, document Config standard + adapter, pools, rôles, priorités, limites, retry/backoff et couverture complète de la documentation HTTP ciblée.
|
||||
- [ ] `0.2.2` — Introduire `ksp-wallet-lib`, le format `.kspwallet`, la gestion sûre des secrets et une architecture d'import/export extensible ; exclure `WalletPolicy`.
|
||||
- [ ] `0.2.3` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet.
|
||||
- [ ] `0.2.4` — É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.5` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.
|
||||
- [ ] `0.2.6` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
|
||||
- [ ] `0.2.7` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
||||
- [ ] `0.2.8` — Introduire une petite application desk de visualisation des prix.
|
||||
- [ ] `0.2.9` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
||||
- [ ] `0.2.10` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
||||
- [X] `0.2.1` — **HTTP transport foundation réduite par le gate `pre.001`** : crate/settings/JSON-RPC/registry 52 current + 14 deprecated historiques, pool/rôles/limites/retry, Config adapter, documentation et 4 méthodes typées canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) publiés stables.
|
||||
- [ ] `0.2.2` — Compléter HTTP Accounts + Tokens + Cluster : 5 méthodes Accounts restantes + 5 Tokens + 12 Cluster restantes, soit 22 méthodes.
|
||||
- [ ] `0.2.3` — Compléter les 11 méthodes HTTP Transactions, y compris write/submission technique avec politique no-resend ambigu.
|
||||
- [ ] `0.2.4` — Compléter les 10 méthodes HTTP Blocks + 5 Economics et exécuter la compliance finale de toute la surface HTTP 52 current + 14 deprecated historiques.
|
||||
- [ ] `0.2.5` — Introduire `ksp-wallet-lib`, le format `.kspwallet`, la gestion sûre des secrets et une architecture d'import/export extensible ; exclure `WalletPolicy`.
|
||||
- [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet.
|
||||
- [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe.
|
||||
- [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.
|
||||
- [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
|
||||
- [ ] `0.2.10` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
||||
- [ ] `0.2.11` — Introduire une petite application desk de visualisation des prix.
|
||||
- [ ] `0.2.12` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
||||
- [ ] `0.2.13` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
||||
|
||||
### Règles Transport pour toute la série
|
||||
|
||||
|
||||
@@ -61,4 +61,4 @@
|
||||
"target_filters": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
64
config/examples/std.transport.example.json
Normal file
64
config/examples/std.transport.example.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"retry": {
|
||||
"max_retries": 3,
|
||||
"initial_backoff_ms": 150,
|
||||
"max_backoff_ms": 3000
|
||||
},
|
||||
"default_profile": "mainnet_mixed",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "mainnet_mixed",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "mainnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"priority": 200,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mainnet_private",
|
||||
"enabled": true,
|
||||
"provider": "private-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_SECRET_SOLANA_HTTP_URL:-https://example.invalid}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 10000,
|
||||
"max_idle_connections_per_host": 16,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 20,
|
||||
"burst_capacity": 40,
|
||||
"max_concurrent_requests": 16,
|
||||
"pause_after_rate_limit_ms": 750
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -263,4 +263,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
124
config/schemas/std.transport.schema.json
Normal file
124
config/schemas/std.transport.schema.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:ksp:schema:std.transport:v1",
|
||||
"title": "KSP standard HTTP Transport configuration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["format_version", "retry", "default_profile", "profiles"],
|
||||
"properties": {
|
||||
"format_version": {"const": 1},
|
||||
"retry": {"$ref": "#/$defs/retry"},
|
||||
"default_profile": {"$ref": "#/$defs/profileId"},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/profile"}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||
},
|
||||
"descriptor": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^\\S(?:.*\\S)?$"
|
||||
},
|
||||
"positiveMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"positiveU32": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"retry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["max_retries", "initial_backoff_ms", "max_backoff_ms"],
|
||||
"properties": {
|
||||
"max_retries": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||
"initial_backoff_ms": {"$ref": "#/$defs/positiveMs"},
|
||||
"max_backoff_ms": {"$ref": "#/$defs/positiveMs"}
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"requests_per_second": {"$ref": "#/$defs/positiveU32"},
|
||||
"burst_capacity": {"$ref": "#/$defs/positiveU32"},
|
||||
"max_concurrent_requests": {"$ref": "#/$defs/positiveU32"},
|
||||
"pause_after_rate_limit_ms": {"$ref": "#/$defs/positiveMs"}
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["role", "enabled", "request_kinds", "priority", "limits"],
|
||||
"properties": {
|
||||
"role": {"$ref": "#/$defs/descriptor"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"request_kinds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {"$ref": "#/$defs/descriptor"},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"contains": {"const": "*"}},
|
||||
"then": {"maxItems": 1}
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": {"type": "integer", "minimum": 0, "maximum": 4294967295},
|
||||
"limits": {"$ref": "#/$defs/limits"}
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"enabled",
|
||||
"provider",
|
||||
"cluster",
|
||||
"url",
|
||||
"connect_timeout_ms",
|
||||
"request_timeout_ms",
|
||||
"roles"
|
||||
],
|
||||
"properties": {
|
||||
"name": {"$ref": "#/$defs/descriptor"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"provider": {"$ref": "#/$defs/descriptor"},
|
||||
"cluster": {"$ref": "#/$defs/descriptor"},
|
||||
"url": {"type": "string", "minLength": 1},
|
||||
"connect_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
||||
"request_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
||||
"max_idle_connections_per_host": {"type": "integer", "minimum": 1},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/role"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["profile_id", "endpoints"],
|
||||
"properties": {
|
||||
"profile_id": {"$ref": "#/$defs/profileId"},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/endpoint"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,23 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.onchain_transport.info",
|
||||
"enabled": true,
|
||||
"path": "transport/onchain/ksp-onchain-transport.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "info",
|
||||
"targets": [
|
||||
"ksp-onchain-transport-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.config.error",
|
||||
"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
69
config/std.transport.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"retry": {
|
||||
"max_retries": 2,
|
||||
"initial_backoff_ms": 100,
|
||||
"max_backoff_ms": 2000
|
||||
},
|
||||
"default_profile": "devnet_public",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "devnet_public",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "solana_devnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet_public",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "solana_mainnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-app-config-desk/Cargo.toml
|
||||
# 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]
|
||||
|
||||
@@ -10,4 +10,4 @@
|
||||
"core:default",
|
||||
"tracing:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
@@ -157,4 +157,4 @@
|
||||
color: $list-group-disabled-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -16,4 +16,4 @@
|
||||
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
|
||||
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
|
||||
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
|
||||
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
||||
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
/* Rtl support */
|
||||
[data-simplebar] {
|
||||
@@ -245,4 +245,4 @@
|
||||
|
||||
.simplebar-hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_variables.scss
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
@@ -92,4 +92,4 @@ $list-group-border-color: transparent !default;
|
||||
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
|
||||
$list-group-active-color: $white !default;
|
||||
$list-group-active-bg: $list-group-bg !default;
|
||||
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
||||
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
||||
|
||||
@@ -28,4 +28,4 @@
|
||||
"frontend",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ;
|
||||
|
||||
@@ -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, §7–8 |
|
||||
| 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` | §6–7 |
|
||||
| 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`.
|
||||
|
||||
|
||||
7
crates/ksp-config-lib/src/constants.rs
Normal file
7
crates/ksp-config-lib/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// file: crates/ksp-config-lib/src/constants.rs
|
||||
// version: 1
|
||||
|
||||
//! Config-owned tracing constants.
|
||||
|
||||
/// Owning tracing target for events emitted by the Config crate.
|
||||
pub(crate) const TRACING_TARGET: &str = "ksp-config-lib";
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/environment.rs
|
||||
// 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
316
crates/ksp-config-lib/src/transport.rs
Normal file
316
crates/ksp-config-lib/src/transport.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
// file: crates/ksp-config-lib/src/transport.rs
|
||||
// version: 1
|
||||
|
||||
/// Effective standard HTTP Transport configuration resolved from Config and mapped to the Transport runtime contract.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct ResolvedTransportConfig {
|
||||
file_id: crate::ConfigFileId,
|
||||
source_path: std::path::PathBuf,
|
||||
profile_id: String,
|
||||
selection_source: crate::ConfigProfileSelectionSource,
|
||||
effective: crate::ResolvedConfigJson,
|
||||
settings: ksp_onchain_transport_lib::HttpTransportSettings,
|
||||
}
|
||||
|
||||
impl ResolvedTransportConfig {
|
||||
/// Returns the logical Config document identifier used by this runtime configuration.
|
||||
#[must_use]
|
||||
pub const fn file_id(&self) -> &crate::ConfigFileId {
|
||||
return &self.file_id;
|
||||
}
|
||||
|
||||
/// Returns the physical source Config document path.
|
||||
#[must_use]
|
||||
pub fn source_path(&self) -> &std::path::Path {
|
||||
return self.source_path.as_path();
|
||||
}
|
||||
|
||||
/// Returns the selected standard Transport profile identifier.
|
||||
#[must_use]
|
||||
pub fn profile_id(&self) -> &str {
|
||||
return self.profile_id.as_str();
|
||||
}
|
||||
|
||||
/// Returns the source that selected the standard Transport profile.
|
||||
#[must_use]
|
||||
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||
return self.selection_source;
|
||||
}
|
||||
|
||||
/// Returns the detailed environment-resolved effective Config view.
|
||||
///
|
||||
/// The real tree is available to legitimate runtime consumers. The safe tree redacts values originating from `KSP_SECRET_*` or `KSPB_SECRET_*`
|
||||
/// placeholders and is the only representation used by this type's [`std::fmt::Debug`] implementation.
|
||||
#[must_use]
|
||||
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||
return &self.effective;
|
||||
}
|
||||
|
||||
/// Returns the validated runtime HTTP Transport settings.
|
||||
#[must_use]
|
||||
pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Consumes this resolved Config and returns the mapped runtime HTTP Transport settings.
|
||||
#[must_use]
|
||||
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return self.settings;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ResolvedTransportConfig {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("ResolvedTransportConfig")
|
||||
.field("file_id", &self.file_id)
|
||||
.field("source_path", &self.source_path)
|
||||
.field("profile_id", &self.profile_id)
|
||||
.field("selection_source", &self.selection_source)
|
||||
.field("effective", &self.effective)
|
||||
.finish_non_exhaustive();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ConfigDocumentEngine {
|
||||
/// Loads the standard HTTP Transport document, selects a profile, resolves environment placeholders and maps it to Transport runtime settings.
|
||||
///
|
||||
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed
|
||||
/// because the Transport URL wrapper owns runtime redaction. Invalid environment-resolved values are reported as
|
||||
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] without copying endpoint URL values into ordinary error context.
|
||||
pub fn load_resolved_transport_config(
|
||||
&self,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
environment: &crate::ConfigEnvironment,
|
||||
) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_TRANSPORT);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile = self.load_resolved_profile(&file_id, requested_profile);
|
||||
let profile = match profile {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return resolve_transport_profile(&profile, environment);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveTransportSource {
|
||||
format_version: u32,
|
||||
profile_id: String,
|
||||
retry: EffectiveRetrySource,
|
||||
endpoints: std::vec::Vec<EffectiveEndpointSource>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveRetrySource {
|
||||
max_retries: u32,
|
||||
initial_backoff_ms: u64,
|
||||
max_backoff_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveEndpointSource {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
provider: String,
|
||||
cluster: String,
|
||||
url: String,
|
||||
connect_timeout_ms: u64,
|
||||
request_timeout_ms: u64,
|
||||
max_idle_connections_per_host: std::option::Option<usize>,
|
||||
roles: std::vec::Vec<EffectiveRoleSource>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveRoleSource {
|
||||
role: String,
|
||||
enabled: bool,
|
||||
request_kinds: std::vec::Vec<String>,
|
||||
priority: u32,
|
||||
limits: EffectiveLimitsSource,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveLimitsSource {
|
||||
requests_per_second: std::option::Option<u32>,
|
||||
burst_capacity: std::option::Option<u32>,
|
||||
max_concurrent_requests: std::option::Option<u32>,
|
||||
pause_after_rate_limit_ms: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||
let effective = match effective {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let source = serde_json::from_value::<EffectiveTransportSource>(effective.value().clone());
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "effective Transport Config cannot be decoded into the runtime adapter contract").with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if source.format_version != 1 {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported"));
|
||||
}
|
||||
if source.profile_id != profile.profile_id() {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Transport profile_id does not match the selected profile"));
|
||||
}
|
||||
let retry = ksp_onchain_transport_lib::HttpRetrySettings::new(
|
||||
source.retry.max_retries,
|
||||
std::time::Duration::from_millis(source.retry.initial_backoff_ms),
|
||||
std::time::Duration::from_millis(source.retry.max_backoff_ms),
|
||||
);
|
||||
let endpoints = map_endpoints(source.endpoints, profile);
|
||||
let endpoints = match endpoints {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(endpoints, retry);
|
||||
let validation = settings.validate();
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(transport_contract_error(profile, "effective Transport settings fail the Transport runtime contract", &error));
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
endpoint_count = settings.endpoints().len(),
|
||||
"mapped standard Transport Config to runtime settings"
|
||||
);
|
||||
return std::result::Result::Ok(ResolvedTransportConfig {
|
||||
file_id: profile.file_id().clone(),
|
||||
source_path: profile.path().to_path_buf(),
|
||||
profile_id: profile.profile_id().to_owned(),
|
||||
selection_source: profile.selection_source(),
|
||||
effective,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
fn map_endpoints(
|
||||
sources: std::vec::Vec<EffectiveEndpointSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointSettings>> {
|
||||
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::HttpEndpointSettings>::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
let endpoint_name = source.name.clone();
|
||||
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse(source.url);
|
||||
let url = match url {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
let roles = map_roles(source.roles, profile, endpoint_name.as_str());
|
||||
let roles = match roles {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
endpoints.push(ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||
source.name,
|
||||
source.enabled,
|
||||
ksp_onchain_transport_lib::HttpProviderName::new(source.provider),
|
||||
ksp_onchain_transport_lib::HttpClusterName::new(source.cluster),
|
||||
url,
|
||||
std::time::Duration::from_millis(source.connect_timeout_ms),
|
||||
std::time::Duration::from_millis(source.request_timeout_ms),
|
||||
source.max_idle_connections_per_host,
|
||||
roles,
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(endpoints);
|
||||
}
|
||||
|
||||
fn map_roles(
|
||||
sources: std::vec::Vec<EffectiveRoleSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointRoleSettings>> {
|
||||
let mut roles = std::vec::Vec::<ksp_onchain_transport_lib::HttpEndpointRoleSettings>::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
let requests_per_second = map_non_zero(source.limits.requests_per_second, profile, "limits.requests_per_second", endpoint_name);
|
||||
let requests_per_second = match requests_per_second {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let burst_capacity = map_non_zero(source.limits.burst_capacity, profile, "limits.burst_capacity", endpoint_name);
|
||||
let burst_capacity = match burst_capacity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let max_concurrent_requests = map_non_zero(source.limits.max_concurrent_requests, profile, "limits.max_concurrent_requests", endpoint_name);
|
||||
let max_concurrent_requests = match max_concurrent_requests {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let pause_after_rate_limit = match source.limits.pause_after_rate_limit_ms {
|
||||
std::option::Option::Some(value) => std::option::Option::Some(std::time::Duration::from_millis(value)),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
let limits = ksp_onchain_transport_lib::HttpRoleLimits::new(requests_per_second, burst_capacity, max_concurrent_requests, pause_after_rate_limit);
|
||||
let mut request_kinds = std::vec::Vec::<ksp_onchain_transport_lib::HttpRequestKind>::with_capacity(source.request_kinds.len());
|
||||
for request_kind in source.request_kinds {
|
||||
request_kinds.push(ksp_onchain_transport_lib::HttpRequestKind::new(request_kind));
|
||||
}
|
||||
roles.push(ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
ksp_onchain_transport_lib::HttpRoleName::new(source.role),
|
||||
source.enabled,
|
||||
request_kinds,
|
||||
source.priority,
|
||||
limits,
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(roles);
|
||||
}
|
||||
|
||||
fn map_non_zero(
|
||||
value: std::option::Option<u32>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
field: &'static str,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<std::option::Option<std::num::NonZeroU32>> {
|
||||
let value = match value {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let non_zero = std::num::NonZeroU32::new(value);
|
||||
return match non_zero {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(std::option::Option::Some(value)),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
effective_error(profile, "effective Transport role limit must be greater than zero")
|
||||
.with_context("field", field)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn transport_contract_error(profile: &crate::ResolvedConfigProfile, reason: &'static str, transport_error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||
return effective_error(profile, reason)
|
||||
.with_context("transport_error_domain", transport_error.code().domain())
|
||||
.with_context("transport_error_code", transport_error.code().code());
|
||||
}
|
||||
|
||||
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
|
||||
.with_context("file_id", profile.file_id().as_str())
|
||||
.with_context("profile_id", profile.profile_id())
|
||||
.with_context("reason", reason);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/transport.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/tests/ownership.rs
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
32
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
Normal file
32
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
// file: crates/ksp-config-lib/tests/transport_devnet_smoke.rs
|
||||
// version: 1
|
||||
|
||||
//! Opt-in live Devnet smoke for the Config -> Transport foundation path.
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "opt-in live Solana Devnet smoke; performs external network requests"]
|
||||
async fn committed_devnet_transport_profile_reaches_all_foundation_canaries() {
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"))
|
||||
.expect("committed Config roots must be valid");
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults().expect("default Config registry must be valid");
|
||||
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load().expect("Config must capture the opt-in smoke environment");
|
||||
let resolved = engine
|
||||
.load_resolved_transport_config(std::option::Option::Some("devnet_public"), &environment)
|
||||
.expect("committed devnet_public Transport profile must resolve");
|
||||
assert_eq!(resolved.profile_id(), "devnet_public");
|
||||
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings()).expect("resolved Transport settings must construct the HTTP pool");
|
||||
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
|
||||
let health = pool.get_health(&role).await.expect("Devnet getHealth smoke must succeed");
|
||||
assert_eq!(health, ksp_onchain_transport_lib::SolanaNodeHealth::Healthy);
|
||||
let genesis_hash = pool.get_genesis_hash(&role).await.expect("Devnet getGenesisHash smoke must succeed");
|
||||
assert!(!genesis_hash.as_str().is_empty());
|
||||
let version = pool.get_version(&role).await.expect("Devnet getVersion smoke must succeed");
|
||||
assert!(!version.solana_core().is_empty());
|
||||
let balance = pool
|
||||
.get_balance(&role, &ksp_core_lib::PRGIDPK_SOLANA_SYSTEM, std::option::Option::None)
|
||||
.await
|
||||
.expect("Devnet getBalance smoke must succeed");
|
||||
assert!(balance.context().slot() > 0);
|
||||
}
|
||||
@@ -22,4 +22,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
40
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
Normal file
40
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"retry": {
|
||||
"max_retries": 4,
|
||||
"initial_backoff_ms": 125,
|
||||
"max_backoff_ms": 2500
|
||||
},
|
||||
"default_profile": "secret_test",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "secret_test",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "fixture_private",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "fixture-cluster",
|
||||
"url": "${KSP_SECRET_TRANSPORT_TEST_URL:-https://fallback.invalid}",
|
||||
"connect_timeout_ms": 750,
|
||||
"request_timeout_ms": 2500,
|
||||
"max_idle_connections_per_host": 3,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"priority": 7,
|
||||
"limits": {
|
||||
"requests_per_second": 9,
|
||||
"burst_capacity": 12,
|
||||
"max_concurrent_requests": 4,
|
||||
"pause_after_rate_limit_ms": 650
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
||||
// 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();
|
||||
|
||||
202
crates/ksp-config-lib/unit_tests/transport.rs
Normal file
202
crates/ksp-config-lib/unit_tests/transport.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
// file: crates/ksp-config-lib/unit_tests/transport.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn fixture_transport_profile_maps_complete_runtime_contract() {
|
||||
let engine = fixture_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_ok(), "fixture Transport Config should map: {resolved:?}");
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
|
||||
assert_eq!(resolved.profile_id(), "secret_test");
|
||||
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||
assert_eq!(resolved.settings().retry().max_retries(), 4);
|
||||
assert_eq!(resolved.settings().retry().initial_backoff(), std::time::Duration::from_millis(125));
|
||||
assert_eq!(resolved.settings().retry().max_backoff(), std::time::Duration::from_millis(2500));
|
||||
assert_eq!(resolved.settings().endpoints().len(), 1);
|
||||
let endpoint = &resolved.settings().endpoints()[0];
|
||||
assert_eq!(endpoint.name(), "fixture_private");
|
||||
assert_eq!(endpoint.provider().as_str(), "fixture-provider");
|
||||
assert_eq!(endpoint.cluster().as_str(), "fixture-cluster");
|
||||
assert_eq!(endpoint.url().as_str(), "https://fallback.invalid");
|
||||
assert_eq!(endpoint.connect_timeout(), std::time::Duration::from_millis(750));
|
||||
assert_eq!(endpoint.request_timeout(), std::time::Duration::from_millis(2500));
|
||||
assert_eq!(endpoint.max_idle_connections_per_host(), std::option::Option::Some(3));
|
||||
assert_eq!(endpoint.roles().len(), 1);
|
||||
let role = &endpoint.roles()[0];
|
||||
assert_eq!(role.role().as_str(), "default");
|
||||
assert!(role.enabled());
|
||||
assert_eq!(role.priority(), 7);
|
||||
assert_eq!(role.request_kinds().len(), 1);
|
||||
assert!(role.request_kinds()[0].is_wildcard());
|
||||
assert_eq!(role.limits().requests_per_second().map(std::num::NonZeroU32::get), std::option::Option::Some(9));
|
||||
assert_eq!(role.limits().burst_capacity().map(std::num::NonZeroU32::get), std::option::Option::Some(12));
|
||||
assert_eq!(role.limits().max_concurrent_requests().map(std::num::NonZeroU32::get), std::option::Option::Some(4));
|
||||
assert_eq!(role.limits().pause_after_rate_limit(), std::option::Option::Some(std::time::Duration::from_millis(650)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_transport_document_maps_default_and_explicit_profiles() {
|
||||
let engine = committed_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||
let default = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||
let mainnet = engine.load_resolved_transport_config(std::option::Option::Some("mainnet_public"), &environment);
|
||||
assert!(default.is_ok(), "committed default Transport profile should map: {default:?}");
|
||||
assert!(mainnet.is_ok(), "committed explicit Transport profile should map: {mainnet:?}");
|
||||
if let std::result::Result::Ok(default) = default {
|
||||
assert_eq!(default.profile_id(), "devnet_public");
|
||||
assert_eq!(default.settings().endpoints()[0].cluster().as_str(), "devnet");
|
||||
assert_eq!(default.settings().endpoints()[0].url().as_str(), "https://api.devnet.solana.com");
|
||||
}
|
||||
if let std::result::Result::Ok(mainnet) = mainnet {
|
||||
assert_eq!(mainnet.profile_id(), "mainnet_public");
|
||||
assert_eq!(mainnet.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||
assert_eq!(mainnet.settings().endpoints()[0].cluster().as_str(), "mainnet-beta");
|
||||
assert_eq!(mainnet.settings().endpoints()[0].url().as_str(), "https://api.mainnet-beta.solana.com");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_profile_preserves_global_and_profile_origin() {
|
||||
let engine = committed_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_TRANSPORT);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||
assert!(profile.is_ok(), "committed Transport profile should resolve: {profile:?}");
|
||||
if let std::result::Result::Ok(profile) = profile {
|
||||
assert_eq!(profile.origin("retry"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||
assert_eq!(profile.origin("endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
|
||||
assert_eq!(profile.origin("format_version"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_transport_url_is_runtime_available_but_safe_projection_is_redacted() {
|
||||
let engine = fixture_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let canary = "https://secret-provider.invalid/?api-key=transport-secret-canary";
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_TRANSPORT_TEST_URL".to_owned(), canary.to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_ok(), "secret Transport endpoint should map without being rejected: {resolved:?}");
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(resolved.effective().sensitivity(), crate::ConfigSensitivity::Secret);
|
||||
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), canary);
|
||||
let safe_url = resolved.effective().safe_value().pointer("/endpoints/0/url").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(safe_url, std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||
let debug = format!("{resolved:?}");
|
||||
assert!(!debug.contains("transport-secret-canary"));
|
||||
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_secret_url_provenance_uses_process_and_process_beats_dotenv() {
|
||||
let engine = fixture_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_TRANSPORT_TEST_URL".to_owned(), "https://process.invalid".to_owned());
|
||||
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||
dotenv.insert("KSP_SECRET_TRANSPORT_TEST_URL".to_owned(), "https://dotenv.invalid".to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, dotenv);
|
||||
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_ok(), "secret Transport environment precedence should map: {resolved:?}");
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), "https://process.invalid");
|
||||
let provenance = resolved.effective().provenance_at("/endpoints/0/url");
|
||||
assert!(provenance.is_some(), "endpoint URL should retain environment provenance");
|
||||
if let std::option::Option::Some(provenance) = provenance {
|
||||
assert_eq!(provenance.len(), 1);
|
||||
assert_eq!(provenance[0].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||
assert_eq!(provenance[0].variable_name(), std::option::Option::Some("KSP_SECRET_TRANSPORT_TEST_URL"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_secret_transport_url_is_effective_config_error_without_secret_leak() {
|
||||
let engine = fixture_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let canary = "transport-invalid-secret-canary";
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_TRANSPORT_TEST_URL".to_owned(), canary.to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_err(), "invalid secret-derived endpoint URL must fail effective mapping");
|
||||
if let std::result::Result::Err(error) = resolved {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||
let debug = format!("{error:?}");
|
||||
assert!(!debug.contains(canary));
|
||||
assert!(error.context().iter().any(|item| -> bool {
|
||||
return item.key() == "transport_error_code" && item.value() == "invalid_settings";
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||
let workspace = workspace_root();
|
||||
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures");
|
||||
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = crate::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||
}
|
||||
|
||||
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||
let workspace = workspace_root();
|
||||
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = crate::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||
}
|
||||
|
||||
fn workspace_root() -> std::path::PathBuf {
|
||||
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
}
|
||||
68
crates/ksp-core-lib/tests/workspace_dependencies.rs
Normal file
68
crates/ksp-core-lib/tests/workspace_dependencies.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||
// version: 1
|
||||
|
||||
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
|
||||
|
||||
fn workspace_root() -> std::path::PathBuf {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let parent = manifest_directory.parent();
|
||||
assert!(parent.is_some(), "core crate must have a crates directory parent");
|
||||
let parent = match parent {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return manifest_directory.to_path_buf(),
|
||||
};
|
||||
let root = parent.parent();
|
||||
assert!(root.is_some(), "core crate must have a workspace root");
|
||||
return match root {
|
||||
std::option::Option::Some(value) => value.to_path_buf(),
|
||||
std::option::Option::None => parent.to_path_buf(),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_dependency_table_does_not_activate_consumer_features() {
|
||||
let manifest_path = workspace_root().join("Cargo.toml");
|
||||
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
||||
assert!(manifest.is_ok(), "workspace manifest must be readable during integration tests");
|
||||
let manifest = match manifest {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let workspace_dependencies = match manifest.split("[workspace.dependencies]").nth(1) {
|
||||
std::option::Option::Some(tail) => tail.split("[workspace.lints.rust]").next(),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
assert!(workspace_dependencies.is_some(), "workspace dependencies section must exist");
|
||||
let workspace_dependencies = match workspace_dependencies {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
for line in workspace_dependencies.lines() {
|
||||
let content = match line.split('#').next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
for inline_field in content.split(',') {
|
||||
let key = match inline_field.split('=').next() {
|
||||
std::option::Option::Some(value) => value.trim().trim_start_matches('{').trim(),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
assert_ne!(key, "features", "consumer feature activation must stay in member manifests");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_manifest_preserves_ksp_dependency_firewall() {
|
||||
let manifest_path = workspace_root().join("crates/ksp-onchain-transport-lib/Cargo.toml");
|
||||
let manifest = std::fs::read_to_string(manifest_path).expect("transport manifest must be readable during workspace integration tests");
|
||||
for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] {
|
||||
assert!(!manifest.contains(forbidden), "forbidden direct transport dependency detected: {forbidden}");
|
||||
}
|
||||
assert!(manifest.contains("ksp-core-lib"));
|
||||
assert!(manifest.contains("ksp-logging-lib"));
|
||||
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }"));
|
||||
assert!(manifest.contains("[dev-dependencies]"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }"));
|
||||
}
|
||||
121
crates/ksp-core-lib/tests/workspace_logging.rs
Normal file
121
crates/ksp-core-lib/tests/workspace_logging.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
// file: crates/ksp-core-lib/tests/workspace_logging.rs
|
||||
// version: 1
|
||||
|
||||
//! Workspace-level logging ownership canaries for KSP behavioral crates.
|
||||
|
||||
fn workspace_root() -> std::path::PathBuf {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let parent = manifest_directory.parent();
|
||||
assert!(parent.is_some(), "core crate must have a crates directory parent");
|
||||
let parent = match parent {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return manifest_directory.to_path_buf(),
|
||||
};
|
||||
let root = parent.parent();
|
||||
assert!(root.is_some(), "core crate must have a workspace root");
|
||||
return match root {
|
||||
std::option::Option::Some(value) => value.to_path_buf(),
|
||||
std::option::Option::None => parent.to_path_buf(),
|
||||
};
|
||||
}
|
||||
|
||||
fn rust_source_files(directory: &std::path::Path) -> std::vec::Vec<std::path::PathBuf> {
|
||||
let mut files = std::vec::Vec::new();
|
||||
let entries = std::fs::read_dir(directory);
|
||||
if entries.is_err() {
|
||||
return files;
|
||||
}
|
||||
let entries = match entries {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return files,
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
files.extend(rust_source_files(path.as_path()));
|
||||
} else if path.extension() == std::option::Option::Some(std::ffi::OsStr::new("rs")) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
fn package_name(manifest: &str) -> std::option::Option<&str> {
|
||||
for line in manifest.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("name = ") {
|
||||
continue;
|
||||
}
|
||||
return trimmed.split('"').nth(1);
|
||||
}
|
||||
return std::option::Option::None;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_crates_own_explicit_tracing_targets() {
|
||||
let crates_root = workspace_root().join("crates");
|
||||
let entries = std::fs::read_dir(crates_root.as_path());
|
||||
assert!(entries.is_ok(), "workspace crates directory must be readable");
|
||||
let entries = match entries {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
let crate_root = entry.path();
|
||||
if !crate_root.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let source_root = crate_root.join("src");
|
||||
let source_files = rust_source_files(source_root.as_path());
|
||||
let mut emits_ksp_logs = false;
|
||||
for source_file in source_files.iter() {
|
||||
let source = std::fs::read_to_string(source_file.as_path());
|
||||
assert!(source.is_ok(), "Rust source must be readable: {}", source_file.display());
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
if source.contains("ksp_logging_lib::") {
|
||||
emits_ksp_logs = true;
|
||||
}
|
||||
assert!(
|
||||
!source.contains("target: env!(\"CARGO_PKG_NAME\")"),
|
||||
"KSP tracing target must be explicit rather than derived from Cargo metadata: {}",
|
||||
source_file.display()
|
||||
);
|
||||
assert!(!source.contains("target: \"ksp-"), "KSP tracing target literals must be owned by src/constants.rs: {}", source_file.display());
|
||||
}
|
||||
if !emits_ksp_logs {
|
||||
continue;
|
||||
}
|
||||
let manifest = std::fs::read_to_string(crate_root.join("Cargo.toml"));
|
||||
assert!(manifest.is_ok(), "behavioral crate manifest must be readable: {}", crate_root.display());
|
||||
let manifest = match manifest {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
let package_name = package_name(manifest.as_str());
|
||||
assert!(package_name.is_some(), "behavioral crate package name must be discoverable: {}", crate_root.display());
|
||||
let package_name = match package_name {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let constants_path = source_root.join("constants.rs");
|
||||
let constants = std::fs::read_to_string(constants_path.as_path());
|
||||
assert!(constants.is_ok(), "behavioral crate must own src/constants.rs: {}", crate_root.display());
|
||||
let constants = match constants {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
let expected = std::format!("pub(crate) const TRACING_TARGET: &str = \"{package_name}\";");
|
||||
assert!(constants.contains(expected.as_str()), "behavioral crate must own its Cargo-name tracing target: {}", crate_root.display());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-logging-lib/Cargo.toml
|
||||
# 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
|
||||
|
||||
22
crates/ksp-onchain-transport-lib/Cargo.toml
Normal file
22
crates/ksp-onchain-transport-lib/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
# file: crates/ksp-onchain-transport-lib/Cargo.toml
|
||||
# version: 3
|
||||
|
||||
[package]
|
||||
name = "ksp-onchain-transport-lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
reqwest = { workspace = true, features = ["rustls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
135
crates/ksp-onchain-transport-lib/README.md
Normal file
135
crates/ksp-onchain-transport-lib/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# `ksp-onchain-transport-lib`
|
||||
|
||||
`ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Sa première surface est le transport HTTP JSON-RPC ; les extensions WebSocket et gRPC sont introduites séparément lorsque leur release les cible.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
La crate possède :
|
||||
|
||||
- les settings runtime HTTP publics ;
|
||||
- les endpoints nommés et leurs metadata provider/cluster ;
|
||||
- les rôles, capabilities/request kinds et priorités ;
|
||||
- la sélection/fairness/fallback du pool ;
|
||||
- les limites RPS/burst/concurrence et le cooldown ;
|
||||
- les deadlines et timeouts ;
|
||||
- le retry/backoff borné et la règle no-resend après dispatch ambigu ;
|
||||
- les enveloppes JSON-RPC 2.0 et leur validation ;
|
||||
- le registre audité des méthodes Solana HTTP ;
|
||||
- l'exécution générique des méthodes standard supportées ;
|
||||
- les wrappers typés explicitement livrés par KSP ;
|
||||
- les snapshots runtime sûrs ;
|
||||
- l'observabilité Transport via `ksp-logging-lib`.
|
||||
|
||||
La crate ne possède ni documents Config, ni persistence Store, ni modèles Program/métier.
|
||||
|
||||
## Frontières de dépendances
|
||||
|
||||
La direction autorisée est :
|
||||
|
||||
```text
|
||||
ksp-config-lib
|
||||
-> ksp-onchain-transport-lib
|
||||
-> ksp-core-lib
|
||||
-> ksp-logging-lib
|
||||
-> reqwest / tokio / serde
|
||||
```
|
||||
|
||||
La direction inverse est interdite :
|
||||
|
||||
```text
|
||||
ksp-onchain-transport-lib -X-> ksp-config-lib
|
||||
ksp-onchain-transport-lib -X-> Store
|
||||
ksp-onchain-transport-lib -X-> Program
|
||||
ksp-onchain-transport-lib -X-> tracing direct
|
||||
```
|
||||
|
||||
`ksp-config-lib` peut donc charger `std.transport.json` et construire `HttpTransportSettings`, tandis que Transport reste directement utilisable par un consumer qui fournit lui-même ses settings.
|
||||
|
||||
## Surface HTTP standard
|
||||
|
||||
Le registre KSP conserve deux inventaires distincts :
|
||||
|
||||
```text
|
||||
52 méthodes HTTP courantes
|
||||
14 méthodes historiques Deprecated / runtime Removed
|
||||
```
|
||||
|
||||
Le registre porte notamment :
|
||||
|
||||
- catégorie ;
|
||||
- request kind ;
|
||||
- statut documentaire ;
|
||||
- statut runtime ;
|
||||
- forme de requête stable/legacy ;
|
||||
- type d'opération ;
|
||||
- classe de retry ;
|
||||
- remplacement historique éventuel ;
|
||||
- release de couverture typée KSP.
|
||||
|
||||
Les quatre wrappers typés de la foundation sont :
|
||||
|
||||
```text
|
||||
getBalance
|
||||
getGenesisHash
|
||||
getHealth
|
||||
getVersion
|
||||
```
|
||||
|
||||
Les autres méthodes courantes peuvent déjà passer par l'exécuteur JSON-RPC standard générique lorsqu'un consumer fournit explicitement descriptor et paramètres JSON. Cette surface raw/générique **ne vaut pas couverture typée** : les wrappers et DTOs typés restants sont introduits selon la matrice HTTP KSP.
|
||||
|
||||
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
|
||||
|
||||
## Résilience
|
||||
|
||||
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
||||
|
||||
1. rôle et capability ;
|
||||
2. priorité croissante ;
|
||||
3. round-robin dans le meilleur tier ;
|
||||
4. RPS/burst ;
|
||||
5. concurrence ;
|
||||
6. cooldown rate-limit ;
|
||||
7. fallback vers les pairs puis les tiers inférieurs ;
|
||||
8. deadline commune à l'opération et ses retries.
|
||||
|
||||
Les retries ne sont autorisés que lorsque la metadata de méthode et l'état de dispatch les rendent sûrs. `WriteSubmission / NeverAfterDispatch` interdit tout resend automatique après un dispatch ambigu.
|
||||
|
||||
Les erreurs JSON-RPC applicatives ne sont pas transformées en retries transport génériques.
|
||||
|
||||
## Sécurité et diagnostics
|
||||
|
||||
Les URLs d'endpoint peuvent contenir des credentials. Elles ne sont donc pas exposées par les `Debug`, snapshots ou logs ordinaires.
|
||||
|
||||
Les `reqwest::Error` attachées comme source sont neutralisées avec `without_url()` avant exposition dans le contrat d'erreur KSP.
|
||||
|
||||
Le target de tracing est possédé explicitement par :
|
||||
|
||||
```text
|
||||
src/constants.rs
|
||||
TRACING_TARGET = "ksp-onchain-transport-lib"
|
||||
```
|
||||
|
||||
La configuration Logging de référence conserve un fichier dédié Transport à niveau `info`. Un niveau `debug`/`trace` ciblé peut être réactivé temporairement via Config lors d'un développement ou diagnostic explicite.
|
||||
|
||||
## Tests
|
||||
|
||||
Les tests par défaut sont déterministes et n'exigent pas Internet : fixtures JSON et serveur HTTP local couvrent requêtes, réponses, retry, 429, timeout, redaction et routing.
|
||||
|
||||
Un smoke Devnet live existe côté `ksp-config-lib` afin de tester la chaîne réelle :
|
||||
|
||||
```text
|
||||
Config -> std.transport/devnet_public -> HttpTransportPool
|
||||
-> getHealth/getGenesisHash/getVersion/getBalance
|
||||
```
|
||||
|
||||
Il est `ignored` par défaut et doit être exécuté explicitement.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw et inspection runtime ;
|
||||
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — plan et matrice HTTP ;
|
||||
- [`../../docs/validation/003-V0_2_1_ONCHAIN_HTTP.md`](../../docs/validation/003-V0_2_1_ONCHAIN_HTTP.md) — matrice de clôture ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP.
|
||||
164
crates/ksp-onchain-transport-lib/USAGE.md
Normal file
164
crates/ksp-onchain-transport-lib/USAGE.md
Normal file
@@ -0,0 +1,164 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Utilisation de `ksp-onchain-transport-lib`
|
||||
|
||||
Ce guide présente les surfaces publiques destinées aux consumers. Les notes de release restent dans `CHANGELOG.md` et les deltas.
|
||||
|
||||
## 1. Construction directe du runtime
|
||||
|
||||
Transport peut être utilisé sans Config. Le consumer construit les settings publics puis le pool :
|
||||
|
||||
```rust
|
||||
let url = match ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://api.devnet.solana.com") {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||
true,
|
||||
vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
|
||||
100,
|
||||
ksp_onchain_transport_lib::HttpRoleLimits::new(None, None, None, None),
|
||||
);
|
||||
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||
"solana_devnet_public",
|
||||
true,
|
||||
ksp_onchain_transport_lib::HttpProviderName::new("solana-public"),
|
||||
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||
url,
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(15),
|
||||
Some(8),
|
||||
vec![role],
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||
vec![endpoint],
|
||||
ksp_onchain_transport_lib::HttpRetrySettings::new(
|
||||
2,
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_secs(2),
|
||||
),
|
||||
);
|
||||
let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(settings) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
`HttpTransportSettings::validate()` peut être appelé explicitement avant la construction du pool lorsque le consumer veut séparer validation et initialisation.
|
||||
|
||||
## 2. Construction via `ksp-config-lib`
|
||||
|
||||
Lorsque le consumer utilise Config, la direction reste Config -> Transport :
|
||||
|
||||
```rust
|
||||
let resolved = match engine.load_resolved_transport_config(Some("devnet_public"), &environment) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings()) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
Le document standard peut contenir une URL provenant d'un `KSP_SECRET_*`. La valeur réelle est transmise au runtime, mais les projections sûres et `Debug` restent redacted.
|
||||
|
||||
## 3. Appels typés
|
||||
|
||||
Les wrappers typés se trouvent directement sur `HttpTransportPool`.
|
||||
|
||||
```rust
|
||||
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
|
||||
let health = pool.get_health(&role).await;
|
||||
let genesis_hash = pool.get_genesis_hash(&role).await;
|
||||
let version = pool.get_version(&role).await;
|
||||
let balance = pool
|
||||
.get_balance(
|
||||
&role,
|
||||
&ksp_core_lib::PRGIDPK_SOLANA_SYSTEM,
|
||||
Some(&ksp_onchain_transport_lib::GetBalanceConfig::new(
|
||||
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
None,
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
```
|
||||
|
||||
Les types de retour associés sont :
|
||||
|
||||
```text
|
||||
SolanaNodeHealth
|
||||
SolanaGenesisHash
|
||||
SolanaNodeVersion
|
||||
GetBalanceResult
|
||||
SolanaRpcContext
|
||||
```
|
||||
|
||||
`GetBalanceResult::value()` renvoie les lamports et `context()` fournit le slot/API version retournés par Solana.
|
||||
|
||||
## 4. Exécution JSON-RPC standard générique
|
||||
|
||||
Une méthode courante auditée peut être appelée via son descriptor :
|
||||
|
||||
```rust
|
||||
if let Some(descriptor) = ksp_onchain_transport_lib::find_http_rpc_method("getSlot") {
|
||||
let _result = pool.execute_standard_rpc(&role, descriptor, vec![]).await;
|
||||
}
|
||||
```
|
||||
|
||||
Cette API retourne un `serde_json::Value`. Elle est utile pour les consumers techniques et pour préparer les futures surfaces typées, mais elle ne remplace pas le wrapper typé d'une méthode dans la matrice de couverture KSP.
|
||||
|
||||
Avant exécution, `ensure_runtime_supported()` est appliqué. Une méthode historique `Removed` retourne `ERROR_CODE_METHOD_REMOVED` au lieu d'émettre un appel réseau fictif.
|
||||
|
||||
## 5. Sélection et admission sans exécuter la requête
|
||||
|
||||
Pour inspecter le routing :
|
||||
|
||||
```rust
|
||||
if let Some(descriptor) = ksp_onchain_transport_lib::find_http_rpc_method("getBalance") {
|
||||
let _selection = pool.select_for_method(&role, descriptor);
|
||||
let _permit = pool.acquire_for_method(&role, descriptor).await;
|
||||
}
|
||||
```
|
||||
|
||||
Dans le même bloc, `acquire_for_method()` réserve réellement la capacité RPS/concurrence sous deadline.
|
||||
|
||||
`HttpRequestPermit` détient la capacité de concurrence jusqu'à sa destruction. Aucun verrou synchrone n'est conservé pendant l'attente réseau.
|
||||
|
||||
## 6. Snapshots runtime
|
||||
|
||||
`HttpTransportPool::snapshot()` fournit une vue sûre des endpoints/rôles : disponibilité, limites, requêtes en vol, cooldown restant et compteurs runtime.
|
||||
|
||||
Les URLs d'endpoint n'y apparaissent jamais.
|
||||
|
||||
## 7. Retry et write submissions
|
||||
|
||||
La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`.
|
||||
|
||||
Les reads/simulations classés `RetrySafe` peuvent être réessayés dans le budget configuré lorsqu'une cause transport est explicitement retryable.
|
||||
|
||||
Pour une opération `WriteSubmission / NeverAfterDispatch`, un timeout ou autre résultat ambigu après dispatch arrête la resoumission automatique. Le consumer métier ne doit pas contourner cette protection avec une boucle de retry externe aveugle.
|
||||
|
||||
## 8. Logging
|
||||
|
||||
Les événements Transport utilisent le target :
|
||||
|
||||
```text
|
||||
ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
Ne jamais journaliser l'URL complète, un token provider, un body massif, une transaction complète ou une réponse complète.
|
||||
|
||||
La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement.
|
||||
|
||||
## 9. Smoke Devnet opt-in
|
||||
|
||||
Le smoke live est volontairement hors des tests par défaut :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Il charge le profil Config `devnet_public`, construit le pool puis appelle les quatre wrappers typés. Les endpoints publics Solana étant rate-limités et non destinés à la production, un échec réseau externe n'est pas interprété comme un échec déterministe de la suite locale.
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"3.1.8","slot":123456789},"value":424242},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":"GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC","id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":"ok","id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"solana-core":"3.1.8","feature-set":2891131721},"id":1}
|
||||
505
crates/ksp-onchain-transport-lib/src/client.rs
Normal file
505
crates/ksp-onchain-transport-lib/src/client.rs
Normal file
@@ -0,0 +1,505 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/client.rs
|
||||
// version: 5
|
||||
|
||||
/// Passive runtime availability reported for one logical HTTP endpoint or role.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum HttpEndpointAvailability {
|
||||
/// The endpoint or role is administratively disabled and cannot be selected.
|
||||
Disabled,
|
||||
/// The endpoint or role is enabled and currently eligible for selection.
|
||||
Available,
|
||||
/// The endpoint or role is enabled but degraded by recent passive runtime observations.
|
||||
Degraded,
|
||||
/// The endpoint or role is temporarily excluded after provider rate limiting.
|
||||
RateLimited,
|
||||
}
|
||||
|
||||
/// Safe routing and resilience snapshot for one configured endpoint role.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpEndpointRoleSnapshot {
|
||||
role: std::string::String,
|
||||
enabled: bool,
|
||||
request_kinds: std::vec::Vec<std::string::String>,
|
||||
priority: u32,
|
||||
availability: crate::HttpEndpointAvailability,
|
||||
requests_per_second: std::option::Option<u32>,
|
||||
burst_capacity: std::option::Option<u32>,
|
||||
max_concurrent_requests: std::option::Option<u32>,
|
||||
in_flight_requests: std::option::Option<u32>,
|
||||
cooldown_remaining: std::option::Option<std::time::Duration>,
|
||||
success_count: u64,
|
||||
failure_count: u64,
|
||||
rate_limit_count: u64,
|
||||
}
|
||||
|
||||
impl HttpEndpointRoleSnapshot {
|
||||
/// Returns the logical role name.
|
||||
#[must_use]
|
||||
pub fn role(&self) -> &str {
|
||||
return self.role.as_str();
|
||||
}
|
||||
|
||||
/// Returns whether the role is enabled.
|
||||
#[must_use]
|
||||
pub const fn enabled(&self) -> bool {
|
||||
return self.enabled;
|
||||
}
|
||||
|
||||
/// Returns request-kind descriptors accepted by the role.
|
||||
#[must_use]
|
||||
pub fn request_kinds(&self) -> &[std::string::String] {
|
||||
return self.request_kinds.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the routing priority where lower values are preferred.
|
||||
#[must_use]
|
||||
pub const fn priority(&self) -> u32 {
|
||||
return self.priority;
|
||||
}
|
||||
|
||||
/// Returns the passive runtime availability of this role.
|
||||
#[must_use]
|
||||
pub const fn availability(&self) -> crate::HttpEndpointAvailability {
|
||||
return self.availability;
|
||||
}
|
||||
|
||||
/// Returns the configured requests-per-second limit.
|
||||
#[must_use]
|
||||
pub const fn requests_per_second(&self) -> std::option::Option<u32> {
|
||||
return self.requests_per_second;
|
||||
}
|
||||
|
||||
/// Returns the configured token-bucket burst capacity.
|
||||
#[must_use]
|
||||
pub const fn burst_capacity(&self) -> std::option::Option<u32> {
|
||||
return self.burst_capacity;
|
||||
}
|
||||
|
||||
/// Returns the configured maximum concurrent request count.
|
||||
#[must_use]
|
||||
pub const fn max_concurrent_requests(&self) -> std::option::Option<u32> {
|
||||
return self.max_concurrent_requests;
|
||||
}
|
||||
|
||||
/// Returns the number of in-flight requests when concurrency is bounded.
|
||||
#[must_use]
|
||||
pub const fn in_flight_requests(&self) -> std::option::Option<u32> {
|
||||
return self.in_flight_requests;
|
||||
}
|
||||
|
||||
/// Returns the remaining provider cooldown when this role is rate-limited.
|
||||
#[must_use]
|
||||
pub const fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
|
||||
return self.cooldown_remaining;
|
||||
}
|
||||
|
||||
/// Returns the number of successful requests passively recorded for this role.
|
||||
#[must_use]
|
||||
pub const fn success_count(&self) -> u64 {
|
||||
return self.success_count;
|
||||
}
|
||||
|
||||
/// Returns the number of failed requests passively recorded for this role.
|
||||
#[must_use]
|
||||
pub const fn failure_count(&self) -> u64 {
|
||||
return self.failure_count;
|
||||
}
|
||||
|
||||
/// Returns the number of provider rate-limit observations recorded for this role.
|
||||
#[must_use]
|
||||
pub const fn rate_limit_count(&self) -> u64 {
|
||||
return self.rate_limit_count;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe metadata snapshot for one logical HTTP endpoint.
|
||||
///
|
||||
/// Endpoint URLs are intentionally absent because they can contain provider credentials.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpEndpointSnapshot {
|
||||
name: std::string::String,
|
||||
provider: std::string::String,
|
||||
cluster: std::string::String,
|
||||
enabled: bool,
|
||||
availability: crate::HttpEndpointAvailability,
|
||||
roles: std::vec::Vec<crate::HttpEndpointRoleSnapshot>,
|
||||
}
|
||||
|
||||
impl HttpEndpointSnapshot {
|
||||
/// Returns the configured endpoint identity.
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
return self.name.as_str();
|
||||
}
|
||||
|
||||
/// Returns the provider descriptor.
|
||||
#[must_use]
|
||||
pub fn provider(&self) -> &str {
|
||||
return self.provider.as_str();
|
||||
}
|
||||
|
||||
/// Returns the cluster descriptor.
|
||||
#[must_use]
|
||||
pub fn cluster(&self) -> &str {
|
||||
return self.cluster.as_str();
|
||||
}
|
||||
|
||||
/// Returns whether the endpoint is administratively enabled.
|
||||
#[must_use]
|
||||
pub const fn enabled(&self) -> bool {
|
||||
return self.enabled;
|
||||
}
|
||||
|
||||
/// Returns the passive runtime availability.
|
||||
#[must_use]
|
||||
pub const fn availability(&self) -> crate::HttpEndpointAvailability {
|
||||
return self.availability;
|
||||
}
|
||||
|
||||
/// Returns safe role snapshots in declaration order.
|
||||
#[must_use]
|
||||
pub fn roles(&self) -> &[crate::HttpEndpointRoleSnapshot] {
|
||||
return self.roles.as_slice();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct HttpEndpointHttpResponse {
|
||||
status: u16,
|
||||
retry_after: std::option::Option<std::time::Duration>,
|
||||
body: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
impl HttpEndpointHttpResponse {
|
||||
pub(crate) const fn status(&self) -> u16 {
|
||||
return self.status;
|
||||
}
|
||||
|
||||
pub(crate) const fn retry_after(&self) -> std::option::Option<std::time::Duration> {
|
||||
return self.retry_after;
|
||||
}
|
||||
|
||||
pub(crate) fn body(&self) -> &[u8] {
|
||||
return self.body.as_slice();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shareable logical HTTP endpoint client owned by KSP Transport.
|
||||
///
|
||||
/// The underlying `reqwest::Client` owns socket pooling. KSP keeps the configured URL private from diagnostics and exposes only safe routing metadata.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpEndpointClient {
|
||||
inner: std::sync::Arc<HttpEndpointClientInner>,
|
||||
}
|
||||
|
||||
struct HttpEndpointClientInner {
|
||||
settings: crate::HttpEndpointSettings,
|
||||
client: reqwest::Client,
|
||||
role_runtimes: std::vec::Vec<std::sync::Arc<crate::resilience::HttpRoleRuntime>>,
|
||||
}
|
||||
|
||||
impl HttpEndpointClient {
|
||||
/// Builds one logical endpoint client from KSP-owned runtime settings.
|
||||
pub fn new(settings: crate::HttpEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||
return Self::new_with_notify(settings, std::sync::Arc::new(tokio::sync::Notify::new()));
|
||||
}
|
||||
|
||||
pub(crate) fn new_with_notify(settings: crate::HttpEndpointSettings, notify: std::sync::Arc<tokio::sync::Notify>) -> ksp_core_lib::Result<Self> {
|
||||
let validation = crate::settings::validate_endpoint_settings(&settings);
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let client_result = build_reqwest_client(&settings);
|
||||
let client = match client_result {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CONNECTION_FAILED, "HTTP endpoint client could not be initialized")
|
||||
.with_context("endpoint_name", settings.name())
|
||||
.with_source(error.without_url()),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut role_runtimes = std::vec::Vec::with_capacity(settings.roles().len());
|
||||
for role in settings.roles() {
|
||||
role_runtimes.push(std::sync::Arc::new(crate::resilience::HttpRoleRuntime::new(role, std::sync::Arc::clone(¬ify))));
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = settings.name(),
|
||||
provider = settings.provider().as_str(),
|
||||
cluster = settings.cluster().as_str(),
|
||||
enabled = settings.enabled(),
|
||||
role_count = settings.roles().len(),
|
||||
"created logical HTTP endpoint client"
|
||||
);
|
||||
return std::result::Result::Ok(Self { inner: std::sync::Arc::new(HttpEndpointClientInner { settings, client, role_runtimes }) });
|
||||
}
|
||||
|
||||
/// Returns the endpoint identity used for safe diagnostics and routing.
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
return self.inner.settings.name();
|
||||
}
|
||||
|
||||
/// Returns the provider descriptor.
|
||||
#[must_use]
|
||||
pub fn provider(&self) -> &crate::HttpProviderName {
|
||||
return self.inner.settings.provider();
|
||||
}
|
||||
|
||||
/// Returns the cluster descriptor.
|
||||
#[must_use]
|
||||
pub fn cluster(&self) -> &crate::HttpClusterName {
|
||||
return self.inner.settings.cluster();
|
||||
}
|
||||
|
||||
/// Returns whether the endpoint is administratively enabled.
|
||||
#[must_use]
|
||||
pub fn enabled(&self) -> bool {
|
||||
return self.inner.settings.enabled();
|
||||
}
|
||||
|
||||
/// Returns the configured end-to-end request timeout.
|
||||
#[must_use]
|
||||
pub fn request_timeout(&self) -> std::time::Duration {
|
||||
return self.inner.settings.request_timeout();
|
||||
}
|
||||
|
||||
pub(crate) async fn post_json_rpc(&self, payload: &str, timeout: std::time::Duration) -> ksp_core_lib::Result<HttpEndpointHttpResponse> {
|
||||
if timeout.is_zero() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, "HTTP JSON-RPC request budget expired before dispatch")
|
||||
.with_context("endpoint_name", self.name()),
|
||||
);
|
||||
}
|
||||
let send_result = self
|
||||
.inner
|
||||
.client
|
||||
.post(self.inner.settings.url().as_str())
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(payload.to_owned())
|
||||
.timeout(timeout)
|
||||
.send()
|
||||
.await;
|
||||
let response = match send_result {
|
||||
std::result::Result::Ok(response) => response,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
|
||||
};
|
||||
let status = response.status().as_u16();
|
||||
let retry_after = parse_retry_after(response.headers());
|
||||
let body_result = response.bytes().await;
|
||||
let body = match body_result {
|
||||
std::result::Result::Ok(body) => body.to_vec(),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
|
||||
};
|
||||
return std::result::Result::Ok(HttpEndpointHttpResponse { status, retry_after, body });
|
||||
}
|
||||
|
||||
/// Returns whether one enabled role can serve the requested capability structurally.
|
||||
#[must_use]
|
||||
pub fn supports(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> bool {
|
||||
return self.matching_role(role, request_kind).is_some();
|
||||
}
|
||||
|
||||
/// Returns a safe endpoint snapshot with no URL or provider credential material.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> crate::HttpEndpointSnapshot {
|
||||
let mut roles = std::vec::Vec::with_capacity(self.inner.settings.roles().len());
|
||||
for (role_index, role) in self.inner.settings.roles().iter().enumerate() {
|
||||
let mut request_kinds = std::vec::Vec::with_capacity(role.request_kinds().len());
|
||||
for request_kind in role.request_kinds() {
|
||||
request_kinds.push(request_kind.as_str().to_owned());
|
||||
}
|
||||
let runtime = self.inner.role_runtimes.get(role_index);
|
||||
let role_snapshot = match runtime {
|
||||
std::option::Option::Some(runtime) => role_snapshot(role, request_kinds, runtime),
|
||||
std::option::Option::None => fallback_role_snapshot(role, request_kinds),
|
||||
};
|
||||
roles.push(role_snapshot);
|
||||
}
|
||||
return crate::HttpEndpointSnapshot {
|
||||
name: self.name().to_owned(),
|
||||
provider: self.provider().as_str().to_owned(),
|
||||
cluster: self.cluster().as_str().to_owned(),
|
||||
enabled: self.enabled(),
|
||||
availability: self.availability(),
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn matching_role<'a>(
|
||||
&'a self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
) -> std::option::Option<&'a crate::HttpEndpointRoleSettings> {
|
||||
if !self.enabled() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
for candidate_role in self.inner.settings.roles() {
|
||||
if !candidate_role.enabled() || candidate_role.role() != role {
|
||||
continue;
|
||||
}
|
||||
for capability in candidate_role.request_kinds() {
|
||||
if capability.is_wildcard() || capability == request_kind {
|
||||
return std::option::Option::Some(candidate_role);
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::option::Option::None;
|
||||
}
|
||||
|
||||
pub(crate) fn matching_role_runtime(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
) -> std::option::Option<(u32, std::sync::Arc<crate::resilience::HttpRoleRuntime>)> {
|
||||
if !self.enabled() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
for (role_index, candidate_role) in self.inner.settings.roles().iter().enumerate() {
|
||||
if !candidate_role.enabled() || candidate_role.role() != role {
|
||||
continue;
|
||||
}
|
||||
let mut handles = false;
|
||||
for capability in candidate_role.request_kinds() {
|
||||
if capability.is_wildcard() || capability == request_kind {
|
||||
handles = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !handles {
|
||||
continue;
|
||||
}
|
||||
let runtime = self.inner.role_runtimes.get(role_index);
|
||||
if let std::option::Option::Some(runtime) = runtime {
|
||||
return std::option::Option::Some((candidate_role.priority(), std::sync::Arc::clone(runtime)));
|
||||
}
|
||||
}
|
||||
return std::option::Option::None;
|
||||
}
|
||||
|
||||
pub(crate) fn availability(&self) -> crate::HttpEndpointAvailability {
|
||||
if !self.enabled() {
|
||||
return crate::HttpEndpointAvailability::Disabled;
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let mut enabled_role_count = 0_usize;
|
||||
let mut rate_limited_count = 0_usize;
|
||||
let mut degraded = false;
|
||||
for (role_index, role) in self.inner.settings.roles().iter().enumerate() {
|
||||
if !role.enabled() {
|
||||
continue;
|
||||
}
|
||||
enabled_role_count = enabled_role_count.saturating_add(1);
|
||||
let runtime = self.inner.role_runtimes.get(role_index);
|
||||
let availability = match runtime {
|
||||
std::option::Option::Some(runtime) => runtime.availability(now),
|
||||
std::option::Option::None => crate::HttpEndpointAvailability::Degraded,
|
||||
};
|
||||
if availability == crate::HttpEndpointAvailability::RateLimited {
|
||||
rate_limited_count = rate_limited_count.saturating_add(1);
|
||||
}
|
||||
if availability == crate::HttpEndpointAvailability::Degraded || availability == crate::HttpEndpointAvailability::RateLimited {
|
||||
degraded = true;
|
||||
}
|
||||
}
|
||||
if enabled_role_count > 0 && rate_limited_count == enabled_role_count {
|
||||
return crate::HttpEndpointAvailability::RateLimited;
|
||||
}
|
||||
if degraded || enabled_role_count == 0 {
|
||||
return crate::HttpEndpointAvailability::Degraded;
|
||||
}
|
||||
return crate::HttpEndpointAvailability::Available;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HttpEndpointClient {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("HttpEndpointClient").field("snapshot", &self.snapshot()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn role_snapshot(
|
||||
role: &crate::HttpEndpointRoleSettings,
|
||||
request_kinds: std::vec::Vec<std::string::String>,
|
||||
runtime: &crate::resilience::HttpRoleRuntime,
|
||||
) -> crate::HttpEndpointRoleSnapshot {
|
||||
let availability = if role.enabled() { runtime.availability(std::time::Instant::now()) } else { crate::HttpEndpointAvailability::Disabled };
|
||||
return crate::HttpEndpointRoleSnapshot {
|
||||
role: role.role().as_str().to_owned(),
|
||||
enabled: role.enabled(),
|
||||
request_kinds,
|
||||
priority: role.priority(),
|
||||
availability,
|
||||
requests_per_second: role.limits().requests_per_second().map(|value| return value.get()),
|
||||
burst_capacity: role.limits().burst_capacity().map(|value| return value.get()),
|
||||
max_concurrent_requests: runtime.max_concurrent_requests(),
|
||||
in_flight_requests: runtime.in_flight_requests(),
|
||||
cooldown_remaining: runtime.cooldown_remaining(),
|
||||
success_count: runtime.success_count(),
|
||||
failure_count: runtime.failure_count(),
|
||||
rate_limit_count: runtime.rate_limit_count(),
|
||||
};
|
||||
}
|
||||
|
||||
fn fallback_role_snapshot(role: &crate::HttpEndpointRoleSettings, request_kinds: std::vec::Vec<std::string::String>) -> crate::HttpEndpointRoleSnapshot {
|
||||
return crate::HttpEndpointRoleSnapshot {
|
||||
role: role.role().as_str().to_owned(),
|
||||
enabled: role.enabled(),
|
||||
request_kinds,
|
||||
priority: role.priority(),
|
||||
availability: crate::HttpEndpointAvailability::Degraded,
|
||||
requests_per_second: role.limits().requests_per_second().map(|value| return value.get()),
|
||||
burst_capacity: role.limits().burst_capacity().map(|value| return value.get()),
|
||||
max_concurrent_requests: role.limits().max_concurrent_requests().map(|value| return value.get()),
|
||||
in_flight_requests: std::option::Option::None,
|
||||
cooldown_remaining: std::option::Option::None,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
rate_limit_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn map_reqwest_error(endpoint_name: &str, error: reqwest::Error) -> ksp_core_lib::Error {
|
||||
let code = if error.is_timeout() {
|
||||
crate::ERROR_CODE_TIMEOUT
|
||||
} else if error.is_connect() {
|
||||
crate::ERROR_CODE_HTTP_CONNECTION_FAILED
|
||||
} else {
|
||||
crate::ERROR_CODE_HTTP_REQUEST_FAILED
|
||||
};
|
||||
return ksp_core_lib::Error::new(code, "HTTP JSON-RPC request failed").with_context("endpoint_name", endpoint_name).with_source(error.without_url());
|
||||
}
|
||||
|
||||
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> std::option::Option<std::time::Duration> {
|
||||
let value = match headers.get(reqwest::header::RETRY_AFTER) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let text = match value.to_str() {
|
||||
std::result::Result::Ok(text) => text.trim(),
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let seconds = match text.parse::<u64>() {
|
||||
std::result::Result::Ok(seconds) => seconds,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(std::time::Duration::from_secs(seconds));
|
||||
}
|
||||
|
||||
fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::Result<reqwest::Client, reqwest::Error> {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.connect_timeout(settings.connect_timeout())
|
||||
.timeout(settings.request_timeout())
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")));
|
||||
if let std::option::Option::Some(max_idle) = settings.max_idle_connections_per_host() {
|
||||
builder = builder.pool_max_idle_per_host(max_idle);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/client.rs"]
|
||||
mod tests;
|
||||
7
crates/ksp-onchain-transport-lib/src/constants.rs
Normal file
7
crates/ksp-onchain-transport-lib/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/constants.rs
|
||||
// version: 1
|
||||
|
||||
//! Transport-owned tracing constants.
|
||||
|
||||
/// Owning tracing target for events emitted by the on-chain transport crate.
|
||||
pub(crate) const TRACING_TARGET: &str = "ksp-onchain-transport-lib";
|
||||
27
crates/ksp-onchain-transport-lib/src/error.rs
Normal file
27
crates/ksp-onchain-transport-lib/src/error.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
||||
// version: 1
|
||||
|
||||
/// Error code used when HTTP transport runtime settings are invalid.
|
||||
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_settings");
|
||||
/// Error code used when no logical endpoint can satisfy a request.
|
||||
pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
|
||||
/// Error code used when an HTTP connection cannot be established.
|
||||
pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_connection_failed");
|
||||
/// Error code used when an HTTP request fails after a connection exists.
|
||||
pub const ERROR_CODE_HTTP_REQUEST_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_request_failed");
|
||||
/// Error code used when a transport deadline expires.
|
||||
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
||||
/// Error code used when an endpoint or provider rate-limits a request.
|
||||
pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rate_limited");
|
||||
/// Error code used when a JSON-RPC request cannot be encoded.
|
||||
pub const ERROR_CODE_JSON_ENCODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_encode_failed");
|
||||
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
|
||||
pub const ERROR_CODE_JSON_DECODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_decode_failed");
|
||||
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
|
||||
pub const ERROR_CODE_JSON_RPC_PROTOCOL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_rpc_protocol_invalid");
|
||||
/// Error code used when a remote JSON-RPC endpoint returns an application-level RPC error.
|
||||
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
|
||||
/// Error code used when a historically documented RPC method is no longer supported by the targeted runtime.
|
||||
pub const ERROR_CODE_METHOD_REMOVED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "method_removed");
|
||||
/// Error code used when a decoded response cannot satisfy the KSP transport contract expected by the caller.
|
||||
pub const ERROR_CODE_INVALID_RESPONSE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_response");
|
||||
232
crates/ksp-onchain-transport-lib/src/executor.rs
Normal file
232
crates/ksp-onchain-transport-lib/src/executor.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/executor.rs
|
||||
// version: 2
|
||||
|
||||
const HTTP_REQUEST_TIMEOUT: u16 = 408;
|
||||
const HTTP_TOO_MANY_REQUESTS: u16 = 429;
|
||||
const HTTP_INTERNAL_SERVER_ERROR: u16 = 500;
|
||||
const HTTP_BAD_GATEWAY: u16 = 502;
|
||||
const HTTP_SERVICE_UNAVAILABLE: u16 = 503;
|
||||
const HTTP_GATEWAY_TIMEOUT: u16 = 504;
|
||||
|
||||
impl crate::HttpTransportPool {
|
||||
/// Executes one audited standard Solana HTTP JSON-RPC method through KSP routing, admission and bounded retry policy.
|
||||
///
|
||||
/// This generic transport surface intentionally returns the raw JSON result. Typed method coverage remains explicit and is provided separately by
|
||||
/// method-specific KSP adapters.
|
||||
pub async fn execute_standard_rpc(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
method: &crate::HttpRpcMethodDescriptor,
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
let support = method.ensure_runtime_supported();
|
||||
if let std::result::Result::Err(error) = support {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let request_id = self.next_request_id();
|
||||
let request_result = crate::JsonRpcRequest::new(request_id, method.method(), params);
|
||||
let request = match request_result {
|
||||
std::result::Result::Ok(request) => request,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let payload_result = request.to_json_string();
|
||||
let payload = match payload_result {
|
||||
std::result::Result::Ok(payload) => payload,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let request_kind = crate::HttpRequestKind::new(method.request_kind());
|
||||
let timeout_result = self.common_request_timeout(role, &request_kind);
|
||||
let timeout = match timeout_result {
|
||||
std::result::Result::Ok(timeout) => timeout,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
let deadline = match started.checked_add(timeout) {
|
||||
std::option::Option::Some(deadline) => deadline,
|
||||
std::option::Option::None => return execution_timeout(method, "HTTP JSON-RPC execution deadline could not be represented"),
|
||||
};
|
||||
let mut completed_retries = 0_u32;
|
||||
loop {
|
||||
let remaining = remaining_budget(deadline);
|
||||
if remaining.is_zero() {
|
||||
return execution_timeout(method, "HTTP JSON-RPC execution deadline expired before transport attempt");
|
||||
}
|
||||
let permit_result = self.acquire_for_request_kind_with_timeout(role, &request_kind, remaining).await;
|
||||
let permit = match permit_result {
|
||||
std::result::Result::Ok(permit) => permit,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let send_timeout = std::cmp::min(remaining_budget(deadline), permit.client().request_timeout());
|
||||
let response_result = permit.client().post_json_rpc(payload.as_str(), send_timeout).await;
|
||||
let response = match response_result {
|
||||
std::result::Result::Ok(response) => response,
|
||||
std::result::Result::Err(error) => {
|
||||
permit.record_failure();
|
||||
let cause = retry_cause_for_error(&error);
|
||||
let dispatch_state = dispatch_state_for_error(&error);
|
||||
let decision =
|
||||
crate::evaluate_transport_retry(method, self.retry_settings(), cause, dispatch_state, completed_retries, std::option::Option::None);
|
||||
drop(permit);
|
||||
if let std::option::Option::Some(delay) = decision.delay() {
|
||||
let waited = wait_retry_delay(delay, deadline).await;
|
||||
if waited {
|
||||
completed_retries = completed_retries.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
return execution_timeout(method, "HTTP JSON-RPC retry delay exceeded the common request deadline");
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let status = response.status();
|
||||
if status == HTTP_TOO_MANY_REQUESTS {
|
||||
let provider_retry_after = response.retry_after();
|
||||
permit.record_rate_limited(provider_retry_after);
|
||||
let decision = crate::evaluate_transport_retry(
|
||||
method,
|
||||
self.retry_settings(),
|
||||
crate::HttpRetryCause::RateLimited,
|
||||
crate::HttpDispatchState::DispatchedAmbiguous,
|
||||
completed_retries,
|
||||
provider_retry_after,
|
||||
);
|
||||
drop(permit);
|
||||
if let std::option::Option::Some(delay) = decision.delay() {
|
||||
let waited = wait_retry_delay(delay, deadline).await;
|
||||
if waited {
|
||||
completed_retries = completed_retries.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
return execution_timeout(method, "HTTP JSON-RPC rate-limit retry exceeded the common request deadline");
|
||||
}
|
||||
return rate_limited_error(method, provider_retry_after);
|
||||
}
|
||||
if is_temporary_http_status(status) {
|
||||
permit.record_failure();
|
||||
let decision = crate::evaluate_transport_retry(
|
||||
method,
|
||||
self.retry_settings(),
|
||||
crate::HttpRetryCause::TemporaryHttp,
|
||||
crate::HttpDispatchState::DispatchedAmbiguous,
|
||||
completed_retries,
|
||||
std::option::Option::None,
|
||||
);
|
||||
drop(permit);
|
||||
if let std::option::Option::Some(delay) = decision.delay() {
|
||||
let waited = wait_retry_delay(delay, deadline).await;
|
||||
if waited {
|
||||
completed_retries = completed_retries.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
return execution_timeout(method, "HTTP JSON-RPC temporary-status retry exceeded the common request deadline");
|
||||
}
|
||||
return http_status_error(method, status);
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
permit.record_success();
|
||||
return http_status_error(method, status);
|
||||
}
|
||||
let response_text = match std::str::from_utf8(response.body()) {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(error) => {
|
||||
permit.record_failure();
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "HTTP JSON-RPC response body is not valid UTF-8")
|
||||
.with_context("rpc_method", method.method())
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let parsed_result = crate::parse_json_rpc_response_text(response_text, request_id);
|
||||
let parsed = match parsed_result {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
permit.record_failure();
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
permit.record_success();
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = permit.selection().endpoint_name(),
|
||||
role = permit.selection().role().as_str(),
|
||||
rpc_method = method.method(),
|
||||
request_id,
|
||||
completed_retries,
|
||||
http_status = status,
|
||||
"completed Solana HTTP JSON-RPC request"
|
||||
);
|
||||
return parsed.into_result();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_cause_for_error(error: &ksp_core_lib::Error) -> crate::HttpRetryCause {
|
||||
if error.code() == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
|
||||
return crate::HttpRetryCause::Connection;
|
||||
}
|
||||
if error.code() == crate::ERROR_CODE_TIMEOUT {
|
||||
return crate::HttpRetryCause::Timeout;
|
||||
}
|
||||
return crate::HttpRetryCause::Request;
|
||||
}
|
||||
|
||||
fn dispatch_state_for_error(error: &ksp_core_lib::Error) -> crate::HttpDispatchState {
|
||||
if error.code() == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
|
||||
return crate::HttpDispatchState::NotDispatched;
|
||||
}
|
||||
return crate::HttpDispatchState::DispatchedAmbiguous;
|
||||
}
|
||||
|
||||
const fn is_temporary_http_status(status: u16) -> bool {
|
||||
return status == HTTP_REQUEST_TIMEOUT
|
||||
|| status == HTTP_INTERNAL_SERVER_ERROR
|
||||
|| status == HTTP_BAD_GATEWAY
|
||||
|| status == HTTP_SERVICE_UNAVAILABLE
|
||||
|| status == HTTP_GATEWAY_TIMEOUT;
|
||||
}
|
||||
|
||||
fn remaining_budget(deadline: std::time::Instant) -> std::time::Duration {
|
||||
let now = std::time::Instant::now();
|
||||
if now >= deadline {
|
||||
return std::time::Duration::ZERO;
|
||||
}
|
||||
return deadline.duration_since(now);
|
||||
}
|
||||
|
||||
async fn wait_retry_delay(delay: std::time::Duration, deadline: std::time::Instant) -> bool {
|
||||
let remaining = remaining_budget(deadline);
|
||||
if remaining.is_zero() || delay >= remaining {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
return std::time::Instant::now() < deadline;
|
||||
}
|
||||
|
||||
fn execution_timeout(method: &crate::HttpRpcMethodDescriptor, message: &str) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message).with_context("rpc_method", method.method()));
|
||||
}
|
||||
|
||||
fn rate_limited_error(
|
||||
method: &crate::HttpRpcMethodDescriptor,
|
||||
provider_retry_after: std::option::Option<std::time::Duration>,
|
||||
) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
let mut error = ksp_core_lib::Error::new(crate::ERROR_CODE_RATE_LIMITED, "Solana HTTP endpoint rate-limited the JSON-RPC request")
|
||||
.with_context("rpc_method", method.method());
|
||||
if let std::option::Option::Some(delay) = provider_retry_after {
|
||||
error = error.with_context("retry_after_seconds", delay.as_secs().to_string());
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
fn http_status_error(method: &crate::HttpRpcMethodDescriptor, status: u16) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "Solana HTTP endpoint returned an unsuccessful status")
|
||||
.with_context("rpc_method", method.method())
|
||||
.with_context("http_status", status.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/executor.rs"]
|
||||
mod tests;
|
||||
280
crates/ksp-onchain-transport-lib/src/json_rpc.rs
Normal file
280
crates/ksp-onchain-transport-lib/src/json_rpc.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/json_rpc.rs
|
||||
// version: 1
|
||||
|
||||
const JSON_RPC_VERSION: &str = "2.0";
|
||||
|
||||
/// JSON-RPC 2.0 HTTP request envelope emitted by KSP.
|
||||
#[derive(Clone, PartialEq, serde::Serialize)]
|
||||
pub struct JsonRpcRequest {
|
||||
jsonrpc: &'static str,
|
||||
id: u64,
|
||||
method: std::string::String,
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcRequest {
|
||||
/// Creates a JSON-RPC 2.0 request with a KSP-owned numeric identifier.
|
||||
pub fn new(id: u64, method: impl std::convert::Into<std::string::String>, params: std::vec::Vec<serde_json::Value>) -> ksp_core_lib::Result<Self> {
|
||||
let method = method.into();
|
||||
if method.trim().is_empty() || method.trim() != method {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, "JSON-RPC method must be a non-empty trimmed string")
|
||||
.with_context("field", "method"),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(Self { jsonrpc: JSON_RPC_VERSION, id, method, params });
|
||||
}
|
||||
|
||||
/// Returns the numeric request identifier.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> u64 {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the RPC method name.
|
||||
#[must_use]
|
||||
pub fn method(&self) -> &str {
|
||||
return self.method.as_str();
|
||||
}
|
||||
|
||||
/// Returns ordered request parameters.
|
||||
#[must_use]
|
||||
pub fn params(&self) -> &[serde_json::Value] {
|
||||
return self.params.as_slice();
|
||||
}
|
||||
|
||||
/// Serializes the request into compact JSON text.
|
||||
pub fn to_json_string(&self) -> ksp_core_lib::Result<std::string::String> {
|
||||
let serialization_result = serde_json::to_string(self);
|
||||
return match serialization_result {
|
||||
std::result::Result::Ok(text) => std::result::Result::Ok(text),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_ENCODE_FAILED, "cannot encode JSON-RPC request")
|
||||
.with_context("method", self.method())
|
||||
.with_source(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JsonRpcRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("JsonRpcRequest")
|
||||
.field("jsonrpc", &self.jsonrpc)
|
||||
.field("id", &self.id)
|
||||
.field("method", &self.method)
|
||||
.field("param_count", &self.params.len())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
||||
#[derive(Clone, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct JsonRpcErrorObject {
|
||||
code: i64,
|
||||
message: std::string::String,
|
||||
#[serde(default)]
|
||||
data: std::option::Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcErrorObject {
|
||||
/// Returns the remote JSON-RPC application error code.
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> i64 {
|
||||
return self.code;
|
||||
}
|
||||
|
||||
/// Returns the remote human-readable RPC error message.
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
return self.message.as_str();
|
||||
}
|
||||
|
||||
/// Returns optional provider-supplied error data.
|
||||
#[must_use]
|
||||
pub const fn data(&self) -> std::option::Option<&serde_json::Value> {
|
||||
return self.data.as_ref();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JsonRpcErrorObject {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("JsonRpcErrorObject")
|
||||
.field("code", &self.code)
|
||||
.field("message", &"<redacted>")
|
||||
.field("data", &if self.data.is_some() { "present" } else { "absent" })
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated JSON-RPC 2.0 success response.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct JsonRpcSuccessResponse {
|
||||
id: u64,
|
||||
result: serde_json::Value,
|
||||
}
|
||||
|
||||
impl JsonRpcSuccessResponse {
|
||||
/// Returns the echoed request identifier.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> u64 {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the raw JSON result, including JSON `null` when the method legitimately returns it.
|
||||
#[must_use]
|
||||
pub const fn result(&self) -> &serde_json::Value {
|
||||
return &self.result;
|
||||
}
|
||||
|
||||
/// Consumes the response and returns its raw JSON result.
|
||||
#[must_use]
|
||||
pub fn into_result(self) -> serde_json::Value {
|
||||
return self.result;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JsonRpcSuccessResponse {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("JsonRpcSuccessResponse").field("id", &self.id).field("result", &"<omitted>").finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated JSON-RPC 2.0 error response.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct JsonRpcErrorResponse {
|
||||
id: u64,
|
||||
error: crate::JsonRpcErrorObject,
|
||||
}
|
||||
|
||||
impl JsonRpcErrorResponse {
|
||||
/// Returns the echoed request identifier.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> u64 {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the remote JSON-RPC application error payload.
|
||||
#[must_use]
|
||||
pub const fn error(&self) -> &crate::JsonRpcErrorObject {
|
||||
return &self.error;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated JSON-RPC 2.0 HTTP response preserving success and application-error payloads separately.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum JsonRpcResponse {
|
||||
/// Successful response containing a raw method result.
|
||||
Success(crate::JsonRpcSuccessResponse),
|
||||
/// Application-level JSON-RPC error returned by the remote endpoint.
|
||||
Error(crate::JsonRpcErrorResponse),
|
||||
}
|
||||
|
||||
impl JsonRpcResponse {
|
||||
/// Converts a validated response into the raw success result or a KSP application-error classification.
|
||||
pub fn into_result(self) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
return match self {
|
||||
Self::Success(success) => std::result::Result::Ok(success.into_result()),
|
||||
Self::Error(error_response) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_RPC_APPLICATION_ERROR, "Solana JSON-RPC endpoint returned an application error")
|
||||
.with_context("rpc_code", error_response.error().code().to_string()),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses and validates a JSON-RPC HTTP response from UTF-8 JSON text.
|
||||
pub fn parse_json_rpc_response_text(text: &str, expected_id: u64) -> ksp_core_lib::Result<crate::JsonRpcResponse> {
|
||||
let decode_result = serde_json::from_str::<serde_json::Value>(text);
|
||||
let value = match decode_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_DECODE_FAILED, "cannot decode JSON-RPC response as JSON").with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
return crate::parse_json_rpc_response_value(value, expected_id);
|
||||
}
|
||||
|
||||
/// Validates a decoded JSON value as one JSON-RPC HTTP response for the expected KSP request identifier.
|
||||
pub fn parse_json_rpc_response_value(value: serde_json::Value, expected_id: u64) -> ksp_core_lib::Result<crate::JsonRpcResponse> {
|
||||
let object = match value.as_object() {
|
||||
std::option::Option::Some(object) => object,
|
||||
std::option::Option::None => {
|
||||
return protocol_error("JSON-RPC response must be an object", "response");
|
||||
},
|
||||
};
|
||||
let version = match object.get("jsonrpc") {
|
||||
std::option::Option::Some(serde_json::Value::String(version)) => version.as_str(),
|
||||
std::option::Option::Some(_) => {
|
||||
return protocol_error("JSON-RPC version must be a string", "jsonrpc");
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return protocol_error("JSON-RPC response is missing its version", "jsonrpc");
|
||||
},
|
||||
};
|
||||
if version != JSON_RPC_VERSION {
|
||||
return protocol_error("JSON-RPC version must be exactly 2.0", "jsonrpc");
|
||||
}
|
||||
let response_id = match object.get("id") {
|
||||
std::option::Option::Some(id) => match id.as_u64() {
|
||||
std::option::Option::Some(id) => id,
|
||||
std::option::Option::None => {
|
||||
return protocol_error("JSON-RPC response id must be an unsigned integer", "id");
|
||||
},
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return protocol_error("JSON-RPC response is missing its id", "id");
|
||||
},
|
||||
};
|
||||
if response_id != expected_id {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, "JSON-RPC response id does not match the request")
|
||||
.with_context("expected_id", expected_id.to_string())
|
||||
.with_context("actual_id", response_id.to_string()),
|
||||
);
|
||||
}
|
||||
let has_result = object.contains_key("result");
|
||||
let has_error = object.contains_key("error");
|
||||
if has_result == has_error {
|
||||
return protocol_error("JSON-RPC response must contain exactly one of result or error", "response");
|
||||
}
|
||||
if has_result {
|
||||
let result = match object.get("result") {
|
||||
std::option::Option::Some(result) => result.clone(),
|
||||
std::option::Option::None => {
|
||||
return protocol_error("JSON-RPC result field disappeared during validation", "result");
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(crate::JsonRpcResponse::Success(crate::JsonRpcSuccessResponse { id: response_id, result }));
|
||||
}
|
||||
let error_value = match object.get("error") {
|
||||
std::option::Option::Some(error) => error.clone(),
|
||||
std::option::Option::None => {
|
||||
return protocol_error("JSON-RPC error field disappeared during validation", "error");
|
||||
},
|
||||
};
|
||||
let error_decode_result = serde_json::from_value::<crate::JsonRpcErrorObject>(error_value);
|
||||
let error = match error_decode_result {
|
||||
std::result::Result::Ok(error) => error,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, "JSON-RPC error object is invalid")
|
||||
.with_context("field", "error")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(crate::JsonRpcResponse::Error(crate::JsonRpcErrorResponse { id: response_id, error }));
|
||||
}
|
||||
|
||||
fn protocol_error(message: &str, field: &str) -> ksp_core_lib::Result<crate::JsonRpcResponse> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, message).with_context("field", field));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/json_rpc.rs"]
|
||||
mod tests;
|
||||
145
crates/ksp-onchain-transport-lib/src/lib.rs
Normal file
145
crates/ksp-onchain-transport-lib/src/lib.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
// version: 6
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! KSP-owned Solana on-chain transport foundation.
|
||||
//!
|
||||
//! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains
|
||||
//! independent from `ksp-config-lib`, Store and Program layers. `ksp-config-lib` now constructs these public settings through its one-way Config ->
|
||||
//! Transport adapter without creating a reverse dependency. Logical endpoint clients, priority-aware pools, bounded admission limits and retry/no-resend policy
|
||||
//! are available. The first typed Solana HTTP canaries execute real
|
||||
//! JSON-RPC requests while the remaining audited methods stay staged by subsequent `0.2.x` releases.
|
||||
|
||||
mod client;
|
||||
mod constants;
|
||||
mod error;
|
||||
mod executor;
|
||||
mod json_rpc;
|
||||
mod pool;
|
||||
mod resilience;
|
||||
mod rpc_canary;
|
||||
mod rpc_method;
|
||||
mod settings;
|
||||
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
|
||||
/// Passive runtime availability reported for one logical HTTP endpoint.
|
||||
pub use self::client::HttpEndpointAvailability;
|
||||
/// Shareable logical HTTP endpoint client owned by KSP Transport.
|
||||
pub use self::client::HttpEndpointClient;
|
||||
/// Safe routing snapshot for one configured endpoint role.
|
||||
pub use self::client::HttpEndpointRoleSnapshot;
|
||||
/// Safe metadata snapshot for one logical HTTP endpoint.
|
||||
pub use self::client::HttpEndpointSnapshot;
|
||||
/// Error code used when no logical endpoint can satisfy a request.
|
||||
pub use self::error::ERROR_CODE_ENDPOINT_SELECTION_FAILED;
|
||||
/// Error code used when an HTTP connection cannot be established.
|
||||
pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
|
||||
/// Error code used when an HTTP request fails after connection establishment.
|
||||
pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED;
|
||||
/// Error code used when a decoded response cannot satisfy the expected KSP transport contract.
|
||||
pub use self::error::ERROR_CODE_INVALID_RESPONSE;
|
||||
/// Error code used when HTTP transport runtime settings are invalid.
|
||||
pub use self::error::ERROR_CODE_INVALID_SETTINGS;
|
||||
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
|
||||
pub use self::error::ERROR_CODE_JSON_DECODE_FAILED;
|
||||
/// Error code used when a JSON-RPC request cannot be encoded.
|
||||
pub use self::error::ERROR_CODE_JSON_ENCODE_FAILED;
|
||||
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
|
||||
pub use self::error::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID;
|
||||
/// Error code used when a historically documented RPC method has been removed from the targeted runtime.
|
||||
pub use self::error::ERROR_CODE_METHOD_REMOVED;
|
||||
/// Error code used when an endpoint or provider rate-limits a request.
|
||||
pub use self::error::ERROR_CODE_RATE_LIMITED;
|
||||
/// Error code used when a remote endpoint returns an application-level JSON-RPC error.
|
||||
pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR;
|
||||
/// Error code used when a transport deadline expires.
|
||||
pub use self::error::ERROR_CODE_TIMEOUT;
|
||||
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
||||
pub use self::json_rpc::JsonRpcErrorObject;
|
||||
/// Validated JSON-RPC 2.0 error response.
|
||||
pub use self::json_rpc::JsonRpcErrorResponse;
|
||||
/// JSON-RPC 2.0 HTTP request envelope emitted by KSP.
|
||||
pub use self::json_rpc::JsonRpcRequest;
|
||||
/// Validated JSON-RPC 2.0 HTTP response.
|
||||
pub use self::json_rpc::JsonRpcResponse;
|
||||
/// Validated JSON-RPC 2.0 success response.
|
||||
pub use self::json_rpc::JsonRpcSuccessResponse;
|
||||
/// Parses and validates a JSON-RPC HTTP response from UTF-8 JSON text.
|
||||
pub use self::json_rpc::parse_json_rpc_response_text;
|
||||
/// Validates a decoded JSON value as one JSON-RPC HTTP response.
|
||||
pub use self::json_rpc::parse_json_rpc_response_value;
|
||||
/// Result of one logical endpoint selection.
|
||||
pub use self::pool::HttpEndpointSelection;
|
||||
/// Runtime admission permit for one HTTP request.
|
||||
pub use self::pool::HttpRequestPermit;
|
||||
/// Shareable logical HTTP endpoint pool with priority routing, admission limits and bounded deadlines.
|
||||
pub use self::pool::HttpTransportPool;
|
||||
/// Safe snapshot of the logical HTTP endpoint pool.
|
||||
pub use self::pool::HttpTransportPoolSnapshot;
|
||||
/// Dispatch knowledge used to prevent ambiguous automatic resubmission.
|
||||
pub use self::resilience::HttpDispatchState;
|
||||
/// Transport-level cause considered by the bounded retry policy.
|
||||
pub use self::resilience::HttpRetryCause;
|
||||
/// Result of evaluating one bounded transport retry opportunity.
|
||||
pub use self::resilience::HttpRetryDecision;
|
||||
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
|
||||
pub use self::resilience::evaluate_transport_retry;
|
||||
/// Optional typed configuration for the `getBalance` canary.
|
||||
pub use self::rpc_canary::GetBalanceConfig;
|
||||
/// Typed lamport balance returned by the `getBalance` canary.
|
||||
pub use self::rpc_canary::GetBalanceResult;
|
||||
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
|
||||
pub use self::rpc_canary::SolanaCommitment;
|
||||
/// Typed genesis hash returned by the `getGenesisHash` canary.
|
||||
pub use self::rpc_canary::SolanaGenesisHash;
|
||||
/// Typed healthy result returned by the `getHealth` canary.
|
||||
pub use self::rpc_canary::SolanaNodeHealth;
|
||||
/// Typed software-version response returned by the `getVersion` canary.
|
||||
pub use self::rpc_canary::SolanaNodeVersion;
|
||||
/// Typed Solana RPC context used by the initial account canary.
|
||||
pub use self::rpc_canary::SolanaRpcContext;
|
||||
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
|
||||
pub use self::rpc_method::HttpRpcCategory;
|
||||
/// Release that owns typed KSP coverage for one audited HTTP RPC method.
|
||||
pub use self::rpc_method::HttpRpcCoverageRelease;
|
||||
/// Immutable audited descriptor for one Solana HTTP JSON-RPC method.
|
||||
pub use self::rpc_method::HttpRpcMethodDescriptor;
|
||||
/// Documentation lifecycle status of one audited RPC method.
|
||||
pub use self::rpc_method::RpcDocumentationStatus;
|
||||
/// Technical operation kind used to separate reads, simulations and submissions.
|
||||
pub use self::rpc_method::RpcOperationKind;
|
||||
/// Request-form policy attached to a stable RPC method.
|
||||
pub use self::rpc_method::RpcRequestFormStatus;
|
||||
/// Runtime availability status of one audited RPC method.
|
||||
pub use self::rpc_method::RpcRuntimeStatus;
|
||||
/// HTTP transport retry classification attached to an RPC method descriptor.
|
||||
pub use self::rpc_method::TransportRetryClass;
|
||||
/// Returns all current Solana HTTP RPC method descriptors audited for the `0.2.1`–`0.2.4` coverage sequence.
|
||||
pub use self::rpc_method::current_http_rpc_methods;
|
||||
/// Finds a current or historical standard Solana HTTP RPC descriptor by exact method name.
|
||||
pub use self::rpc_method::find_http_rpc_method;
|
||||
/// Returns historically documented deprecated HTTP RPC descriptors retained for compliance history.
|
||||
pub use self::rpc_method::historical_http_rpc_methods;
|
||||
/// Open cluster or network descriptor used by HTTP endpoint settings.
|
||||
pub use self::settings::HttpClusterName;
|
||||
/// Runtime settings for one role declared by an HTTP endpoint.
|
||||
pub use self::settings::HttpEndpointRoleSettings;
|
||||
/// Runtime settings for one named Solana HTTP endpoint.
|
||||
pub use self::settings::HttpEndpointSettings;
|
||||
/// Runtime HTTP endpoint URL with redacted diagnostics.
|
||||
pub use self::settings::HttpEndpointUrl;
|
||||
/// Open provider descriptor used by HTTP endpoint settings.
|
||||
pub use self::settings::HttpProviderName;
|
||||
/// Open request-kind descriptor used by logical endpoint capabilities.
|
||||
pub use self::settings::HttpRequestKind;
|
||||
/// Bounded retry settings owned by the HTTP transport runtime.
|
||||
pub use self::settings::HttpRetrySettings;
|
||||
/// Local limits attached to one logical HTTP endpoint role.
|
||||
pub use self::settings::HttpRoleLimits;
|
||||
/// Open logical endpoint role descriptor.
|
||||
pub use self::settings::HttpRoleName;
|
||||
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
|
||||
pub use self::settings::HttpTransportSettings;
|
||||
618
crates/ksp-onchain-transport-lib/src/pool.rs
Normal file
618
crates/ksp-onchain-transport-lib/src/pool.rs
Normal file
@@ -0,0 +1,618 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/pool.rs
|
||||
// version: 5
|
||||
|
||||
/// Safe snapshot of the logical HTTP endpoint pool.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpTransportPoolSnapshot {
|
||||
endpoints: std::vec::Vec<crate::HttpEndpointSnapshot>,
|
||||
}
|
||||
|
||||
impl HttpTransportPoolSnapshot {
|
||||
/// Returns safe endpoint snapshots in configured declaration order.
|
||||
#[must_use]
|
||||
pub fn endpoints(&self) -> &[crate::HttpEndpointSnapshot] {
|
||||
return self.endpoints.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the total number of configured logical endpoints.
|
||||
#[must_use]
|
||||
pub fn endpoint_count(&self) -> usize {
|
||||
return self.endpoints.len();
|
||||
}
|
||||
|
||||
/// Returns the number of endpoints currently eligible for normal routing.
|
||||
#[must_use]
|
||||
pub fn available_endpoint_count(&self) -> usize {
|
||||
return self.endpoints.iter().filter(|endpoint| return endpoint.availability() == crate::HttpEndpointAvailability::Available).count();
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of one logical endpoint selection.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpEndpointSelection {
|
||||
client: crate::HttpEndpointClient,
|
||||
role: crate::HttpRoleName,
|
||||
request_kind: crate::HttpRequestKind,
|
||||
priority: u32,
|
||||
}
|
||||
|
||||
impl HttpEndpointSelection {
|
||||
/// Returns the selected endpoint client.
|
||||
#[must_use]
|
||||
pub const fn client(&self) -> &crate::HttpEndpointClient {
|
||||
return &self.client;
|
||||
}
|
||||
|
||||
/// Returns the selected endpoint identity.
|
||||
#[must_use]
|
||||
pub fn endpoint_name(&self) -> &str {
|
||||
return self.client.name();
|
||||
}
|
||||
|
||||
/// Returns the matched logical role.
|
||||
#[must_use]
|
||||
pub const fn role(&self) -> &crate::HttpRoleName {
|
||||
return &self.role;
|
||||
}
|
||||
|
||||
/// Returns the matched request-kind capability.
|
||||
#[must_use]
|
||||
pub const fn request_kind(&self) -> &crate::HttpRequestKind {
|
||||
return &self.request_kind;
|
||||
}
|
||||
|
||||
/// Returns the selected role priority where lower values are preferred.
|
||||
#[must_use]
|
||||
pub const fn priority(&self) -> u32 {
|
||||
return self.priority;
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime admission permit for one HTTP request.
|
||||
///
|
||||
/// The permit reserves configured concurrency capacity and carries the common request deadline. Dropping it releases any semaphore capacity immediately.
|
||||
pub struct HttpRequestPermit {
|
||||
selection: crate::HttpEndpointSelection,
|
||||
deadline: std::time::Instant,
|
||||
role_runtime: std::sync::Arc<crate::resilience::HttpRoleRuntime>,
|
||||
_concurrency_permit: crate::resilience::HttpConcurrencyPermit,
|
||||
}
|
||||
|
||||
impl HttpRequestPermit {
|
||||
/// Returns the selected logical endpoint and role.
|
||||
#[must_use]
|
||||
pub const fn selection(&self) -> &crate::HttpEndpointSelection {
|
||||
return &self.selection;
|
||||
}
|
||||
|
||||
/// Returns the selected endpoint client.
|
||||
#[must_use]
|
||||
pub fn client(&self) -> &crate::HttpEndpointClient {
|
||||
return self.selection.client();
|
||||
}
|
||||
|
||||
/// Returns the remaining duration in the common request budget.
|
||||
#[must_use]
|
||||
pub fn remaining_timeout(&self) -> std::time::Duration {
|
||||
let now = std::time::Instant::now();
|
||||
if now >= self.deadline {
|
||||
return std::time::Duration::ZERO;
|
||||
}
|
||||
return self.deadline.duration_since(now);
|
||||
}
|
||||
|
||||
/// Records a successful request and clears the passive degraded state for this endpoint role.
|
||||
pub fn record_success(&self) {
|
||||
self.role_runtime.record_success();
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = self.selection.endpoint_name(),
|
||||
role = self.selection.role().as_str(),
|
||||
request_kind = self.selection.request_kind().as_str(),
|
||||
"recorded successful HTTP endpoint observation"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Records a transport failure and marks the endpoint role degraded until a later success.
|
||||
pub fn record_failure(&self) {
|
||||
self.role_runtime.record_failure();
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = self.selection.endpoint_name(),
|
||||
provider = self.selection.client().provider().as_str(),
|
||||
cluster = self.selection.client().cluster().as_str(),
|
||||
role = self.selection.role().as_str(),
|
||||
request_kind = self.selection.request_kind().as_str(),
|
||||
"HTTP endpoint role marked degraded after transport failure"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Records provider rate limiting and applies the role cooldown.
|
||||
///
|
||||
/// A provider delay can extend the configured cooldown but is defensively capped by the Transport runtime before use.
|
||||
pub fn record_rate_limited(&self, provider_retry_after: std::option::Option<std::time::Duration>) -> std::time::Duration {
|
||||
let pause = self.role_runtime.record_rate_limited(provider_retry_after);
|
||||
let cooldown_ms = duration_millis_u64(pause);
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = self.selection.endpoint_name(),
|
||||
provider = self.selection.client().provider().as_str(),
|
||||
cluster = self.selection.client().cluster().as_str(),
|
||||
role = self.selection.role().as_str(),
|
||||
request_kind = self.selection.request_kind().as_str(),
|
||||
cooldown_ms,
|
||||
"HTTP endpoint role entered provider rate-limit cooldown"
|
||||
);
|
||||
return pause;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HttpRequestPermit {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("HttpRequestPermit")
|
||||
.field("selection", &self.selection)
|
||||
.field("remaining_timeout", &self.remaining_timeout())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shareable logical HTTP endpoint pool with priority routing, admission limits and bounded request deadlines.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpTransportPool {
|
||||
inner: std::sync::Arc<HttpTransportPoolInner>,
|
||||
}
|
||||
|
||||
struct HttpTransportPoolInner {
|
||||
clients: std::vec::Vec<crate::HttpEndpointClient>,
|
||||
retry: crate::HttpRetrySettings,
|
||||
notify: std::sync::Arc<tokio::sync::Notify>,
|
||||
request_ids: std::sync::atomic::AtomicU64,
|
||||
cursors: std::sync::Mutex<std::collections::BTreeMap<(std::string::String, std::string::String, u32), usize>>,
|
||||
}
|
||||
|
||||
impl HttpTransportPool {
|
||||
/// Builds a logical endpoint pool after validating all Transport-owned runtime settings.
|
||||
pub fn new(settings: crate::HttpTransportSettings) -> ksp_core_lib::Result<Self> {
|
||||
let validation = settings.validate();
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let notify = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
let mut clients = std::vec::Vec::with_capacity(settings.endpoints().len());
|
||||
for endpoint in settings.endpoints() {
|
||||
let client_result = crate::HttpEndpointClient::new_with_notify(endpoint.clone(), std::sync::Arc::clone(¬ify));
|
||||
let client = match client_result {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
clients.push(client);
|
||||
}
|
||||
let pool = Self {
|
||||
inner: std::sync::Arc::new(HttpTransportPoolInner {
|
||||
clients,
|
||||
retry: settings.retry().clone(),
|
||||
notify,
|
||||
request_ids: std::sync::atomic::AtomicU64::new(1),
|
||||
cursors: std::sync::Mutex::new(std::collections::BTreeMap::new()),
|
||||
}),
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_count = pool.inner.clients.len(),
|
||||
available_endpoint_count = pool.snapshot().available_endpoint_count(),
|
||||
max_retries = pool.inner.retry.max_retries(),
|
||||
"created logical HTTP endpoint pool"
|
||||
);
|
||||
return std::result::Result::Ok(pool);
|
||||
}
|
||||
|
||||
/// Returns the bounded transport retry settings owned by this pool.
|
||||
#[must_use]
|
||||
pub fn retry_settings(&self) -> &crate::HttpRetrySettings {
|
||||
return &self.inner.retry;
|
||||
}
|
||||
|
||||
pub(crate) fn next_request_id(&self) -> u64 {
|
||||
let id = self.inner.request_ids.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if id == 0 {
|
||||
return self.inner.request_ids.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Selects an endpoint for one standard audited RPC method without reserving runtime capacity.
|
||||
///
|
||||
/// Request execution should use `acquire_for_method` so rate, cooldown and concurrency limits are enforced.
|
||||
pub fn select_for_method(&self, role: &crate::HttpRoleName, method: &crate::HttpRpcMethodDescriptor) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||
let support = method.ensure_runtime_supported();
|
||||
if let std::result::Result::Err(error) = support {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return self.select_for_request_kind(role, &crate::HttpRequestKind::new(method.request_kind()));
|
||||
}
|
||||
|
||||
/// Selects an endpoint for an open request-kind descriptor without reserving runtime capacity.
|
||||
pub fn select_for_request_kind(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||
let candidates = self.static_candidates(role, request_kind);
|
||||
if candidates.is_empty() {
|
||||
return selection_failed(role, request_kind);
|
||||
}
|
||||
let best_priority = candidates[0].priority;
|
||||
let mut tier_size = 0_usize;
|
||||
for candidate in &candidates {
|
||||
if candidate.priority != best_priority {
|
||||
break;
|
||||
}
|
||||
tier_size = tier_size.saturating_add(1);
|
||||
}
|
||||
let selected_position = self.next_position(role, request_kind, best_priority, tier_size);
|
||||
let selected = match candidates.get(selected_position) {
|
||||
std::option::Option::Some(selected) => selected,
|
||||
std::option::Option::None => return selection_failed(role, request_kind),
|
||||
};
|
||||
return self.selection_from_candidate(role, request_kind, selected);
|
||||
}
|
||||
|
||||
/// Acquires runtime capacity for one standard audited RPC method using the common timeout of matching endpoints.
|
||||
pub async fn acquire_for_method(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
method: &crate::HttpRpcMethodDescriptor,
|
||||
) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||
let support = method.ensure_runtime_supported();
|
||||
if let std::result::Result::Err(error) = support {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return self.acquire_for_request_kind(role, &crate::HttpRequestKind::new(method.request_kind())).await;
|
||||
}
|
||||
|
||||
/// Acquires runtime capacity for an open request-kind descriptor using the shortest configured request timeout among matching endpoints as the common
|
||||
/// deadline.
|
||||
pub async fn acquire_for_request_kind(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||
let timeout_result = self.common_request_timeout(role, request_kind);
|
||||
let timeout = match timeout_result {
|
||||
std::result::Result::Ok(timeout) => timeout,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return self.acquire_for_request_kind_with_timeout(role, request_kind, timeout).await;
|
||||
}
|
||||
|
||||
/// Acquires runtime capacity with an explicit end-to-end admission budget.
|
||||
///
|
||||
/// This is primarily useful when a higher layer already owns a stricter request deadline. A zero timeout is rejected as immediately expired.
|
||||
pub async fn acquire_for_request_kind_with_timeout(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
timeout: std::time::Duration,
|
||||
) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||
if timeout.is_zero() {
|
||||
return request_timeout(role, request_kind, "HTTP request admission deadline expired before selection");
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let deadline = match now.checked_add(timeout) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return request_timeout(role, request_kind, "HTTP request admission deadline could not be represented"),
|
||||
};
|
||||
return self.acquire_until(role, request_kind, deadline).await;
|
||||
}
|
||||
|
||||
/// Returns a safe pool snapshot without endpoint URLs.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> crate::HttpTransportPoolSnapshot {
|
||||
let endpoints = self.inner.clients.iter().map(|client| return client.snapshot()).collect();
|
||||
return crate::HttpTransportPoolSnapshot { endpoints };
|
||||
}
|
||||
|
||||
async fn acquire_until(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
deadline: std::time::Instant,
|
||||
) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||
let candidates = self.runtime_candidates(role, request_kind);
|
||||
if candidates.is_empty() {
|
||||
return request_selection_failed(role, request_kind);
|
||||
}
|
||||
loop {
|
||||
let now = std::time::Instant::now();
|
||||
if now >= deadline {
|
||||
return request_timeout(role, request_kind, "HTTP request admission deadline expired while waiting for endpoint capacity");
|
||||
}
|
||||
let attempt = self.try_candidates(role, request_kind, candidates.as_slice(), now, deadline);
|
||||
match attempt {
|
||||
RuntimeSelectionAttempt::Ready(permit) => return std::result::Result::Ok(permit),
|
||||
RuntimeSelectionAttempt::Blocked { earliest_ready, concurrency_saturated } => {
|
||||
let wait_result = self.wait_for_capacity(earliest_ready, concurrency_saturated, deadline).await;
|
||||
if !wait_result {
|
||||
return request_timeout(role, request_kind, "HTTP request admission deadline expired while waiting for endpoint capacity");
|
||||
}
|
||||
},
|
||||
RuntimeSelectionAttempt::Unavailable => return request_selection_failed(role, request_kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_candidates(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
candidates: &[RuntimePoolCandidate],
|
||||
now: std::time::Instant,
|
||||
deadline: std::time::Instant,
|
||||
) -> RuntimeSelectionAttempt {
|
||||
let mut earliest_ready: std::option::Option<std::time::Instant> = std::option::Option::None;
|
||||
let mut concurrency_saturated = false;
|
||||
let mut tier_start = 0_usize;
|
||||
while tier_start < candidates.len() {
|
||||
let priority = candidates[tier_start].priority;
|
||||
let mut tier_end = tier_start;
|
||||
while tier_end < candidates.len() && candidates[tier_end].priority == priority {
|
||||
tier_end = tier_end.saturating_add(1);
|
||||
}
|
||||
let tier_size = tier_end.saturating_sub(tier_start);
|
||||
let start_position = self.next_position(role, request_kind, priority, tier_size);
|
||||
let mut offset = 0_usize;
|
||||
while offset < tier_size {
|
||||
let position = tier_start.saturating_add((start_position.saturating_add(offset)) % tier_size);
|
||||
let candidate = &candidates[position];
|
||||
let admission = candidate.runtime.try_acquire(now);
|
||||
match admission {
|
||||
crate::resilience::RoleAdmissionAttempt::Ready(concurrency_permit) => {
|
||||
let selection_result = self.selection_from_runtime_candidate(role, request_kind, candidate);
|
||||
let selection = match selection_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return RuntimeSelectionAttempt::Unavailable,
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = selection.endpoint_name(),
|
||||
role = role.as_str(),
|
||||
request_kind = request_kind.as_str(),
|
||||
priority,
|
||||
remaining_deadline_ms = duration_millis_u64(deadline.saturating_duration_since(now)),
|
||||
"admitted HTTP request through logical endpoint pool"
|
||||
);
|
||||
return RuntimeSelectionAttempt::Ready(crate::HttpRequestPermit {
|
||||
selection,
|
||||
deadline,
|
||||
role_runtime: std::sync::Arc::clone(&candidate.runtime),
|
||||
_concurrency_permit: concurrency_permit,
|
||||
});
|
||||
},
|
||||
crate::resilience::RoleAdmissionAttempt::BlockedUntil(ready_at) => {
|
||||
earliest_ready = earlier_instant(earliest_ready, ready_at);
|
||||
},
|
||||
crate::resilience::RoleAdmissionAttempt::ConcurrencySaturated => {
|
||||
concurrency_saturated = true;
|
||||
},
|
||||
crate::resilience::RoleAdmissionAttempt::Unavailable => {},
|
||||
}
|
||||
offset = offset.saturating_add(1);
|
||||
}
|
||||
tier_start = tier_end;
|
||||
}
|
||||
if earliest_ready.is_none() && !concurrency_saturated {
|
||||
return RuntimeSelectionAttempt::Unavailable;
|
||||
}
|
||||
return RuntimeSelectionAttempt::Blocked { earliest_ready, concurrency_saturated };
|
||||
}
|
||||
|
||||
async fn wait_for_capacity(
|
||||
&self,
|
||||
earliest_ready: std::option::Option<std::time::Instant>,
|
||||
concurrency_saturated: bool,
|
||||
deadline: std::time::Instant,
|
||||
) -> bool {
|
||||
let now = std::time::Instant::now();
|
||||
if now >= deadline {
|
||||
return false;
|
||||
}
|
||||
let wake_at = match earliest_ready {
|
||||
std::option::Option::Some(ready_at) => std::cmp::min(ready_at, deadline),
|
||||
std::option::Option::None => deadline,
|
||||
};
|
||||
if concurrency_saturated {
|
||||
tokio::select! {
|
||||
() = self.inner.notify.notified() => {},
|
||||
() = tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)) => {},
|
||||
}
|
||||
} else {
|
||||
tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)).await;
|
||||
}
|
||||
return std::time::Instant::now() < deadline;
|
||||
}
|
||||
|
||||
pub(crate) fn common_request_timeout(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
) -> ksp_core_lib::Result<std::time::Duration> {
|
||||
let mut timeout: std::option::Option<std::time::Duration> = std::option::Option::None;
|
||||
for client in &self.inner.clients {
|
||||
if client.matching_role(role, request_kind).is_none() {
|
||||
continue;
|
||||
}
|
||||
timeout = match timeout {
|
||||
std::option::Option::Some(current) => std::option::Option::Some(std::cmp::min(current, client.request_timeout())),
|
||||
std::option::Option::None => std::option::Option::Some(client.request_timeout()),
|
||||
};
|
||||
}
|
||||
return match timeout {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => selection_failed_duration(role, request_kind),
|
||||
};
|
||||
}
|
||||
|
||||
fn static_candidates(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> std::vec::Vec<PoolCandidate> {
|
||||
let mut candidates = std::vec::Vec::new();
|
||||
for (client_index, client) in self.inner.clients.iter().enumerate() {
|
||||
let matching_role = client.matching_role(role, request_kind);
|
||||
if let std::option::Option::Some(matching_role) = matching_role {
|
||||
candidates.push(PoolCandidate { client_index, priority: matching_role.priority() });
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|candidate| return candidate.priority);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
fn runtime_candidates(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> std::vec::Vec<RuntimePoolCandidate> {
|
||||
let mut candidates = std::vec::Vec::new();
|
||||
for (client_index, client) in self.inner.clients.iter().enumerate() {
|
||||
let runtime_match = client.matching_role_runtime(role, request_kind);
|
||||
if let std::option::Option::Some((priority, runtime)) = runtime_match {
|
||||
candidates.push(RuntimePoolCandidate { client_index, priority, runtime });
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|candidate| return candidate.priority);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
fn selection_from_candidate(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
candidate: &PoolCandidate,
|
||||
) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||
let selected_client = self.inner.clients.get(candidate.client_index);
|
||||
let client = match selected_client {
|
||||
std::option::Option::Some(client) => client.clone(),
|
||||
std::option::Option::None => return selection_failed(role, request_kind),
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = client.name(),
|
||||
role = role.as_str(),
|
||||
request_kind = request_kind.as_str(),
|
||||
priority = candidate.priority,
|
||||
"selected logical HTTP endpoint without runtime admission"
|
||||
);
|
||||
return std::result::Result::Ok(crate::HttpEndpointSelection {
|
||||
client,
|
||||
role: role.clone(),
|
||||
request_kind: request_kind.clone(),
|
||||
priority: candidate.priority,
|
||||
});
|
||||
}
|
||||
|
||||
fn selection_from_runtime_candidate(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
candidate: &RuntimePoolCandidate,
|
||||
) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||
let selected_client = self.inner.clients.get(candidate.client_index);
|
||||
let client = match selected_client {
|
||||
std::option::Option::Some(client) => client.clone(),
|
||||
std::option::Option::None => return selection_failed(role, request_kind),
|
||||
};
|
||||
return std::result::Result::Ok(crate::HttpEndpointSelection {
|
||||
client,
|
||||
role: role.clone(),
|
||||
request_kind: request_kind.clone(),
|
||||
priority: candidate.priority,
|
||||
});
|
||||
}
|
||||
|
||||
fn next_position(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind, priority: u32, tier_size: usize) -> usize {
|
||||
let key = (role.as_str().to_owned(), request_kind.as_str().to_owned(), priority);
|
||||
let lock_result = self.inner.cursors.lock();
|
||||
let mut cursors = match lock_result {
|
||||
std::result::Result::Ok(cursors) => cursors,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let cursor = cursors.entry(key).or_insert(0);
|
||||
if tier_size == 0 {
|
||||
return 0;
|
||||
}
|
||||
let selected = *cursor % tier_size;
|
||||
*cursor = (*cursor).wrapping_add(1);
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HttpTransportPool {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("HttpTransportPool").field("snapshot", &self.snapshot()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PoolCandidate {
|
||||
client_index: usize,
|
||||
priority: u32,
|
||||
}
|
||||
|
||||
struct RuntimePoolCandidate {
|
||||
client_index: usize,
|
||||
priority: u32,
|
||||
runtime: std::sync::Arc<crate::resilience::HttpRoleRuntime>,
|
||||
}
|
||||
|
||||
enum RuntimeSelectionAttempt {
|
||||
Ready(crate::HttpRequestPermit),
|
||||
Blocked { earliest_ready: std::option::Option<std::time::Instant>, concurrency_saturated: bool },
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
|
||||
let converted = u64::try_from(duration.as_millis());
|
||||
return match converted {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => u64::MAX,
|
||||
};
|
||||
}
|
||||
|
||||
fn earlier_instant(current: std::option::Option<std::time::Instant>, candidate: std::time::Instant) -> std::option::Option<std::time::Instant> {
|
||||
return match current {
|
||||
std::option::Option::Some(value) => std::option::Option::Some(std::cmp::min(value, candidate)),
|
||||
std::option::Option::None => std::option::Option::Some(candidate),
|
||||
};
|
||||
}
|
||||
|
||||
fn selection_failed(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
|
||||
return std::result::Result::Err(selection_error(role, request_kind));
|
||||
}
|
||||
|
||||
fn selection_failed_duration(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result<std::time::Duration> {
|
||||
return std::result::Result::Err(selection_error(role, request_kind));
|
||||
}
|
||||
|
||||
fn request_selection_failed(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||
return std::result::Result::Err(selection_error(role, request_kind));
|
||||
}
|
||||
|
||||
fn selection_error(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENDPOINT_SELECTION_FAILED, "no HTTP endpoint can satisfy the requested role and request kind")
|
||||
.with_context("role", role.as_str())
|
||||
.with_context("request_kind", request_kind.as_str());
|
||||
}
|
||||
|
||||
fn request_timeout(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind, message: &str) -> ksp_core_lib::Result<crate::HttpRequestPermit> {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
role = role.as_str(),
|
||||
request_kind = request_kind.as_str(),
|
||||
"HTTP request admission deadline expired"
|
||||
);
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message)
|
||||
.with_context("role", role.as_str())
|
||||
.with_context("request_kind", request_kind.as_str()),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/pool.rs"]
|
||||
mod tests;
|
||||
390
crates/ksp-onchain-transport-lib/src/resilience.rs
Normal file
390
crates/ksp-onchain-transport-lib/src/resilience.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/resilience.rs
|
||||
// version: 1
|
||||
|
||||
const DEFAULT_RATE_LIMIT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
const MAX_PROVIDER_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Transport-level cause considered by the bounded retry policy.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum HttpRetryCause {
|
||||
/// A connection could not be established and no usable response exists.
|
||||
Connection,
|
||||
/// The request exceeded its transport deadline without a usable response.
|
||||
Timeout,
|
||||
/// The provider returned HTTP 429 or an equivalent transport-level rate-limit signal.
|
||||
RateLimited,
|
||||
/// The provider returned an HTTP status classified by the caller as temporary.
|
||||
TemporaryHttp,
|
||||
/// A generic request failure is not known to be safe to retry automatically.
|
||||
Request,
|
||||
/// A JSON-RPC application error was returned by the provider.
|
||||
RpcApplication,
|
||||
/// The response violated the KSP transport contract.
|
||||
InvalidResponse,
|
||||
}
|
||||
|
||||
impl HttpRetryCause {
|
||||
#[must_use]
|
||||
const fn is_retryable(self) -> bool {
|
||||
return match self {
|
||||
Self::Connection | Self::Timeout | Self::RateLimited | Self::TemporaryHttp => true,
|
||||
Self::Request | Self::RpcApplication | Self::InvalidResponse => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch knowledge used to prevent ambiguous automatic resubmission.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum HttpDispatchState {
|
||||
/// The transport knows that the request was not dispatched to the provider.
|
||||
NotDispatched,
|
||||
/// The transport cannot prove whether a dispatched request was processed remotely.
|
||||
DispatchedAmbiguous,
|
||||
}
|
||||
|
||||
/// Result of evaluating one bounded transport retry opportunity.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum HttpRetryDecision {
|
||||
/// Stop retrying this transport request.
|
||||
Stop,
|
||||
/// Retry after the bounded delay.
|
||||
RetryAfter(std::time::Duration),
|
||||
}
|
||||
|
||||
impl HttpRetryDecision {
|
||||
/// Returns whether the decision authorizes another transport attempt.
|
||||
#[must_use]
|
||||
pub const fn should_retry(self) -> bool {
|
||||
return match self {
|
||||
Self::Stop => false,
|
||||
Self::RetryAfter(_) => true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the retry delay when another attempt is authorized.
|
||||
#[must_use]
|
||||
pub const fn delay(self) -> std::option::Option<std::time::Duration> {
|
||||
return match self {
|
||||
Self::Stop => std::option::Option::None,
|
||||
Self::RetryAfter(delay) => std::option::Option::Some(delay),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
|
||||
///
|
||||
/// `completed_retries` counts retries already performed after the initial attempt. Provider `Retry-After` values are defensively bounded to sixty seconds
|
||||
/// before they can extend the local exponential backoff. RPC application errors are never converted into transport retries.
|
||||
#[must_use]
|
||||
pub fn evaluate_transport_retry(
|
||||
method: &crate::HttpRpcMethodDescriptor,
|
||||
settings: &crate::HttpRetrySettings,
|
||||
cause: crate::HttpRetryCause,
|
||||
dispatch_state: crate::HttpDispatchState,
|
||||
completed_retries: u32,
|
||||
provider_retry_after: std::option::Option<std::time::Duration>,
|
||||
) -> crate::HttpRetryDecision {
|
||||
if completed_retries >= settings.max_retries() || !cause.is_retryable() {
|
||||
return crate::HttpRetryDecision::Stop;
|
||||
}
|
||||
if method.transport_retry_class() == crate::TransportRetryClass::NotApplicable {
|
||||
return crate::HttpRetryDecision::Stop;
|
||||
}
|
||||
if method.transport_retry_class() == crate::TransportRetryClass::NeverAfterDispatch && dispatch_state == crate::HttpDispatchState::DispatchedAmbiguous {
|
||||
return crate::HttpRetryDecision::Stop;
|
||||
}
|
||||
let retry_number = completed_retries.saturating_add(1);
|
||||
let mut delay = retry_backoff(settings, retry_number);
|
||||
if cause == crate::HttpRetryCause::RateLimited
|
||||
&& let std::option::Option::Some(provider_delay) = provider_retry_after
|
||||
{
|
||||
let bounded_provider_delay = std::cmp::min(provider_delay, MAX_PROVIDER_RETRY_AFTER);
|
||||
if bounded_provider_delay > delay {
|
||||
delay = bounded_provider_delay;
|
||||
}
|
||||
}
|
||||
return crate::HttpRetryDecision::RetryAfter(delay);
|
||||
}
|
||||
|
||||
pub(crate) fn retry_backoff(settings: &crate::HttpRetrySettings, retry_number: u32) -> std::time::Duration {
|
||||
let mut delay = settings.initial_backoff();
|
||||
if retry_number <= 1 {
|
||||
return std::cmp::min(delay, settings.max_backoff());
|
||||
}
|
||||
let mut step = 1_u32;
|
||||
while step < retry_number {
|
||||
let doubled = match delay.checked_mul(2) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => settings.max_backoff(),
|
||||
};
|
||||
delay = std::cmp::min(doubled, settings.max_backoff());
|
||||
if delay >= settings.max_backoff() {
|
||||
return settings.max_backoff();
|
||||
}
|
||||
step = step.saturating_add(1);
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
pub(crate) struct HttpRoleRuntime {
|
||||
limits: crate::HttpRoleLimits,
|
||||
bucket: std::sync::Mutex<std::option::Option<HttpTokenBucketState>>,
|
||||
semaphore: std::option::Option<std::sync::Arc<tokio::sync::Semaphore>>,
|
||||
notify: std::sync::Arc<tokio::sync::Notify>,
|
||||
cooldown_until: std::sync::Mutex<std::option::Option<std::time::Instant>>,
|
||||
degraded: std::sync::atomic::AtomicBool,
|
||||
success_count: std::sync::atomic::AtomicU64,
|
||||
failure_count: std::sync::atomic::AtomicU64,
|
||||
rate_limit_count: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl HttpRoleRuntime {
|
||||
pub(crate) fn new(settings: &crate::HttpEndpointRoleSettings, notify: std::sync::Arc<tokio::sync::Notify>) -> Self {
|
||||
let bucket = match settings.limits().requests_per_second() {
|
||||
std::option::Option::Some(requests_per_second) => {
|
||||
let burst_capacity = match settings.limits().burst_capacity() {
|
||||
std::option::Option::Some(capacity) => capacity,
|
||||
std::option::Option::None => requests_per_second,
|
||||
};
|
||||
std::option::Option::Some(HttpTokenBucketState::new(requests_per_second.get(), burst_capacity.get(), std::time::Instant::now()))
|
||||
},
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
let semaphore = match settings.limits().max_concurrent_requests() {
|
||||
std::option::Option::Some(max_concurrent) => {
|
||||
std::option::Option::Some(std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.get() as usize)))
|
||||
},
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
return Self {
|
||||
limits: settings.limits().clone(),
|
||||
bucket: std::sync::Mutex::new(bucket),
|
||||
semaphore,
|
||||
notify,
|
||||
cooldown_until: std::sync::Mutex::new(std::option::Option::None),
|
||||
degraded: std::sync::atomic::AtomicBool::new(false),
|
||||
success_count: std::sync::atomic::AtomicU64::new(0),
|
||||
failure_count: std::sync::atomic::AtomicU64::new(0),
|
||||
rate_limit_count: std::sync::atomic::AtomicU64::new(0),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn availability(&self, now: std::time::Instant) -> crate::HttpEndpointAvailability {
|
||||
if self.cooldown_remaining_at(now).is_some() {
|
||||
return crate::HttpEndpointAvailability::RateLimited;
|
||||
}
|
||||
if self.degraded.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return crate::HttpEndpointAvailability::Degraded;
|
||||
}
|
||||
return crate::HttpEndpointAvailability::Available;
|
||||
}
|
||||
|
||||
pub(crate) fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
|
||||
return self.cooldown_remaining_at(std::time::Instant::now());
|
||||
}
|
||||
|
||||
pub(crate) fn max_concurrent_requests(&self) -> std::option::Option<u32> {
|
||||
return self.limits.max_concurrent_requests().map(|value| return value.get());
|
||||
}
|
||||
|
||||
pub(crate) fn in_flight_requests(&self) -> std::option::Option<u32> {
|
||||
let semaphore = match &self.semaphore {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let maximum = match self.limits.max_concurrent_requests() {
|
||||
std::option::Option::Some(value) => value.get(),
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let available = semaphore.available_permits();
|
||||
let available_u32 = match u32::try_from(available) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => maximum,
|
||||
};
|
||||
return std::option::Option::Some(maximum.saturating_sub(available_u32));
|
||||
}
|
||||
|
||||
pub(crate) fn success_count(&self) -> u64 {
|
||||
return self.success_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn failure_count(&self) -> u64 {
|
||||
return self.failure_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn rate_limit_count(&self) -> u64 {
|
||||
return self.rate_limit_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn try_acquire(self: &std::sync::Arc<Self>, now: std::time::Instant) -> RoleAdmissionAttempt {
|
||||
if let std::option::Option::Some(remaining) = self.cooldown_remaining_at(now) {
|
||||
let ready_at = match now.checked_add(remaining) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => now,
|
||||
};
|
||||
return RoleAdmissionAttempt::BlockedUntil(ready_at);
|
||||
}
|
||||
let semaphore_permit = match &self.semaphore {
|
||||
std::option::Option::Some(semaphore) => {
|
||||
let permit_result = std::sync::Arc::clone(semaphore).try_acquire_owned();
|
||||
match permit_result {
|
||||
std::result::Result::Ok(permit) => std::option::Option::Some(permit),
|
||||
std::result::Result::Err(tokio::sync::TryAcquireError::NoPermits) => return RoleAdmissionAttempt::ConcurrencySaturated,
|
||||
std::result::Result::Err(tokio::sync::TryAcquireError::Closed) => return RoleAdmissionAttempt::Unavailable,
|
||||
}
|
||||
},
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
let token_result = self.try_consume_token(now);
|
||||
if let std::option::Option::Some(ready_at) = token_result {
|
||||
drop(semaphore_permit);
|
||||
self.notify.notify_one();
|
||||
return RoleAdmissionAttempt::BlockedUntil(ready_at);
|
||||
}
|
||||
return RoleAdmissionAttempt::Ready(HttpConcurrencyPermit { semaphore_permit, notify: std::sync::Arc::clone(&self.notify) });
|
||||
}
|
||||
|
||||
pub(crate) fn record_success(&self) {
|
||||
self.success_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.degraded.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
self.notify.notify_waiters();
|
||||
return;
|
||||
}
|
||||
|
||||
pub(crate) fn record_failure(&self) {
|
||||
self.failure_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.degraded.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
self.notify.notify_waiters();
|
||||
return;
|
||||
}
|
||||
|
||||
pub(crate) fn record_rate_limited(&self, provider_retry_after: std::option::Option<std::time::Duration>) -> std::time::Duration {
|
||||
self.failure_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.rate_limit_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.degraded.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let configured_pause = match self.limits.pause_after_rate_limit() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => DEFAULT_RATE_LIMIT_COOLDOWN,
|
||||
};
|
||||
let provider_pause = match provider_retry_after {
|
||||
std::option::Option::Some(value) => std::cmp::min(value, MAX_PROVIDER_RETRY_AFTER),
|
||||
std::option::Option::None => std::time::Duration::ZERO,
|
||||
};
|
||||
let effective_pause = std::cmp::max(configured_pause, provider_pause);
|
||||
let now = std::time::Instant::now();
|
||||
let candidate = match now.checked_add(effective_pause) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => now,
|
||||
};
|
||||
let lock_result = self.cooldown_until.lock();
|
||||
let mut cooldown_until = match lock_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let replace = match *cooldown_until {
|
||||
std::option::Option::Some(current) => candidate > current,
|
||||
std::option::Option::None => true,
|
||||
};
|
||||
if replace {
|
||||
*cooldown_until = std::option::Option::Some(candidate);
|
||||
}
|
||||
drop(cooldown_until);
|
||||
self.notify.notify_waiters();
|
||||
return effective_pause;
|
||||
}
|
||||
|
||||
fn cooldown_remaining_at(&self, now: std::time::Instant) -> std::option::Option<std::time::Duration> {
|
||||
let lock_result = self.cooldown_until.lock();
|
||||
let mut cooldown_until = match lock_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let deadline = match *cooldown_until {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if deadline <= now {
|
||||
*cooldown_until = std::option::Option::None;
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some(deadline.duration_since(now));
|
||||
}
|
||||
|
||||
fn try_consume_token(&self, now: std::time::Instant) -> std::option::Option<std::time::Instant> {
|
||||
let lock_result = self.bucket.lock();
|
||||
let mut bucket = match lock_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let state = match bucket.as_mut() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
return state.try_consume_at(now);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum RoleAdmissionAttempt {
|
||||
Ready(HttpConcurrencyPermit),
|
||||
BlockedUntil(std::time::Instant),
|
||||
ConcurrencySaturated,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub(crate) struct HttpConcurrencyPermit {
|
||||
semaphore_permit: std::option::Option<tokio::sync::OwnedSemaphorePermit>,
|
||||
notify: std::sync::Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl Drop for HttpConcurrencyPermit {
|
||||
fn drop(&mut self) {
|
||||
let permit = self.semaphore_permit.take();
|
||||
drop(permit);
|
||||
self.notify.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HttpTokenBucketState {
|
||||
available_tokens: f64,
|
||||
requests_per_second: u32,
|
||||
burst_capacity: u32,
|
||||
last_refill: std::time::Instant,
|
||||
}
|
||||
|
||||
impl HttpTokenBucketState {
|
||||
fn new(requests_per_second: u32, burst_capacity: u32, now: std::time::Instant) -> Self {
|
||||
return Self { available_tokens: f64::from(burst_capacity), requests_per_second, burst_capacity, last_refill: now };
|
||||
}
|
||||
|
||||
fn try_consume_at(&mut self, now: std::time::Instant) -> std::option::Option<std::time::Instant> {
|
||||
self.refill_at(now);
|
||||
if self.available_tokens >= 1.0 {
|
||||
self.available_tokens -= 1.0;
|
||||
return std::option::Option::None;
|
||||
}
|
||||
let missing_tokens = 1.0 - self.available_tokens;
|
||||
let wait_seconds = missing_tokens / f64::from(self.requests_per_second);
|
||||
let wait = std::time::Duration::from_secs_f64(wait_seconds);
|
||||
let ready_at = match now.checked_add(wait) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => now,
|
||||
};
|
||||
return std::option::Option::Some(ready_at);
|
||||
}
|
||||
|
||||
fn refill_at(&mut self, now: std::time::Instant) {
|
||||
if now <= self.last_refill {
|
||||
return;
|
||||
}
|
||||
let elapsed_seconds = now.duration_since(self.last_refill).as_secs_f64();
|
||||
let refill = elapsed_seconds * f64::from(self.requests_per_second);
|
||||
self.available_tokens = (self.available_tokens + refill).min(f64::from(self.burst_capacity));
|
||||
self.last_refill = now;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/resilience.rs"]
|
||||
mod tests;
|
||||
301
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
Normal file
301
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_canary.rs
|
||||
// version: 1
|
||||
|
||||
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaCommitment {
|
||||
/// Query the most recent processed bank.
|
||||
Processed,
|
||||
/// Query a bank confirmed by cluster vote.
|
||||
Confirmed,
|
||||
/// Query a finalized bank.
|
||||
Finalized,
|
||||
}
|
||||
|
||||
impl SolanaCommitment {
|
||||
/// Returns the Solana JSON-RPC commitment string.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Processed => "processed",
|
||||
Self::Confirmed => "confirmed",
|
||||
Self::Finalized => "finalized",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional typed configuration for `getBalance`.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct GetBalanceConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl GetBalanceConfig {
|
||||
/// Creates an explicit `getBalance` configuration.
|
||||
#[must_use]
|
||||
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, min_context_slot: std::option::Option<u64>) -> Self {
|
||||
return Self { commitment, min_context_slot };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns the optional minimum context slot.
|
||||
#[must_use]
|
||||
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
|
||||
return self.min_context_slot;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.commitment.is_none() && self.min_context_slot.is_none();
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
|
||||
object.insert("minContextSlot".to_owned(), serde_json::Value::Number(min_context_slot.into()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed healthy result returned by the `getHealth` canary.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaNodeHealth {
|
||||
/// The RPC node returned the stable `"ok"` health result.
|
||||
Healthy,
|
||||
}
|
||||
|
||||
/// Typed genesis hash returned by the `getGenesisHash` canary.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct SolanaGenesisHash {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl SolanaGenesisHash {
|
||||
/// Returns the base58-encoded genesis hash text.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed software-version response returned by the `getVersion` canary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaNodeVersion {
|
||||
solana_core: std::string::String,
|
||||
feature_set: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
impl SolanaNodeVersion {
|
||||
/// Returns the node software version string from the `solana-core` field.
|
||||
#[must_use]
|
||||
pub fn solana_core(&self) -> &str {
|
||||
return self.solana_core.as_str();
|
||||
}
|
||||
|
||||
/// Returns the optional runtime feature-set identifier.
|
||||
#[must_use]
|
||||
pub const fn feature_set(&self) -> std::option::Option<u32> {
|
||||
return self.feature_set;
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed Solana RPC context used by the initial account canary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaRpcContext {
|
||||
slot: u64,
|
||||
api_version: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
impl SolanaRpcContext {
|
||||
/// Returns the context slot reported by the RPC node.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the optional RPC API version reported by the node.
|
||||
#[must_use]
|
||||
pub fn api_version(&self) -> std::option::Option<&str> {
|
||||
return match self.api_version.as_ref() {
|
||||
std::option::Option::Some(value) => std::option::Option::Some(value.as_str()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed lamport balance returned by the `getBalance` canary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct GetBalanceResult {
|
||||
context: crate::SolanaRpcContext,
|
||||
value: u64,
|
||||
}
|
||||
|
||||
impl GetBalanceResult {
|
||||
/// Returns the Solana response context.
|
||||
#[must_use]
|
||||
pub const fn context(&self) -> &crate::SolanaRpcContext {
|
||||
return &self.context;
|
||||
}
|
||||
|
||||
/// Returns the account balance in lamports.
|
||||
#[must_use]
|
||||
pub const fn value(&self) -> u64 {
|
||||
return self.value;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HttpTransportPool {
|
||||
/// Executes the typed `getHealth` foundation canary.
|
||||
pub async fn get_health(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeHealth> {
|
||||
let method_result = canary_descriptor("getHealth");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if value.as_str() == std::option::Option::Some("ok") {
|
||||
return std::result::Result::Ok(crate::SolanaNodeHealth::Healthy);
|
||||
}
|
||||
return invalid_canary_response("getHealth", "result must be exactly the string ok");
|
||||
}
|
||||
|
||||
/// Executes the typed `getGenesisHash` foundation canary.
|
||||
pub async fn get_genesis_hash(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaGenesisHash> {
|
||||
let method_result = canary_descriptor("getGenesisHash");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let hash = match value.as_str() {
|
||||
std::option::Option::Some(hash) => hash,
|
||||
std::option::Option::None => return invalid_canary_response("getGenesisHash", "result must be a string"),
|
||||
};
|
||||
if hash.is_empty() || hash.trim() != hash {
|
||||
return invalid_canary_response("getGenesisHash", "result must be a non-empty trimmed string");
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaGenesisHash { value: hash.to_owned() });
|
||||
}
|
||||
|
||||
/// Executes the typed `getVersion` foundation canary.
|
||||
pub async fn get_version(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeVersion> {
|
||||
let method_result = canary_descriptor("getVersion");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decode_result = serde_json::from_value::<WireNodeVersion>(value);
|
||||
let decoded = match decode_result {
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(error) => return invalid_canary_decode("getVersion", error),
|
||||
};
|
||||
if decoded.solana_core.is_empty() || decoded.solana_core.trim() != decoded.solana_core {
|
||||
return invalid_canary_response("getVersion", "solana-core must be a non-empty trimmed string");
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaNodeVersion { solana_core: decoded.solana_core, feature_set: decoded.feature_set });
|
||||
}
|
||||
|
||||
/// Executes the typed `getBalance` foundation canary.
|
||||
pub async fn get_balance(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::GetBalanceConfig>,
|
||||
) -> ksp_core_lib::Result<crate::GetBalanceResult> {
|
||||
let method_result = canary_descriptor("getBalance");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec![serde_json::Value::String(account.to_string())];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decode_result = serde_json::from_value::<WireBalanceResult>(value);
|
||||
let decoded = match decode_result {
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(error) => return invalid_canary_decode("getBalance", error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::GetBalanceResult {
|
||||
context: crate::SolanaRpcContext { slot: decoded.context.slot, api_version: decoded.context.api_version },
|
||||
value: decoded.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireNodeVersion {
|
||||
#[serde(rename = "solana-core")]
|
||||
solana_core: std::string::String,
|
||||
#[serde(rename = "feature-set", default)]
|
||||
feature_set: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcContext {
|
||||
slot: u64,
|
||||
#[serde(rename = "apiVersion", default)]
|
||||
api_version: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireBalanceResult {
|
||||
context: WireRpcContext,
|
||||
value: u64,
|
||||
}
|
||||
|
||||
fn canary_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
|
||||
let descriptor = crate::find_http_rpc_method(method);
|
||||
return match descriptor {
|
||||
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed canary descriptor is missing from the audited registry")
|
||||
.with_context("rpc_method", method),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn invalid_canary_decode<T>(method: &str, error: serde_json::Error) -> ksp_core_lib::Result<T> {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP canary response has an invalid shape")
|
||||
.with_context("rpc_method", method)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
|
||||
fn invalid_canary_response<T>(method: &str, message: &str) -> ksp_core_lib::Result<T> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/rpc_canary.rs"]
|
||||
mod tests;
|
||||
1108
crates/ksp-onchain-transport-lib/src/rpc_method.rs
Normal file
1108
crates/ksp-onchain-transport-lib/src/rpc_method.rs
Normal file
File diff suppressed because it is too large
Load Diff
585
crates/ksp-onchain-transport-lib/src/settings.rs
Normal file
585
crates/ksp-onchain-transport-lib/src/settings.rs
Normal file
@@ -0,0 +1,585 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/settings.rs
|
||||
// version: 5
|
||||
|
||||
/// Runtime HTTP endpoint URL owned by Transport.
|
||||
///
|
||||
/// The actual URL can contain provider credentials. Its [`std::fmt::Debug`] implementation is intentionally redacted.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct HttpEndpointUrl {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl HttpEndpointUrl {
|
||||
/// Parses and validates one HTTP or HTTPS endpoint URL.
|
||||
pub fn parse(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
let value = value.into();
|
||||
let parsed_result = reqwest::Url::parse(value.as_str());
|
||||
let parsed = match parsed_result {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint URL is invalid")
|
||||
.with_context("field", "endpoints.url")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint URL must use http or https")
|
||||
.with_context("field", "endpoints.url")
|
||||
.with_context("scheme", parsed.scheme()),
|
||||
);
|
||||
}
|
||||
if parsed.host_str().is_none() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint URL must contain a host").with_context("field", "endpoints.url"),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(Self { value });
|
||||
}
|
||||
|
||||
/// Returns the sensitive runtime URL text.
|
||||
///
|
||||
/// Callers must not write this value to logs, generic diagnostics or snapshots.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HttpEndpointUrl {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("HttpEndpointUrl(<redacted>)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Open provider descriptor used by HTTP endpoint settings.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct HttpProviderName {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl HttpProviderName {
|
||||
/// Creates an open provider descriptor. Validation is performed by [`HttpTransportSettings::validate`].
|
||||
#[must_use]
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||
return Self { value: value.into() };
|
||||
}
|
||||
|
||||
/// Returns the provider descriptor text.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Open cluster or network descriptor used by HTTP endpoint settings.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct HttpClusterName {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl HttpClusterName {
|
||||
/// Creates an open cluster descriptor. Validation is performed by [`HttpTransportSettings::validate`].
|
||||
#[must_use]
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||
return Self { value: value.into() };
|
||||
}
|
||||
|
||||
/// Returns the cluster descriptor text.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Open logical endpoint role descriptor.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct HttpRoleName {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl HttpRoleName {
|
||||
/// Creates an open role descriptor. Validation is performed by [`HttpTransportSettings::validate`].
|
||||
#[must_use]
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||
return Self { value: value.into() };
|
||||
}
|
||||
|
||||
/// Returns the role descriptor text.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Open request-kind descriptor used by logical endpoint capabilities.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct HttpRequestKind {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl HttpRequestKind {
|
||||
/// Creates an open request-kind descriptor. `*` is reserved as the wildcard accepted by all standard request kinds.
|
||||
#[must_use]
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||
return Self { value: value.into() };
|
||||
}
|
||||
|
||||
/// Creates the wildcard request-kind descriptor.
|
||||
#[must_use]
|
||||
pub fn wildcard() -> Self {
|
||||
return Self::new("*");
|
||||
}
|
||||
|
||||
/// Returns the request-kind descriptor text.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
|
||||
/// Returns whether this descriptor is the wildcard capability.
|
||||
#[must_use]
|
||||
pub fn is_wildcard(&self) -> bool {
|
||||
return self.value == "*";
|
||||
}
|
||||
}
|
||||
|
||||
/// Local limits attached to one logical HTTP endpoint role.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpRoleLimits {
|
||||
requests_per_second: std::option::Option<std::num::NonZeroU32>,
|
||||
burst_capacity: std::option::Option<std::num::NonZeroU32>,
|
||||
max_concurrent_requests: std::option::Option<std::num::NonZeroU32>,
|
||||
pause_after_rate_limit: std::option::Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl HttpRoleLimits {
|
||||
/// Creates explicit role limits.
|
||||
///
|
||||
/// When RPS is configured and burst capacity is absent, runtime burst defaults to one second of RPS capacity. An absent concurrency limit is
|
||||
/// unbounded by this KSP transport layer. An absent rate-limit cooldown uses the Transport runtime fallback cooldown.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
requests_per_second: std::option::Option<std::num::NonZeroU32>,
|
||||
burst_capacity: std::option::Option<std::num::NonZeroU32>,
|
||||
max_concurrent_requests: std::option::Option<std::num::NonZeroU32>,
|
||||
pause_after_rate_limit: std::option::Option<std::time::Duration>,
|
||||
) -> Self {
|
||||
return Self { requests_per_second, burst_capacity, max_concurrent_requests, pause_after_rate_limit };
|
||||
}
|
||||
|
||||
/// Returns the configured requests-per-second limit.
|
||||
#[must_use]
|
||||
pub const fn requests_per_second(&self) -> std::option::Option<std::num::NonZeroU32> {
|
||||
return self.requests_per_second;
|
||||
}
|
||||
|
||||
/// Returns the configured token-bucket burst capacity. `None` means the runtime derives capacity from configured RPS.
|
||||
#[must_use]
|
||||
pub const fn burst_capacity(&self) -> std::option::Option<std::num::NonZeroU32> {
|
||||
return self.burst_capacity;
|
||||
}
|
||||
|
||||
/// Returns the configured maximum concurrent request count.
|
||||
#[must_use]
|
||||
pub const fn max_concurrent_requests(&self) -> std::option::Option<std::num::NonZeroU32> {
|
||||
return self.max_concurrent_requests;
|
||||
}
|
||||
|
||||
/// Returns the configured cooldown applied after rate limiting. `None` delegates to the Transport runtime fallback cooldown.
|
||||
#[must_use]
|
||||
pub const fn pause_after_rate_limit(&self) -> std::option::Option<std::time::Duration> {
|
||||
return self.pause_after_rate_limit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded retry settings owned by the HTTP transport runtime.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpRetrySettings {
|
||||
max_retries: u32,
|
||||
initial_backoff: std::time::Duration,
|
||||
max_backoff: std::time::Duration,
|
||||
}
|
||||
|
||||
impl HttpRetrySettings {
|
||||
/// Creates bounded retry settings.
|
||||
#[must_use]
|
||||
pub const fn new(max_retries: u32, initial_backoff: std::time::Duration, max_backoff: std::time::Duration) -> Self {
|
||||
return Self { max_retries, initial_backoff, max_backoff };
|
||||
}
|
||||
|
||||
/// Returns the number of retries allowed after the initial attempt.
|
||||
#[must_use]
|
||||
pub const fn max_retries(&self) -> u32 {
|
||||
return self.max_retries;
|
||||
}
|
||||
|
||||
/// Returns the initial retry backoff.
|
||||
#[must_use]
|
||||
pub const fn initial_backoff(&self) -> std::time::Duration {
|
||||
return self.initial_backoff;
|
||||
}
|
||||
|
||||
/// Returns the maximum retry backoff.
|
||||
#[must_use]
|
||||
pub const fn max_backoff(&self) -> std::time::Duration {
|
||||
return self.max_backoff;
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime settings for one role declared by an HTTP endpoint.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpEndpointRoleSettings {
|
||||
role: crate::HttpRoleName,
|
||||
enabled: bool,
|
||||
request_kinds: std::vec::Vec<crate::HttpRequestKind>,
|
||||
priority: u32,
|
||||
limits: crate::HttpRoleLimits,
|
||||
}
|
||||
|
||||
impl HttpEndpointRoleSettings {
|
||||
/// Creates explicit settings for one logical endpoint role.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
role: crate::HttpRoleName,
|
||||
enabled: bool,
|
||||
request_kinds: std::vec::Vec<crate::HttpRequestKind>,
|
||||
priority: u32,
|
||||
limits: crate::HttpRoleLimits,
|
||||
) -> Self {
|
||||
return Self { role, enabled, request_kinds, priority, limits };
|
||||
}
|
||||
|
||||
/// Returns the open logical role descriptor.
|
||||
#[must_use]
|
||||
pub const fn role(&self) -> &crate::HttpRoleName {
|
||||
return &self.role;
|
||||
}
|
||||
|
||||
/// Returns whether this role participates in endpoint selection.
|
||||
#[must_use]
|
||||
pub const fn enabled(&self) -> bool {
|
||||
return self.enabled;
|
||||
}
|
||||
|
||||
/// Returns request kinds supported by this role.
|
||||
#[must_use]
|
||||
pub fn request_kinds(&self) -> &[crate::HttpRequestKind] {
|
||||
return self.request_kinds.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the role priority where lower values are preferred.
|
||||
#[must_use]
|
||||
pub const fn priority(&self) -> u32 {
|
||||
return self.priority;
|
||||
}
|
||||
|
||||
/// Returns local rate, burst and concurrency limits.
|
||||
#[must_use]
|
||||
pub const fn limits(&self) -> &crate::HttpRoleLimits {
|
||||
return &self.limits;
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime settings for one named Solana HTTP endpoint.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpEndpointSettings {
|
||||
name: std::string::String,
|
||||
enabled: bool,
|
||||
provider: crate::HttpProviderName,
|
||||
cluster: crate::HttpClusterName,
|
||||
url: crate::HttpEndpointUrl,
|
||||
connect_timeout: std::time::Duration,
|
||||
request_timeout: std::time::Duration,
|
||||
max_idle_connections_per_host: std::option::Option<usize>,
|
||||
roles: std::vec::Vec<crate::HttpEndpointRoleSettings>,
|
||||
}
|
||||
|
||||
impl HttpEndpointSettings {
|
||||
/// Creates explicit runtime settings for one logical HTTP endpoint.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
name: impl std::convert::Into<std::string::String>,
|
||||
enabled: bool,
|
||||
provider: crate::HttpProviderName,
|
||||
cluster: crate::HttpClusterName,
|
||||
url: crate::HttpEndpointUrl,
|
||||
connect_timeout: std::time::Duration,
|
||||
request_timeout: std::time::Duration,
|
||||
max_idle_connections_per_host: std::option::Option<usize>,
|
||||
roles: std::vec::Vec<crate::HttpEndpointRoleSettings>,
|
||||
) -> Self {
|
||||
return Self {
|
||||
name: name.into(),
|
||||
enabled,
|
||||
provider,
|
||||
cluster,
|
||||
url,
|
||||
connect_timeout,
|
||||
request_timeout,
|
||||
max_idle_connections_per_host,
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the endpoint identity used by selection and safe diagnostics.
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
return self.name.as_str();
|
||||
}
|
||||
|
||||
/// Returns whether this endpoint participates in endpoint selection.
|
||||
#[must_use]
|
||||
pub const fn enabled(&self) -> bool {
|
||||
return self.enabled;
|
||||
}
|
||||
|
||||
/// Returns the provider descriptor.
|
||||
#[must_use]
|
||||
pub const fn provider(&self) -> &crate::HttpProviderName {
|
||||
return &self.provider;
|
||||
}
|
||||
|
||||
/// Returns the cluster descriptor.
|
||||
#[must_use]
|
||||
pub const fn cluster(&self) -> &crate::HttpClusterName {
|
||||
return &self.cluster;
|
||||
}
|
||||
|
||||
/// Returns the sensitive endpoint URL wrapper.
|
||||
#[must_use]
|
||||
pub const fn url(&self) -> &crate::HttpEndpointUrl {
|
||||
return &self.url;
|
||||
}
|
||||
|
||||
/// Returns the connection-establishment timeout.
|
||||
#[must_use]
|
||||
pub const fn connect_timeout(&self) -> std::time::Duration {
|
||||
return self.connect_timeout;
|
||||
}
|
||||
|
||||
/// Returns the end-to-end request timeout used by this endpoint.
|
||||
#[must_use]
|
||||
pub const fn request_timeout(&self) -> std::time::Duration {
|
||||
return self.request_timeout;
|
||||
}
|
||||
|
||||
/// Returns the optional per-host idle connection pool limit.
|
||||
#[must_use]
|
||||
pub const fn max_idle_connections_per_host(&self) -> std::option::Option<usize> {
|
||||
return self.max_idle_connections_per_host;
|
||||
}
|
||||
|
||||
/// Returns endpoint roles in declaration order.
|
||||
#[must_use]
|
||||
pub fn roles(&self) -> &[crate::HttpEndpointRoleSettings] {
|
||||
return self.roles.as_slice();
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpTransportSettings {
|
||||
endpoints: std::vec::Vec<crate::HttpEndpointSettings>,
|
||||
retry: crate::HttpRetrySettings,
|
||||
}
|
||||
|
||||
impl HttpTransportSettings {
|
||||
/// Creates complete HTTP transport runtime settings.
|
||||
#[must_use]
|
||||
pub fn new(endpoints: std::vec::Vec<crate::HttpEndpointSettings>, retry: crate::HttpRetrySettings) -> Self {
|
||||
return Self { endpoints, retry };
|
||||
}
|
||||
|
||||
/// Returns configured endpoints in declaration order.
|
||||
#[must_use]
|
||||
pub fn endpoints(&self) -> &[crate::HttpEndpointSettings] {
|
||||
return self.endpoints.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the bounded transport retry settings.
|
||||
#[must_use]
|
||||
pub const fn retry(&self) -> &crate::HttpRetrySettings {
|
||||
return &self.retry;
|
||||
}
|
||||
|
||||
/// Validates structural runtime invariants without reading Config or environment state.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
let retry_validation = validate_retry(self.retry());
|
||||
if let std::result::Result::Err(error) = retry_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if self.endpoints.is_empty() {
|
||||
return invalid_settings("at least one HTTP endpoint must be configured", "endpoints");
|
||||
}
|
||||
let mut enabled_endpoint_count = 0_usize;
|
||||
for (endpoint_index, endpoint) in self.endpoints.iter().enumerate() {
|
||||
let endpoint_validation = validate_endpoint(endpoint, endpoint_index);
|
||||
if let std::result::Result::Err(error) = endpoint_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if endpoint.enabled() {
|
||||
enabled_endpoint_count += 1;
|
||||
}
|
||||
for previous in &self.endpoints[..endpoint_index] {
|
||||
if previous.name() == endpoint.name() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint names must be unique")
|
||||
.with_context("field", format!("endpoints[{endpoint_index}].name"))
|
||||
.with_context("endpoint_name", endpoint.name()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if enabled_endpoint_count == 0 {
|
||||
return invalid_settings("at least one HTTP endpoint must be enabled", "endpoints.enabled");
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_count = self.endpoints.len(),
|
||||
enabled_endpoint_count,
|
||||
"validated HTTP transport settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_endpoint_settings(endpoint: &crate::HttpEndpointSettings) -> ksp_core_lib::Result<()> {
|
||||
return validate_endpoint(endpoint, 0);
|
||||
}
|
||||
|
||||
fn validate_retry(retry: &crate::HttpRetrySettings) -> ksp_core_lib::Result<()> {
|
||||
if retry.initial_backoff().is_zero() {
|
||||
return invalid_settings("initial retry backoff must be greater than zero", "retry.initial_backoff");
|
||||
}
|
||||
if retry.max_backoff().is_zero() {
|
||||
return invalid_settings("maximum retry backoff must be greater than zero", "retry.max_backoff");
|
||||
}
|
||||
if retry.max_backoff() < retry.initial_backoff() {
|
||||
return invalid_settings("maximum retry backoff must not be lower than initial retry backoff", "retry.max_backoff");
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_endpoint(endpoint: &crate::HttpEndpointSettings, endpoint_index: usize) -> ksp_core_lib::Result<()> {
|
||||
let endpoint_name_validation = validate_descriptor(endpoint.name(), format!("endpoints[{endpoint_index}].name").as_str());
|
||||
if let std::result::Result::Err(error) = endpoint_name_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let provider_validation = validate_descriptor(endpoint.provider().as_str(), format!("endpoints[{endpoint_index}].provider").as_str());
|
||||
if let std::result::Result::Err(error) = provider_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let cluster_validation = validate_descriptor(endpoint.cluster().as_str(), format!("endpoints[{endpoint_index}].cluster").as_str());
|
||||
if let std::result::Result::Err(error) = cluster_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if endpoint.connect_timeout().is_zero() {
|
||||
return invalid_settings("HTTP connect timeout must be greater than zero", format!("endpoints[{endpoint_index}].connect_timeout").as_str());
|
||||
}
|
||||
if endpoint.request_timeout().is_zero() {
|
||||
return invalid_settings("HTTP request timeout must be greater than zero", format!("endpoints[{endpoint_index}].request_timeout").as_str());
|
||||
}
|
||||
if let std::option::Option::Some(max_idle) = endpoint.max_idle_connections_per_host()
|
||||
&& max_idle == 0
|
||||
{
|
||||
return invalid_settings(
|
||||
"max idle connections per host must be greater than zero when configured",
|
||||
format!("endpoints[{endpoint_index}].max_idle_connections_per_host").as_str(),
|
||||
);
|
||||
}
|
||||
if endpoint.roles().is_empty() {
|
||||
return invalid_settings("HTTP endpoint must declare at least one role", format!("endpoints[{endpoint_index}].roles").as_str());
|
||||
}
|
||||
let mut enabled_role_count = 0_usize;
|
||||
for (role_index, role) in endpoint.roles().iter().enumerate() {
|
||||
let role_validation = validate_role(role, endpoint_index, role_index);
|
||||
if let std::result::Result::Err(error) = role_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if role.enabled() {
|
||||
enabled_role_count += 1;
|
||||
}
|
||||
for previous in &endpoint.roles()[..role_index] {
|
||||
if previous.role() == role.role() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint role names must be unique per endpoint")
|
||||
.with_context("field", format!("endpoints[{endpoint_index}].roles[{role_index}].role"))
|
||||
.with_context("endpoint_name", endpoint.name())
|
||||
.with_context("role", role.role().as_str()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if endpoint.enabled() && enabled_role_count == 0 {
|
||||
return invalid_settings("enabled HTTP endpoint must expose at least one enabled role", format!("endpoints[{endpoint_index}].roles.enabled").as_str());
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_role(role: &crate::HttpEndpointRoleSettings, endpoint_index: usize, role_index: usize) -> ksp_core_lib::Result<()> {
|
||||
let role_validation = validate_descriptor(role.role().as_str(), format!("endpoints[{endpoint_index}].roles[{role_index}].role").as_str());
|
||||
if let std::result::Result::Err(error) = role_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if role.request_kinds().is_empty() {
|
||||
return invalid_settings(
|
||||
"HTTP endpoint role must declare at least one request kind",
|
||||
format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds").as_str(),
|
||||
);
|
||||
}
|
||||
if role.request_kinds().len() > 1 && role.request_kinds().iter().any(crate::HttpRequestKind::is_wildcard) {
|
||||
return invalid_settings("wildcard request kind must be used alone", format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds").as_str());
|
||||
}
|
||||
for (request_kind_index, request_kind) in role.request_kinds().iter().enumerate() {
|
||||
let request_kind_validation =
|
||||
validate_descriptor(request_kind.as_str(), format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds[{request_kind_index}]").as_str());
|
||||
if let std::result::Result::Err(error) = request_kind_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
for previous in &role.request_kinds()[..request_kind_index] {
|
||||
if previous == request_kind {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP request kinds must be unique per role")
|
||||
.with_context("field", format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds[{request_kind_index}]"))
|
||||
.with_context("request_kind", request_kind.as_str()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if role.limits().burst_capacity().is_some() && role.limits().requests_per_second().is_none() {
|
||||
return invalid_settings(
|
||||
"burst capacity requires a requests-per-second limit",
|
||||
format!("endpoints[{endpoint_index}].roles[{role_index}].limits.burst_capacity").as_str(),
|
||||
);
|
||||
}
|
||||
if let std::option::Option::Some(pause) = role.limits().pause_after_rate_limit()
|
||||
&& pause.is_zero()
|
||||
{
|
||||
return invalid_settings(
|
||||
"rate-limit cooldown must be greater than zero when configured",
|
||||
format!("endpoints[{endpoint_index}].roles[{role_index}].limits.pause_after_rate_limit").as_str(),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_descriptor(value: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return invalid_settings("transport descriptor must not be empty", field);
|
||||
}
|
||||
if value.trim() != value {
|
||||
return invalid_settings("transport descriptor must not contain leading or trailing whitespace", field);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/settings.rs"]
|
||||
mod tests;
|
||||
173
crates/ksp-onchain-transport-lib/tests/public_api.rs
Normal file
173
crates/ksp-onchain-transport-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
// version: 5
|
||||
|
||||
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||
|
||||
#[test]
|
||||
fn public_settings_contract_is_constructible_without_config_dependency() {
|
||||
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://api.devnet.solana.com").expect("public URL parser must accept Devnet endpoint");
|
||||
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
|
||||
100,
|
||||
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
);
|
||||
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||
"solana_devnet_public",
|
||||
true,
|
||||
ksp_onchain_transport_lib::HttpProviderName::new("solana-public"),
|
||||
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||
url,
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(15),
|
||||
std::option::Option::Some(8),
|
||||
std::vec![role],
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
ksp_onchain_transport_lib::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(2)),
|
||||
);
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_json_rpc_contract_round_trips_foundation_shape() {
|
||||
let request = ksp_onchain_transport_lib::JsonRpcRequest::new(1, "getHealth", std::vec![]).expect("public request constructor must succeed");
|
||||
let encoded = request.to_json_string().expect("public request must serialize");
|
||||
assert!(encoded.contains("\"jsonrpc\":\"2.0\""));
|
||||
let response =
|
||||
ksp_onchain_transport_lib::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","id":1}"#, 1).expect("public parser must validate response");
|
||||
let result = response.into_result().expect("success response must return result");
|
||||
assert_eq!(result, serde_json::json!("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_method_registry_exposes_current_and_historical_surfaces() {
|
||||
assert_eq!(ksp_onchain_transport_lib::current_http_rpc_methods().len(), 52);
|
||||
assert_eq!(ksp_onchain_transport_lib::historical_http_rpc_methods().len(), 14);
|
||||
let removed = ksp_onchain_transport_lib::find_http_rpc_method("confirmTransaction").expect("historical method must be discoverable");
|
||||
assert_eq!(removed.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Removed);
|
||||
assert_eq!(removed.ensure_runtime_supported().expect_err("removed method must fail").code(), ksp_onchain_transport_lib::ERROR_CODE_METHOD_REMOVED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_error_codes_share_the_core_error_domain() {
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS.domain(), "onchain_transport");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_RPC_APPLICATION_ERROR.domain(), "onchain_transport");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pool_contract_selects_a_standard_method_without_exposing_url() {
|
||||
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://provider.invalid/rpc?token=SECRET-CANARY")
|
||||
.expect("public URL parser must accept HTTPS endpoint");
|
||||
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![ksp_onchain_transport_lib::HttpRequestKind::new("get_balance")],
|
||||
10,
|
||||
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
);
|
||||
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||
"primary",
|
||||
true,
|
||||
ksp_onchain_transport_lib::HttpProviderName::new("provider"),
|
||||
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||
url,
|
||||
std::time::Duration::from_secs(2),
|
||||
std::time::Duration::from_secs(10),
|
||||
std::option::Option::Some(8),
|
||||
std::vec![role],
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
ksp_onchain_transport_lib::HttpRetrySettings::new(1, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)),
|
||||
);
|
||||
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(settings).expect("public pool constructor must succeed");
|
||||
let method = ksp_onchain_transport_lib::find_http_rpc_method("getBalance").expect("audited method must exist");
|
||||
let selected = pool.select_for_method(&ksp_onchain_transport_lib::HttpRoleName::new("default"), method).expect("public pool must route audited method");
|
||||
assert_eq!(selected.endpoint_name(), "primary");
|
||||
let rendered = format!("{pool:?}");
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_retry_policy_preserves_no_resend_after_ambiguous_write_dispatch() {
|
||||
let method = ksp_onchain_transport_lib::find_http_rpc_method("sendTransaction").expect("sendTransaction must be audited");
|
||||
let settings = ksp_onchain_transport_lib::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(1));
|
||||
let decision = ksp_onchain_transport_lib::evaluate_transport_retry(
|
||||
method,
|
||||
&settings,
|
||||
ksp_onchain_transport_lib::HttpRetryCause::Timeout,
|
||||
ksp_onchain_transport_lib::HttpDispatchState::DispatchedAmbiguous,
|
||||
0,
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert_eq!(decision, ksp_onchain_transport_lib::HttpRetryDecision::Stop);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_async_admission_exposes_bounded_permit_without_endpoint_url() {
|
||||
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://provider.invalid/rpc?token=ASYNC-SECRET-CANARY")
|
||||
.expect("public URL parser must accept HTTPS endpoint");
|
||||
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![ksp_onchain_transport_lib::HttpRequestKind::new("get_balance")],
|
||||
10,
|
||||
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::num::NonZeroU32::new(1),
|
||||
std::option::Option::None,
|
||||
),
|
||||
);
|
||||
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||
"primary",
|
||||
true,
|
||||
ksp_onchain_transport_lib::HttpProviderName::new("provider"),
|
||||
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||
url,
|
||||
std::time::Duration::from_secs(2),
|
||||
std::time::Duration::from_secs(10),
|
||||
std::option::Option::Some(8),
|
||||
std::vec![role],
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
ksp_onchain_transport_lib::HttpRetrySettings::new(1, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)),
|
||||
);
|
||||
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(settings).expect("public pool constructor must succeed");
|
||||
let method = ksp_onchain_transport_lib::find_http_rpc_method("getBalance").expect("audited method must exist");
|
||||
let permit = pool
|
||||
.acquire_for_method(&ksp_onchain_transport_lib::HttpRoleName::new("default"), method)
|
||||
.await
|
||||
.expect("public async admission must acquire capacity");
|
||||
assert_eq!(permit.selection().endpoint_name(), "primary");
|
||||
assert!(permit.remaining_timeout() > std::time::Duration::ZERO);
|
||||
let rendered = format!("{permit:?}");
|
||||
assert!(!rendered.contains("ASYNC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_typed_canary_contracts_are_available_from_crate_root() {
|
||||
let config = ksp_onchain_transport_lib::GetBalanceConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(42),
|
||||
);
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.min_context_slot(), std::option::Option::Some(42));
|
||||
assert_eq!(ksp_onchain_transport_lib::SolanaNodeHealth::Healthy, ksp_onchain_transport_lib::SolanaNodeHealth::Healthy);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||
// version: 1
|
||||
|
||||
//! Release-level completeness canaries for the `0.2.1` HTTP foundation contract.
|
||||
|
||||
#[test]
|
||||
fn release_registry_partition_matches_the_audited_http_plan() {
|
||||
let mut foundation = 0_usize;
|
||||
let mut accounts_tokens_cluster = 0_usize;
|
||||
let mut transactions = 0_usize;
|
||||
let mut blocks_economics = 0_usize;
|
||||
let mut historical_in_current = 0_usize;
|
||||
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
|
||||
match descriptor.coverage_release() {
|
||||
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_1 => foundation += 1,
|
||||
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2 => accounts_tokens_cluster += 1,
|
||||
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3 => transactions += 1,
|
||||
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_4 => blocks_economics += 1,
|
||||
ksp_onchain_transport_lib::HttpRpcCoverageRelease::Historical => historical_in_current += 1,
|
||||
}
|
||||
}
|
||||
assert_eq!(ksp_onchain_transport_lib::current_http_rpc_methods().len(), 52);
|
||||
assert_eq!(ksp_onchain_transport_lib::historical_http_rpc_methods().len(), 14);
|
||||
assert_eq!(foundation, 4);
|
||||
assert_eq!(accounts_tokens_cluster, 22);
|
||||
assert_eq!(transactions, 11);
|
||||
assert_eq!(blocks_economics, 15);
|
||||
assert_eq!(historical_in_current, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_foundation_canaries_and_historical_statuses_are_exact() {
|
||||
let mut foundation_names = std::vec::Vec::<&str>::new();
|
||||
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
|
||||
if descriptor.coverage_release() == ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_1 {
|
||||
foundation_names.push(descriptor.method());
|
||||
assert_eq!(descriptor.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Supported);
|
||||
}
|
||||
}
|
||||
foundation_names.sort_unstable();
|
||||
assert_eq!(foundation_names, std::vec!["getBalance", "getGenesisHash", "getHealth", "getVersion"]);
|
||||
for descriptor in ksp_onchain_transport_lib::historical_http_rpc_methods() {
|
||||
assert_eq!(descriptor.documentation_status(), ksp_onchain_transport_lib::RpcDocumentationStatus::Deprecated);
|
||||
assert_eq!(descriptor.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Removed);
|
||||
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::Historical);
|
||||
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NotApplicable);
|
||||
}
|
||||
}
|
||||
62
crates/ksp-onchain-transport-lib/unit_tests/client.rs
Normal file
62
crates/ksp-onchain-transport-lib/unit_tests/client.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/client.rs
|
||||
// version: 2
|
||||
|
||||
fn endpoint(enabled: bool, url_text: &str) -> crate::HttpEndpointSettings {
|
||||
let url = crate::HttpEndpointUrl::parse(url_text).expect("test endpoint URL must parse");
|
||||
let role = crate::HttpEndpointRoleSettings::new(
|
||||
crate::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![crate::HttpRequestKind::wildcard()],
|
||||
10,
|
||||
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||
);
|
||||
return crate::HttpEndpointSettings::new(
|
||||
"endpoint",
|
||||
enabled,
|
||||
crate::HttpProviderName::new("provider"),
|
||||
crate::HttpClusterName::new("devnet"),
|
||||
url,
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(2),
|
||||
std::option::Option::Some(4),
|
||||
std::vec![role],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_client_snapshot_never_contains_url_or_secret_material() {
|
||||
let client = super::HttpEndpointClient::new(endpoint(true, "https://provider.invalid/rpc?api-key=SECRET-CANARY")).expect("client must build");
|
||||
let snapshot = client.snapshot();
|
||||
let rendered = format!("{snapshot:?} {client:?}");
|
||||
assert_eq!(snapshot.availability(), crate::HttpEndpointAvailability::Available);
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
assert!(!rendered.contains("https://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_endpoint_client_is_visible_but_not_selectable() {
|
||||
let client = super::HttpEndpointClient::new(endpoint(false, "https://api.devnet.solana.com")).expect("disabled client must still build");
|
||||
assert_eq!(client.snapshot().availability(), crate::HttpEndpointAvailability::Disabled);
|
||||
assert!(!client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_client_matches_exact_and_wildcard_capabilities() {
|
||||
let client = super::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
|
||||
assert!(client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")));
|
||||
assert!(!client.supports(&crate::HttpRoleName::new("write"), &crate::HttpRequestKind::new("get_balance")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_role_snapshot_exposes_safe_resilience_state() {
|
||||
let client = super::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
|
||||
let snapshot = client.snapshot();
|
||||
let role = &snapshot.roles()[0];
|
||||
assert_eq!(role.availability(), crate::HttpEndpointAvailability::Available);
|
||||
assert_eq!(role.in_flight_requests(), std::option::Option::None);
|
||||
assert_eq!(role.cooldown_remaining(), std::option::Option::None);
|
||||
assert_eq!(role.success_count(), 0);
|
||||
assert_eq!(role.failure_count(), 0);
|
||||
assert_eq!(role.rate_limit_count(), 0);
|
||||
}
|
||||
141
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
Normal file
141
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/executor.rs
|
||||
// version: 2
|
||||
|
||||
fn pool_for_url(url: &str, request_timeout: std::time::Duration, max_retries: u32) -> crate::HttpTransportPool {
|
||||
let role = crate::HttpEndpointRoleSettings::new(
|
||||
crate::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![crate::HttpRequestKind::wildcard()],
|
||||
10,
|
||||
crate::HttpRoleLimits::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::time::Duration::from_millis(1)),
|
||||
),
|
||||
);
|
||||
let endpoint = crate::HttpEndpointSettings::new(
|
||||
"fixture",
|
||||
true,
|
||||
crate::HttpProviderName::new("fixture"),
|
||||
crate::HttpClusterName::new("local"),
|
||||
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
|
||||
std::time::Duration::from_millis(100),
|
||||
request_timeout,
|
||||
std::option::Option::Some(1),
|
||||
std::vec![role],
|
||||
);
|
||||
let settings = crate::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
crate::HttpRetrySettings::new(max_retries, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
|
||||
);
|
||||
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
|
||||
}
|
||||
|
||||
fn health_method() -> &'static crate::HttpRpcMethodDescriptor {
|
||||
return crate::find_http_rpc_method("getHealth").expect("getHealth descriptor must exist");
|
||||
}
|
||||
|
||||
fn serve_rate_limit_then_success() -> (std::string::String, std::thread::JoinHandle<usize>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut count = 0_usize;
|
||||
while count < 2 {
|
||||
let (mut stream, _) = listener.accept().expect("fixture server must accept request");
|
||||
let _ = read_request(&mut stream);
|
||||
let response = if count == 0 {
|
||||
"HTTP/1.1 429 Too Many Requests\r\nRetry-After: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned()
|
||||
} else {
|
||||
let body = include_str!("../fixtures/http/get_health.success.json");
|
||||
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body)
|
||||
};
|
||||
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
||||
count = count.saturating_add(1);
|
||||
}
|
||||
return count;
|
||||
});
|
||||
return (format!("http://{address}"), handle);
|
||||
}
|
||||
|
||||
fn serve_timeout() -> (std::string::String, std::thread::JoinHandle<()>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("fixture server must accept request");
|
||||
let _ = read_request(&mut stream);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
return;
|
||||
});
|
||||
return (format!("http://{address}"), handle);
|
||||
}
|
||||
|
||||
fn read_request(stream: &mut std::net::TcpStream) -> std::string::String {
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
loop {
|
||||
let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read");
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if request_complete(bytes.as_slice()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
|
||||
}
|
||||
|
||||
fn request_complete(bytes: &[u8]) -> bool {
|
||||
let text = match std::str::from_utf8(bytes) {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(_) => return false,
|
||||
};
|
||||
let header_end = match text.find("\r\n\r\n") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
let mut content_length = 0_usize;
|
||||
for line in text[..header_end].lines() {
|
||||
let (name, value) = match line.split_once(':') {
|
||||
std::option::Option::Some(parts) => parts,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if name.eq_ignore_ascii_case("content-length") {
|
||||
content_length = value.trim().parse::<usize>().expect("content length must parse");
|
||||
}
|
||||
}
|
||||
return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn executor_applies_retry_after_and_retries_http_429_for_retry_safe_method() {
|
||||
let (url, handle) = serve_rate_limit_then_success();
|
||||
let pool = pool_for_url(url.as_str(), std::time::Duration::from_millis(500), 1);
|
||||
let result = pool
|
||||
.execute_standard_rpc(&crate::HttpRoleName::new("default"), health_method(), std::vec::Vec::new())
|
||||
.await
|
||||
.expect("retry-safe request must recover from one 429");
|
||||
assert_eq!(result, serde_json::json!("ok"));
|
||||
assert_eq!(handle.join().expect("fixture server must join"), 2);
|
||||
let snapshot = pool.snapshot();
|
||||
assert_eq!(snapshot.endpoints()[0].roles()[0].rate_limit_count(), 1);
|
||||
assert_eq!(snapshot.endpoints()[0].roles()[0].success_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn executor_maps_reqwest_timeout_to_ksp_timeout_error_without_endpoint_secret_leak() {
|
||||
const SECRET_CANARY: &str = "SECRET-REQWEST-URL-CANARY";
|
||||
let (url, handle) = serve_timeout();
|
||||
let sensitive_url = format!("{url}/rpc?api-key={SECRET_CANARY}");
|
||||
let pool = pool_for_url(sensitive_url.as_str(), std::time::Duration::from_millis(20), 0);
|
||||
let error = pool
|
||||
.execute_standard_rpc(&crate::HttpRoleName::new("default"), health_method(), std::vec::Vec::new())
|
||||
.await
|
||||
.expect_err("timed out request must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT);
|
||||
assert!(!format!("{error:?}").contains(SECRET_CANARY));
|
||||
let source = std::error::Error::source(&error).expect("transport timeout should preserve a sanitized reqwest source");
|
||||
assert!(!format!("{source:?}").contains(SECRET_CANARY));
|
||||
handle.join().expect("fixture server must join");
|
||||
}
|
||||
115
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
Normal file
115
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn request_serialization_matches_json_rpc_2_0_shape() {
|
||||
let request = super::JsonRpcRequest::new(7, "getBalance", std::vec![serde_json::json!("Address111"), serde_json::json!({"commitment":"confirmed"})])
|
||||
.expect("valid request must construct");
|
||||
let encoded = request.to_json_string().expect("serializable request must encode");
|
||||
let value: serde_json::Value = serde_json::from_str(encoded.as_str()).expect("encoded request must remain JSON");
|
||||
assert_eq!(value["jsonrpc"], serde_json::json!("2.0"));
|
||||
assert_eq!(value["id"], serde_json::json!(7));
|
||||
assert_eq!(value["method"], serde_json::json!("getBalance"));
|
||||
assert_eq!(value["params"].as_array().expect("params must be an array").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_rejects_empty_or_untrimmed_method() {
|
||||
assert!(super::JsonRpcRequest::new(1, "", std::vec![]).is_err());
|
||||
assert!(super::JsonRpcRequest::new(1, " getHealth", std::vec![]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parser_preserves_null_success_result() {
|
||||
let response = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":null,"id":9}"#, 9).expect("null result is a valid success payload");
|
||||
assert!(matches!(&response, super::JsonRpcResponse::Success(_)), "success response must not parse as error");
|
||||
if let super::JsonRpcResponse::Success(success) = response {
|
||||
assert!(success.result().is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parser_preserves_rpc_error_payload() {
|
||||
let response = super::parse_json_rpc_response_text(
|
||||
r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is unhealthy","data":{"numSlotsBehind":12}},"id":4}"#,
|
||||
4,
|
||||
)
|
||||
.expect("valid JSON-RPC error envelope must parse");
|
||||
assert!(matches!(&response, super::JsonRpcResponse::Error(_)), "RPC error response must not parse as success");
|
||||
if let super::JsonRpcResponse::Error(error_response) = response {
|
||||
assert_eq!(error_response.error().code(), -32005);
|
||||
assert_eq!(error_response.error().message(), "Node is unhealthy");
|
||||
assert_eq!(error_response.error().data(), std::option::Option::Some(&serde_json::json!({"numSlotsBehind":12})));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parser_rejects_id_mismatch() {
|
||||
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","id":2}"#, 1).expect_err("mismatched id must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parser_rejects_wrong_protocol_version() {
|
||||
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"1.0","result":"ok","id":1}"#, 1).expect_err("wrong version must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parser_rejects_both_result_and_error() {
|
||||
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","error":{"code":-1,"message":"bad"},"id":1}"#, 1)
|
||||
.expect_err("mutually exclusive fields must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parser_rejects_missing_result_and_error() {
|
||||
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","id":1}"#, 1).expect_err("missing outcome must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parser_distinguishes_invalid_json_from_protocol_error() {
|
||||
let error = super::parse_json_rpc_response_text("not-json", 1).expect_err("invalid JSON must fail decoding");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_JSON_DECODE_FAILED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_error_maps_to_shared_ksp_error_without_copying_remote_payload_into_context() {
|
||||
let response = super::parse_json_rpc_response_text(
|
||||
r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"SECRET-CANARY","data":{"payload":"SECRET-DATA"}},"id":1}"#,
|
||||
1,
|
||||
)
|
||||
.expect("valid error envelope must parse");
|
||||
let error = response.into_result().expect_err("RPC application error must map to KSP error");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
||||
let rendered = format!("{error:?}");
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("SECRET-DATA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_debug_omits_parameter_payloads() {
|
||||
let request = super::JsonRpcRequest::new(1, "sendTransaction", std::vec![serde_json::json!("SIGNED-TRANSACTION-SECRET-CANARY")])
|
||||
.expect("test request must construct");
|
||||
let rendered = format!("{request:?}");
|
||||
assert!(rendered.contains("sendTransaction"));
|
||||
assert!(rendered.contains("param_count"));
|
||||
assert!(!rendered.contains("SIGNED-TRANSACTION-SECRET-CANARY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_debug_omits_result_and_remote_error_payloads() {
|
||||
let success = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":{"secret":"RESULT-SECRET-CANARY"},"id":1}"#, 1)
|
||||
.expect("test success response must parse");
|
||||
let success_rendered = format!("{success:?}");
|
||||
assert!(!success_rendered.contains("RESULT-SECRET-CANARY"));
|
||||
let failure = super::parse_json_rpc_response_text(
|
||||
r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"MESSAGE-SECRET-CANARY","data":{"secret":"DATA-SECRET-CANARY"}},"id":2}"#,
|
||||
2,
|
||||
)
|
||||
.expect("test error response must parse");
|
||||
let failure_rendered = format!("{failure:?}");
|
||||
assert!(!failure_rendered.contains("MESSAGE-SECRET-CANARY"));
|
||||
assert!(!failure_rendered.contains("DATA-SECRET-CANARY"));
|
||||
}
|
||||
336
crates/ksp-onchain-transport-lib/unit_tests/pool.rs
Normal file
336
crates/ksp-onchain-transport-lib/unit_tests/pool.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/pool.rs
|
||||
// version: 3
|
||||
|
||||
fn role(name: &str, priority: u32, request_kinds: std::vec::Vec<crate::HttpRequestKind>) -> crate::HttpEndpointRoleSettings {
|
||||
return crate::HttpEndpointRoleSettings::new(
|
||||
crate::HttpRoleName::new(name),
|
||||
true,
|
||||
request_kinds,
|
||||
priority,
|
||||
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||
);
|
||||
}
|
||||
|
||||
fn endpoint(name: &str, enabled: bool, priority: u32, request_kinds: std::vec::Vec<crate::HttpRequestKind>) -> crate::HttpEndpointSettings {
|
||||
return crate::HttpEndpointSettings::new(
|
||||
name,
|
||||
enabled,
|
||||
crate::HttpProviderName::new("provider"),
|
||||
crate::HttpClusterName::new("devnet"),
|
||||
crate::HttpEndpointUrl::parse(format!("https://{name}.invalid/rpc?token=SECRET-CANARY")).expect("test URL must parse"),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(2),
|
||||
std::option::Option::Some(4),
|
||||
std::vec![role("default", priority, request_kinds)],
|
||||
);
|
||||
}
|
||||
|
||||
fn non_zero(value: u32) -> std::num::NonZeroU32 {
|
||||
return std::num::NonZeroU32::new(value).expect("test limit must be non-zero");
|
||||
}
|
||||
|
||||
fn limited_endpoint(
|
||||
name: &str,
|
||||
priority: u32,
|
||||
requests_per_second: std::option::Option<u32>,
|
||||
burst_capacity: std::option::Option<u32>,
|
||||
max_concurrent_requests: std::option::Option<u32>,
|
||||
cooldown: std::option::Option<std::time::Duration>,
|
||||
) -> crate::HttpEndpointSettings {
|
||||
let limits = crate::HttpRoleLimits::new(
|
||||
requests_per_second.map(|value| return non_zero(value)),
|
||||
burst_capacity.map(|value| return non_zero(value)),
|
||||
max_concurrent_requests.map(|value| return non_zero(value)),
|
||||
cooldown,
|
||||
);
|
||||
let role = crate::HttpEndpointRoleSettings::new(crate::HttpRoleName::new("default"), true, std::vec![crate::HttpRequestKind::wildcard()], priority, limits);
|
||||
return crate::HttpEndpointSettings::new(
|
||||
name,
|
||||
true,
|
||||
crate::HttpProviderName::new("provider"),
|
||||
crate::HttpClusterName::new("devnet"),
|
||||
crate::HttpEndpointUrl::parse(format!("https://{name}.invalid/rpc?token=SECRET-CANARY")).expect("test URL must parse"),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(2),
|
||||
std::option::Option::Some(4),
|
||||
std::vec![role],
|
||||
);
|
||||
}
|
||||
|
||||
fn settings(endpoints: std::vec::Vec<crate::HttpEndpointSettings>) -> crate::HttpTransportSettings {
|
||||
return crate::HttpTransportSettings::new(
|
||||
endpoints,
|
||||
crate::HttpRetrySettings::new(2, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_prefers_lowest_priority_tier() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||
endpoint("secondary", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
endpoint("primary", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
]))
|
||||
.expect("pool must build");
|
||||
let selection = pool
|
||||
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
|
||||
.expect("selection must succeed");
|
||||
assert_eq!(selection.endpoint_name(), "primary");
|
||||
assert_eq!(selection.priority(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_round_robins_fairly_inside_best_priority_tier() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||
endpoint("one", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
endpoint("two", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
]))
|
||||
.expect("pool must build");
|
||||
let role = crate::HttpRoleName::new("default");
|
||||
let kind = crate::HttpRequestKind::new("get_balance");
|
||||
let first = pool.select_for_request_kind(&role, &kind).expect("first selection must succeed");
|
||||
let second = pool.select_for_request_kind(&role, &kind).expect("second selection must succeed");
|
||||
let third = pool.select_for_request_kind(&role, &kind).expect("third selection must succeed");
|
||||
assert_eq!(first.endpoint_name(), "one");
|
||||
assert_eq!(second.endpoint_name(), "two");
|
||||
assert_eq!(third.endpoint_name(), "one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_best_priority_endpoint_falls_back_to_next_tier() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||
endpoint("disabled-primary", false, 1, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
]))
|
||||
.expect("pool must build");
|
||||
let selection = pool
|
||||
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
|
||||
.expect("fallback must be selected");
|
||||
assert_eq!(selection.endpoint_name(), "fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_filters_role_and_capability_before_priority() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||
endpoint("wrong-capability", true, 1, std::vec![crate::HttpRequestKind::new("send_transaction")]),
|
||||
endpoint("matching", true, 50, std::vec![crate::HttpRequestKind::new("get_balance")]),
|
||||
]))
|
||||
.expect("pool must build");
|
||||
let selection = pool
|
||||
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
|
||||
.expect("matching capability must be selected");
|
||||
assert_eq!(selection.endpoint_name(), "matching");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_returns_structured_error_when_no_endpoint_matches() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("read-only", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
|
||||
.expect("pool must build");
|
||||
let error = pool
|
||||
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("send_transaction"))
|
||||
.expect_err("unsupported request kind must fail selection");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_ENDPOINT_SELECTION_FAILED);
|
||||
assert!(!format!("{error:?}").contains("SECRET-CANARY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_method_selection_uses_registry_request_kind() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("balance", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
|
||||
.expect("pool must build");
|
||||
let method = crate::find_http_rpc_method("getBalance").expect("audited method must exist");
|
||||
let selection = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect("standard method must route");
|
||||
assert_eq!(selection.request_kind().as_str(), "get_balance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_snapshot_is_safe_and_preserves_disabled_endpoints() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||
endpoint("enabled", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
endpoint("disabled", false, 10, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
]))
|
||||
.expect("pool must build");
|
||||
let snapshot = pool.snapshot();
|
||||
let rendered = format!("{snapshot:?} {pool:?}");
|
||||
assert_eq!(snapshot.endpoint_count(), 2);
|
||||
assert_eq!(snapshot.available_endpoint_count(), 1);
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains(".invalid/rpc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_role_is_excluded_before_priority_selection() {
|
||||
let base = endpoint("disabled-role", true, 1, std::vec![crate::HttpRequestKind::wildcard()]);
|
||||
let disabled_role = crate::HttpEndpointRoleSettings::new(
|
||||
crate::HttpRoleName::new("default"),
|
||||
false,
|
||||
std::vec![crate::HttpRequestKind::wildcard()],
|
||||
1,
|
||||
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||
);
|
||||
let enabled_non_matching_role = crate::HttpEndpointRoleSettings::new(
|
||||
crate::HttpRoleName::new("maintenance"),
|
||||
true,
|
||||
std::vec![crate::HttpRequestKind::wildcard()],
|
||||
1,
|
||||
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||
);
|
||||
let disabled_role_endpoint = crate::HttpEndpointSettings::new(
|
||||
base.name(),
|
||||
true,
|
||||
base.provider().clone(),
|
||||
base.cluster().clone(),
|
||||
base.url().clone(),
|
||||
base.connect_timeout(),
|
||||
base.request_timeout(),
|
||||
base.max_idle_connections_per_host(),
|
||||
std::vec![disabled_role, enabled_non_matching_role],
|
||||
);
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||
disabled_role_endpoint,
|
||||
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
|
||||
]))
|
||||
.expect("pool must build");
|
||||
let selection = pool
|
||||
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
|
||||
.expect("enabled fallback role must be selected");
|
||||
assert_eq!(selection.endpoint_name(), "fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_standard_method_is_rejected_before_endpoint_routing() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("wildcard", true, 10, std::vec![crate::HttpRequestKind::wildcard()],)]))
|
||||
.expect("pool must build");
|
||||
let method = crate::find_http_rpc_method("confirmTransaction").expect("historical method must exist");
|
||||
let error = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect_err("removed standard method must be rejected before routing");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_METHOD_REMOVED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_concurrency_saturation_falls_back_to_lower_priority_tier() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||
limited_endpoint("primary", 1, std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None),
|
||||
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None),
|
||||
]))
|
||||
.expect("pool must build");
|
||||
let role = crate::HttpRoleName::new("default");
|
||||
let kind = crate::HttpRequestKind::new("get_balance");
|
||||
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("first request must acquire primary");
|
||||
assert_eq!(first.selection().endpoint_name(), "primary");
|
||||
let second = pool.acquire_for_request_kind(&role, &kind).await.expect("second request must fall back while primary is saturated");
|
||||
assert_eq!(second.selection().endpoint_name(), "fallback");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_token_bucket_exhaustion_falls_back_without_busy_waiting() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||
limited_endpoint("primary", 1, std::option::Option::Some(1), std::option::Option::Some(1), std::option::Option::None, std::option::Option::None),
|
||||
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||
]))
|
||||
.expect("pool must build");
|
||||
let role = crate::HttpRoleName::new("default");
|
||||
let kind = crate::HttpRequestKind::new("get_balance");
|
||||
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("first request must consume primary token");
|
||||
assert_eq!(first.selection().endpoint_name(), "primary");
|
||||
drop(first);
|
||||
let second = pool.acquire_for_request_kind(&role, &kind).await.expect("fallback must be used while primary token bucket refills");
|
||||
assert_eq!(second.selection().endpoint_name(), "fallback");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_cooldown_excludes_rate_limited_role_and_uses_fallback() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![
|
||||
limited_endpoint(
|
||||
"primary",
|
||||
1,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::time::Duration::from_millis(50)),
|
||||
),
|
||||
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||
]))
|
||||
.expect("pool must build");
|
||||
let role = crate::HttpRoleName::new("default");
|
||||
let kind = crate::HttpRequestKind::new("get_balance");
|
||||
let primary = pool.acquire_for_request_kind(&role, &kind).await.expect("primary must be acquired");
|
||||
assert_eq!(primary.selection().endpoint_name(), "primary");
|
||||
let pause = primary.record_rate_limited(std::option::Option::None);
|
||||
assert_eq!(pause, std::time::Duration::from_millis(50));
|
||||
drop(primary);
|
||||
let fallback = pool.acquire_for_request_kind(&role, &kind).await.expect("fallback must be selected during primary cooldown");
|
||||
assert_eq!(fallback.selection().endpoint_name(), "fallback");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admission_waits_for_released_concurrency_without_holding_a_sync_mutex_across_await() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![limited_endpoint(
|
||||
"primary",
|
||||
1,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(1),
|
||||
std::option::Option::None,
|
||||
)]))
|
||||
.expect("pool must build");
|
||||
let role = crate::HttpRoleName::new("default");
|
||||
let kind = crate::HttpRequestKind::new("get_balance");
|
||||
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("first permit must be acquired");
|
||||
let release_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
drop(first);
|
||||
});
|
||||
let second = pool
|
||||
.acquire_for_request_kind_with_timeout(&role, &kind, std::time::Duration::from_millis(100))
|
||||
.await
|
||||
.expect("second permit must wake after concurrency release");
|
||||
assert_eq!(second.selection().endpoint_name(), "primary");
|
||||
release_task.await.expect("release task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admission_timeout_is_bounded_when_concurrency_never_becomes_available() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![limited_endpoint(
|
||||
"primary",
|
||||
1,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(1),
|
||||
std::option::Option::None,
|
||||
)]))
|
||||
.expect("pool must build");
|
||||
let role = crate::HttpRoleName::new("default");
|
||||
let kind = crate::HttpRequestKind::new("get_balance");
|
||||
let _held = pool.acquire_for_request_kind(&role, &kind).await.expect("first permit must be acquired");
|
||||
let error = pool
|
||||
.acquire_for_request_kind_with_timeout(&role, &kind, std::time::Duration::from_millis(20))
|
||||
.await
|
||||
.expect_err("second permit must time out while concurrency remains saturated");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passive_health_snapshot_moves_from_degraded_back_to_available_after_success() {
|
||||
let pool = super::HttpTransportPool::new(settings(std::vec![limited_endpoint(
|
||||
"primary",
|
||||
1,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
)]))
|
||||
.expect("pool must build");
|
||||
let role = crate::HttpRoleName::new("default");
|
||||
let kind = crate::HttpRequestKind::new("get_balance");
|
||||
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("request permit must be acquired");
|
||||
first.record_failure();
|
||||
drop(first);
|
||||
let degraded = pool.snapshot();
|
||||
assert_eq!(degraded.endpoints()[0].availability(), crate::HttpEndpointAvailability::Degraded);
|
||||
assert_eq!(degraded.endpoints()[0].roles()[0].failure_count(), 1);
|
||||
let second = pool.acquire_for_request_kind(&role, &kind).await.expect("degraded endpoint remains eligible for passive recovery");
|
||||
second.record_success();
|
||||
drop(second);
|
||||
let recovered = pool.snapshot();
|
||||
assert_eq!(recovered.endpoints()[0].availability(), crate::HttpEndpointAvailability::Available);
|
||||
assert_eq!(recovered.endpoints()[0].roles()[0].success_count(), 1);
|
||||
}
|
||||
186
crates/ksp-onchain-transport-lib/unit_tests/resilience.rs
Normal file
186
crates/ksp-onchain-transport-lib/unit_tests/resilience.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/resilience.rs
|
||||
// version: 1
|
||||
|
||||
fn non_zero(value: u32) -> std::num::NonZeroU32 {
|
||||
return std::num::NonZeroU32::new(value).expect("test limit must be non-zero");
|
||||
}
|
||||
|
||||
fn retry_settings() -> crate::HttpRetrySettings {
|
||||
return crate::HttpRetrySettings::new(4, std::time::Duration::from_millis(100), std::time::Duration::from_millis(500));
|
||||
}
|
||||
|
||||
fn method(name: &str) -> &'static crate::HttpRpcMethodDescriptor {
|
||||
return crate::find_http_rpc_method(name).expect("audited test method must exist");
|
||||
}
|
||||
|
||||
fn role_limits(
|
||||
requests_per_second: std::option::Option<u32>,
|
||||
burst_capacity: std::option::Option<u32>,
|
||||
max_concurrent_requests: std::option::Option<u32>,
|
||||
cooldown: std::option::Option<std::time::Duration>,
|
||||
) -> crate::HttpEndpointRoleSettings {
|
||||
return crate::HttpEndpointRoleSettings::new(
|
||||
crate::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![crate::HttpRequestKind::wildcard()],
|
||||
10,
|
||||
crate::HttpRoleLimits::new(
|
||||
requests_per_second.map(|value| return non_zero(value)),
|
||||
burst_capacity.map(|value| return non_zero(value)),
|
||||
max_concurrent_requests.map(|value| return non_zero(value)),
|
||||
cooldown,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_backoff_is_exponential_and_bounded() {
|
||||
let settings = retry_settings();
|
||||
assert_eq!(super::retry_backoff(&settings, 1), std::time::Duration::from_millis(100));
|
||||
assert_eq!(super::retry_backoff(&settings, 2), std::time::Duration::from_millis(200));
|
||||
assert_eq!(super::retry_backoff(&settings, 3), std::time::Duration::from_millis(400));
|
||||
assert_eq!(super::retry_backoff(&settings, 4), std::time::Duration::from_millis(500));
|
||||
assert_eq!(super::retry_backoff(&settings, 32), std::time::Duration::from_millis(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_safe_timeout_is_retried_until_budget_is_exhausted() {
|
||||
let settings = retry_settings();
|
||||
let first = super::evaluate_transport_retry(
|
||||
method("getBalance"),
|
||||
&settings,
|
||||
super::HttpRetryCause::Timeout,
|
||||
super::HttpDispatchState::DispatchedAmbiguous,
|
||||
0,
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert_eq!(first, super::HttpRetryDecision::RetryAfter(std::time::Duration::from_millis(100)));
|
||||
let exhausted = super::evaluate_transport_retry(
|
||||
method("getBalance"),
|
||||
&settings,
|
||||
super::HttpRetryCause::Timeout,
|
||||
super::HttpDispatchState::DispatchedAmbiguous,
|
||||
settings.max_retries(),
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert_eq!(exhausted, super::HttpRetryDecision::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_submission_never_retries_after_ambiguous_dispatch() {
|
||||
let decision = super::evaluate_transport_retry(
|
||||
method("sendTransaction"),
|
||||
&retry_settings(),
|
||||
super::HttpRetryCause::Connection,
|
||||
super::HttpDispatchState::DispatchedAmbiguous,
|
||||
0,
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert_eq!(decision, super::HttpRetryDecision::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_submission_can_retry_when_transport_proves_no_dispatch() {
|
||||
let decision = super::evaluate_transport_retry(
|
||||
method("sendTransaction"),
|
||||
&retry_settings(),
|
||||
super::HttpRetryCause::Connection,
|
||||
super::HttpDispatchState::NotDispatched,
|
||||
0,
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert!(decision.should_retry());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_application_and_invalid_response_are_not_transport_retries() {
|
||||
for cause in [super::HttpRetryCause::RpcApplication, super::HttpRetryCause::InvalidResponse, super::HttpRetryCause::Request] {
|
||||
let decision = super::evaluate_transport_retry(
|
||||
method("getBalance"),
|
||||
&retry_settings(),
|
||||
cause,
|
||||
super::HttpDispatchState::NotDispatched,
|
||||
0,
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert_eq!(decision, super::HttpRetryDecision::Stop);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_retry_after_can_extend_backoff_but_is_defensively_bounded() {
|
||||
let settings = retry_settings();
|
||||
let extended = super::evaluate_transport_retry(
|
||||
method("getBalance"),
|
||||
&settings,
|
||||
super::HttpRetryCause::RateLimited,
|
||||
super::HttpDispatchState::DispatchedAmbiguous,
|
||||
0,
|
||||
std::option::Option::Some(std::time::Duration::from_secs(3)),
|
||||
);
|
||||
assert_eq!(extended.delay(), std::option::Option::Some(std::time::Duration::from_secs(3)));
|
||||
let bounded = super::evaluate_transport_retry(
|
||||
method("getBalance"),
|
||||
&settings,
|
||||
super::HttpRetryCause::RateLimited,
|
||||
super::HttpDispatchState::DispatchedAmbiguous,
|
||||
0,
|
||||
std::option::Option::Some(std::time::Duration::from_secs(600)),
|
||||
);
|
||||
assert_eq!(bounded.delay(), std::option::Option::Some(std::time::Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_bucket_consumes_burst_then_refills_from_elapsed_time() {
|
||||
let start = std::time::Instant::now();
|
||||
let mut bucket = super::HttpTokenBucketState::new(2, 2, start);
|
||||
assert!(bucket.try_consume_at(start).is_none());
|
||||
assert!(bucket.try_consume_at(start).is_none());
|
||||
assert!(bucket.try_consume_at(start).is_some());
|
||||
let later = start.checked_add(std::time::Duration::from_millis(500)).expect("test instant must advance");
|
||||
assert!(bucket.try_consume_at(later).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_burst_capacity_defaults_to_one_second_of_rps_capacity() {
|
||||
let role = role_limits(std::option::Option::Some(2), std::option::Option::None, std::option::Option::None, std::option::Option::None);
|
||||
let runtime = std::sync::Arc::new(super::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
|
||||
let now = std::time::Instant::now();
|
||||
let first = runtime.try_acquire(now);
|
||||
let second = runtime.try_acquire(now);
|
||||
let third = runtime.try_acquire(now);
|
||||
assert!(matches!(first, super::RoleAdmissionAttempt::Ready(_)));
|
||||
assert!(matches!(second, super::RoleAdmissionAttempt::Ready(_)));
|
||||
assert!(matches!(third, super::RoleAdmissionAttempt::BlockedUntil(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrency_semaphore_releases_capacity_when_permit_is_dropped() {
|
||||
let role = role_limits(std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None);
|
||||
let runtime = std::sync::Arc::new(super::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
|
||||
let now = std::time::Instant::now();
|
||||
let first = runtime.try_acquire(now);
|
||||
let held = match first {
|
||||
super::RoleAdmissionAttempt::Ready(permit) => permit,
|
||||
_ => panic!("first concurrency permit must be available"),
|
||||
};
|
||||
assert!(matches!(runtime.try_acquire(now), super::RoleAdmissionAttempt::ConcurrencySaturated));
|
||||
drop(held);
|
||||
assert!(matches!(runtime.try_acquire(now), super::RoleAdmissionAttempt::Ready(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_cooldown_marks_role_and_caps_provider_delay() {
|
||||
let role = role_limits(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::time::Duration::from_millis(10)),
|
||||
);
|
||||
let runtime = super::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new()));
|
||||
let pause = runtime.record_rate_limited(std::option::Option::Some(std::time::Duration::from_secs(600)));
|
||||
assert_eq!(pause, std::time::Duration::from_secs(60));
|
||||
assert_eq!(runtime.rate_limit_count(), 1);
|
||||
assert_eq!(runtime.failure_count(), 1);
|
||||
assert_eq!(runtime.availability(std::time::Instant::now()), crate::HttpEndpointAvailability::RateLimited);
|
||||
}
|
||||
137
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
Normal file
137
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
|
||||
// version: 1
|
||||
|
||||
fn pool_for_url(url: &str) -> crate::HttpTransportPool {
|
||||
let role = crate::HttpEndpointRoleSettings::new(
|
||||
crate::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![crate::HttpRequestKind::wildcard()],
|
||||
10,
|
||||
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||
);
|
||||
let endpoint = crate::HttpEndpointSettings::new(
|
||||
"fixture",
|
||||
true,
|
||||
crate::HttpProviderName::new("fixture"),
|
||||
crate::HttpClusterName::new("local"),
|
||||
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::option::Option::Some(1),
|
||||
std::vec![role],
|
||||
);
|
||||
let settings = crate::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)),
|
||||
);
|
||||
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
|
||||
}
|
||||
|
||||
fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle<std::string::String>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
|
||||
let request = read_request(&mut stream);
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
|
||||
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
||||
return request;
|
||||
});
|
||||
return (format!("http://{address}"), handle);
|
||||
}
|
||||
|
||||
fn read_request(stream: &mut std::net::TcpStream) -> std::string::String {
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
loop {
|
||||
let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read");
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if request_complete(bytes.as_slice()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
|
||||
}
|
||||
|
||||
fn request_complete(bytes: &[u8]) -> bool {
|
||||
let text = match std::str::from_utf8(bytes) {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(_) => return false,
|
||||
};
|
||||
let header_end = match text.find("\r\n\r\n") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
let mut content_length = 0_usize;
|
||||
for line in text[..header_end].lines() {
|
||||
let (name, value) = match line.split_once(':') {
|
||||
std::option::Option::Some(parts) => parts,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if name.eq_ignore_ascii_case("content-length") {
|
||||
content_length = value.trim().parse::<usize>().expect("content length must parse");
|
||||
}
|
||||
}
|
||||
return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length);
|
||||
}
|
||||
|
||||
fn request_body(request: &str) -> serde_json::Value {
|
||||
let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist");
|
||||
return serde_json::from_str(body).expect("fixture request body must be JSON");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn typed_get_health_executes_real_http_fixture() {
|
||||
let (url, handle) = serve_once(include_str!("../fixtures/http/get_health.success.json"));
|
||||
let pool = pool_for_url(url.as_str());
|
||||
let result = pool.get_health(&crate::HttpRoleName::new("default")).await.expect("getHealth fixture must succeed");
|
||||
assert_eq!(result, crate::SolanaNodeHealth::Healthy);
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
let body = request_body(request.as_str());
|
||||
assert_eq!(body["method"], serde_json::json!("getHealth"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn typed_get_genesis_hash_executes_real_http_fixture() {
|
||||
let (url, handle) = serve_once(include_str!("../fixtures/http/get_genesis_hash.success.json"));
|
||||
let pool = pool_for_url(url.as_str());
|
||||
let result = pool.get_genesis_hash(&crate::HttpRoleName::new("default")).await.expect("getGenesisHash fixture must succeed");
|
||||
assert_eq!(result.as_str(), "GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC");
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getGenesisHash"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn typed_get_version_executes_real_http_fixture() {
|
||||
let (url, handle) = serve_once(include_str!("../fixtures/http/get_version.success.json"));
|
||||
let pool = pool_for_url(url.as_str());
|
||||
let result = pool.get_version(&crate::HttpRoleName::new("default")).await.expect("getVersion fixture must succeed");
|
||||
assert_eq!(result.solana_core(), "3.1.8");
|
||||
assert_eq!(result.feature_set(), std::option::Option::Some(2_891_131_721));
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getVersion"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn typed_get_balance_executes_real_http_fixture_and_encodes_config() {
|
||||
let (url, handle) = serve_once(include_str!("../fixtures/http/get_balance.success.json"));
|
||||
let pool = pool_for_url(url.as_str());
|
||||
let account = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("system address must parse");
|
||||
let config = crate::GetBalanceConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(123));
|
||||
let result = pool
|
||||
.get_balance(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config))
|
||||
.await
|
||||
.expect("getBalance fixture must succeed");
|
||||
assert_eq!(result.value(), 424_242);
|
||||
assert_eq!(result.context().slot(), 123_456_789);
|
||||
assert_eq!(result.context().api_version(), std::option::Option::Some("3.1.8"));
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
let body = request_body(request.as_str());
|
||||
assert_eq!(body["method"], serde_json::json!("getBalance"));
|
||||
assert_eq!(body["params"][0], serde_json::json!("11111111111111111111111111111111"));
|
||||
assert_eq!(body["params"][1]["commitment"], serde_json::json!("finalized"));
|
||||
assert_eq!(body["params"][1]["minContextSlot"], serde_json::json!(123));
|
||||
}
|
||||
123
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
Normal file
123
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn audited_registry_has_expected_current_and_historical_counts() {
|
||||
assert_eq!(super::current_http_rpc_methods().len(), 52);
|
||||
assert_eq!(super::historical_http_rpc_methods().len(), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audited_registry_method_names_are_unique() {
|
||||
let mut names = std::collections::BTreeSet::<&str>::new();
|
||||
for descriptor in super::current_http_rpc_methods() {
|
||||
assert!(names.insert(descriptor.method()), "duplicate current method {}", descriptor.method());
|
||||
}
|
||||
for descriptor in super::historical_http_rpc_methods() {
|
||||
assert!(names.insert(descriptor.method()), "duplicate historical method {}", descriptor.method());
|
||||
}
|
||||
assert_eq!(names.len(), 66);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_release_counts_match_recalibrated_matrix() {
|
||||
let mut foundation = 0_usize;
|
||||
let mut accounts_tokens_cluster = 0_usize;
|
||||
let mut transactions = 0_usize;
|
||||
let mut blocks_economics = 0_usize;
|
||||
let mut historical = 0_usize;
|
||||
for descriptor in super::current_http_rpc_methods() {
|
||||
match descriptor.coverage_release() {
|
||||
super::HttpRpcCoverageRelease::V0_2_1 => foundation += 1,
|
||||
super::HttpRpcCoverageRelease::V0_2_2 => accounts_tokens_cluster += 1,
|
||||
super::HttpRpcCoverageRelease::V0_2_3 => transactions += 1,
|
||||
super::HttpRpcCoverageRelease::V0_2_4 => blocks_economics += 1,
|
||||
super::HttpRpcCoverageRelease::Historical => historical += 1,
|
||||
}
|
||||
}
|
||||
assert_eq!(historical, 0);
|
||||
assert_eq!(foundation, 4);
|
||||
assert_eq!(accounts_tokens_cluster, 22);
|
||||
assert_eq!(transactions, 11);
|
||||
assert_eq!(blocks_economics, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_canary_assignment_is_exact() {
|
||||
let mut names = std::vec::Vec::<&str>::new();
|
||||
for descriptor in super::current_http_rpc_methods() {
|
||||
if descriptor.coverage_release() == super::HttpRpcCoverageRelease::V0_2_1 {
|
||||
names.push(descriptor.method());
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, std::vec!["getBalance", "getGenesisHash", "getHealth", "getVersion"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_methods_are_deprecated_removed_and_not_retryable() {
|
||||
for descriptor in super::historical_http_rpc_methods() {
|
||||
assert_eq!(descriptor.documentation_status(), super::RpcDocumentationStatus::Deprecated);
|
||||
assert_eq!(descriptor.runtime_status(), super::RpcRuntimeStatus::Removed);
|
||||
assert_eq!(descriptor.transport_retry_class(), super::TransportRetryClass::NotApplicable);
|
||||
assert_eq!(descriptor.coverage_release(), super::HttpRpcCoverageRelease::Historical);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_transaction_and_get_block_track_deprecated_legacy_request_form() {
|
||||
let get_transaction = super::find_http_rpc_method("getTransaction").expect("getTransaction descriptor must exist");
|
||||
let get_block = super::find_http_rpc_method("getBlock").expect("getBlock descriptor must exist");
|
||||
assert!(get_transaction.request_form_status().has_deprecated_legacy());
|
||||
assert!(get_block.request_form_status().has_deprecated_legacy());
|
||||
let get_balance = super::find_http_rpc_method("getBalance").expect("getBalance descriptor must exist");
|
||||
assert!(!get_balance.request_form_status().has_deprecated_legacy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_submission_methods_are_never_retry_after_ambiguous_dispatch() {
|
||||
for method in ["sendTransaction", "requestAirdrop"] {
|
||||
let descriptor = super::find_http_rpc_method(method).expect("write descriptor must exist");
|
||||
assert_eq!(descriptor.operation_kind(), super::RpcOperationKind::WriteSubmission);
|
||||
assert_eq!(descriptor.transport_retry_class(), super::TransportRetryClass::NeverAfterDispatch);
|
||||
}
|
||||
let simulation = super::find_http_rpc_method("simulateTransaction").expect("simulation descriptor must exist");
|
||||
assert_eq!(simulation.operation_kind(), super::RpcOperationKind::Simulation);
|
||||
assert_eq!(simulation.transport_retry_class(), super::TransportRetryClass::RetrySafe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_method_support_check_returns_method_removed_error() {
|
||||
let descriptor = super::find_http_rpc_method("confirmTransaction").expect("historical descriptor must exist");
|
||||
let error = descriptor.ensure_runtime_supported().expect_err("removed method must not be callable");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_METHOD_REMOVED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_supported_method_passes_runtime_support_check() {
|
||||
let descriptor = super::find_http_rpc_method("getHealth").expect("current descriptor must exist");
|
||||
assert!(descriptor.ensure_runtime_supported().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_unstable_descriptor_executes_central_warning_path() {
|
||||
let descriptor = super::HttpRpcMethodDescriptor::new(
|
||||
"experimentalMethod",
|
||||
super::HttpRpcCategory::Cluster,
|
||||
"experimental_method",
|
||||
super::RpcDocumentationStatus::Unstable,
|
||||
super::RpcRuntimeStatus::Supported,
|
||||
super::RpcRequestFormStatus::Stable,
|
||||
super::RpcOperationKind::Read,
|
||||
super::TransportRetryClass::RetrySafe,
|
||||
std::option::Option::None,
|
||||
super::HttpRpcCoverageRelease::V0_2_1,
|
||||
);
|
||||
assert!(descriptor.requires_method_usage_warning());
|
||||
assert!(descriptor.ensure_runtime_supported().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_rejects_unknown_method_without_affecting_raw_provider_extensions() {
|
||||
assert!(super::find_http_rpc_method("providerCustomMethod").is_none());
|
||||
}
|
||||
207
crates/ksp-onchain-transport-lib/unit_tests/settings.rs
Normal file
207
crates/ksp-onchain-transport-lib/unit_tests/settings.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/settings.rs
|
||||
// version: 1
|
||||
|
||||
fn non_zero(value: u32) -> std::num::NonZeroU32 {
|
||||
return std::num::NonZeroU32::new(value).expect("test non-zero value must remain non-zero");
|
||||
}
|
||||
|
||||
fn valid_settings(url_text: &str) -> super::HttpTransportSettings {
|
||||
let url = super::HttpEndpointUrl::parse(url_text).expect("test URL must be valid");
|
||||
let limits = super::HttpRoleLimits::new(
|
||||
std::option::Option::Some(non_zero(10)),
|
||||
std::option::Option::Some(non_zero(20)),
|
||||
std::option::Option::Some(non_zero(4)),
|
||||
std::option::Option::Some(std::time::Duration::from_millis(500)),
|
||||
);
|
||||
let role = super::HttpEndpointRoleSettings::new(super::HttpRoleName::new("default"), true, std::vec![super::HttpRequestKind::wildcard()], 100, limits);
|
||||
let endpoint = super::HttpEndpointSettings::new(
|
||||
"devnet_public",
|
||||
true,
|
||||
super::HttpProviderName::new("solana-public"),
|
||||
super::HttpClusterName::new("devnet"),
|
||||
url,
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(15),
|
||||
std::option::Option::Some(8),
|
||||
std::vec![role],
|
||||
);
|
||||
return super::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
super::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(2)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_url_accepts_http_and_https() {
|
||||
assert!(super::HttpEndpointUrl::parse("https://api.devnet.solana.com").is_ok());
|
||||
assert!(super::HttpEndpointUrl::parse("http://127.0.0.1:8899").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_url_rejects_non_http_schemes() {
|
||||
let result = super::HttpEndpointUrl::parse("ws://api.devnet.solana.com");
|
||||
let error = result.expect_err("WebSocket URL must not be accepted by HTTP settings");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_url_debug_redacts_secret_material() {
|
||||
let url = super::HttpEndpointUrl::parse("https://provider.invalid/rpc?api-key=SECRET-CANARY").expect("test URL must parse");
|
||||
let rendered = format!("{url:?}");
|
||||
assert!(rendered.contains("<redacted>"));
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_transport_settings_pass_validation() {
|
||||
let settings = valid_settings("https://api.devnet.solana.com");
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_settings_debug_does_not_leak_endpoint_url() {
|
||||
let settings = valid_settings("https://provider.invalid/rpc?api-key=SECRET-CANARY");
|
||||
let rendered = format!("{settings:?}");
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
assert!(rendered.contains("HttpEndpointUrl(<redacted>)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_settings_require_one_enabled_endpoint() {
|
||||
let url = super::HttpEndpointUrl::parse("https://api.devnet.solana.com").expect("test URL must parse");
|
||||
let role = super::HttpEndpointRoleSettings::new(
|
||||
super::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![super::HttpRequestKind::wildcard()],
|
||||
100,
|
||||
super::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||
);
|
||||
let endpoint = super::HttpEndpointSettings::new(
|
||||
"disabled",
|
||||
false,
|
||||
super::HttpProviderName::new("provider"),
|
||||
super::HttpClusterName::new("devnet"),
|
||||
url,
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::option::Option::None,
|
||||
std::vec![role],
|
||||
);
|
||||
let settings = super::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
super::HttpRetrySettings::new(1, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
|
||||
);
|
||||
let error = settings.validate().expect_err("all-disabled settings must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_settings_reject_duplicate_endpoint_names() {
|
||||
let first = valid_settings("https://one.invalid");
|
||||
let second = valid_settings("https://two.invalid");
|
||||
let settings = super::HttpTransportSettings::new(std::vec![first.endpoints()[0].clone(), second.endpoints()[0].clone()], first.retry().clone());
|
||||
let error = settings.validate().expect_err("duplicate endpoint names must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_settings_reject_duplicate_roles() {
|
||||
let base = valid_settings("https://api.devnet.solana.com");
|
||||
let endpoint = &base.endpoints()[0];
|
||||
let duplicated_endpoint = super::HttpEndpointSettings::new(
|
||||
endpoint.name(),
|
||||
true,
|
||||
endpoint.provider().clone(),
|
||||
endpoint.cluster().clone(),
|
||||
endpoint.url().clone(),
|
||||
endpoint.connect_timeout(),
|
||||
endpoint.request_timeout(),
|
||||
endpoint.max_idle_connections_per_host(),
|
||||
std::vec![endpoint.roles()[0].clone(), endpoint.roles()[0].clone()],
|
||||
);
|
||||
let settings = super::HttpTransportSettings::new(std::vec![duplicated_endpoint], base.retry().clone());
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_settings_reject_wildcard_mixed_with_specific_kind() {
|
||||
let base = valid_settings("https://api.devnet.solana.com");
|
||||
let endpoint = &base.endpoints()[0];
|
||||
let role = super::HttpEndpointRoleSettings::new(
|
||||
super::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![super::HttpRequestKind::wildcard(), super::HttpRequestKind::new("get_balance")],
|
||||
100,
|
||||
endpoint.roles()[0].limits().clone(),
|
||||
);
|
||||
let modified_endpoint = super::HttpEndpointSettings::new(
|
||||
endpoint.name(),
|
||||
true,
|
||||
endpoint.provider().clone(),
|
||||
endpoint.cluster().clone(),
|
||||
endpoint.url().clone(),
|
||||
endpoint.connect_timeout(),
|
||||
endpoint.request_timeout(),
|
||||
endpoint.max_idle_connections_per_host(),
|
||||
std::vec![role],
|
||||
);
|
||||
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_settings_reject_burst_without_rps() {
|
||||
let base = valid_settings("https://api.devnet.solana.com");
|
||||
let endpoint = &base.endpoints()[0];
|
||||
let role = super::HttpEndpointRoleSettings::new(
|
||||
super::HttpRoleName::new("default"),
|
||||
true,
|
||||
std::vec![super::HttpRequestKind::wildcard()],
|
||||
100,
|
||||
super::HttpRoleLimits::new(std::option::Option::None, std::option::Option::Some(non_zero(2)), std::option::Option::None, std::option::Option::None),
|
||||
);
|
||||
let modified_endpoint = super::HttpEndpointSettings::new(
|
||||
endpoint.name(),
|
||||
true,
|
||||
endpoint.provider().clone(),
|
||||
endpoint.cluster().clone(),
|
||||
endpoint.url().clone(),
|
||||
endpoint.connect_timeout(),
|
||||
endpoint.request_timeout(),
|
||||
endpoint.max_idle_connections_per_host(),
|
||||
std::vec![role],
|
||||
);
|
||||
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_settings_reject_reversed_retry_backoff() {
|
||||
let base = valid_settings("https://api.devnet.solana.com");
|
||||
let settings = super::HttpTransportSettings::new(
|
||||
base.endpoints().to_vec(),
|
||||
super::HttpRetrySettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
|
||||
);
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_settings_reject_zero_request_timeout() {
|
||||
let base = valid_settings("https://api.devnet.solana.com");
|
||||
let endpoint = &base.endpoints()[0];
|
||||
let modified_endpoint = super::HttpEndpointSettings::new(
|
||||
endpoint.name(),
|
||||
true,
|
||||
endpoint.provider().clone(),
|
||||
endpoint.cluster().clone(),
|
||||
endpoint.url().clone(),
|
||||
endpoint.connect_timeout(),
|
||||
std::time::Duration::ZERO,
|
||||
endpoint.max_idle_connections_per_host(),
|
||||
endpoint.roles().to_vec(),
|
||||
);
|
||||
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
171
deltas/0.2.1/pre.001-fix.001.md
Normal file
171
deltas/0.2.1/pre.001-fix.001.md
Normal file
@@ -0,0 +1,171 @@
|
||||
<!-- file: deltas/0.2.1/pre.001-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.1-pre.001-fix.001` — recalibrage du split HTTP et fusion possible de sessions
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente :
|
||||
|
||||
```text
|
||||
0.2.1-pre.001
|
||||
workspace.package.version = "0.2.1-pre.1"
|
||||
```
|
||||
|
||||
Ce correctif est exclusivement documentaire. Il corrige le dimensionnement issu de `pre.001` avant toute implémentation fonctionnelle lourde de `ksp-onchain-transport-lib`.
|
||||
|
||||
Le delta historique :
|
||||
|
||||
```text
|
||||
deltas/0.2.1/pre.001.md
|
||||
```
|
||||
|
||||
reste inchangé et continue de tracer le premier split décidé par `pre.001`.
|
||||
|
||||
## Objectif
|
||||
|
||||
Réduire le nombre de releases HTTP complémentaires sans diminuer la couverture exhaustive de la surface normative et préciser qu'une même session de chat peut clôturer plusieurs releases successives lorsque leur sizing réel le permet.
|
||||
|
||||
Le correctif ne fusionne jamais les releases elles-mêmes : chaque release conserve son numéro, ses prereleases, son signal de version, ses deltas, ses validations et sa clôture stable propres.
|
||||
|
||||
## Split HTTP corrigé
|
||||
|
||||
Le premier split de `pre.001` était :
|
||||
|
||||
```text
|
||||
0.2.1 foundation + 4 canaris
|
||||
0.2.2 Accounts + Tokens
|
||||
0.2.3 Transactions
|
||||
0.2.4 Blocks
|
||||
0.2.5 Cluster
|
||||
0.2.6 Economics + compliance finale
|
||||
```
|
||||
|
||||
Il est remplacé pour l'exécution par :
|
||||
|
||||
```text
|
||||
0.2.1 HTTP foundation + 4 canaris
|
||||
0.2.2 Accounts + Tokens + Cluster 22 méthodes
|
||||
0.2.3 Transactions 11 méthodes
|
||||
0.2.4 Blocks + Economics + compliance 15 méthodes
|
||||
```
|
||||
|
||||
La matrice reste exhaustive :
|
||||
|
||||
```text
|
||||
4 + 22 + 11 + 15 = 52 méthodes HTTP courantes
|
||||
```
|
||||
|
||||
Les 14 méthodes historiques de la section Deprecated restent présentes dans la matrice documentaire avec leur statut runtime déjà décidé par `pre.001`.
|
||||
|
||||
Aucune méthode n'est supprimée ou masquée pour faire tenir le planning.
|
||||
|
||||
## Règle de session précisée
|
||||
|
||||
La règle KSP « une release concrète doit pouvoir être ouverte et clôturée dans une seule session » définit une borne maximale, pas une obligation de changer de chat après chaque release.
|
||||
|
||||
Après clôture complète d'une release, la même session peut ouvrir puis clôturer la suivante si :
|
||||
|
||||
- le gate de sizing de la release suivante reste raisonnablement positif ;
|
||||
- la release précédente est réellement clôturée avant l'ouverture de la suivante ;
|
||||
- versions, deltas, validations et critères de clôture restent séparés ;
|
||||
- aucune dette de validation n'est reportée implicitement sur la release suivante.
|
||||
|
||||
Ainsi, `0.2.2`, `0.2.3` et `0.2.4` représentent **trois sessions nominales au maximum**, mais peuvent tenir dans deux sessions, voire être enchaînées plus rapidement si le travail réel le permet.
|
||||
|
||||
## Séquence `0.2.x` recalibrée
|
||||
|
||||
```text
|
||||
0.2.1 HTTP transport foundation + 4 canaris
|
||||
0.2.2 HTTP Accounts + Tokens + Cluster
|
||||
0.2.3 HTTP Transactions
|
||||
0.2.4 HTTP Blocks + Economics + compliance complète
|
||||
0.2.5 wallet foundation (.kspwallet)
|
||||
0.2.6 Wallet Desk
|
||||
0.2.7 standard Solana WebSocket
|
||||
0.2.8 Helius LaserStream WebSocket
|
||||
0.2.9 Yellowstone gRPC standard foundation
|
||||
0.2.10 off-chain price transport
|
||||
0.2.11 price visualization desk
|
||||
0.2.12 interface/wire foundation
|
||||
0.2.13 program-api foundation
|
||||
```
|
||||
|
||||
Le plan historique `docs/plans/007-V0_2_0_SERIES_PLANNING.md` reste inchangé : il documente la planification telle qu'elle existait à la clôture de `0.2.0`. Le présent fix et les documents actifs portent la séquence courante.
|
||||
|
||||
## Synchronisation documentaire
|
||||
|
||||
Le correctif met à jour :
|
||||
|
||||
- le roadmap et la séquence fonctionnelle ;
|
||||
- le plan actif `008` et sa matrice méthode -> release ;
|
||||
- les index documentaires ;
|
||||
- le prompt historique `006`, en conservant son rôle de trace d'ouverture ;
|
||||
- l'inventaire courant des composants ;
|
||||
- les références temporelles de `docs/IDEAS.md` devenues obsolètes après renumérotation.
|
||||
|
||||
L'historique est explicite : `pre.001` garde son premier split `0.2.1`–`0.2.6`; ce `fix.001` porte le recalibrage `0.2.1`–`0.2.4`.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
ROADMAP.md
|
||||
docs/000-README.md
|
||||
docs/IDEAS.md
|
||||
docs/architecture/004-COMPONENT_INVENTORY.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
prompts/006-V0_2_1_START_PROMPT.md
|
||||
```
|
||||
|
||||
## Fichier ajouté
|
||||
|
||||
```text
|
||||
deltas/0.2.1/pre.001-fix.001.md
|
||||
```
|
||||
|
||||
## Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
deltas/0.2.1/pre.001.md
|
||||
docs/plans/007-V0_2_0_SERIES_PLANNING.md
|
||||
```
|
||||
|
||||
`pre.001.md` reste la trace immuable de la livraison initiale. Le plan `007` reste l'historique stable de `0.2.0`.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
Aucune modification de `Cargo.toml`.
|
||||
|
||||
Le correctif est documentaire uniquement ; `workspace.package.version` reste :
|
||||
|
||||
```text
|
||||
0.2.1-pre.1
|
||||
```
|
||||
|
||||
L'identifiant de livraison est :
|
||||
|
||||
```text
|
||||
0.2.1-pre.001-fix.001
|
||||
```
|
||||
|
||||
## Dépendances
|
||||
|
||||
Aucune dépendance ajoutée ou modifiée.
|
||||
|
||||
## Validations du correctif
|
||||
|
||||
À la livraison du fix :
|
||||
|
||||
- le delta historique `pre.001.md` doit conserver exactement son contenu ;
|
||||
- le plan `008` doit contenir 52 méthodes courantes réparties exactement en `4 / 22 / 11 / 15` sur `0.2.1 / 0.2.2 / 0.2.3 / 0.2.4` ;
|
||||
- aucune méthode courante ne doit rester affectée à `0.2.5` ou `0.2.6` ;
|
||||
- les 14 entrées Deprecated historiques doivent rester dans la matrice ;
|
||||
- Wallet doit être `0.2.5`, Wallet Desk `0.2.6`, WebSocket `0.2.7`, LaserStream `0.2.8`, Yellowstone `0.2.9` dans les documents actifs ;
|
||||
- l'enchaînement de plusieurs releases dans une même session doit être autorisé uniquement après clôture complète et nouveau sizing positif ;
|
||||
- `Cargo.toml` doit rester à `0.2.1-pre.1` ;
|
||||
- l'archive du fix doit être limitée aux huit fichiers modifiés et au nouveau delta.
|
||||
|
||||
Aucune commande Cargo n'est requise spécifiquement pour ce correctif documentaire. Les validations Rust prévues pour `0.2.1-pre.002` et les frontières globales de release restent inchangées.
|
||||
151
deltas/0.2.1/pre.001.md
Normal file
151
deltas/0.2.1/pre.001.md
Normal file
@@ -0,0 +1,151 @@
|
||||
<!-- file: deltas/0.2.1/pre.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.1-pre.001` — audit HTTP, matrice exhaustive, architecture et sizing
|
||||
|
||||
## Base requise
|
||||
|
||||
Release stable attendue :
|
||||
|
||||
```text
|
||||
v0.2.0
|
||||
```
|
||||
|
||||
L'archive KSP fournie porte `workspace.package.version = "0.2.0"` et contient les quatre crates N1 attendues. Elle ne contient pas `.git`; le tag `v0.2.0` n'est donc pas revérifiable localement depuis le zip.
|
||||
|
||||
Archive historique auditée :
|
||||
|
||||
```text
|
||||
khadhroony-bot3_v0.5.3-pre.005-fix010.zip
|
||||
workspace.package.version = "0.5.3-pre.5"
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Exécuter la première tranche obligatoire d'audit/conception de `0.2.1` sans grosse implémentation : relecture KSP, audit détaillé du transport bot3, inventaire officiel Solana HTTP actuel, matrice exhaustive, décisions d'architecture et gate de sizing.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
Conformément à `VER-ID-009`, la prerelease non-fix synchronise le signal technique :
|
||||
|
||||
```text
|
||||
0.2.0 -> 0.2.1-pre.1
|
||||
```
|
||||
|
||||
Aucune crate, source Rust ou dépendance n'est ajoutée dans cette tranche.
|
||||
|
||||
## Résultats principaux
|
||||
|
||||
- index HTTP Solana courant audité : **52 méthodes** ;
|
||||
- navigation Deprecated officielle : **14 méthodes historiques** ;
|
||||
- changelog Agave : endpoints RPC v1 obsolete/deprecated retirés en v2 ; les 14 pages sont donc conservées dans la matrice comme `Deprecated / runtime Removed`, pas comme wrappers callables ;
|
||||
- aucune méthode HTTP courante marquée unstable/experimental trouvée dans l'audit du 2026-08-17 ; les marqueurs Unstable trouvés concernent WebSocket/PubSub ;
|
||||
- `getBlock` et `getTransaction` sont des méthodes courantes stables mais leur deuxième paramètre legacy sous forme de bare encoding string est explicitement deprecated ;
|
||||
- bot3 contient exactement les 52 noms courants, tous `TypedAdapter`, sans manque ni extra ; il n'intègre pas les 14 Deprecated au registre HTTP ;
|
||||
- bot3 doit être refondu pour supprimer Transport -> Config, Transport -> `ks-lib` et `tracing` direct.
|
||||
|
||||
## Gate de sizing
|
||||
|
||||
Réponse pour le périmètre monolithique du prompt `006` :
|
||||
|
||||
```text
|
||||
NON
|
||||
```
|
||||
|
||||
Le scope est scindé immédiatement, sans masquer aucune méthode :
|
||||
|
||||
```text
|
||||
0.2.1 foundation + registry exhaustif + pool/resilience/config + 4 canaris
|
||||
0.2.2 Accounts + Tokens
|
||||
0.2.3 Transactions
|
||||
0.2.4 Blocks
|
||||
0.2.5 Cluster
|
||||
0.2.6 Economics + compliance finale HTTP
|
||||
```
|
||||
|
||||
Wallet est décalé à `0.2.7`, Wallet Desk à `0.2.8`, puis les releases suivantes sont renumérotées.
|
||||
|
||||
Réponse pour la `0.2.1` réduite définie par le plan `008` :
|
||||
|
||||
```text
|
||||
OUI, raisonnablement clôturable dans la session.
|
||||
```
|
||||
|
||||
## Architecture décidée
|
||||
|
||||
- settings runtime publics possédés par Transport, utilisant notamment `Duration`;
|
||||
- provider/cluster/role/request kind sous descriptors ouverts/newtypes ;
|
||||
- URL encapsulée avec `Debug` redacted ;
|
||||
- descriptor central séparant statut documentaire, statut runtime, statut de forme de requête, operation kind et retry class ;
|
||||
- pool logique : priorité globale puis round-robin/fallback, avec endpoints disabled exclus ;
|
||||
- RPS/burst/concurrence/cooldown par endpoint/rôle ;
|
||||
- retry transport borné seulement pour opérations retry-safe ; `sendTransaction`/`requestAirdrop` ne sont jamais resend automatiquement après dispatch ambigu ;
|
||||
- erreurs via l'unique `ksp_core_lib::Error` ;
|
||||
- réponses Transport/wire, aucun modèle Store/Program/canonical ;
|
||||
- Config standard IDs candidats `cfg.std.transport` / `schema.std.transport` et adapter Config -> Transport ;
|
||||
- aucune nouvelle variable `.env.example` dans `pre.001`.
|
||||
|
||||
## Dépendances auditées
|
||||
|
||||
`reqwest` courant observé : `0.13.4`; Tokio courant observé : `1.53.1`. Le workspace possède déjà `serde`, `serde_json` et `tokio ^1.53`. `reqwest` sera ajouté seulement à `pre.002` après vérification exacte de ses features. `base64`/`bs58` sont différés jusqu'au premier besoin concret.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
deltas/0.2.1/pre.001.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
ROADMAP.md
|
||||
docs/000-README.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
prompts/006-V0_2_1_START_PROMPT.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Validations exécutées
|
||||
|
||||
- relecture des sources internes obligatoires et des contrats publics Core/Logging/Config ;
|
||||
- inspection du workspace stable fourni ;
|
||||
- inspection du transport/config bot3 fourni ;
|
||||
- comparaison automatisée locale des 52 noms bot3 avec les 52 noms de l'index officiel audité : `missing = []`, `extra = []` ;
|
||||
- consultation de la documentation officielle Solana HTTP et Deprecated ;
|
||||
- consultation de JSON-RPC 2.0 ;
|
||||
- consultation du changelog Agave pour le statut runtime des endpoints deprecated ; aucun POST JSON-RPC live vers un provider n’a été exécuté, les éventuelles divergences provider restent donc à documenter séparément ;
|
||||
- vérification des versions actuelles candidates `reqwest`/`tokio` ;
|
||||
- vérification de l'absence de `.git` dans l'archive KSP.
|
||||
|
||||
## Validations non exécutées
|
||||
|
||||
Le sandbox ne contient pas le binaire `cargo` (`cargo: command not found`). Les commandes suivantes n'ont donc pas été exécutées ici et doivent être rejouées sur le checkout de développement avant commit :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucun `cargo tree` n'est pertinent avant ajout de la crate/dépendance ; les audits `cargo tree -p ksp-onchain-transport-lib ...` deviennent obligatoires dès `pre.002`.
|
||||
|
||||
L'archive source ne contient pas `.git`; le commit attendu après application/validation suit `VER-GIT-001` :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.001
|
||||
```
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
Aucune question bloquante pour ouvrir `pre.002`. Les détails de nommage Rust peuvent encore être ajustés si une contrainte concrète apparaît, sans modifier l'ownership et les invariants fixés par le plan.
|
||||
|
||||
## Suite
|
||||
|
||||
`0.2.1-pre.002` : créer la crate, fixer les settings/validation, JSON-RPC, descriptors/status, erreurs et base Logging sur le périmètre réduit.
|
||||
171
deltas/0.2.1/pre.002-fix.001.md
Normal file
171
deltas/0.2.1/pre.002-fix.001.md
Normal file
@@ -0,0 +1,171 @@
|
||||
<!-- file: deltas/0.2.1/pre.002-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.1-pre.002-fix.001` — nettoyage Clippy et documentation des tests
|
||||
|
||||
## Base requise
|
||||
|
||||
```text
|
||||
release : 0.2.1
|
||||
prerelease corrigée : pre.002
|
||||
identifiant de commit attendu : v0.2.1-pre.002-fix.001
|
||||
workspace.package.version : 0.2.1-pre.2.fix.1
|
||||
base : v0.2.1-pre.002
|
||||
```
|
||||
|
||||
Le correctif est ouvert à partir de la validation locale transmise après application de `pre.002`.
|
||||
|
||||
## Objectif
|
||||
|
||||
Corriger exclusivement les warnings révélés par `cargo clippy --workspace --all-targets` et `cargo test`, sans modifier le périmètre fonctionnel ni les contrats publics introduits par `pre.002` :
|
||||
|
||||
- supprimer trois usages de `assert!(false, ...)` signalés par `clippy::assertions_on_constants` dans les tests unitaires ;
|
||||
- appliquer les deux simplifications `clippy::collapsible_if` dans la validation des settings ;
|
||||
- documenter les deux crates de tests d'intégration afin de satisfaire `missing_docs` ;
|
||||
- conserver inchangés le registre 52 current + 14 historiques, les settings publics, les envelopes JSON-RPC, les codes d'erreur, les descriptors et le firewall de dépendances.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
Le correctif touche du Rust de production et de test. Conformément à `VER-ID-007` et `VER-ID-010` :
|
||||
|
||||
```text
|
||||
0.2.1-pre.2 -> 0.2.1-pre.2.fix.1
|
||||
```
|
||||
|
||||
Toutes les crates membres continuent d'hériter `version.workspace = true`.
|
||||
|
||||
## Corrections Rust
|
||||
|
||||
### Validation des settings
|
||||
|
||||
Les deux validations imbriquées suivantes sont exprimées sous forme de let-chain Rust 2024 :
|
||||
|
||||
```text
|
||||
max_idle_connections_per_host = Some(0)
|
||||
pause_after_rate_limit = Some(Duration::ZERO)
|
||||
```
|
||||
|
||||
La sémantique reste strictement identique : les valeurs nulles configurées restent rejetées avec `ERROR_CODE_INVALID_SETTINGS`.
|
||||
|
||||
### Tests JSON-RPC
|
||||
|
||||
Les tests qui validaient la variante attendue avec un `match` et `assert!(false, ...)` utilisent désormais :
|
||||
|
||||
1. une assertion `matches!` sur la variante attendue ;
|
||||
2. un `if let` pour vérifier le payload de cette variante.
|
||||
|
||||
Aucun `panic!`/`unreachable!` explicite n'est introduit.
|
||||
|
||||
### Test de matrice de couverture
|
||||
|
||||
Le cas `HttpRpcCoverageRelease::Historical` rencontré dans `current_http_rpc_methods()` est désormais comptabilisé dans un compteur local puis vérifié avec :
|
||||
|
||||
```text
|
||||
historical == 0
|
||||
```
|
||||
|
||||
La preuve reste donc explicite sans assertion constante fausse.
|
||||
|
||||
### Tests d'intégration
|
||||
|
||||
Les targets Cargo :
|
||||
|
||||
```text
|
||||
tests/public_api.rs
|
||||
tests/dependency_boundary.rs
|
||||
```
|
||||
|
||||
possèdent maintenant une documentation de crate `//!`, ce qui satisfait le lint workspace `missing_docs` sans masquer le warning global.
|
||||
|
||||
## Graphe de dépendances
|
||||
|
||||
Les quatre commandes `cargo tree` ont été exécutées localement sur `pre.002` avant ce fix.
|
||||
|
||||
Constats :
|
||||
|
||||
- aucune dépendance directe interdite Transport -> Config/Store/Program/tracing n'apparaît ;
|
||||
- `reqwest 0.13.4` reste la dépendance HTTP retenue par `pre.002` ;
|
||||
- `cargo tree -d` ne rapporte comme duplication que `syn 2.0.119` / `syn 3.0.3`, issue des chaînes proc-macro/ICU/Serde et ne justifiant pas une modification du graphe KSP dans ce correctif ;
|
||||
- aucune dépendance n'est ajoutée ou retirée par `fix.001`.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.2.1/pre.002-fix.001.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
ROADMAP.md
|
||||
crates/ksp-onchain-transport-lib/src/settings.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
|
||||
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Validation de `pre.002` ayant déclenché le correctif
|
||||
|
||||
Les commandes suivantes ont été exécutées par le user sur `v0.2.1-pre.002` avant création du fix :
|
||||
|
||||
```text
|
||||
cargo fmt --all : exécuté
|
||||
cargo check --workspace : OK
|
||||
cargo clippy --workspace --all-targets : terminé avec warnings
|
||||
cargo test -p ksp-onchain-transport-lib : OK, 40 tests passés
|
||||
cargo test --workspace : OK ; 1 test diagnostic ignoré comme prévu
|
||||
cargo tree -p ksp-onchain-transport-lib : exécuté
|
||||
cargo tree -p ksp-onchain-transport-lib -d : exécuté
|
||||
cargo tree -p ksp-onchain-transport-lib -e features : exécuté
|
||||
cargo tree -p ksp-onchain-transport-lib -e normal : exécuté
|
||||
```
|
||||
|
||||
Warnings observés et ciblés par ce fix :
|
||||
|
||||
```text
|
||||
3 x clippy::assertions_on_constants
|
||||
2 x clippy::collapsible_if
|
||||
2 x missing documentation for integration-test crate
|
||||
```
|
||||
|
||||
## Validations du correctif
|
||||
|
||||
Le sandbox de préparation ne possède pas `cargo`, `rustc` ni `rustfmt`. Les validations Rust du correctif ne peuvent donc pas y être exécutées et ne sont pas déclarées réussies.
|
||||
|
||||
Après application du delta, exécuter dans cet ordre :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Les quatre `cargo tree` ne sont pas obligatoires à répéter pour ce fix puisqu'aucune dépendance n'a changé. Ils peuvent être relancés si une vérification de non-régression du graphe est souhaitée.
|
||||
|
||||
## Contrôles statiques effectués pendant la préparation
|
||||
|
||||
- aucune occurrence restante de `assert!(false` dans la crate Transport ;
|
||||
- les deux blocs signalés `collapsible_if` ont été remplacés ;
|
||||
- les deux tests d'intégration possèdent une rustdoc de crate ;
|
||||
- les headers/version des fichiers modifiés ont été incrémentés ;
|
||||
- `pre.002.md` n'est pas modifié ;
|
||||
- le périmètre public de `pre.002` reste inchangé.
|
||||
|
||||
## Décisions prises
|
||||
|
||||
- ne pas masquer les lints par `#[allow(...)]` ; corriger leur cause ;
|
||||
- ne pas remplacer `assert!(false)` par `panic!()` ou `unreachable!()` ;
|
||||
- ne pas modifier le graphe de dépendances pour le seul doublon `syn 2/3` ;
|
||||
- conserver ce travail sous un vrai `pre.002-fix.001`, conformément à l'historique Git KSP.
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
Aucune pour ce correctif. Après validation et commit, `0.2.1` peut reprendre avec la prerelease suivante prévue par le plan `008`.
|
||||
333
deltas/0.2.1/pre.002.md
Normal file
333
deltas/0.2.1/pre.002.md
Normal file
@@ -0,0 +1,333 @@
|
||||
<!-- file: deltas/0.2.1/pre.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.1-pre.002` — fondation crate/settings/JSON-RPC/descriptors
|
||||
|
||||
## Base requise
|
||||
|
||||
```text
|
||||
release : 0.2.1
|
||||
prerelease : pre.002
|
||||
identifiant de commit attendu : v0.2.1-pre.002
|
||||
workspace.package.version : 0.2.1-pre.2
|
||||
base : v0.2.1-pre.001-fix.001
|
||||
```
|
||||
|
||||
Le user a confirmé avoir commité `v0.2.1-pre.001` puis `v0.2.1-pre.001-fix.001` avant l'ouverture de cette tranche.
|
||||
|
||||
## Objectif
|
||||
|
||||
Matérialiser la fondation Rust de `ksp-onchain-transport-lib` sans ouvrir encore le client/pool HTTP :
|
||||
|
||||
- ajouter la crate au workspace ;
|
||||
- stabiliser les codes d'erreur Transport ;
|
||||
- posséder les settings runtime publics et leur validation ;
|
||||
- posséder les envelopes JSON-RPC 2.0 HTTP ;
|
||||
- matérialiser le registre central des méthodes/statuts issu de la matrice `pre.001` ;
|
||||
- installer la base de warnings KSP de statut sans dépendance directe à `tracing` ;
|
||||
- ajouter les tests unitaires/intégration correspondant à ces contrats.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
Conformément à `VER-ID-009` :
|
||||
|
||||
```text
|
||||
0.2.1-pre.1 -> 0.2.1-pre.2
|
||||
```
|
||||
|
||||
Toutes les crates membres continuent d'hériter `version.workspace = true`.
|
||||
|
||||
## Workspace et dépendances
|
||||
|
||||
Nouveau membre :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
Dépendances directes de la crate :
|
||||
|
||||
```text
|
||||
ksp-core-lib
|
||||
ksp-logging-lib
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
```
|
||||
|
||||
`reqwest` reste centralisé au workspace :
|
||||
|
||||
```toml
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
```
|
||||
|
||||
Le `pre.001` avait vérifié le 2026-08-17 la génération `reqwest 0.13` et observé `0.13.4`. Cette tranche n'active volontairement aucune feature TLS/JSON/client : `reqwest` est utilisé uniquement pour `Url::parse` dans la validation de `HttpEndpointUrl`. Conformément à `RUST-DEP-001` / `RUST-DEP-003`, les features réseau et Tokio seront ajoutés seulement lorsque `pre.003` introduira un vrai client HTTP async.
|
||||
|
||||
Aucune dépendance directe vers :
|
||||
|
||||
```text
|
||||
ksp-config-lib
|
||||
ksp-store-api
|
||||
ksp-store-lib
|
||||
ksp-program-api
|
||||
ksp-program-lib
|
||||
tracing
|
||||
```
|
||||
|
||||
## Contrats publics Transport
|
||||
|
||||
### Settings runtime
|
||||
|
||||
La crate expose :
|
||||
|
||||
```text
|
||||
HttpTransportSettings
|
||||
HttpEndpointSettings
|
||||
HttpEndpointRoleSettings
|
||||
HttpRoleLimits
|
||||
HttpRetrySettings
|
||||
HttpEndpointUrl
|
||||
HttpProviderName
|
||||
HttpClusterName
|
||||
HttpRoleName
|
||||
HttpRequestKind
|
||||
```
|
||||
|
||||
Décisions :
|
||||
|
||||
- Transport reste totalement constructible/testable sans Config ;
|
||||
- `provider`, `cluster`, `role` et `request_kind` sont des descriptors ouverts, pas des enums fermées ;
|
||||
- `HttpEndpointUrl` accepte uniquement HTTP/HTTPS, conserve la valeur runtime réelle mais redacted systématiquement son `Debug` ;
|
||||
- `HttpTransportSettings::validate()` vérifie notamment endpoints activés, identités/roles uniques, timeouts positifs, request kinds, wildcard, limites et backoff cohérents ;
|
||||
- `NonZeroU32` empêche structurellement les zéros sur RPS/burst/concurrence lorsqu'ils sont configurés ;
|
||||
- aucun accès direct à `.env` ou `std::env::var*` n'existe dans Transport.
|
||||
|
||||
### Codes d'erreur
|
||||
|
||||
Le domaine unique reste :
|
||||
|
||||
```text
|
||||
onchain_transport
|
||||
```
|
||||
|
||||
Codes stabilisés/réservés :
|
||||
|
||||
```text
|
||||
invalid_settings
|
||||
endpoint_selection_failed
|
||||
http_connection_failed
|
||||
http_request_failed
|
||||
timeout
|
||||
rate_limited
|
||||
json_encode_failed
|
||||
json_decode_failed
|
||||
json_rpc_protocol_invalid
|
||||
rpc_application_error
|
||||
method_removed
|
||||
invalid_response
|
||||
```
|
||||
|
||||
Ils utilisent exclusivement `ksp_core_lib::Error` / `ErrorCode`.
|
||||
|
||||
## JSON-RPC HTTP
|
||||
|
||||
La fondation expose :
|
||||
|
||||
```text
|
||||
JsonRpcRequest
|
||||
JsonRpcSuccessResponse
|
||||
JsonRpcErrorObject
|
||||
JsonRpcErrorResponse
|
||||
JsonRpcResponse
|
||||
parse_json_rpc_response_text
|
||||
parse_json_rpc_response_value
|
||||
```
|
||||
|
||||
Invariants :
|
||||
|
||||
- request ids KSP numériques `u64` ;
|
||||
- `jsonrpc` exactement `"2.0"` ;
|
||||
- id de réponse exactement égal à celui de la requête ;
|
||||
- présence exclusive de `result` ou `error` ;
|
||||
- JSON `null` conservé comme résultat valide ;
|
||||
- erreur JSON syntaxique distincte d'une violation protocolaire ;
|
||||
- application error RPC préservée dans `JsonRpcResponse::Error`, puis mappable vers `rpc_application_error` ;
|
||||
- le mapping KSP ne copie pas le message/data distant dans le contexte générique ;
|
||||
- `Debug` des requests masque les paramètres ; `Debug` des succès masque le résultat ; `Debug` des erreurs masque message/data distants.
|
||||
|
||||
Ce dernier point empêche un log/debug accidentel d'une transaction, d'un token provider ou d'une réponse massive tout en laissant les getters explicites accessibles au consumer légitime.
|
||||
|
||||
## Registre central des méthodes
|
||||
|
||||
`rpc_method.rs` matérialise exactement :
|
||||
|
||||
```text
|
||||
52 méthodes current
|
||||
14 méthodes historiques Deprecated / Removed
|
||||
```
|
||||
|
||||
Chaque descriptor possède :
|
||||
|
||||
```text
|
||||
method
|
||||
category
|
||||
request_kind
|
||||
documentation_status
|
||||
runtime_status
|
||||
request_form_status
|
||||
operation_kind
|
||||
transport_retry_class
|
||||
replacement
|
||||
coverage_release
|
||||
```
|
||||
|
||||
La distribution de couverture est encodée et testée :
|
||||
|
||||
```text
|
||||
0.2.1 : 4
|
||||
0.2.2 : 22
|
||||
0.2.3 : 11
|
||||
0.2.4 : 15
|
||||
----
|
||||
52 current
|
||||
```
|
||||
|
||||
Cas structurants déjà encodés :
|
||||
|
||||
```text
|
||||
getTransaction -> StableWithDeprecatedLegacy
|
||||
getBlock -> StableWithDeprecatedLegacy
|
||||
sendTransaction -> WriteSubmission / NeverAfterDispatch
|
||||
requestAirdrop -> WriteSubmission / NeverAfterDispatch
|
||||
simulateTransaction -> Simulation / RetrySafe
|
||||
14 historiques -> Deprecated / Removed / NotApplicable
|
||||
```
|
||||
|
||||
`HttpRpcMethodDescriptor::ensure_runtime_supported()` centralise le comportement de statut :
|
||||
|
||||
- Stable + Supported : succès silencieux ;
|
||||
- Deprecated/Unstable + Supported : `warn` via `ksp-logging-lib` ;
|
||||
- Removed : `warn` puis erreur `method_removed`, sans simuler un appel supporté.
|
||||
|
||||
Le target utilisé est toujours le nom Cargo réel via `env!("CARGO_PKG_NAME")`; aucune dépendance directe `tracing` n'est introduite.
|
||||
|
||||
## Tests ajoutés
|
||||
|
||||
40 tests Rust sont définis :
|
||||
|
||||
- validation des settings et erreurs structurées ;
|
||||
- HTTP/HTTPS uniquement ;
|
||||
- redaction URL et `Debug` global settings ;
|
||||
- endpoint enabled, doublons endpoint/rôle, wildcard, burst/RPS, timeouts, backoff ;
|
||||
- sérialisation JSON-RPC request ;
|
||||
- parse success/error/null ;
|
||||
- id mismatch, version, `result xor error`, invalid JSON ;
|
||||
- redaction des payloads request/result/error dans `Debug` ;
|
||||
- mapping RPC application error ;
|
||||
- registre 52 + 14 et unicité ;
|
||||
- distribution 4/22/11/15 ;
|
||||
- canaris exacts `getBalance/getGenesisHash/getHealth/getVersion` ;
|
||||
- request forms deprecated de `getTransaction/getBlock` ;
|
||||
- no-resend descriptors write ;
|
||||
- warning path Unstable supporté ;
|
||||
- erreur `method_removed` ;
|
||||
- tests d'intégration de la façade publique ;
|
||||
- canary de firewall des dépendances directes du manifest.
|
||||
|
||||
Les vrais tests unitaires sont physiquement sous `unit_tests/` et rattachés aux modules de production via `#[cfg(test)]` + `#[path = ...]`. `tests/` est réservé aux tests d'intégration publics.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/Cargo.toml
|
||||
crates/ksp-onchain-transport-lib/src/error.rs
|
||||
crates/ksp-onchain-transport-lib/src/json_rpc.rs
|
||||
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
crates/ksp-onchain-transport-lib/src/rpc_method.rs
|
||||
crates/ksp-onchain-transport-lib/src/settings.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/settings.rs
|
||||
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
deltas/0.2.1/pre.002.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
ROADMAP.md
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Validations exécutées dans l'environnement d'échange
|
||||
|
||||
Le conteneur ne fournit ni `cargo`, ni `rustc`, ni `rustfmt`. Les validations exécutables disponibles ont donc été limitées aux contrôles statiques suivants :
|
||||
|
||||
- parsing TOML du `Cargo.toml` racine et du manifest de la nouvelle crate ;
|
||||
- contrôle `workspace.package.version = 0.2.1-pre.2` ;
|
||||
- contrôle présence du nouveau membre workspace ;
|
||||
- contrôle des headers `file:` et newline finale sur tous les nouveaux fichiers ;
|
||||
- scan production : aucun `use`, `?`, `unwrap`, `expect`, `panic!`, `pub mod` ;
|
||||
- scan manifest : aucune dépendance directe Config/Store/Program/`tracing` ;
|
||||
- scan production : aucune lecture directe `std::env::var*` ;
|
||||
- audit heuristique de rustdoc sur les éléments publics ;
|
||||
- comparaison automatique du registre Rust avec la matrice `008` : 52 current identiques, dans le même ordre, et 14 historiques identiques ;
|
||||
- contrôle de distribution de release : `4 / 22 / 11 / 15` ;
|
||||
- contrôle des deux seuls marqueurs `StableWithDeprecatedLegacy` : `getTransaction`, `getBlock` ;
|
||||
- contrôle des fences Markdown et headers des documents modifiés ;
|
||||
- contrôle des lignes Rust > 160 colonnes après formatage manuel : aucune.
|
||||
|
||||
## Validations non exécutées
|
||||
|
||||
Obligatoires sur le checkout de développement avant commit :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib -d
|
||||
cargo tree -p ksp-onchain-transport-lib -e features
|
||||
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||
```
|
||||
|
||||
Les `cargo tree` sont désormais obligatoires parce que `reqwest` entre réellement dans le graphe à cette tranche. Les doublons significatifs doivent être examinés avant validation du commit.
|
||||
|
||||
Aucun build Tauri n'est requis : aucune application Tauri/frontend n'est modifiée.
|
||||
|
||||
## Décisions prises
|
||||
|
||||
- conserver un identifiant JSON-RPC KSP numérique `u64` pour les requêtes émises ;
|
||||
- préserver les payloads JSON dynamiques via `serde_json::Value` sans introduire de DTO Solana SDK massif ;
|
||||
- redacter les `Debug` wire susceptibles de contenir des secrets/payloads massifs ;
|
||||
- encoder dès maintenant la couverture release et la retry class dans le registre central ;
|
||||
- ne pas activer de features réseau `reqwest` ni Tokio avant leur premier usage réel ;
|
||||
- ne pas introduire Config, Store, Program, Solana SDK haut niveau, base64, bs58, WebSocket ou gRPC.
|
||||
|
||||
## Questions ouvertes / reports explicites
|
||||
|
||||
Aucune question bloquante pour `pre.003`.
|
||||
|
||||
À décider avec l'implémentation réelle de `pre.003` :
|
||||
|
||||
- features `reqwest 0.13` exactes nécessaires au client async/TLS ;
|
||||
- ownership précis du `reqwest::Client` par endpoint logique ;
|
||||
- structures runtime du pool, fairness et snapshots ;
|
||||
- première utilisation effective de Tokio et des primitives `sync` si nécessaires.
|
||||
|
||||
## Suite
|
||||
|
||||
```text
|
||||
0.2.1-pre.003
|
||||
```
|
||||
|
||||
Mission prévue : endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs, sans encore ouvrir rate-limit/concurrence/retry effectif de `pre.004` au-delà des structures strictement nécessaires.
|
||||
135
deltas/0.2.1/pre.003-fix.001.md
Normal file
135
deltas/0.2.1/pre.003-fix.001.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<!-- file: deltas/0.2.1/pre.003-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-pre.003-fix.001`
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.003
|
||||
```
|
||||
|
||||
Version Cargo cible :
|
||||
|
||||
```text
|
||||
0.2.1-pre.3.fix.1
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Corriger exclusivement les écarts révélés par la validation locale de `pre.003`, sans modifier le contrat fonctionnel du client/pool HTTP ni la planification de `0.2.1` :
|
||||
|
||||
- satisfaire la règle workspace `clippy::implicit-return` dans trois closures de `pool.rs` ;
|
||||
- corriger le test `disabled_role_is_excluded_before_priority_selection` afin qu'il construise des settings valides tout en vérifiant réellement qu'un rôle disabled n'est pas sélectionnable ;
|
||||
- conserver inchangés la stratégie priority + round-robin, le fallback, les snapshots, la feature TLS `reqwest/rustls` et les frontières de `pre.003`.
|
||||
|
||||
`ROADMAP.md` n'est pas modifié par ce fix : le détail des corrections appartient au delta et ne constitue pas un changement de roadmap.
|
||||
|
||||
## Validation ayant déclenché le fix
|
||||
|
||||
Après application de `pre.003`, les commandes exécutées localement par le user ont donné :
|
||||
|
||||
```text
|
||||
cargo fmt --all : exécuté
|
||||
cargo check --workspace : OK
|
||||
cargo clippy --workspace --all-targets : échec, 3 x clippy::implicit-return
|
||||
cargo test -p ksp-onchain-transport-lib : échec, 46 passés / 1 échoué
|
||||
cargo test --workspace : échec sur le même test Transport
|
||||
cargo tree -p ksp-onchain-transport-lib : exécuté
|
||||
cargo tree -p ksp-onchain-transport-lib -d : exécuté
|
||||
cargo tree -p ksp-onchain-transport-lib -e features : exécuté
|
||||
cargo tree -p ksp-onchain-transport-lib -e normal : exécuté
|
||||
```
|
||||
|
||||
Les trois erreurs Clippy concernaient uniquement des closures dans `pool.rs` :
|
||||
|
||||
```text
|
||||
filter(...)
|
||||
sort_by_key(...)
|
||||
take_while(...)
|
||||
```
|
||||
|
||||
Le test en échec construisait un endpoint `enabled = true` dont l'unique rôle était `enabled = false`. Cette fixture contredisait la validation Transport déjà définie, qui exige qu'un endpoint activé expose au moins un rôle activé.
|
||||
|
||||
## Corrections
|
||||
|
||||
### `pool.rs`
|
||||
|
||||
Les trois closures signalées utilisent maintenant un `return` explicite, conformément à la configuration Clippy KSP :
|
||||
|
||||
```text
|
||||
filter(|endpoint| return ...)
|
||||
sort_by_key(|candidate| return ...)
|
||||
take_while(|candidate| return ...)
|
||||
```
|
||||
|
||||
Aucune sémantique de sélection n'est modifiée.
|
||||
|
||||
### Test du rôle disabled
|
||||
|
||||
La fixture de test conserve désormais sur le premier endpoint :
|
||||
|
||||
- un rôle `default` disabled, de priorité 1 ;
|
||||
- un rôle `maintenance` enabled, également valide pour l'endpoint mais ne correspondant pas au rôle demandé.
|
||||
|
||||
Le pool est donc valide à la construction. Lors d'une sélection sur le rôle `default`, le rôle disabled du premier endpoint doit être ignoré et l'endpoint fallback reste sélectionné.
|
||||
|
||||
Cette forme teste réellement le comportement annoncé sans contourner l'invariant de validation des settings.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
Le fix touche du Rust de production et de test :
|
||||
|
||||
```text
|
||||
0.2.1-pre.3 -> 0.2.1-pre.3.fix.1
|
||||
```
|
||||
|
||||
Toutes les crates membres continuent d'hériter `version.workspace = true`.
|
||||
|
||||
## Graphe de dépendances
|
||||
|
||||
Aucune dépendance ni feature n'est ajoutée, retirée ou modifiée par ce fix.
|
||||
|
||||
Les `cargo tree` exécutés sur `pre.003` montrent notamment :
|
||||
|
||||
- `reqwest 0.13.4` avec la feature `rustls` attendue ;
|
||||
- aucun retour de dépendance Transport vers Config/Store/Program ;
|
||||
- `cargo tree -d` ne signale que `syn 2.x / 3.x`, déjà identifié comme duplication transitive de proc-macros et ne nécessitant pas de modification KSP.
|
||||
|
||||
Il n'est donc pas nécessaire de répéter les quatre `cargo tree` pour ce correctif.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.2.1/pre.003-fix.001.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-onchain-transport-lib/src/pool.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/pool.rs
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Validation du fix
|
||||
|
||||
Le sandbox de préparation ne dispose pas de `cargo`/`rustc`. Les validations Rust ne sont donc pas déclarées réussies ici.
|
||||
|
||||
Après application du delta :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Les commandes `cargo tree` n'ont pas à être répétées puisque le graphe de dépendances est inchangé.
|
||||
196
deltas/0.2.1/pre.003.md
Normal file
196
deltas/0.2.1/pre.003.md
Normal file
@@ -0,0 +1,196 @@
|
||||
<!-- file: deltas/0.2.1/pre.003.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-pre.003`
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue : `v0.2.1-pre.002-fix.001`, validée localement avant ouverture de cette tranche.
|
||||
|
||||
Version Cargo cible :
|
||||
|
||||
```text
|
||||
0.2.1-pre.3
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Matérialiser la première couche de client/routing HTTP de `ksp-onchain-transport-lib` sans encore exécuter de méthode JSON-RPC :
|
||||
|
||||
- client endpoint logique autour de `reqwest::Client` ;
|
||||
- pool logique KSP ;
|
||||
- matching rôle/capability ;
|
||||
- priorité globale ;
|
||||
- round-robin équitable dans un même tier ;
|
||||
- fallback lorsque les candidats plus prioritaires ne sont pas sélectionnables ;
|
||||
- snapshots sûrs sans URL ;
|
||||
- préparation des états passifs nécessaires à la résilience de `pre.004`.
|
||||
|
||||
Cette tranche corrige également l'usage de `ROADMAP.md` afin de respecter son contrat existant : le ROADMAP décrit l'état synthétique et la planification majeure, pas l'historique des prereleases/fixes.
|
||||
|
||||
## Modifications
|
||||
|
||||
### Workspace
|
||||
|
||||
- `workspace.package.version` passe de `0.2.1-pre.2.fix.1` à `0.2.1-pre.3` ;
|
||||
- `reqwest` reste centralisé sous `[workspace.dependencies]`, avec `default-features = false` ;
|
||||
- activation explicite de la feature `rustls`, nécessaire à la construction réelle de clients HTTPS dans cette tranche ;
|
||||
- aucune feature `json` n'est ajoutée : les envelopes JSON-RPC restent possédées par KSP via `serde_json` ;
|
||||
- aucune dépendance Tokio directe n'est ajoutée à Transport dans cette tranche.
|
||||
|
||||
### `HttpEndpointClient`
|
||||
|
||||
Nouveau client endpoint logique :
|
||||
|
||||
- encapsule un `reqwest::Client` partageable ;
|
||||
- applique `connect_timeout`, `request_timeout` et `max_idle_connections_per_host` depuis les settings KSP ;
|
||||
- désactive les redirects automatiques ;
|
||||
- désactive les proxies système/environnement implicites ;
|
||||
- fixe un `User-Agent` KSP `ksp-onchain-transport-lib/<version>` ;
|
||||
- conserve l'URL hors de toute surface `Debug`/snapshot ;
|
||||
- expose uniquement identité, provider, cluster, état et capability matching sûrs.
|
||||
|
||||
### Snapshots et état passif
|
||||
|
||||
Nouveaux contrats publics :
|
||||
|
||||
```text
|
||||
HttpEndpointAvailability
|
||||
HttpEndpointRoleSnapshot
|
||||
HttpEndpointSnapshot
|
||||
HttpTransportPoolSnapshot
|
||||
```
|
||||
|
||||
Les snapshots ne contiennent jamais l'URL endpoint.
|
||||
|
||||
`HttpEndpointAvailability` réserve :
|
||||
|
||||
```text
|
||||
Disabled
|
||||
Available
|
||||
Degraded
|
||||
RateLimited
|
||||
```
|
||||
|
||||
`pre.003` ne produit activement que `Disabled` et `Available`. Les transitions `Degraded` / `RateLimited` appartiennent à `pre.004` avec cooldown, limiter et observations runtime.
|
||||
|
||||
### `HttpTransportPool`
|
||||
|
||||
Nouveaux contrats publics :
|
||||
|
||||
```text
|
||||
HttpTransportPool
|
||||
HttpEndpointSelection
|
||||
```
|
||||
|
||||
Algorithme concret de `pre.003` :
|
||||
|
||||
1. valider les settings Transport ;
|
||||
2. construire un client logique par endpoint ;
|
||||
3. exclure les endpoints disabled ;
|
||||
4. rechercher un rôle exact enabled ;
|
||||
5. exiger la capability exacte ou `*` ;
|
||||
6. choisir la plus faible priorité numérique ;
|
||||
7. appliquer un round-robin par couple rôle/request-kind dans ce meilleur tier ;
|
||||
8. utiliser un tier moins prioritaire lorsque les candidats plus prioritaires ne sont pas sélectionnables ;
|
||||
9. retourner `endpoint_selection_failed` si aucun candidat n'existe.
|
||||
|
||||
Le mutex synchrone du pool protège uniquement les curseurs de fairness et ne couvre aucune I/O ni aucun `await` réseau.
|
||||
|
||||
`select_for_method()` dérive le `request_kind` depuis le registre central et appelle `ensure_runtime_supported()` avant sélection ; une méthode historique `Removed` ne peut donc pas être routée comme méthode standard.
|
||||
|
||||
### ROADMAP
|
||||
|
||||
`ROADMAP.md` est ramené à son rôle défini par `FILE_CONTRACTS.md` :
|
||||
|
||||
- suppression des lignes servant de journal `0.2.1-pre.*` / `fix.*` ;
|
||||
- conservation d'un état synthétique de `0.2.0` ;
|
||||
- état synthétique courant de `0.2.1` ;
|
||||
- conservation des releases fonctionnelles `0.2.1+` et de leur planification majeure.
|
||||
|
||||
Aucune règle normative nouvelle n'est nécessaire : cette correction applique le contrat `ROADMAP.md` déjà documenté.
|
||||
|
||||
### Plan `008`
|
||||
|
||||
Le plan est synchronisé avec :
|
||||
|
||||
- la feature TLS réellement retenue ;
|
||||
- la politique client `reqwest` ;
|
||||
- le statut réalisé de `pre.003` ;
|
||||
- l'état exact après la tranche ;
|
||||
- le report explicite des limiteurs/concurrence/cooldown/retry effectifs à `pre.004`.
|
||||
|
||||
## Tests ajoutés
|
||||
|
||||
La crate Transport passe de 40 à **53 tests déclarés**.
|
||||
|
||||
Nouvelles preuves :
|
||||
|
||||
- snapshot client sans URL/secret ;
|
||||
- endpoint disabled visible mais non sélectionnable ;
|
||||
- matching rôle/wildcard capability ;
|
||||
- priorité globale ;
|
||||
- round-robin dans le meilleur tier ;
|
||||
- fallback depuis un endpoint plus prioritaire disabled ;
|
||||
- filtrage capability avant priorité ;
|
||||
- erreur structurée lorsque rien ne correspond ;
|
||||
- routing d'une méthode standard via son descriptor ;
|
||||
- snapshot pool sûr conservant les endpoints disabled ;
|
||||
- consommation du nouveau pool depuis l'API publique.
|
||||
|
||||
## Hors périmètre conservé
|
||||
|
||||
Restent à `pre.004` :
|
||||
|
||||
- token bucket RPS/burst ;
|
||||
- semaphore/max concurrent ;
|
||||
- cooldown après `429` ;
|
||||
- deadline effective de sélection/exécution ;
|
||||
- retry/backoff effectif ;
|
||||
- classification des erreurs `reqwest` pendant une requête ;
|
||||
- transitions runtime `Degraded` / `RateLimited`.
|
||||
|
||||
Restent à `pre.005+` :
|
||||
|
||||
- exécution JSON-RPC HTTP ;
|
||||
- méthodes canari typées ;
|
||||
- document Config standard et adapter ;
|
||||
- smoke tests réseau.
|
||||
|
||||
## Sources externes revérifiées
|
||||
|
||||
Pour `reqwest 0.13` :
|
||||
|
||||
- dépôt/documentation officielle `reqwest` : rustls est le backend TLS de référence actuel ;
|
||||
- changelog `reqwest` : en `0.13`, la feature historique `rustls-tls` a été renommée `rustls` ;
|
||||
- `ClientBuilder` officiel : `connect_timeout`, `timeout`, `pool_max_idle_per_host`, `redirect` et `no_proxy` sont disponibles sur le client async retenu.
|
||||
|
||||
## Validation
|
||||
|
||||
Non exécutée dans le sandbox de génération : `cargo` et `rustc` n'y sont pas installés.
|
||||
|
||||
À exécuter après application :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib -d
|
||||
cargo tree -p ksp-onchain-transport-lib -e features
|
||||
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||
```
|
||||
|
||||
Les quatre `cargo tree` doivent être rejoués dans cette tranche car le feature-set de `reqwest` change avec l'activation de `rustls`.
|
||||
|
||||
## Suite
|
||||
|
||||
Tranche suivante prévue :
|
||||
|
||||
```text
|
||||
0.2.1-pre.004
|
||||
```
|
||||
|
||||
Périmètre : RPS/burst, concurrence, cooldown/429, timeout/deadline et retry/backoff borné, avec respect strict de `TransportRetryClass` et interdiction de resend après dispatch ambigu.
|
||||
248
deltas/0.2.1/pre.004-fix.001.md
Normal file
248
deltas/0.2.1/pre.004-fix.001.md
Normal file
@@ -0,0 +1,248 @@
|
||||
<!-- file: deltas/0.2.1/pre.004-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-pre.004-fix.001`
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.004
|
||||
```
|
||||
|
||||
Version Cargo cible :
|
||||
|
||||
```text
|
||||
0.2.1-pre.4.fix.1
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Corriger la responsabilité Cargo des features externes révélée pendant l'audit de `pre.004`, sans modifier le comportement fonctionnel de la résilience HTTP :
|
||||
|
||||
- conserver au `Cargo.toml` racine la version et les options communes de résolution (`default-features`, etc.) ;
|
||||
- retirer les activations `features = [...]` de `[workspace.dependencies]` ;
|
||||
- activer chaque feature dans le manifeste de la crate KSP qui utilise réellement l'API correspondante ;
|
||||
- distinguer lorsque c'est utile les features de production et celles requises uniquement par les tests ;
|
||||
- formaliser explicitement cette règle sous `DEP-CARGO-*` ;
|
||||
- ajouter une canarie empêchant le retour d'une activation de feature consumer dans `[workspace.dependencies]`.
|
||||
|
||||
Ce fix ne modifie pas `ROADMAP.md` : il corrige un contrat Cargo et sa règle normative, pas la roadmap de `0.2.1`.
|
||||
|
||||
## Validation de `pre.004` ayant précédé le fix
|
||||
|
||||
Après application de `pre.004`, le user a exécuté :
|
||||
|
||||
```text
|
||||
cargo fmt --all : exécuté
|
||||
cargo check --workspace : OK
|
||||
cargo clippy --workspace --all-targets : OK
|
||||
cargo test -p ksp-onchain-transport-lib : OK, 72 tests Transport au total
|
||||
cargo test --workspace : OK ; 1 test diagnostic ignoré comme prévu
|
||||
cargo tree -p ksp-onchain-transport-lib -e features : exécuté
|
||||
cargo tree -p ksp-onchain-transport-lib -e normal : exécuté
|
||||
```
|
||||
|
||||
Le problème n'est donc pas un échec fonctionnel de `pre.004`. L'audit `cargo tree -e features` a cependant rendu visible que les features Tokio déclarées au niveau workspace s'appliquaient au graphe Transport en plus de sa feature locale `sync`, ce qui rendait l'ownership des besoins réels trop global.
|
||||
|
||||
## Décision normative
|
||||
|
||||
La politique KSP retenue est désormais explicite :
|
||||
|
||||
```text
|
||||
[workspace.dependencies]
|
||||
-> version
|
||||
-> default-features et autres options communes de résolution
|
||||
-> aucune activation features = [...]
|
||||
|
||||
crates/<consumer>/Cargo.toml
|
||||
-> dependency.workspace = true
|
||||
-> features = [...] nécessaires à ce consumer
|
||||
```
|
||||
|
||||
Lorsqu'une feature n'est nécessaire qu'aux tests/benchmarks/examples, son activation appartient à la section de dépendances de développement correspondante.
|
||||
|
||||
Cette politique conserve la centralisation des versions sans transformer le feature-set d'une crate en politique implicite pour tous les autres consumers du workspace.
|
||||
|
||||
## Répartition des features
|
||||
|
||||
### `serde`
|
||||
|
||||
Le root conserve uniquement :
|
||||
|
||||
```toml
|
||||
serde = { version = "^1.0" }
|
||||
```
|
||||
|
||||
La feature `derive` est activée dans les trois crates qui utilisent réellement `serde::Serialize` / `serde::Deserialize` dérivés :
|
||||
|
||||
```text
|
||||
ksp-app-config-desk
|
||||
ksp-config-lib
|
||||
ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
### `reqwest`
|
||||
|
||||
Le root conserve :
|
||||
|
||||
```toml
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
```
|
||||
|
||||
`ksp-onchain-transport-lib` active localement :
|
||||
|
||||
```text
|
||||
rustls
|
||||
```
|
||||
|
||||
### `tracing` / `tracing-subscriber`
|
||||
|
||||
Le root conserve les versions avec `default-features = false`.
|
||||
|
||||
Seul `ksp-logging-lib`, propriétaire KSP de ces dépendances, active :
|
||||
|
||||
```text
|
||||
tracing : std
|
||||
tracing-subscriber : fmt, json, ansi
|
||||
```
|
||||
|
||||
`tracing-appender` ne possédait aucune feature explicite à déplacer.
|
||||
|
||||
### `chrono`
|
||||
|
||||
Le root conserve uniquement la version avec `default-features = false`.
|
||||
|
||||
`ksp-app-config-desk`, seul consumer direct actuel de `chrono::Utc::now`, active localement :
|
||||
|
||||
```text
|
||||
std, now
|
||||
```
|
||||
|
||||
### `tokio`
|
||||
|
||||
Le root devient :
|
||||
|
||||
```toml
|
||||
tokio = { version = "^1.53", default-features = false }
|
||||
```
|
||||
|
||||
Les activations sont réparties selon l'usage réel :
|
||||
|
||||
```text
|
||||
ksp-app-config-desk
|
||||
dependencies : time
|
||||
|
||||
ksp-logging-lib
|
||||
dev-dependencies : macros, rt, rt-multi-thread
|
||||
|
||||
ksp-onchain-transport-lib
|
||||
dependencies : macros, sync, time
|
||||
dev-dependencies : rt
|
||||
```
|
||||
|
||||
Pour Transport :
|
||||
|
||||
- `sync` couvre `Notify`, `Semaphore` et `OwnedSemaphorePermit` ;
|
||||
- `time` couvre `sleep_until` / `Instant` ;
|
||||
- `macros` est une dépendance de production car `pool.rs` utilise `tokio::select!` ;
|
||||
- `rt` est requis par `#[tokio::test]` et reste donc activé uniquement côté développement ;
|
||||
- aucun besoin Transport actuel ne justifie `rt-multi-thread`.
|
||||
|
||||
## Règles KSP
|
||||
|
||||
`docs/rules/RULES_DEPENDENCIES.md` passe de la version `11` à `12`.
|
||||
|
||||
`DEP-CARGO-003` précise maintenant que les features d'usage ne sont pas activées sous `[workspace.dependencies]` et appartiennent aux manifests consumers.
|
||||
|
||||
Nouvelle règle `DEP-CARGO-006` : une feature requise uniquement par tests/benchmarks/examples doit être activée dans la section de dépendances de développement appropriée ; l'unification Cargo ne transfère pas cet ownership au root.
|
||||
|
||||
## Canarie Cargo
|
||||
|
||||
`crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs` vérifie désormais aussi :
|
||||
|
||||
- le feature-set direct attendu de `reqwest` et `tokio` pour Transport ;
|
||||
- la séparation du `tokio/rt` de test sous `[dev-dependencies]` ;
|
||||
- l'absence de toute chaîne `features =` dans la section `[workspace.dependencies]` du manifeste racine.
|
||||
|
||||
Cette canarie complète le firewall Transport existant sans introduire de nouvelle dépendance d'audit.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
Le fix modifie plusieurs manifests et donc le contrat de build :
|
||||
|
||||
```text
|
||||
0.2.1-pre.4 -> 0.2.1-pre.4.fix.1
|
||||
```
|
||||
|
||||
Toutes les crates KSP continuent d'hériter `version.workspace = true`.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.2.1/pre.004-fix.001.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-app-config-desk/Cargo.toml
|
||||
crates/ksp-config-lib/Cargo.toml
|
||||
crates/ksp-logging-lib/Cargo.toml
|
||||
crates/ksp-onchain-transport-lib/Cargo.toml
|
||||
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||
docs/rules/RULES_DEPENDENCIES.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Graphe de dépendances
|
||||
|
||||
Aucune crate externe n'est ajoutée ou retirée. Le fix modifie en revanche volontairement les feature-sets directs par consumer ; les audits Cargo doivent donc être rejoués après application.
|
||||
|
||||
Pour Transport, vérifier au minimum :
|
||||
|
||||
```bash
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib -d
|
||||
cargo tree -p ksp-onchain-transport-lib -e features
|
||||
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||
```
|
||||
|
||||
Il est également utile de vérifier les consumers dont les features ont été relocalisées :
|
||||
|
||||
```bash
|
||||
cargo tree -p ksp-app-config-desk -e features
|
||||
cargo tree -p ksp-config-lib -e features
|
||||
cargo tree -p ksp-logging-lib -e features
|
||||
```
|
||||
|
||||
## Validation du fix
|
||||
|
||||
Le sandbox de préparation ne dispose pas de `cargo`/`rustc`. Les validations Rust du correctif ne sont donc pas déclarées réussies ici.
|
||||
|
||||
Après application :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Puis exécuter les `cargo tree` indiqués ci-dessus afin de confirmer que chaque crate expose uniquement les features directes qu'elle possède, sous réserve de l'unification transitive normale de Cargo au niveau du build effectif.
|
||||
|
||||
## Suite
|
||||
|
||||
Après validation et commit de ce fix, `0.2.1` reprend avec :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.005
|
||||
```
|
||||
|
||||
Le périmètre fonctionnel prévu de `pre.005` reste inchangé : exécuteur HTTP JSON-RPC réel et quatre méthodes canari typées.
|
||||
40
deltas/0.2.1/pre.004-fix.002.md
Normal file
40
deltas/0.2.1/pre.004-fix.002.md
Normal file
@@ -0,0 +1,40 @@
|
||||
<!-- file: deltas/0.2.1/pre.004-fix.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-pre.004-fix.002`
|
||||
|
||||
## Objet
|
||||
|
||||
Corriger la canarie de frontière Cargo introduite par `v0.2.1-pre.004-fix.001` sans modifier la politique de features adoptée par ce fix.
|
||||
|
||||
Le test `workspace_dependency_table_does_not_activate_consumer_features` recherchait naïvement la sous-chaîne `features =` dans `[workspace.dependencies]`. Cette recherche détectait aussi `default-features = false`, alors que `default-features` est précisément une option de résolution commune autorisée au niveau workspace par les règles KSP.
|
||||
|
||||
## Modifications
|
||||
|
||||
- `workspace.package.version` passe de `0.2.1-pre.4.fix.1` à `0.2.1-pre.4.fix.2` ;
|
||||
- la canarie Cargo analyse désormais les champs des tables inline et interdit uniquement la clé exacte `features` dans `[workspace.dependencies]` ;
|
||||
- `default-features` reste explicitement autorisé au niveau workspace ;
|
||||
- suppression de la closure qui déclenchait `clippy::implicit-return` dans cette même canarie ;
|
||||
- aucune modification des features réellement activées dans les manifests membres ;
|
||||
- aucune modification du graphe fonctionnel Transport ;
|
||||
- aucune modification du `ROADMAP.md`.
|
||||
|
||||
## Politique KSP conservée
|
||||
|
||||
La politique introduite par `v0.2.1-pre.004-fix.001` reste inchangée :
|
||||
|
||||
- le workspace centralise les versions et les options communes de résolution telles que `default-features` ;
|
||||
- chaque crate consommatrice active localement les `features = [...]` dont elle a réellement besoin ;
|
||||
- les features requises uniquement par les tests sont placées dans les `dev-dependencies` du consumer concerné.
|
||||
|
||||
## Validation attendue
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Les `cargo tree` n'ont pas besoin d'être rejoués pour ce fix : aucune dépendance, aucun `default-features` et aucune activation locale de feature ne changent.
|
||||
231
deltas/0.2.1/pre.004.md
Normal file
231
deltas/0.2.1/pre.004.md
Normal file
@@ -0,0 +1,231 @@
|
||||
<!-- file: deltas/0.2.1/pre.004.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-pre.004`
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.003-fix.001
|
||||
```
|
||||
|
||||
Cette base a été validée localement par le user avec `cargo fmt`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, les tests ciblés Transport et `cargo test --workspace`.
|
||||
|
||||
Version Cargo cible :
|
||||
|
||||
```text
|
||||
0.2.1-pre.4
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Matérialiser la résilience runtime prévue par le plan `008` autour du client/pool HTTP déjà introduit :
|
||||
|
||||
- token bucket RPS/burst par rôle ;
|
||||
- limite de concurrence par semaphore ;
|
||||
- cooldown après rate-limit provider ;
|
||||
- admission async avec deadline commune ;
|
||||
- fallback vers les pairs et tiers moins prioritaires lorsqu'un candidat est temporairement indisponible ;
|
||||
- états passifs `Degraded` / `RateLimited` et snapshots sûrs ;
|
||||
- retry/backoff transport borné ;
|
||||
- interdiction centralisée d'un resend après dispatch ambigu pour les opérations `NeverAfterDispatch`.
|
||||
|
||||
Cette tranche ne réalise toujours pas de POST JSON-RPC ni de méthode Solana typée ; le raccordement réseau appartient à `pre.005`.
|
||||
|
||||
## Modifications
|
||||
|
||||
### Workspace et dépendances
|
||||
|
||||
- `workspace.package.version` passe de `0.2.1-pre.3.fix.1` à `0.2.1-pre.4` ;
|
||||
- aucune nouvelle dépendance tierce n'est introduite ;
|
||||
- `ksp-onchain-transport-lib` consomme désormais `tokio.workspace = true` avec la feature locale `sync`, nécessaire au semaphore et à `Notify` ;
|
||||
- les features runtime/time Tokio restent possédées par la déclaration workspace existante ;
|
||||
- le firewall Transport -> Config/Store/Program et l'absence de `tracing` direct restent inchangés.
|
||||
|
||||
### Admission RPS / burst
|
||||
|
||||
Chaque rôle endpoint possède son état runtime indépendant.
|
||||
|
||||
Lorsque `requests_per_second` est configuré :
|
||||
|
||||
- un token bucket est créé au niveau endpoint/rôle ;
|
||||
- `burst_capacity` fixe la capacité maximale ;
|
||||
- si le burst n'est pas explicite, la capacité dérive de la valeur RPS, soit une seconde de capacité ;
|
||||
- les tokens se reforment proportionnellement au temps écoulé ;
|
||||
- une admission sans token disponible expose l'instant de prochaine admissibilité au pool plutôt que de bloquer un thread.
|
||||
|
||||
Sans RPS, le rôle n'est pas limité par le token bucket KSP.
|
||||
|
||||
### Concurrence
|
||||
|
||||
`max_concurrent_requests` est matérialisé par un `tokio::sync::Semaphore` par rôle.
|
||||
|
||||
Le nouveau `HttpRequestPermit` :
|
||||
|
||||
- réserve la capacité de concurrence ;
|
||||
- conserve la sélection endpoint/rôle/request-kind ;
|
||||
- transporte la deadline commune ;
|
||||
- libère automatiquement la capacité à son drop ;
|
||||
- réveille les waiters lorsque de la capacité redevient disponible.
|
||||
|
||||
Un candidat saturé n'empêche pas d'essayer les autres candidats du même tier puis les tiers moins prioritaires.
|
||||
|
||||
### Cooldown et rate-limit
|
||||
|
||||
`HttpRequestPermit::record_rate_limited()` :
|
||||
|
||||
- incrémente les compteurs failure/rate-limit ;
|
||||
- marque le rôle dégradé ;
|
||||
- applique le cooldown configuré par `pause_after_rate_limit` ;
|
||||
- utilise un fallback runtime de 1 seconde lorsque ce cooldown n'est pas configuré ;
|
||||
- accepte un délai provider déjà résolu et le borne à 60 secondes avant de pouvoir prolonger le cooldown local ;
|
||||
- notifie le pool de la nouvelle disponibilité temporelle.
|
||||
|
||||
Le parsing concret du header HTTP `Retry-After` reste au futur exécuteur HTTP de `pre.005`; `pre.004` stabilise le contrat en `Duration`.
|
||||
|
||||
### Admission async et deadline commune
|
||||
|
||||
Nouvelles surfaces publiques :
|
||||
|
||||
```text
|
||||
HttpRequestPermit
|
||||
HttpTransportPool::acquire_for_method(...)
|
||||
HttpTransportPool::acquire_for_request_kind(...)
|
||||
HttpTransportPool::acquire_for_request_kind_with_timeout(...)
|
||||
```
|
||||
|
||||
Algorithme runtime :
|
||||
|
||||
1. filtrer endpoint/rôle/capability comme en `pre.003` ;
|
||||
2. ordonner les candidats par priorité ;
|
||||
3. appliquer le round-robin dans chaque tier ;
|
||||
4. tenter l'admission cooldown + concurrence + token bucket ;
|
||||
5. essayer les pairs puis les tiers inférieurs lorsqu'un candidat est temporairement bloqué ;
|
||||
6. si tous les candidats sont temporairement bloqués, attendre notification ou prochain instant de refill/cooldown ;
|
||||
7. ne jamais dépasser la deadline commune.
|
||||
|
||||
Par défaut, la deadline commune utilise le plus petit `request_timeout` des endpoints structurellement compatibles. Une surcharge permet à une couche supérieure d'imposer un budget plus strict.
|
||||
|
||||
### Retry / backoff / no-resend
|
||||
|
||||
Nouveaux contrats publics :
|
||||
|
||||
```text
|
||||
HttpRetryCause
|
||||
HttpDispatchState
|
||||
HttpRetryDecision
|
||||
evaluate_transport_retry(...)
|
||||
```
|
||||
|
||||
La policy centralisée :
|
||||
|
||||
- respecte `HttpRetrySettings::max_retries` ;
|
||||
- applique un backoff exponentiel borné entre `initial_backoff` et `max_backoff` ;
|
||||
- autorise seulement les causes transport classées retryables (`Connection`, `Timeout`, `RateLimited`, `TemporaryHttp`) ;
|
||||
- exclut `Request`, `RpcApplication` et `InvalidResponse` du retry automatique ;
|
||||
- respecte `TransportRetryClass::NotApplicable` ;
|
||||
- interdit tout retry d'une méthode `NeverAfterDispatch` lorsque l'état est `DispatchedAmbiguous` ;
|
||||
- autorise encore une tentative si le transport sait explicitement que la requête n'a pas été dispatchée ;
|
||||
- peut prolonger le backoff d'un rate-limit par un délai provider borné à 60 secondes.
|
||||
|
||||
Aucune logique de retry métier/exécution Solana n'est introduite.
|
||||
|
||||
### Santé passive et snapshots
|
||||
|
||||
Les rôles endpoint exposent maintenant de manière sûre :
|
||||
|
||||
- `availability` ;
|
||||
- RPS/burst configurés ;
|
||||
- concurrence maximale et nombre en vol ;
|
||||
- cooldown restant ;
|
||||
- compteurs success/failure/rate-limit.
|
||||
|
||||
Transitions :
|
||||
|
||||
```text
|
||||
Available --failure--> Degraded
|
||||
Available/Degraded --rate-limit--> RateLimited
|
||||
RateLimited --cooldown écoulé--> Degraded
|
||||
Degraded --success--> Available
|
||||
```
|
||||
|
||||
Les snapshots n'exposent toujours aucune URL ni credential provider.
|
||||
|
||||
### ROADMAP et plan
|
||||
|
||||
`ROADMAP.md` reçoit uniquement la mise à jour synthétique normale de l'état de `0.2.1` : la résilience runtime devient acquise et les prochains jalons majeurs restent les 4 canaris, Config standard et la clôture. Aucun historique de prerelease/fix n'y est ajouté.
|
||||
|
||||
Le plan `008` enregistre `pre.004` comme réalisé et décrit l'état précis dans sa section de suivi.
|
||||
|
||||
## Tests ajoutés
|
||||
|
||||
La suite Transport passe de **53 à 72 tests déclarés**.
|
||||
|
||||
Les nouveaux tests couvrent notamment :
|
||||
|
||||
- backoff exponentiel et borne maximale ;
|
||||
- budget `max_retries` ;
|
||||
- no-resend après dispatch ambigu ;
|
||||
- retry permis lorsqu'un non-dispatch est prouvé ;
|
||||
- RPC application errors et réponses invalides non retryées ;
|
||||
- extension/cap du délai provider ;
|
||||
- token bucket burst/refill déterministe ;
|
||||
- burst implicite dérivé du RPS ;
|
||||
- libération de semaphore ;
|
||||
- cooldown/rate-limit ;
|
||||
- fallback vers un tier inférieur sur saturation concurrence, RPS ou cooldown ;
|
||||
- réveil après libération de concurrence ;
|
||||
- timeout d'admission borné ;
|
||||
- transition passive degraded -> available ;
|
||||
- snapshot public de résilience ;
|
||||
- admission async depuis l'API publique sans fuite de l'URL ;
|
||||
- consommation publique de la policy de retry.
|
||||
|
||||
## Hors périmètre conservé
|
||||
|
||||
Restent à `pre.005` :
|
||||
|
||||
- exécution HTTP POST JSON-RPC réelle ;
|
||||
- mapping concret des erreurs `reqwest`/HTTP vers `HttpRetryCause` ;
|
||||
- parsing de `Retry-After` HTTP ;
|
||||
- méthodes typées `getHealth`, `getVersion`, `getGenesisHash`, `getBalance` ;
|
||||
- fixtures RPC de ces méthodes.
|
||||
|
||||
Restent à `pre.006+` :
|
||||
|
||||
- document/schema Config standard Transport ;
|
||||
- adapter Config -> settings Transport ;
|
||||
- smoke tests réseau opt-in et documentation finale.
|
||||
|
||||
## Validation
|
||||
|
||||
Non exécutée dans le sandbox de génération : `cargo` et `rustc` n'y sont pas installés.
|
||||
|
||||
Après application :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Le graphe de dépendances n'ajoute aucune crate nouvelle, mais la feature locale `tokio/sync` devient directement utilisée par Transport. Pour auditer le feature-set effectif de cette tranche :
|
||||
|
||||
```bash
|
||||
cargo tree -p ksp-onchain-transport-lib -e features
|
||||
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||
```
|
||||
|
||||
## Suite
|
||||
|
||||
Tranche suivante prévue :
|
||||
|
||||
```text
|
||||
0.2.1-pre.005
|
||||
```
|
||||
|
||||
Périmètre : exécuteur HTTP JSON-RPC réel + quatre méthodes canari typées `getHealth`, `getVersion`, `getGenesisHash`, `getBalance`, avec fixtures déterministes et raccordement des timeouts/429/erreurs transport à la résilience de `pre.004`.
|
||||
63
deltas/0.2.1/pre.005-fix.001.md
Normal file
63
deltas/0.2.1/pre.005-fix.001.md
Normal file
@@ -0,0 +1,63 @@
|
||||
<!-- file: deltas/0.2.1/pre.005-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# `0.2.1-pre.005-fix.001` — normalisation des targets Logging KSP
|
||||
|
||||
## Base
|
||||
|
||||
- base fonctionnelle : `0.2.1-pre.005` ;
|
||||
- validations utilisateur avant correctif : `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport, Core et workspace propres ;
|
||||
- le correctif répond à l'audit manuel du code de logging après cette validation.
|
||||
|
||||
## Problème corrigé
|
||||
|
||||
Transport utilisait `target: env!("CARGO_PKG_NAME")` dans ses émissions `ksp-logging-lib`. Ce mécanisme produit actuellement le bon texte mais ne matérialise pas le target comme contrat d'observabilité possédé par la crate. Config présentait en parallèle un target local dans `environment.rs` et un littéral équivalent dans `persistence.rs`.
|
||||
|
||||
## Décision
|
||||
|
||||
- toute crate KSP comportementale qui émet via `ksp-logging-lib` possède son target principal dans `src/constants.rs` sous `pub(crate) const TRACING_TARGET: &str` ;
|
||||
- le target principal est égal au nom Cargo de la crate ;
|
||||
- les callsites utilisent `crate::TRACING_TARGET` ou un target spécialisé également possédé par `constants.rs` ;
|
||||
- `env!("CARGO_PKG_NAME")` reste autorisé lorsqu'il représente réellement une metadata Cargo, notamment le User-Agent HTTP ;
|
||||
- les règles sont figées par `DEP-LOG-010` et `DEP-LOG-011`.
|
||||
|
||||
## Modifications
|
||||
|
||||
### Transport
|
||||
|
||||
- ajout de `crates/ksp-onchain-transport-lib/src/constants.rs` ;
|
||||
- export crate-private de `TRACING_TARGET` depuis `lib.rs` ;
|
||||
- remplacement des targets `env!("CARGO_PKG_NAME")` dans `settings.rs`, `rpc_method.rs`, `client.rs`, `pool.rs` et `executor.rs` ;
|
||||
- conservation volontaire de `env!("CARGO_PKG_NAME")`/`CARGO_PKG_VERSION` pour le User-Agent HTTP.
|
||||
|
||||
### Config
|
||||
|
||||
- ajout de `crates/ksp-config-lib/src/constants.rs` ;
|
||||
- export crate-private de `TRACING_TARGET` depuis `lib.rs` ;
|
||||
- suppression du `LOGGING_TARGET` local de `environment.rs` ;
|
||||
- remplacement du target littéral de `persistence.rs`.
|
||||
|
||||
### Gouvernance
|
||||
|
||||
- ajout de `DEP-LOG-010/011` dans `docs/rules/RULES_DEPENDENCIES.md` ;
|
||||
- ajout de `crates/ksp-core-lib/tests/workspace_logging.rs`, canarie workspace vérifiant l'ownership explicite des targets des crates comportementales et interdisant les targets dérivés de `CARGO_PKG_NAME` ou les littéraux dispersés ;
|
||||
- correction du plan actif `008` pour refléter la feature `reqwest/rustls` locale à Transport et la convention de target explicite ;
|
||||
- aucun changement de `ROADMAP.md`.
|
||||
|
||||
## Version
|
||||
|
||||
`workspace.package.version` passe à `0.2.1-pre.5.fix.1` car le correctif modifie du code Rust participant au runtime.
|
||||
|
||||
## Validation requise après application
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-core-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucun `cargo tree` supplémentaire n'est requis : le graphe de dépendances et les features ne changent pas.
|
||||
282
deltas/0.2.1/pre.005.md
Normal file
282
deltas/0.2.1/pre.005.md
Normal file
@@ -0,0 +1,282 @@
|
||||
<!-- file: deltas/0.2.1/pre.005.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-pre.005`
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.004-fix.002
|
||||
```
|
||||
|
||||
Cette base a été validée localement par le user avec `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib` et `cargo test --workspace`.
|
||||
|
||||
Version Cargo cible :
|
||||
|
||||
```text
|
||||
0.2.1-pre.5
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Matérialiser la première exécution réseau HTTP complète de `ksp-onchain-transport-lib` et les quatre méthodes typées canari assignées à `0.2.1` :
|
||||
|
||||
```text
|
||||
getHealth
|
||||
getVersion
|
||||
getGenesisHash
|
||||
getBalance
|
||||
```
|
||||
|
||||
La tranche doit également corriger l'ownership des canaries de dépendances générales : les contrôles portant sur le workspace entier ne restent pas dans la crate métier Transport et sont centralisés dans la surface de tests de `ksp-core-lib` tant qu'aucun outil d'audit dédié n'existe.
|
||||
|
||||
## Vérification de la surface Solana
|
||||
|
||||
La documentation Solana HTTP officielle a été revérifiée le 17 août 2026 pour les quatre canaris :
|
||||
|
||||
- `getHealth` : aucun paramètre, résultat stable `"ok"` lorsque le node est sain ;
|
||||
- `getGenesisHash` : aucun paramètre, résultat string base58 ;
|
||||
- `getVersion` : aucun paramètre, objet contenant `solana-core` et `feature-set` optionnel/null ;
|
||||
- `getBalance` : pubkey base58 obligatoire, configuration optionnelle `commitment` / `minContextSlot`, réponse `{ context, value }` où `value` est le solde en lamports.
|
||||
|
||||
## Exécuteur HTTP JSON-RPC
|
||||
|
||||
Nouvelle surface publique :
|
||||
|
||||
```text
|
||||
HttpTransportPool::execute_standard_rpc(...)
|
||||
```
|
||||
|
||||
L'exécuteur :
|
||||
|
||||
1. vérifie le descriptor RPC audité et son support runtime ;
|
||||
2. alloue un identifiant JSON-RPC numérique KSP ;
|
||||
3. construit et sérialise `JsonRpcRequest` sans exposer les params dans `Debug` ;
|
||||
4. dérive le request-kind depuis le registre ;
|
||||
5. fixe une deadline commune à partir du plus petit `request_timeout` des endpoints compatibles ;
|
||||
6. acquiert un `HttpRequestPermit`, donc respecte rôles/capabilities/priorité/fairness/RPS/burst/concurrence/cooldown ;
|
||||
7. exécute un POST `application/json` via le `reqwest::Client` privé de l'endpoint ;
|
||||
8. mappe connexion, timeout, HTTP status, JSON invalide, protocole JSON-RPC et RPC application error vers les codes KSP existants ;
|
||||
9. met à jour la santé passive du rôle ;
|
||||
10. applique le retry/backoff centralisé sans dépasser la deadline commune.
|
||||
|
||||
Les retries libèrent le permit précédent puis réacquièrent explicitement capacité RPS/concurrence : ils ne contournent donc pas les limites runtime du pool.
|
||||
|
||||
## HTTP status, `Retry-After` et retry
|
||||
|
||||
Le raccordement réseau de la résilience est désormais effectif :
|
||||
|
||||
- `429` -> `record_rate_limited`, cooldown de rôle et `HttpRetryCause::RateLimited` ;
|
||||
- `Retry-After` sous forme HTTP delta-seconds -> `Duration`, puis borne défensive déjà possédée par la résilience ;
|
||||
- `408`, `500`, `502`, `503`, `504` -> `HttpRetryCause::TemporaryHttp` ;
|
||||
- erreur de connexion reqwest -> `HttpRetryCause::Connection` + `NotDispatched` ;
|
||||
- timeout reqwest -> `HttpRetryCause::Timeout` + `DispatchedAmbiguous` ;
|
||||
- autres erreurs request -> non retryables automatiquement ;
|
||||
- RPC application error et réponse invalide restent hors retry transport.
|
||||
|
||||
Le parsing d'une forme HTTP-date de `Retry-After` n'est pas introduit dans cette tranche : si le header n'est pas un delta-seconds valide, le cooldown local configuré/fallback reste utilisé.
|
||||
|
||||
La règle `NeverAfterDispatch` reste centralisée dans `evaluate_transport_retry()` ; l'exécuteur générique ne la contourne pas et prépare donc correctement les futurs appels write/submission de `0.2.3`.
|
||||
|
||||
## Quatre canaris typés
|
||||
|
||||
### `getHealth`
|
||||
|
||||
Nouvelle méthode :
|
||||
|
||||
```text
|
||||
HttpTransportPool::get_health(...)
|
||||
```
|
||||
|
||||
Résultat public :
|
||||
|
||||
```text
|
||||
SolanaNodeHealth::Healthy
|
||||
```
|
||||
|
||||
Toute autre forme qu'un résultat string exact `"ok"` est classée `invalid_response`.
|
||||
|
||||
### `getGenesisHash`
|
||||
|
||||
Nouvelle méthode :
|
||||
|
||||
```text
|
||||
HttpTransportPool::get_genesis_hash(...)
|
||||
```
|
||||
|
||||
Résultat public :
|
||||
|
||||
```text
|
||||
SolanaGenesisHash
|
||||
```
|
||||
|
||||
Le wrapper exige un string non vide et trimé et n'utilise pas abusivement `Pubkey` pour représenter sémantiquement un hash de genesis.
|
||||
|
||||
### `getVersion`
|
||||
|
||||
Nouvelle méthode :
|
||||
|
||||
```text
|
||||
HttpTransportPool::get_version(...)
|
||||
```
|
||||
|
||||
Résultat public :
|
||||
|
||||
```text
|
||||
SolanaNodeVersion
|
||||
solana_core: String
|
||||
feature_set: Option<u32>
|
||||
```
|
||||
|
||||
### `getBalance`
|
||||
|
||||
Nouvelle méthode :
|
||||
|
||||
```text
|
||||
HttpTransportPool::get_balance(...)
|
||||
```
|
||||
|
||||
L'adresse est reçue sous forme `ksp_core_lib::Pubkey`, conformément à l'ownership Core du type Solana bas niveau.
|
||||
|
||||
Nouveaux contrats :
|
||||
|
||||
```text
|
||||
SolanaCommitment
|
||||
GetBalanceConfig
|
||||
SolanaRpcContext
|
||||
GetBalanceResult
|
||||
```
|
||||
|
||||
`GetBalanceConfig` encode uniquement les champs présents ; un config vide n'ajoute pas un second paramètre inutile.
|
||||
|
||||
## Fixtures déterministes
|
||||
|
||||
Nouvelles fixtures :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json
|
||||
```
|
||||
|
||||
Les tests utilisent un serveur TCP local déterministe pour exercer réellement le POST HTTP sans dépendre de Devnet ou d'Internet.
|
||||
|
||||
Ils vérifient notamment :
|
||||
|
||||
- les quatre réponses typées ;
|
||||
- le nom de méthode envoyé ;
|
||||
- les params `getBalance`, dont `commitment` et `minContextSlot` ;
|
||||
- un cycle `429 + Retry-After: 0 -> retry -> succès` ;
|
||||
- le mapping d'un timeout reqwest vers `ERROR_CODE_TIMEOUT`.
|
||||
|
||||
Le smoke réseau réel reste opt-in et appartient toujours à `pre.007`.
|
||||
|
||||
## Centralisation des canaries de dépendances
|
||||
|
||||
Le fichier suivant est supprimé :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||
```
|
||||
|
||||
Les deux contrôles qu'il contenait sont transférés vers :
|
||||
|
||||
```text
|
||||
crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||
```
|
||||
|
||||
Ils continuent de vérifier :
|
||||
|
||||
- absence d'activation `features = [...]` sous `[workspace.dependencies]` ;
|
||||
- maintien de `default-features` au root ;
|
||||
- firewall direct Transport -> pas de Config/Store/Program/`tracing` ;
|
||||
- feature-set direct attendu de `reqwest` et `tokio` dans le manifest Transport.
|
||||
|
||||
Nouvelle règle `DEP-CARGO-007` : les canaries qui inspectent une politique générale du workspace ou plusieurs manifests appartiennent à une surface de gouvernance/fondation, actuellement `ksp-core-lib`, et non à une crate métier spécialisée.
|
||||
|
||||
## ROADMAP et plan
|
||||
|
||||
`ROADMAP.md` reçoit uniquement la mise à jour synthétique normale de la release : exécution HTTP et quatre canaris deviennent acquis ; Config standard et clôture restent à venir.
|
||||
|
||||
Le plan `008` marque `pre.005` réalisé et ajoute son état détaillé. L'historique technique reste dans `deltas/`.
|
||||
|
||||
## Tests
|
||||
|
||||
Après déplacement des deux canaries workspace hors de Transport et ajout des nouveaux tests :
|
||||
|
||||
```text
|
||||
ksp-onchain-transport-lib : 78 tests Rust déclarés
|
||||
ksp-core-lib : +2 tests d'intégration workspace_dependencies
|
||||
```
|
||||
|
||||
La suite Transport reste propriétaire uniquement des tests fonctionnels/API de son domaine ; les canaries workspace générales sont exécutées via Core lors du `cargo test --workspace`.
|
||||
|
||||
## Dépendances
|
||||
|
||||
Aucune nouvelle dépendance externe et aucune nouvelle feature externe ne sont introduites.
|
||||
|
||||
La politique de `pre.004-fix.001/.002` reste inchangée : versions/default-features au root, features d'usage dans chaque crate consommatrice.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json
|
||||
crates/ksp-onchain-transport-lib/src/executor.rs
|
||||
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
|
||||
deltas/0.2.1/pre.005.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
ROADMAP.md
|
||||
crates/ksp-onchain-transport-lib/src/client.rs
|
||||
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
crates/ksp-onchain-transport-lib/src/pool.rs
|
||||
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
docs/rules/RULES_DEPENDENCIES.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Non exécutée dans le sandbox de génération : `cargo` et `rustc` n'y sont pas installés.
|
||||
|
||||
Après application :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-core-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucune dépendance/feature n'ayant changé, un `cargo tree` complet n'est pas requis pour cette tranche. Il peut néanmoins être rejoué au checkpoint final `pre.007` comme prévu.
|
||||
|
||||
## Suite
|
||||
|
||||
Tranche suivante prévue :
|
||||
|
||||
```text
|
||||
0.2.1-pre.006
|
||||
```
|
||||
|
||||
Périmètre : document/schema/exemple Config standard `std.transport`, enregistrement dans Config, adapter Config -> Transport et tests de sensibilité/provenance/env sans créer de dépendance inverse Transport -> Config.
|
||||
121
deltas/0.2.1/pre.006-fix.001.md
Normal file
121
deltas/0.2.1/pre.006-fix.001.md
Normal file
@@ -0,0 +1,121 @@
|
||||
<!-- file: deltas/0.2.1/pre.006-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# `0.2.1-pre.006-fix.001` — inventaire Config Desk et routing debug Transport ciblé
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.006
|
||||
```
|
||||
|
||||
La validation utilisateur de `pre.006` a confirmé :
|
||||
|
||||
- `cargo fmt --all` propre ;
|
||||
- `cargo check --workspace` propre ;
|
||||
- `cargo clippy --workspace --all-targets` propre ;
|
||||
- `cargo test -p ksp-config-lib` : 95 unit tests + 5 ownership + 13 public API, propres ;
|
||||
- `cargo test -p ksp-onchain-transport-lib` : 70 unit tests + 8 public API, propres ;
|
||||
- `cargo test -p ksp-core-lib` : 14 unit tests + 3 public API + 2 workspace dependencies + 1 workspace logging, propres ;
|
||||
- `cargo test --workspace` échoue uniquement sur `ksp-app-config-desk::profiles::tests::profile_inventory_exposes_logging_default_and_available_profiles`, qui attend encore un seul document profilé alors que `cfg.std.transport` est désormais le second ;
|
||||
- les audits `cargo tree` Config montrent la dépendance interne attendue `ksp-config-lib -> ksp-onchain-transport-lib`, sans nouvelle dépendance externe volontaire ; le doublon `syn 2/3` reste transitif.
|
||||
|
||||
## Problème 1 — inventaire Profils Config Desk
|
||||
|
||||
Le panneau Profils est volontairement générique et parcourt les documents Config enregistrés qui exposent `default_profile` / `profiles`. Le test historique supposait cependant que seul `cfg.std.logging` possédait ce contrat et imposait `inventory.len() == 1`.
|
||||
|
||||
`pre.006` ajoute légitimement `cfg.std.transport`, donc cette assertion est devenue obsolète alors que l'implémentation runtime se comporte correctement.
|
||||
|
||||
### Correction
|
||||
|
||||
Le test devient `profile_inventory_exposes_registered_standard_profile_documents` et vérifie :
|
||||
|
||||
- présence de `cfg.std.logging` ;
|
||||
- présence de `cfg.std.transport` ;
|
||||
- pour chaque document retourné, `default_profile` est non vide et appartient à `profile_ids`.
|
||||
|
||||
Le test ne fige plus le nombre total de documents profilés, afin que l'ajout futur d'un autre document standard ne provoque pas la même régression artificielle.
|
||||
|
||||
## Problème 2 — verbosité Transport pendant le développement actif
|
||||
|
||||
Le target `ksp-onchain-transport-lib` est désormais explicite et stable. Pendant le développement actif de `0.2.1`, ses événements `debug` doivent pouvoir être inspectés sans relever toute la configuration Logging au niveau `debug`.
|
||||
|
||||
La solution retenue utilise la granularité déjà possédée par `ksp-logging-lib` :
|
||||
|
||||
```text
|
||||
default_filter = warn
|
||||
console.filter.level = info
|
||||
file.all.info = info
|
||||
ksp-config-lib = info
|
||||
ksp-logging-lib = info
|
||||
ksp-app-config-desk = info
|
||||
ksp-onchain-transport-lib = debug
|
||||
file.onchain_transport.debug = debug / target ksp-onchain-transport-lib uniquement
|
||||
```
|
||||
|
||||
Nouveau sink canonique :
|
||||
|
||||
```text
|
||||
output_id = file.onchain_transport.debug
|
||||
path = transport/onchain/ksp-onchain-transport-debug.log
|
||||
level = debug
|
||||
targets = [ksp-onchain-transport-lib]
|
||||
domains = [*]
|
||||
```
|
||||
|
||||
Le `target_filter` Transport à `debug` autorise ces événements au niveau du takeover global. Les filtres de sortie maintiennent cependant la console et `file.all.info` à `info`; les événements Transport `debug` sont donc persistés uniquement dans le fichier dédié.
|
||||
|
||||
Aucun profil global `debug` n'est ajouté : cette option produirait inutilement davantage d'événements pour les autres crates.
|
||||
|
||||
## Règle Logging
|
||||
|
||||
Ajout de `DEP-LOG-012` : lorsqu'une seule crate/target nécessite temporairement `debug`/`trace`, KSP privilégie un `target_filter` ciblé et un sink dédié plutôt qu'un relèvement global du profil.
|
||||
|
||||
Cette règle complète `KSP-APP-031` : la verbosité élevée reste temporaire pendant le développement/correctif de la crate concernée. La tranche finale `pre.007` doit réévaluer le niveau Transport et le ramener à `info`/`warn` avant la stable, sauf justification opératoire explicite.
|
||||
|
||||
Le plan actif `008` est synchronisé avec cette décision.
|
||||
|
||||
## Version
|
||||
|
||||
`workspace.package.version` passe à :
|
||||
|
||||
```text
|
||||
0.2.1-pre.6.fix.1
|
||||
```
|
||||
|
||||
Le fix touche un test Rust participant au workspace et la configuration runtime Logging canonique ; le signal Cargo est donc incrémenté.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
config/std.logging.json
|
||||
crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
docs/rules/RULES_DEPENDENCIES.md
|
||||
```
|
||||
|
||||
## Fichier ajouté
|
||||
|
||||
```text
|
||||
deltas/0.2.1/pre.006-fix.001.md
|
||||
```
|
||||
|
||||
`ROADMAP.md`, `pre.006.md` et les deltas historiques restent inchangés.
|
||||
|
||||
## Validation requise après application
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-app-config-desk
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-core-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucun `cargo tree` supplémentaire n'est requis pour ce fix : aucune dépendance ni feature n'est modifiée.
|
||||
155
deltas/0.2.1/pre.006-fix.002.md
Normal file
155
deltas/0.2.1/pre.006-fix.002.md
Normal file
@@ -0,0 +1,155 @@
|
||||
<!-- file: deltas/0.2.1/pre.006-fix.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# `0.2.1-pre.006-fix.002` — audit complet workspace, source `reqwest` sûre et conformité des fichiers courants
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.006-fix.001
|
||||
```
|
||||
|
||||
La validation utilisateur de `pre.006-fix.001` est entièrement propre :
|
||||
|
||||
- `cargo fmt --all` ;
|
||||
- `cargo check --workspace` ;
|
||||
- `cargo clippy --workspace --all-targets` ;
|
||||
- `cargo test -p ksp-app-config-desk` ;
|
||||
- `cargo test -p ksp-config-lib` ;
|
||||
- `cargo test -p ksp-onchain-transport-lib` ;
|
||||
- `cargo test -p ksp-core-lib` ;
|
||||
- `cargo test --workspace`.
|
||||
|
||||
Le présent fix est déclenché non par une régression de compilation/test, mais par l'audit complet de la copie zippée du workspace demandé avant `pre.007`.
|
||||
|
||||
## Audit complet du workspace
|
||||
|
||||
La copie complète auditée contient 377 fichiers et les cinq crates actuelles. L'audit a revérifié :
|
||||
|
||||
- workspace/Cargo, versions, lints, features et frontières de dépendances ;
|
||||
- règles Rust de production, visibilité, erreurs, imports et organisation des tests ;
|
||||
- ownership Config/Logging/Transport et sensibilité des diagnostics ;
|
||||
- surface HTTP/JSON-RPC, pool, admission, retry/no-resend et canaris typés ;
|
||||
- configuration standard Logging/Transport, schemas, exemples et `.env.example` ;
|
||||
- Tauri/frontend, commandes, dialogues natifs, persistence navigateur et contrats build ;
|
||||
- headers/version de fichiers, fins de ligne, lockfiles/artefacts générés ;
|
||||
- documentation normative, plans actifs, ROADMAP/CHANGELOG et liens relatifs.
|
||||
|
||||
Les contrôles transversaux ne révèlent pas de nouvelle dérive de dépendances/features, de lecture d'environnement hors Config, de tracing direct chez les consumers, de `unsafe`, `unwrap`/`expect`/`panic`/`?` en production, de `pub mod`, de dialogue navigateur natif ou de schéma JSON invalide.
|
||||
|
||||
## Correction 1 — neutralisation des URLs dans les sources `reqwest`
|
||||
|
||||
`HttpEndpointUrl` masque déjà la valeur dans son `Debug`, mais le chemin d'erreur HTTP attachait directement une `reqwest::Error` à `ksp_core_lib::Error::source`.
|
||||
|
||||
Une erreur `reqwest` peut conserver l'URL de requête. Une URL provider pouvant porter une API key dans la query ou un autre segment, la chaîne `Debug`/`source` de l'erreur KSP pouvait donc contourner la redaction du wrapper `HttpEndpointUrl`.
|
||||
|
||||
### Correction
|
||||
|
||||
Toutes les `reqwest::Error` actuellement attachées par Transport sont neutralisées avant `with_source` :
|
||||
|
||||
```rust
|
||||
.with_source(error.without_url())
|
||||
```
|
||||
|
||||
Cela couvre :
|
||||
|
||||
- l'échec de construction du client `reqwest` ;
|
||||
- les erreurs d'envoi/réception remappées par `map_reqwest_error`.
|
||||
|
||||
La canarie timeout existante utilise désormais une URL locale portant `SECRET-REQWEST-URL-CANARY` dans sa query et vérifie que ni le `Debug` de `ksp_core_lib::Error` ni celui de sa source ne contient le secret.
|
||||
|
||||
Ajout de `RUST-ERR-007` : une erreur externe n'est attachée à `ksp_core_lib::Error` qu'après vérification/neutralisation de toute donnée sensible potentiellement exposée par sa chaîne `Debug`/`source`.
|
||||
|
||||
## Correction 2 — rustdoc Transport devenu obsolète
|
||||
|
||||
Le crate-root Transport indiquait encore que Config « pourrait plus tard » construire les settings Transport par un adapter unidirectionnel.
|
||||
|
||||
Depuis `pre.006`, cet adapter existe réellement dans `ksp-config-lib`. Le rustdoc décrit maintenant l'état courant : Config construit les settings via l'adapter Config -> Transport, sans dépendance inverse.
|
||||
|
||||
## Correction 3 — `GEN-FILE-005`
|
||||
|
||||
L'audit a trouvé neuf fichiers courants code/config/build/test sans fin de ligne finale alors que leur format le permet :
|
||||
|
||||
```text
|
||||
config/examples/std.logging.example.json
|
||||
config/schemas/std.logging.schema.json
|
||||
crates/ksp-app-config-desk/capabilities/default.json
|
||||
crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
|
||||
crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
|
||||
crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
|
||||
crates/ksp-app-config-desk/frontend/sass/_variables.scss
|
||||
crates/ksp-app-config-desk/tsconfig.json
|
||||
crates/ksp-config-lib/unit_tests/fixtures/examples/composite.example.json
|
||||
```
|
||||
|
||||
Ils se terminent désormais par exactement une fin de ligne. Les quatre fichiers SCSS possédant un header de version passent de `version: 1` à `version: 2` conformément à `GEN-FILE-004`.
|
||||
|
||||
Deux occurrences historiques ne sont volontairement pas réécrites dans ce fix :
|
||||
|
||||
- `deltas/0.1.4/pre.015-fix.002.md` : delta historique immuable ;
|
||||
- `docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md` : écart documentaire historique, sans impact runtime, laissé à la politique de nettoyage documentaire de `pre.007` plutôt que réécrit dans un fix code.
|
||||
|
||||
## Dette documentaire réservée à `pre.007`
|
||||
|
||||
L'audit a aussi identifié des écarts sans impact code/config/build. Ils ne justifient pas d'élargir ce fix et seront traités dans la tranche documentaire finale :
|
||||
|
||||
- `docs/rules/FILE_CONTRACTS.md` contient encore `FILE-GEN-003` formulée comme si les artefacts Tauri `bindings/`/`gen/` n'étaient pas encore apparus, alors que `KSP-APP-023` définit désormais leur convention et `.gitignore` les couvre ;
|
||||
- `crates/ksp-config-lib/README.md` parle encore de la « future `ksp-app-config-desk` », alors que l'application est stable depuis `0.1.4` ;
|
||||
- la documentation Transport dédiée `README.md`/`USAGE.md`, l'audit final de complétude, le smoke opt-in, le prompt `0.2.2` et les mises à jour finales ROADMAP/CHANGELOG restent les livrables prévus de `pre.007`.
|
||||
|
||||
La présence de bindings TS-RS générés dans la copie complète n'est pas interprétée comme une violation de versionnement : l'archive fournie ne contient pas les métadonnées Git et `.gitignore` exclut déjà `bindings/` et `gen/`. Aucun lockfile, `target/`, `node_modules/` ou `dist/` n'est présent dans la copie auditée.
|
||||
|
||||
## Version
|
||||
|
||||
`workspace.package.version` passe à :
|
||||
|
||||
```text
|
||||
0.2.1-pre.6.fix.2
|
||||
```
|
||||
|
||||
Le fix touche du code Rust, un test, des fichiers Config/build courants et des règles associées ; le signal Cargo est donc incrémenté.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
config/examples/std.logging.example.json
|
||||
config/schemas/std.logging.schema.json
|
||||
crates/ksp-app-config-desk/capabilities/default.json
|
||||
crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
|
||||
crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
|
||||
crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
|
||||
crates/ksp-app-config-desk/frontend/sass/_variables.scss
|
||||
crates/ksp-app-config-desk/tsconfig.json
|
||||
crates/ksp-config-lib/unit_tests/fixtures/examples/composite.example.json
|
||||
crates/ksp-onchain-transport-lib/src/client.rs
|
||||
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
docs/rules/RULES_RUST.md
|
||||
```
|
||||
|
||||
## Fichier ajouté
|
||||
|
||||
```text
|
||||
deltas/0.2.1/pre.006-fix.002.md
|
||||
```
|
||||
|
||||
`ROADMAP.md`, `CHANGELOG.md`, `pre.006.md`, `pre.006-fix.001.md` et tous les deltas historiques restent inchangés.
|
||||
|
||||
## Validation requise après application
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-app-config-desk
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-core-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucun `cargo tree` supplémentaire n'est requis : aucune dépendance ni feature n'est modifiée par ce fix.
|
||||
263
deltas/0.2.1/pre.006.md
Normal file
263
deltas/0.2.1/pre.006.md
Normal file
@@ -0,0 +1,263 @@
|
||||
<!-- file: deltas/0.2.1/pre.006.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-pre.006`
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.005-fix.001
|
||||
```
|
||||
|
||||
Cette base a été validée localement par le user avec `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, les tests Transport, Config, Core et `cargo test --workspace`. Les canaries workspace de dépendances et de targets Logging sont également propres.
|
||||
|
||||
Version Cargo cible :
|
||||
|
||||
```text
|
||||
0.2.1-pre.6
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Matérialiser la frontière de configuration standard de `ksp-onchain-transport-lib` sans inverser l'ownership :
|
||||
|
||||
```text
|
||||
Config document / environment -> ksp-config-lib adapter -> HttpTransportSettings
|
||||
```
|
||||
|
||||
Transport ne lit toujours ni Config, ni `.env`, ni `KSP_*` / `KSPB_*`.
|
||||
|
||||
## Nouveau document standard Transport
|
||||
|
||||
Nouvelles ressources gérées :
|
||||
|
||||
```text
|
||||
cfg.std.transport -> config/std.transport.json
|
||||
schema.std.transport -> config/schemas/std.transport.schema.json
|
||||
```
|
||||
|
||||
Le document standard possède :
|
||||
|
||||
- `format_version = 1` ;
|
||||
- `retry` au niveau global ;
|
||||
- `default_profile` autonome ;
|
||||
- `profiles[]` contenant les endpoints propres au profil ;
|
||||
- endpoints avec identité/provider/cluster/URL/timeouts/idle-pool ;
|
||||
- rôles avec capabilities/request kinds, priorité et limites RPS/burst/concurrence/cooldown.
|
||||
|
||||
Le profil par défaut committé est `devnet_public`. Le document fournit également `mainnet_public`.
|
||||
|
||||
Les URLs publiques utilisent :
|
||||
|
||||
```text
|
||||
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL
|
||||
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL
|
||||
```
|
||||
|
||||
avec fallback vers les endpoints publics Solana correspondants.
|
||||
|
||||
## Schema
|
||||
|
||||
`config/schemas/std.transport.schema.json` :
|
||||
|
||||
- Draft 2020-12 ;
|
||||
- `additionalProperties = false` aux frontières structurées ;
|
||||
- `format_version` fixé à `1` ;
|
||||
- invariants numériques positifs pour timeouts/limites ;
|
||||
- `request_kinds` non vide et wildcard `*` exclusif ;
|
||||
- profils et endpoints non vides ;
|
||||
- descriptors non vides sans whitespace de bord.
|
||||
|
||||
Le schema protège la forme source ; la validation finale des invariants runtime reste possédée par `HttpTransportSettings::validate()`.
|
||||
|
||||
## Exemple provider-neutral
|
||||
|
||||
`config/examples/std.transport.example.json` démontre un profil `mainnet_mixed` avec :
|
||||
|
||||
- endpoint public de fallback ;
|
||||
- endpoint privé prioritaire ;
|
||||
- URL privée provenant de `KSP_SECRET_SOLANA_HTTP_URL`.
|
||||
|
||||
Aucun credential réel n'est committé.
|
||||
|
||||
`.env.example` inventorie les deux URLs publiques et documente l'URL privée optionnelle sous forme commentée.
|
||||
|
||||
## Registry Config
|
||||
|
||||
Nouveaux contrats :
|
||||
|
||||
```text
|
||||
FILE_ID_STD_TRANSPORT
|
||||
FILE_ID_SCHEMA_STD_TRANSPORT
|
||||
DEFAULT_STD_TRANSPORT_FILENAME
|
||||
DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME
|
||||
```
|
||||
|
||||
`ConfigFileRegistry::defaults()` contient désormais cinq descriptors ordonnés :
|
||||
|
||||
```text
|
||||
cfg.std.logging
|
||||
cfg.std.transport
|
||||
schema.composite
|
||||
schema.std.logging
|
||||
schema.std.transport
|
||||
```
|
||||
|
||||
Le document Transport référence explicitement son schema.
|
||||
|
||||
## Adapter Config -> Transport
|
||||
|
||||
Nouvelle surface publique :
|
||||
|
||||
```text
|
||||
ResolvedTransportConfig
|
||||
ConfigDocumentEngine::load_resolved_transport_config(...)
|
||||
```
|
||||
|
||||
L'adapter :
|
||||
|
||||
1. charge et valide `cfg.std.transport` ;
|
||||
2. sélectionne le `default_profile` ou le profil explicite ;
|
||||
3. résout les placeholders via `ConfigEnvironment` ;
|
||||
4. préserve valeur réelle, valeur sûre, sensibilité et provenance JSON Pointer ;
|
||||
5. décode le contrat effectif Config ;
|
||||
6. convertit les scalaires `*_ms` en `std::time::Duration` ;
|
||||
7. construit les newtypes/roles/limits/endpoints Transport ;
|
||||
8. parse les URLs via `HttpEndpointUrl` ;
|
||||
9. construit `HttpTransportSettings` ;
|
||||
10. délègue la validation structurelle finale à Transport.
|
||||
|
||||
La dépendance est donc :
|
||||
|
||||
```text
|
||||
ksp-config-lib -> ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
Le firewall inverse reste inchangé : Transport ne dépend pas de Config.
|
||||
|
||||
## Secrets, safe view et provenance
|
||||
|
||||
Contrairement au document Logging, le document Transport peut légitimement consommer une valeur `Secret` pour une URL endpoint complète.
|
||||
|
||||
`ResolvedTransportConfig` :
|
||||
|
||||
- expose `effective()` pour conserver le réel/safe/provenance ;
|
||||
- expose `settings()` / `into_settings()` au runtime légitime ;
|
||||
- n'imprime dans `Debug` que la projection `ResolvedConfigJson` sûre ;
|
||||
- ne copie pas l'URL réelle dans le contexte d'une erreur d'adaptation.
|
||||
|
||||
Un échec Transport est projeté vers `ERROR_CODE_EFFECTIVE_CONFIG_INVALID` avec uniquement le domaine/code Transport et les identités non sensibles utiles.
|
||||
|
||||
## Tests
|
||||
|
||||
Nouveau module unitaire `unit_tests/transport.rs` :
|
||||
|
||||
- mapping complet des scalaires Config vers le runtime Transport ;
|
||||
- validation des profils committés `devnet_public` / `mainnet_public` ;
|
||||
- provenance top-level `Global` pour `retry` et `Profile` pour `endpoints` ;
|
||||
- URL `KSP_SECRET_*` disponible au runtime mais redacted dans la safe view et `Debug` ;
|
||||
- precedence process > `.env` ;
|
||||
- provenance JSON Pointer de l'URL endpoint ;
|
||||
- URL secrète invalide -> `ERROR_CODE_EFFECTIVE_CONFIG_INVALID` sans fuite du canary.
|
||||
|
||||
Le registry gagne un test dédié au document/schema Transport et la surface publique Config gagne un canary de disponibilité de l'adapter.
|
||||
|
||||
La surface Config déclare désormais :
|
||||
|
||||
```text
|
||||
95 tests unitaires
|
||||
18 tests d'intégration
|
||||
113 tests au total
|
||||
```
|
||||
|
||||
## Ownership et documentation
|
||||
|
||||
`DEP-TRANSPORT-005` précise maintenant explicitement que `ksp-config-lib` peut dépendre des crates Transport pour posséder les adapters Config -> runtime settings, jamais l'inverse.
|
||||
|
||||
Le README/USAGE/TODO de Config est synchronisé avec `std.transport`. Le ROADMAP reçoit uniquement la mise à jour synthétique normale de l'état `0.2.1`; il ne journalise pas les détails du delta.
|
||||
|
||||
## Dépendances
|
||||
|
||||
Nouvelle dépendance interne de `ksp-config-lib` :
|
||||
|
||||
```toml
|
||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||
```
|
||||
|
||||
Aucune nouvelle dépendance externe et aucune nouvelle feature externe.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
config/examples/std.transport.example.json
|
||||
config/schemas/std.transport.schema.json
|
||||
config/std.transport.json
|
||||
crates/ksp-config-lib/src/transport.rs
|
||||
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
|
||||
crates/ksp-config-lib/unit_tests/transport.rs
|
||||
deltas/0.2.1/pre.006.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
.env.example
|
||||
Cargo.toml
|
||||
ROADMAP.md
|
||||
crates/ksp-config-lib/Cargo.toml
|
||||
crates/ksp-config-lib/README.md
|
||||
crates/ksp-config-lib/TODO.md
|
||||
crates/ksp-config-lib/USAGE.md
|
||||
crates/ksp-config-lib/src/lib.rs
|
||||
crates/ksp-config-lib/src/registry.rs
|
||||
crates/ksp-config-lib/tests/ownership.rs
|
||||
crates/ksp-config-lib/tests/public_api.rs
|
||||
crates/ksp-config-lib/unit_tests/registry.rs
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
docs/rules/RULES_DEPENDENCIES.md
|
||||
```
|
||||
|
||||
## Validation de génération
|
||||
|
||||
Effectuée dans le sandbox :
|
||||
|
||||
- parsing TOML des manifests modifiés ;
|
||||
- parsing JSON des nouvelles ressources ;
|
||||
- validation Draft 2020-12 des deux documents Transport et de la fixture contre le nouveau schema via l'implémentation Python `jsonschema` disponible ;
|
||||
- contrôle des lignes Rust/TOML modifiées <= 160 colonnes ;
|
||||
- contrôle des EOF et headers ;
|
||||
- scan statique des interdits KSP sur le nouveau code de production ;
|
||||
- contrôle de l'inventaire des variables `KSP_*` dans `.env.example` ;
|
||||
- contrôle différentiel des fichiers livrés.
|
||||
|
||||
Non exécutée dans le sandbox : validation Cargo/Rust, les binaires `cargo`, `rustc` et `rustfmt` n'étant pas disponibles.
|
||||
|
||||
Après application :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-core-lib
|
||||
cargo test --workspace
|
||||
cargo tree -p ksp-config-lib
|
||||
cargo tree -p ksp-config-lib -d
|
||||
cargo tree -p ksp-config-lib -e features
|
||||
cargo tree -p ksp-config-lib -e normal
|
||||
```
|
||||
|
||||
Le `cargo tree` Config est requis cette fois : la dépendance interne Config -> Transport modifie réellement le graphe de la crate Config, même sans nouvelle dépendance externe.
|
||||
|
||||
## Suite
|
||||
|
||||
Tranche suivante prévue :
|
||||
|
||||
```text
|
||||
0.2.1-pre.007
|
||||
```
|
||||
|
||||
Périmètre : completeness/canaries finales, smoke réseau opt-in, audit `cargo tree`, README/USAGE Transport, documentation de clôture, prompt `0.2.2` et préparation de `rel.001`.
|
||||
310
deltas/0.2.1/pre.007.md
Normal file
310
deltas/0.2.1/pre.007.md
Normal file
@@ -0,0 +1,310 @@
|
||||
<!-- file: deltas/0.2.1/pre.007.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-pre.007`
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.006-fix.002
|
||||
```
|
||||
|
||||
Cette base a été validée localement par l'opérateur le 2026-08-17 avec :
|
||||
|
||||
- `cargo fmt --all` ;
|
||||
- `cargo check --workspace` ;
|
||||
- `cargo clippy --workspace --all-targets` ;
|
||||
- `cargo test -p ksp-onchain-transport-lib` ;
|
||||
- `cargo test -p ksp-app-config-desk` ;
|
||||
- `cargo test -p ksp-config-lib` ;
|
||||
- `cargo test -p ksp-core-lib` ;
|
||||
- `cargo test --workspace`.
|
||||
|
||||
Le test Transport renforcé contre la fuite d'URL dans les sources `reqwest::Error` passe également sur cette base.
|
||||
|
||||
Version Cargo cible :
|
||||
|
||||
```text
|
||||
0.2.1-pre.7
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Fermer la dernière prerelease fonctionnelle de `0.2.1` sans rouvrir le périmètre HTTP :
|
||||
|
||||
- revérifier la matrice officielle Solana ;
|
||||
- figer la complétude `52 + 14` et la partition `4 / 22 / 11 / 15` par des canaries publiques ;
|
||||
- fournir un smoke Devnet opt-in de bout en bout Config -> Transport ;
|
||||
- produire le README et le guide d'utilisation durables de Transport ;
|
||||
- remettre la baseline Logging Transport à `info` avant stable ;
|
||||
- absorber les écarts documentaires réservés par l'audit complet de `pre.006-fix.002` ;
|
||||
- produire la matrice de validation finale et le prompt de démarrage `0.2.2` ;
|
||||
- préparer un `rel.001` strictement publicationnel.
|
||||
|
||||
Aucune cinquième méthode HTTP typée n'est ajoutée dans cette tranche.
|
||||
|
||||
## Revérification officielle HTTP Solana
|
||||
|
||||
La documentation officielle Solana a été revérifiée le 2026-08-17 :
|
||||
|
||||
```text
|
||||
https://solana.com/docs/rpc/http
|
||||
https://solana.com/docs/rpc/deprecated/confirmtransaction
|
||||
```
|
||||
|
||||
Le résultat reste compatible avec la matrice acquise :
|
||||
|
||||
```text
|
||||
52 méthodes HTTP courantes
|
||||
14 méthodes historiques Deprecated
|
||||
```
|
||||
|
||||
L'index courant confirme également le transport JSON-RPC 2.0 sur HTTP `POST` avec `Content-Type: application/json`.
|
||||
|
||||
Les 14 méthodes historiques restent représentées dans KSP comme `Deprecated / Removed / Historical` et ne sont pas simulées comme appelables.
|
||||
|
||||
## Canaries de complétude de release
|
||||
|
||||
Nouveau test public :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||
```
|
||||
|
||||
Il fige depuis l'API publique :
|
||||
|
||||
```text
|
||||
current == 52
|
||||
historical == 14
|
||||
coverage == 4 / 22 / 11 / 15
|
||||
V0_2_1 == getBalance/getGenesisHash/getHealth/getVersion
|
||||
historical => Deprecated + Removed + Historical + NotApplicable
|
||||
```
|
||||
|
||||
La candidate déclare désormais **80 tests Transport**.
|
||||
|
||||
La surface raw/générique `execute_standard_rpc()` reste disponible mais ne compte pas comme couverture typée des 48 méthodes reportées à `0.2.2`–`0.2.4`.
|
||||
|
||||
## Smoke Devnet opt-in Config -> Transport
|
||||
|
||||
Nouveau test :
|
||||
|
||||
```text
|
||||
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
|
||||
```
|
||||
|
||||
Il est volontairement :
|
||||
|
||||
```rust
|
||||
#[ignore = "opt-in live Solana Devnet smoke; performs external network requests"]
|
||||
```
|
||||
|
||||
Le chemin validé est :
|
||||
|
||||
```text
|
||||
ConfigFileRegistry::defaults()
|
||||
-> cfg.std.transport
|
||||
-> profile devnet_public
|
||||
-> ConfigEnvironment
|
||||
-> ResolvedTransportConfig
|
||||
-> HttpTransportSettings
|
||||
-> HttpTransportPool
|
||||
-> getHealth
|
||||
-> getGenesisHash
|
||||
-> getVersion
|
||||
-> getBalance(System Program)
|
||||
```
|
||||
|
||||
Transport ne lit donc toujours pas directement l'environnement.
|
||||
|
||||
Le test Config nécessite Tokio uniquement comme dev-dependency local :
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
```
|
||||
|
||||
La candidate déclare **114 tests Config**, dont cet unique smoke Devnet `ignored`.
|
||||
|
||||
Exécution opt-in :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Un incident ou rate-limit du RPC public Devnet reste un signal externe à analyser ; les gates reproductibles restent les tests déterministes par défaut.
|
||||
|
||||
## Logging Transport avant stable
|
||||
|
||||
Le sink dédié introduit pendant le développement est conservé, mais sa baseline revient de `debug` à `info` conformément à la politique de clôture :
|
||||
|
||||
```text
|
||||
output_id : file.onchain_transport.info
|
||||
path : transport/onchain/ksp-onchain-transport.log
|
||||
target : ksp-onchain-transport-lib
|
||||
level : info
|
||||
```
|
||||
|
||||
La console et le fichier général restent à `info`. Un niveau `debug`/`trace` ciblé peut être réactivé temporairement via Config lors d'un futur développement, puis doit être refermé avant publication stable.
|
||||
|
||||
## Documentation durable Transport
|
||||
|
||||
Nouveaux documents :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
```
|
||||
|
||||
Ils documentent :
|
||||
|
||||
- responsabilités et firewall de dépendances ;
|
||||
- registre `52 current + 14 historical` ;
|
||||
- différence raw/générique vs couverture typée ;
|
||||
- quatre canaris `0.2.1` ;
|
||||
- pool/routing/admission/résilience ;
|
||||
- retry/no-resend ;
|
||||
- sécurité des URLs et sources `reqwest` ;
|
||||
- target Logging explicite ;
|
||||
- construction directe ou via Config ;
|
||||
- snapshots et smoke Devnet opt-in.
|
||||
|
||||
## Normalisation documentaire issue de l'audit complet
|
||||
|
||||
Les écarts purement documentaires réservés par `pre.006-fix.002` sont absorbés ici :
|
||||
|
||||
- `FILE-GEN-003` décrit désormais `bindings/` et `gen/` comme artefacts générés/reconstructibles ignorés par défaut, et non comme convention encore future ;
|
||||
- `crates/ksp-config-lib/README.md` ne présente plus `ksp-app-config-desk` comme future ;
|
||||
- l'inventaire des composants décrit `0.2.1` comme foundation HTTP + registre `52+14` + quatre wrappers canari, et non comme surface typed HTTP complète ;
|
||||
- les index docs/plans/validation/prompts sont synchronisés avec les nouveaux livrables ;
|
||||
- le ROADMAP conserve uniquement l'état synthétique de la candidate et reste `[/]` jusqu'à la publication stable.
|
||||
|
||||
Les prompts et deltas historiques consommés restent immuables même lorsqu'ils décrivent l'état de leur époque.
|
||||
|
||||
## Matrice de validation finale
|
||||
|
||||
Nouveau document :
|
||||
|
||||
```text
|
||||
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
|
||||
```
|
||||
|
||||
Il regroupe les critères de clôture, les preuves acquises, les commandes Cargo/cargo-tree attendues et les conditions de passage à `rel.001`.
|
||||
|
||||
## Prompt de reprise `0.2.2`
|
||||
|
||||
Nouveau prompt :
|
||||
|
||||
```text
|
||||
prompts/007-V0_2_2_START_PROMPT.md
|
||||
```
|
||||
|
||||
Il ouvre `0.2.2 — HTTP Accounts + Tokens + Cluster` par un nouveau `pre.001` d'audit/brainstorming/sizing et conserve la partition nominale actuelle de 22 méthodes :
|
||||
|
||||
```text
|
||||
Accounts : 5
|
||||
Tokens : 5
|
||||
Cluster : 12
|
||||
```
|
||||
|
||||
Il exige une revérification officielle du jour avant toute implémentation lourde et rappelle que l'appel raw ne vaut jamais couverture typée.
|
||||
|
||||
## Préparation de `rel.001`
|
||||
|
||||
`CHANGELOG.md` n'est volontairement pas modifié dans cette prerelease. Si la candidate passe les validations opérateur, `rel.001` doit rester minimal :
|
||||
|
||||
```text
|
||||
workspace.package.version -> 0.2.1
|
||||
ROADMAP : 0.2.1 -> [X]
|
||||
CHANGELOG : synthèse stable 0.2.1
|
||||
plan 008 / validation 003 : statut clôturé + preuves opérateur
|
||||
deltas/0.2.1/rel.001.md
|
||||
commit v0.2.1-rel.001
|
||||
tag v0.2.1
|
||||
```
|
||||
|
||||
Aucune nouvelle fonctionnalité HTTP ne doit être introduite dans `rel.001`.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
|
||||
prompts/007-V0_2_2_START_PROMPT.md
|
||||
deltas/0.2.1/pre.007.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
ROADMAP.md
|
||||
config/std.logging.json
|
||||
crates/ksp-config-lib/Cargo.toml
|
||||
crates/ksp-config-lib/README.md
|
||||
docs/000-README.md
|
||||
docs/architecture/004-COMPONENT_INVENTORY.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
docs/rules/FILE_CONTRACTS.md
|
||||
docs/validation/000-README.md
|
||||
prompts/000-README.md
|
||||
```
|
||||
|
||||
Aucun ancien delta et aucun `CHANGELOG.md` ne sont modifiés.
|
||||
|
||||
## Validation statique de génération
|
||||
|
||||
Effectuée dans le sandbox :
|
||||
|
||||
- comparaison différentielle avec `pre.006-fix.002` ;
|
||||
- parsing de tous les TOML et JSON du workspace ;
|
||||
- validation Draft 2020-12 de `std.logging.json`, `std.transport.json` et de l'exemple Transport contre leurs schemas ;
|
||||
- contrôle des headers et incréments de version des fichiers modifiés ;
|
||||
- contrôle de l'unique fin de ligne des fichiers livrés ;
|
||||
- contrôle des lignes Rust/TOML <= 160 colonnes ;
|
||||
- contrôle des fences Markdown et des liens Markdown relatifs des fichiers modifiés ;
|
||||
- confirmation que les anciens deltas sont byte-for-byte inchangés ;
|
||||
- contrôle de la baseline Logging Transport `info` ;
|
||||
- comptage statique de 80 tests Transport et 114 tests Config, dont un smoke Config `ignored` ;
|
||||
- contrôle des tableaux de registre Rust `[52]` et `[14]`.
|
||||
|
||||
Non exécutées dans le sandbox : `cargo`, `rustc` et `rustfmt` n'y sont pas disponibles. Aucune réussite Cargo de `pre.007` n'est donc déclarée avant validation opérateur.
|
||||
|
||||
## Validation requise après application
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-core-lib
|
||||
cargo test -p ksp-app-config-desk
|
||||
cargo test --workspace
|
||||
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib -d
|
||||
cargo tree -p ksp-onchain-transport-lib -e features
|
||||
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||
|
||||
cargo tree -p ksp-config-lib
|
||||
cargo tree -p ksp-config-lib -d
|
||||
cargo tree -p ksp-config-lib -e features
|
||||
cargo tree -p ksp-config-lib -e normal
|
||||
```
|
||||
|
||||
Puis, de manière opt-in :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Les `cargo tree` Config sont requis car `pre.007` ajoute un dev-dependency Tokio local au smoke live. Si les validations déterministes et les audits de graphe sont propres, la tranche suivante est `0.2.1-rel.001`.
|
||||
161
deltas/0.2.1/rel.001.md
Normal file
161
deltas/0.2.1/rel.001.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<!-- file: deltas/0.2.1/rel.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-rel.001`
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.007
|
||||
```
|
||||
|
||||
`pre.007` a été validée localement par l'opérateur le 2026-08-17 avec :
|
||||
|
||||
- `cargo fmt --all` ;
|
||||
- `cargo check --workspace` ;
|
||||
- `cargo clippy --workspace --all-targets` ;
|
||||
- `cargo test -p ksp-onchain-transport-lib` ;
|
||||
- `cargo test -p ksp-config-lib` ;
|
||||
- `cargo test -p ksp-core-lib` ;
|
||||
- `cargo test -p ksp-app-config-desk` ;
|
||||
- `cargo test --workspace` ;
|
||||
- les graphes `cargo tree`, `cargo tree -d`, `cargo tree -e features` et `cargo tree -e normal` pour Transport et Config ;
|
||||
- le smoke Devnet opt-in `cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture`, avec `1 passed; 0 failed`.
|
||||
|
||||
Version Cargo cible :
|
||||
|
||||
```text
|
||||
0.2.1
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Publier `0.2.1 — HTTP Solana foundation` sans ajouter de capacité fonctionnelle après la candidate `pre.007`.
|
||||
|
||||
`rel.001` est strictement publicationnelle :
|
||||
|
||||
- passage du workspace à la version stable `0.2.1` ;
|
||||
- clôture `[X]` de `0.2.1` dans le ROADMAP ;
|
||||
- ajout de l'entrée stable `0.2.1` au CHANGELOG ;
|
||||
- clôture du plan HTTP et de sa matrice de validation avec les preuves opérateur ;
|
||||
- synchronisation des index/plans généraux ;
|
||||
- conservation explicite du TODO d'ownership du smoke cross-crates.
|
||||
|
||||
Aucun fichier Rust de production, aucune API publique, aucune configuration runtime, aucune dépendance et aucune feature Cargo ne changent dans cette livraison.
|
||||
|
||||
## Surface stable publiée
|
||||
|
||||
`0.2.1` stabilise `ksp-onchain-transport-lib` avec :
|
||||
|
||||
```text
|
||||
JSON-RPC 2.0 sur HTTP POST
|
||||
52 méthodes HTTP courantes auditées
|
||||
14 méthodes historiques Deprecated / Removed
|
||||
4 wrappers typés : getBalance/getGenesisHash/getHealth/getVersion
|
||||
partition typed restante : 22 / 11 / 15 sur 0.2.2–0.2.4
|
||||
pool rôles/capabilities/priorités/fairness
|
||||
RPS/burst/concurrence/cooldown
|
||||
deadline commune + timeout/retry/backoff
|
||||
no-resend après dispatch ambigu
|
||||
429/Retry-After + statuts temporaires
|
||||
std.transport + schema + example
|
||||
adapter ksp-config-lib -> ksp-onchain-transport-lib
|
||||
redaction URL/provider credentials
|
||||
neutralisation des URLs contenues dans reqwest::Error
|
||||
sink Logging Transport dédié à info
|
||||
fixtures HTTP déterministes
|
||||
canaries de complétude
|
||||
smoke Devnet opt-in
|
||||
README/USAGE Transport
|
||||
```
|
||||
|
||||
La surface raw/générique reste distincte de la couverture typée : elle ne vaut pas implémentation des 48 wrappers reportés.
|
||||
|
||||
## Validation de la candidate
|
||||
|
||||
Les tests de `pre.007` ont confirmé notamment :
|
||||
|
||||
```text
|
||||
Transport : 70 unit + 8 public API + 2 release completeness = 80
|
||||
Config : 95 unit + 5 ownership + 13 public API + 1 smoke ignored = 114
|
||||
Core : tests unit/public/workspace canaries propres
|
||||
Config Desk : tests unit/desktop/public propres
|
||||
workspace : propre, hors probes explicitement ignored
|
||||
```
|
||||
|
||||
Le smoke Devnet a ensuite été lancé explicitement et a atteint les quatre canaris foundation via le profil `devnet_public` committé.
|
||||
|
||||
Les graphes Cargo confirment la frontière voulue :
|
||||
|
||||
```text
|
||||
ksp-config-lib -> ksp-onchain-transport-lib
|
||||
ksp-onchain-transport-lib -X-> ksp-config-lib
|
||||
Transport -X-> Store/Program/tracing direct
|
||||
```
|
||||
|
||||
Les features directes restent activées localement dans les crates consommatrices. Les doublons observés par `cargo tree -d` sur les graphes inspectés se limitent à `syn` 2.x/3.x dans les chaînes transitive/proc-macro ; aucune stack HTTP/Tokio KSP concurrente n'est introduite.
|
||||
|
||||
## TODO ownership des smoke tests cross-crates
|
||||
|
||||
Le fichier actuel :
|
||||
|
||||
```text
|
||||
crates/ksp-config-lib/tests/transport_devnet_smoke.rs
|
||||
```
|
||||
|
||||
reste temporairement sous Config parce que la direction de dépendance existante permet d'y composer Config -> Transport sans violer le firewall Transport -> Config.
|
||||
|
||||
Cette localisation est une **exception transitoire** et ne devient jamais une convention KSP :
|
||||
|
||||
- `ksp-config-lib` ne doit pas devenir la destination générale des smoke tests ;
|
||||
- cela inclut explicitement les futurs scénarios `Config + autre crate` ;
|
||||
- les tests déterministes de mapping/validation Config restent dans Config ;
|
||||
- un smoke autonome d'une crate peut rester dans cette crate s'il n'exige pas de composition interdite ;
|
||||
- les smokes cross-crates doivent appartenir à une future surface dédiée d'intégration/orchestration/demo ;
|
||||
- le smoke `Config -> Transport -> Devnet` devra migrer vers cette surface lorsqu'elle existera.
|
||||
|
||||
## Documentation de clôture
|
||||
|
||||
Mises à jour :
|
||||
|
||||
```text
|
||||
CHANGELOG.md
|
||||
docs/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
|
||||
ROADMAP.md
|
||||
```
|
||||
|
||||
Le prompt de reprise `prompts/007-V0_2_2_START_PROMPT.md` était déjà livré en `pre.007` et reste inchangé ; il attend comme base le tag stable `v0.2.1`.
|
||||
|
||||
## Commit et tag
|
||||
|
||||
Identifiant de commit attendu :
|
||||
|
||||
```text
|
||||
v0.2.1-rel.001
|
||||
```
|
||||
|
||||
Le tag stable ne doit être créé qu'après application et validation de ce delta :
|
||||
|
||||
```text
|
||||
v0.2.1
|
||||
```
|
||||
|
||||
Le tag porte uniquement la publication stable ; aucun tag prerelease/fix n'est requis.
|
||||
|
||||
## Validation finale après application
|
||||
|
||||
Comme `rel.001` ne modifie aucun code de production et ne change que la version workspace et la documentation, la gate finale reste :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Après succès : commit `v0.2.1-rel.001`, puis création du tag `v0.2.1`.
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/000-README.md -->
|
||||
<!-- version: 20 -->
|
||||
<!-- version: 24 -->
|
||||
|
||||
# Documentation KSP
|
||||
|
||||
@@ -39,11 +39,13 @@ docs/
|
||||
│ ├── 004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||
│ ├── 005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||
│ ├── 006-V0_1_4_CONFIG_DESKTOP_PLAN.md
|
||||
│ └── 007-V0_2_0_SERIES_PLANNING.md
|
||||
│ ├── 007-V0_2_0_SERIES_PLANNING.md
|
||||
│ └── 008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
├── validation/
|
||||
│ ├── 000-README.md
|
||||
│ ├── 001-V0_1_4_CONFIG_DESKTOP.md
|
||||
│ └── 002-V0_2_0_SERIES_PLANNING.md
|
||||
│ ├── 002-V0_2_0_SERIES_PLANNING.md
|
||||
│ └── 003-V0_2_1_ONCHAIN_HTTP.md
|
||||
└── rules/
|
||||
├── FILE_CONTRACTS.md
|
||||
├── PROMPT_STRUCTURE.md
|
||||
@@ -60,7 +62,7 @@ D'autres sous-répertoires seront ajoutés uniquement lorsque leur rôle aura é
|
||||
|
||||
## Documents de planification
|
||||
|
||||
Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La prochaine release fonctionnelle est `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation`, à ouvrir avec [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md).
|
||||
Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La release stable `0.2.1 — HTTP Solana foundation` a été ouverte par [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md). Son gate de sizing et sa matrice exhaustive sont conservés dans [`plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md), avec la validation finale [`validation/003-V0_2_1_ONCHAIN_HTTP.md`](validation/003-V0_2_1_ONCHAIN_HTTP.md), README/USAGE Transport et le smoke Devnet opt-in de composition Config -> Transport. Le prompt [`../prompts/007-V0_2_2_START_PROMPT.md`](../prompts/007-V0_2_2_START_PROMPT.md) devient le point d'entrée de `0.2.2`.
|
||||
|
||||
`IDEAS.md` conserve les pistes et questions qui ne sont pas encore des engagements du roadmap ni des décisions architecturales.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/IDEAS.md -->
|
||||
<!-- version: 17 -->
|
||||
<!-- version: 18 -->
|
||||
|
||||
# Idées à explorer
|
||||
|
||||
@@ -104,7 +104,7 @@ Réévaluer seulement si les premières implémentations montrent une duplicatio
|
||||
|
||||
### Pool automatique de sessions WebSocket
|
||||
|
||||
**Status :** À explorer après `0.2.4`
|
||||
**Status :** À explorer après `0.2.7`
|
||||
|
||||
Le contrat WebSocket doit autoriser plusieurs sessions physiques sur une même URL, chaque session portant plusieurs subscriptions.
|
||||
|
||||
@@ -182,7 +182,7 @@ Un wallet web/online utilisant les contrats KSP est envisagé. La gestion des se
|
||||
|
||||
### Formats Wallet import/export supplémentaires
|
||||
|
||||
**Status :** À explorer avec `0.2.2` et après
|
||||
**Status :** À explorer avec `0.2.5` et après
|
||||
|
||||
Le format natif KSP est `.kspwallet`. L'architecture d'import/export doit rester extensible, mais seules les conversions réellement nécessaires sont implémentées immédiatement.
|
||||
|
||||
@@ -395,6 +395,6 @@ Si cette capacité devient utile, l’intégration doit être conçue dans la pi
|
||||
|
||||
**Status :** Transférée au roadmap pour le début de `0.2.x`
|
||||
|
||||
`0.2.0-pre.002` fixe désormais le début concret `0.2.1 -> 0.2.10` sous réserve du gate de dimensionnement de chaque `pre.001`.
|
||||
`0.2.0-pre.002` avait fixé le premier séquencement concret. `0.2.1-pre.001-fix.001` le recalibre désormais sur `0.2.1 -> 0.2.13`, sous réserve du gate de dimensionnement de chaque `pre.001` et avec possibilité d'enchaîner plusieurs releases complètement clôturées dans une même session lorsque le sizing le permet.
|
||||
|
||||
Les séries après RAW/CORE ne sont volontairement pas numérotées programme par programme à ce stade : la règle est de redécouper chaque vertical slice selon sa taille réelle et de ne jamais ouvrir une release qui ne peut pas être clôturée dans sa session.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/architecture/004-COMPONENT_INVENTORY.md -->
|
||||
<!-- version: 6 -->
|
||||
<!-- version: 8 -->
|
||||
|
||||
# Inventaire initial des composants KSP
|
||||
|
||||
@@ -23,16 +23,16 @@ Ce document maintient l'inventaire synthétique des composants retenus ou presse
|
||||
| Logging | `ksp-logging-lib` | lib | Stable | `0.1.2` | façade unique tracing KSP |
|
||||
| Config | `ksp-config-lib` | lib | Stable | `0.1.3` | documents, profils, env et persistence Config |
|
||||
| Config Desk | `ksp-app-config-desk` | app | Stable | `0.1.4` | validation/management Config |
|
||||
| On-chain HTTP | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.1` | JSON-RPC HTTP complet, settings, pools, rôles |
|
||||
| Wallet | `ksp-wallet-lib` | lib | Retenu | `0.2.2` | `.kspwallet`, secrets, signature, import/export |
|
||||
| Wallet Desk | `ksp-app-wallet-desk` | app | Retenu | `0.2.3` | Wallet + Config composite + HTTP/balance |
|
||||
| Standard WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.4` | WebSocket Solana complet, sessions/subscriptions |
|
||||
| Helius WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.5` | LaserStream WebSocket comme extension du moteur standard |
|
||||
| Yellowstone | `ksp-onchain-transport-lib` | lib | Pressenti | `0.2.6` | client gRPC standard/provider-neutral |
|
||||
| Off-chain price | `ksp-offchain-transport-lib` | lib | Retenu | `0.2.7` | première abstraction/provider de prix SOL/USD, SOL/EUR |
|
||||
| Price Desk | nom à fixer | app | Retenu | `0.2.8` | visualisation/validation des prix |
|
||||
| Wire | `ksp-interface-lib` | lib | Retenu | `0.2.9` | façade wire officielle + API publique wire |
|
||||
| Program API | `ksp-program-api` | API | Retenu | `0.2.10` | contrats extensibles Program |
|
||||
| On-chain HTTP | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.1` | foundation HTTP, registry 52+14, pools/rôles, 4 canaris |
|
||||
| Wallet | `ksp-wallet-lib` | lib | Retenu | `0.2.5` | `.kspwallet`, secrets, signature, import/export |
|
||||
| Wallet Desk | `ksp-app-wallet-desk` | app | Retenu | `0.2.6` | Wallet + Config composite + HTTP/balance |
|
||||
| Standard WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.7` | WebSocket Solana complet, sessions/subscriptions |
|
||||
| Helius WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.8` | LaserStream WebSocket comme extension du moteur standard |
|
||||
| Yellowstone | `ksp-onchain-transport-lib` | lib | Pressenti | `0.2.9` | client gRPC standard/provider-neutral |
|
||||
| Off-chain price | `ksp-offchain-transport-lib` | lib | Retenu | `0.2.10` | première abstraction/provider de prix SOL/USD, SOL/EUR |
|
||||
| Price Desk | nom à fixer | app | Retenu | `0.2.11` | visualisation/validation des prix |
|
||||
| Wire | `ksp-interface-lib` | lib | Retenu | `0.2.12` | façade wire officielle + API publique wire |
|
||||
| Program API | `ksp-program-api` | API | Retenu | `0.2.13` | contrats extensibles Program |
|
||||
| Program impl. | `ksp-program-lib` | lib | Retenu | vertical slices ultérieurs | implementations Program officielles |
|
||||
| Program extension | `ksp-program-<name>-lib` | lib externe | À la demande | dès besoin | implementation externe de `ksp-program-api` |
|
||||
| Store API | `ksp-store-api` | API | Retenu | `0.3.1` | contrats persistence backend-agnostic, RAW d'abord |
|
||||
@@ -78,7 +78,7 @@ ksp-data-api
|
||||
|
||||
## Transport
|
||||
|
||||
`ksp-onchain-transport-lib` doit couvrir l'intégralité des opérations documentées de la surface ciblée par chaque release. Les statuts deprecated/obsolete encore fonctionnels et unstable/experimental restent exposés avec warning runtime KSP.
|
||||
`ksp-onchain-transport-lib` doit couvrir l'intégralité des opérations documentées de la surface ciblée par chaque release. `0.2.1` stabilise la foundation HTTP et quatre wrappers typés canari ; la couverture typée des 48 autres méthodes courantes reste explicitement répartie sur `0.2.2`–`0.2.4`. Les statuts deprecated/obsolete encore fonctionnels et unstable/experimental restent exposés avec warning runtime KSP.
|
||||
|
||||
La Config standard Transport appartient à `ksp-config-lib`, qui adapte vers les settings publics du transport ; le transport ne dépend jamais de Config.
|
||||
|
||||
@@ -146,7 +146,7 @@ Market Desk est progressive : V1 après les DEX prioritaires, puis enrichissemen
|
||||
## Questions restantes
|
||||
|
||||
- noms exacts de Price Desk et Backfill Desk ;
|
||||
- surface exacte Yellowstone après audit normatif de `0.2.6-pre.001` ;
|
||||
- surface exacte Yellowstone après audit normatif de `0.2.9-pre.001` ;
|
||||
- nécessité future d'un pool automatique WS ;
|
||||
- types exacts `ksp-program-api`/`ksp-materializer-api`/`ksp-store-api` ;
|
||||
- nom/packaging précis du premier RAW worker et du CORE normalizer ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/000-README.md -->
|
||||
<!-- version: 28 -->
|
||||
<!-- version: 31 -->
|
||||
|
||||
# Plans KSP
|
||||
|
||||
@@ -16,6 +16,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou
|
||||
- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, exécuté jusqu'à `pre.015` puis publié par `0.1.3-rel.001`.
|
||||
- [`006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](006-V0_1_4_CONFIG_DESKTOP_PLAN.md) — plan historique clôturé de la release stable `0.1.4 — ksp-app-config-desk`, établi par `0.1.4-pre.001` puis consolidé jusqu'à `0.1.4-rel.001`.
|
||||
- [`007-V0_2_0_SERIES_PLANNING.md`](007-V0_2_0_SERIES_PLANNING.md) — plan historique clôturé de la release stable `0.2.0`, ouvert par `pre.001`, consolidé par `pre.002`, audité par `pre.003` puis publié par `rel.001`; il fixe l'ordre `0.2.1+`, la stratégie RAW/CORE/DECODE/SPECIALIZED, les vertical slices Program et le prompt `0.2.1`.
|
||||
- [`008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — plan de `0.2.1`, établi par `0.2.1-pre.001`, recalibré par `pre.001-fix.001` et amené en clôture candidate par `pre.007`; il conserve l'inventaire 52 méthodes HTTP courantes + 14 Deprecated historiques, le design Transport/Config et le split de couverture typée sur `0.2.1`–`0.2.4`.
|
||||
|
||||
Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md -->
|
||||
<!-- version: 29 -->
|
||||
<!-- version: 33 -->
|
||||
|
||||
# Séquence des releases fonctionnelles KSP
|
||||
|
||||
@@ -15,6 +15,7 @@ Principes :
|
||||
- une release concrète `X.Y.Z` est une unité de travail/session dimensionnée ;
|
||||
- chaque release concrète possède ses propres prereleases ;
|
||||
- une release trop grosse est scindée au lieu d'être forcée dans une session ;
|
||||
- une session de chat peut enchaîner plusieurs releases si chacune est entièrement clôturée avant l'ouverture de la suivante et si le sizing de la suivante reste raisonnablement positif ; cette possibilité ne fusionne ni les numéros, ni les deltas, ni les validations ;
|
||||
- les numéros futurs sont confirmés lorsque leur série approche et que les dépendances réelles sont connues.
|
||||
|
||||
# Première série fonctionnelle : `0.1.x`
|
||||
@@ -36,7 +37,7 @@ Séquence par défaut :
|
||||
0.1.4 ksp-app-config-desk
|
||||
```
|
||||
|
||||
`0.1.1`, `0.1.2`, `0.1.3` et `0.1.4` sont désormais des releases stables.
|
||||
`0.1.1`, `0.1.2`, `0.1.3`, `0.1.4`, `0.2.0` et `0.2.1` sont désormais des releases stables.
|
||||
|
||||
`0.1.4 — ksp-app-config-desk` établit le modèle de référence des futures applications Tauri KSP sans déplacer la logique Config dans l'application. Sa matrice finale a été validée avant `rel.001`, avec le build Tauri exécuté en dernière opération.
|
||||
|
||||
@@ -354,41 +355,42 @@ Par défaut :
|
||||
`0.2.0` est publiée stable par `0.2.0-rel.001`. `pre.002` a fixé le début de la séquence fonctionnelle suivante et `pre.003` en a réalisé l'audit final de cohérence :
|
||||
|
||||
```text
|
||||
0.2.1 on-chain transport HTTP foundation
|
||||
0.2.2 wallet foundation (.kspwallet)
|
||||
0.2.3 Wallet Desk
|
||||
0.2.4 standard Solana WebSocket
|
||||
0.2.5 Helius LaserStream WebSocket
|
||||
0.2.6 Yellowstone gRPC standard foundation
|
||||
0.2.7 off-chain price transport
|
||||
0.2.8 price visualization desk
|
||||
0.2.9 interface/wire foundation
|
||||
0.2.10 program-api foundation
|
||||
0.2.1 HTTP transport foundation + 4 méthodes canari
|
||||
0.2.2 HTTP Accounts + Tokens + Cluster
|
||||
0.2.3 HTTP Transactions
|
||||
0.2.4 HTTP Blocks + Economics + compliance complète
|
||||
0.2.5 wallet foundation (.kspwallet)
|
||||
0.2.6 Wallet Desk
|
||||
0.2.7 standard Solana WebSocket
|
||||
0.2.8 Helius LaserStream WebSocket
|
||||
0.2.9 Yellowstone gRPC standard foundation
|
||||
0.2.10 off-chain price transport
|
||||
0.2.11 price visualization desk
|
||||
0.2.12 interface/wire foundation
|
||||
0.2.13 program-api foundation
|
||||
```
|
||||
|
||||
Cette séquence est motivée par les dépendances fonctionnelles : un Wallet Desk utile doit pouvoir lire le solde du wallet, donc HTTP précède Wallet ; les transports live arrivent ensuite ; Interface/Program sont préparés avant les couches de données décodées.
|
||||
`0.2.1-pre.001` a appliqué le gate de sizing et refusé le scope HTTP monolithique initial : l'inventaire du 2026-08-17 contient 52 méthodes courantes et 14 méthodes Deprecated historiques. Ce premier delta avait réparti la couverture typée sur `0.2.1`–`0.2.6`. `0.2.1-pre.001-fix.001` recalibre ensuite les 48 méthodes restantes sur trois releases complémentaires `0.2.2`–`0.2.4`, soit trois sessions nominales au maximum si chaque release utilise sa session complète. Si une release se clôt plus vite que prévu, la même session peut enchaîner la suivante après clôture complète de la précédente et nouveau gate de sizing positif. Un Wallet Desk utile doit pouvoir lire le solde du wallet : `getBalance` fait donc partie des quatre canaris de la foundation `0.2.1`, avant Wallet. Les transports live arrivent ensuite ; Interface/Program restent préparés avant les couches de données décodées.
|
||||
|
||||
## `0.2.1` — HTTP Solana foundation
|
||||
## `0.2.1` — HTTP transport foundation réduite
|
||||
|
||||
Mission : créer `ksp-onchain-transport-lib` avec une surface HTTP JSON-RPC complète et indépendante de Config/Store/Program.
|
||||
Mission : créer `ksp-onchain-transport-lib` avec la foundation HTTP JSON-RPC indépendante de Config/Store/Program, le registry documentaire exhaustif, la résilience/pool et quatre méthodes typed canari.
|
||||
|
||||
Inclure :
|
||||
Inclure : settings publics ; endpoint/provider/cluster ; pool logique ; rôles/capabilities/request kinds ouverts ; priorités/limites/concurrence ; timeout/retry/backoff ; JSON-RPC ; metadata centrale de statut méthode + forme de requête + runtime ; warning centralisé lorsqu'un contrat supported est deprecated/unstable ; document Config standard + adapter Config -> Transport ; `getBalance`, `getGenesisHash`, `getHealth`, `getVersion`.
|
||||
|
||||
- settings publics transport ;
|
||||
- endpoint/provider/cluster ;
|
||||
- pools logiques ;
|
||||
- rôles/capabilities/request kinds ;
|
||||
- priorités/limites/concurrence ;
|
||||
- timeout/retry/backoff ;
|
||||
- JSON-RPC ;
|
||||
- méthodes read et write/execution technique ;
|
||||
- statut centralisé `stable/deprecated/unstable` ;
|
||||
- warning runtime KSP pour deprecated/obsolete encore fonctionnel et unstable/experimental ;
|
||||
- document Config standard Transport + adapter dans `ksp-config-lib`, sans dépendance Transport -> Config.
|
||||
Le plan détaillé clôturé est `docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`. `0.2.1-rel.001` publie la foundation après validation des canaries de complétude, du smoke Devnet opt-in Config -> Transport, des README/USAGE et des graphes Cargo. Le smoke cross-crates hébergé dans Config est transitoire et devra migrer vers une future surface d’intégration/orchestration ; aucun futur smoke `Config + autre crate` ne doit prendre Config comme destination générale. Un appel raw/générique ne compte pas comme couverture typée des méthodes reportées.
|
||||
|
||||
La documentation officielle actuelle de la surface ciblée doit être inventoriée exhaustivement dans `pre.001`.
|
||||
## `0.2.2` à `0.2.4` — complétude HTTP Solana
|
||||
|
||||
## `0.2.2` — Wallet foundation
|
||||
- `0.2.2` : 5 Accounts restants + 5 Tokens + 12 Cluster restants = 22 méthodes ;
|
||||
- `0.2.3` : 11 Transactions, y compris write/submission technique et no-resend ambigu ;
|
||||
- `0.2.4` : 10 Blocks + 5 Economics = 15 méthodes, puis compliance finale des 52 méthodes courantes et 14 historiques Deprecated.
|
||||
|
||||
Ces trois releases constituent le découpage nominal, pas une obligation de trois chats distincts. Une session qui clôture complètement une release plus vite que prévu peut ouvrir immédiatement la suivante si son sizing permet encore raisonnablement de la clôturer dans cette même session. Les releases restent séparées : version, prereleases, delta, validations et clôture stable propres à chacune.
|
||||
|
||||
Chaque `pre.001` réaudite la documentation officielle actuelle. Les méthodes Deprecated réellement retirées restent tracées comme historiques/runtime removed au lieu d'être simulées.
|
||||
|
||||
## `0.2.5` — Wallet foundation
|
||||
|
||||
Mission : créer `ksp-wallet-lib` et le format `.kspwallet`.
|
||||
|
||||
@@ -396,23 +398,23 @@ Inclure protection du secret, pubkey/identity, signature, import/export extensib
|
||||
|
||||
Exclure : temporary wallet JSON historique et `WalletPolicy`.
|
||||
|
||||
## `0.2.3` — Wallet Desk
|
||||
## `0.2.6` — Wallet Desk
|
||||
|
||||
Mission : valider Config composite + `.kspwallet` + transport HTTP dans une application Tauri mince.
|
||||
|
||||
Le solde d'un wallet constitue un premier cas de validation réseau obligatoire.
|
||||
|
||||
## `0.2.4` — WebSocket Solana standard
|
||||
## `0.2.7` — WebSocket Solana standard
|
||||
|
||||
Mission : couvrir la surface WebSocket standard officielle ciblée.
|
||||
|
||||
Une URL peut avoir plusieurs sessions physiques ; une session peut avoir plusieurs subscriptions. Un pool automatique de sessions est reporté jusqu'à besoin concret.
|
||||
|
||||
## `0.2.5` — Helius LaserStream WebSocket
|
||||
## `0.2.8` — Helius LaserStream WebSocket
|
||||
|
||||
Mission : étendre le moteur WebSocket standard avec les opérations/filtres/capabilities Helius ciblés sans copier le client.
|
||||
|
||||
## `0.2.6` — Yellowstone gRPC standard
|
||||
## `0.2.9` — Yellowstone gRPC standard
|
||||
|
||||
Mission : introduire un backend Yellowstone standard/provider-neutral.
|
||||
|
||||
@@ -420,21 +422,21 @@ Le `pre.001` est un gate de sizing : inventorier toute la surface normative cibl
|
||||
|
||||
Les profiles/adapters Helius/Triton/ERPC/Chainstack/Shyft sont reportés après les priorités fondatrices.
|
||||
|
||||
## `0.2.7` / `0.2.8` — Off-chain price + app
|
||||
## `0.2.10` / `0.2.11` — Off-chain price + app
|
||||
|
||||
`0.2.7` introduit `ksp-offchain-transport-lib` avec au minimum SOL/USD et SOL/EUR via une abstraction indépendante du premier provider.
|
||||
`0.2.10` introduit `ksp-offchain-transport-lib` avec au minimum SOL/USD et SOL/EUR via une abstraction indépendante du premier provider.
|
||||
|
||||
`0.2.8` ajoute une petite application desk de visualisation/validation.
|
||||
`0.2.11` ajoute une petite application desk de visualisation/validation.
|
||||
|
||||
Metadata HTTP/IPFS/Arweave viendra au premier besoin Metadata réel.
|
||||
|
||||
## `0.2.9` — Interface foundation
|
||||
## `0.2.12` — Interface foundation
|
||||
|
||||
`ksp-interface-lib` devient la façade wire officielle et expose une API publique wire utilisable par les implementations officielles et externes.
|
||||
|
||||
Aucune `ksp-interface-api` séparée n'est retenue pour l'instant.
|
||||
|
||||
## `0.2.10` — Program API foundation
|
||||
## `0.2.13` — Program API foundation
|
||||
|
||||
Introduire `ksp-program-api`, sans suffixe `-lib`, comme contrat d'extension Program.
|
||||
|
||||
|
||||
1070
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
Normal file
1070
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/rules/FILE_CONTRACTS.md -->
|
||||
<!-- version: 15 -->
|
||||
<!-- version: 16 -->
|
||||
|
||||
# Contrats des fichiers
|
||||
|
||||
@@ -85,7 +85,7 @@ Pour un document standard profilé, `default_profile` et `profiles` sont des cl
|
||||
|
||||
- **FILE-GEN-001** — Un fichier généré n'est jamais modifié manuellement lorsque sa source de vérité est un générateur.
|
||||
- **FILE-GEN-002** — Le choix de versionner ou ignorer une famille générée est décidé explicitement lorsqu'elle apparaît.
|
||||
- **FILE-GEN-003** — Les futurs artefacts Tauri `bindings/` et `gen/` ne sont pas encore une convention KSP ; ils seront traités lorsqu'ils apparaîtront.
|
||||
- **FILE-GEN-003** — Les répertoires générés `bindings/` et `gen/` des toolchains Tauri/TS-RS restent des artefacts reconstruisibles et ne sont pas versionnés par défaut. Ils sont ignorés par le dépôt ; leurs sources de vérité restent les DTO/configurations/générateurs KSP. Toute exception de versionnement doit être explicitement justifiée et documentée.
|
||||
|
||||
## Documentation durable des crates et applications
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/rules/RULES_DEPENDENCIES.md -->
|
||||
<!-- version: 11 -->
|
||||
<!-- version: 16 -->
|
||||
|
||||
# Règles des dépendances KSP
|
||||
|
||||
@@ -27,9 +27,11 @@ Elles complètent les règles Rust générales et le graphe de `docs/architectur
|
||||
|
||||
- **DEP-CARGO-001** — Toute dépendance externe utilisée par une crate membre du workspace est déclarée une seule fois dans le `Cargo.toml` racine sous `[workspace.dependencies]`.
|
||||
- **DEP-CARGO-002** — Une crate membre consomme une dépendance centralisée avec `<dependency>.workspace = true` et ne redéclare pas localement sa version.
|
||||
- **DEP-CARGO-003** — Les options communes de résolution telles que `default-features` et la contrainte de version sont définies au niveau `[workspace.dependencies]`. Une crate membre n'ajoute localement que des features réellement propres à son usage lorsqu'elles sont nécessaires et compatibles avec l'héritage Cargo.
|
||||
- **DEP-CARGO-003** — Les options communes de résolution telles que `default-features` et la contrainte de version sont définies au niveau `[workspace.dependencies]`. Les features d'usage ne sont pas activées dans cette table commune : chaque crate membre active localement uniquement les features nécessaires à son propre code avec `<dependency> = { workspace = true, features = [...] }`.
|
||||
- **DEP-CARGO-004** — Lorsqu'une génération majeure/mineure compatible est retenue, KSP exprime explicitement l'intention sous forme caret `^M.m` (par exemple `^4.3`) plutôt qu'avec une écriture patch telle que `4.3.0`. Même si Cargo interprète aussi par défaut cette dernière comme une contrainte compatible caret, KSP normalise la syntaxe pour rendre l'intention manifeste. Un pin exact `=M.m.p` ou un bornage différent requiert une justification explicite.
|
||||
- **DEP-CARGO-005** — Le `Cargo.lock` résout la version patch concrète à l'intérieur de la contrainte du workspace ; cette résolution ne remplace pas la politique de version déclarée dans le manifeste racine.
|
||||
- **DEP-CARGO-006** — Lorsqu'une feature externe n'est nécessaire qu'aux tests/benchmarks/examples d'une crate, son activation appartient à la section de dépendances de développement correspondante plutôt qu'aux dépendances de production. L'unification des features effectuée par Cargo lors d'un build ne transfère pas cet ownership vers le `Cargo.toml` racine.
|
||||
- **DEP-CARGO-007** — Les canaries qui inspectent une politique générale du workspace ou plusieurs manifests membres appartiennent à une surface de gouvernance/fondation (actuellement les tests de `ksp-core-lib` tant qu'aucun outil d'audit dédié n'existe). Une crate métier spécialisée conserve uniquement les tests de frontière propres à son domaine et ne devient pas propriétaire d'une règle globale du workspace.
|
||||
|
||||
## Codecs wire et cohérence des versions
|
||||
|
||||
@@ -52,6 +54,9 @@ Elles complètent les règles Rust générales et le graphe de `docs/architectur
|
||||
- **DEP-LOG-007** — Une application/framework peut exceptionnellement intégrer directement un plugin/dépendance tracing imposé par son framework, notamment Tauri, sans créer une seconde politique de logging parallèle à `ksp-logging-lib`; les événements KSP restent émis via la façade KSP.
|
||||
- **DEP-LOG-008** — `ksp-logging-lib` possède ses settings runtime et son hot reload ; `ksp-config-lib` peut plus tard construire ces settings et demander une reconfiguration sans créer de dépendance inverse Logging -> Config.
|
||||
- **DEP-LOG-009** — Tout bridge `tracing` public mais caché de la documentation rendu techniquement nécessaire par l’expansion des macros de `ksp-logging-lib` est un détail d’implémentation réservé à ces macros ; une crate consommatrice ne l’utilise jamais directement et reste limitée à la façade KSP documentée.
|
||||
- **DEP-LOG-010** — Toute crate KSP comportementale qui émet des événements/spans via `ksp-logging-lib` possède un target principal explicite `pub(crate) const TRACING_TARGET: &str` dans `src/constants.rs`, égal au nom Cargo de la crate. Les appels utilisent ce symbole (ou un target spécialisé possédé par le même `constants.rs`) plutôt qu’un littéral dispersé.
|
||||
- **DEP-LOG-011** — `env!("CARGO_PKG_NAME")` ne sert pas de target de tracing/logging KSP : le target appartient au contrat d’observabilité et doit rester explicite dans le code. Les metadata Cargo restent autorisées lorsqu’elles sont réellement la donnée recherchée, par exemple pour un User-Agent ou une information de build.
|
||||
- **DEP-LOG-012** — Lorsqu’une seule crate/target KSP nécessite temporairement une verbosité `debug` ou `trace`, le profil Logging général n’est pas relevé par défaut : un `target_filter` autorise cette verbosité uniquement pour le target concerné et un sink dédié la route avec son propre `OutputFilter`. Les sinks généraux peuvent ainsi rester à `info`/`warn`. Un relèvement global du profil n’est retenu que lorsqu’un diagnostic transversal le justifie explicitement.
|
||||
|
||||
## Program / Execution
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/rules/RULES_RUST.md -->
|
||||
<!-- version: 4 -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# Règles Rust générales
|
||||
|
||||
@@ -49,6 +49,7 @@ Les règles `RUST-*` s'appliquent aux crates, sources, tests, exemples et outils
|
||||
- **RUST-ERR-004** — `anyhow` et `thiserror` ne sont pas utilisés par défaut ; leur introduction exige une justification architecturale.
|
||||
- **RUST-ERR-005** — Les erreurs publiques sont typées lorsque leur contrat est stable.
|
||||
- **RUST-ERR-006** — Les tests peuvent utiliser `unwrap` ou `expect` uniquement dans la limite explicitement autorisée par la configuration Clippy.
|
||||
- **RUST-ERR-007** — Avant d’attacher une erreur externe comme `source` de `ksp_core_lib::Error`, la crate propriétaire vérifie que sa chaîne `Debug`/`source` ne peut pas exposer de secret ou de donnée sensible. Lorsqu’une dépendance fournit une primitive de neutralisation, elle est appliquée avant `with_source`; en particulier une `reqwest::Error` issue d’une requête vers un endpoint potentiellement credential-bearing est attachée uniquement après `without_url()`.
|
||||
|
||||
## Formatage
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/validation/000-README.md -->
|
||||
<!-- version: 3 -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# Validations KSP
|
||||
|
||||
@@ -11,3 +11,4 @@ Documents :
|
||||
|
||||
- [`001-V0_1_4_CONFIG_DESKTOP.md`](001-V0_1_4_CONFIG_DESKTOP.md) — matrice finale de `0.1.4 — ksp-app-config-desk`.
|
||||
- [`002-V0_2_0_SERIES_PLANNING.md`](002-V0_2_0_SERIES_PLANNING.md) — matrice finale de la release stable `0.2.0`, avec audit de cohérence et preuves opérateur de `pre.003`.
|
||||
- [`003-V0_2_1_ONCHAIN_HTTP.md`](003-V0_2_1_ONCHAIN_HTTP.md) — matrice de clôture de `0.2.1 — HTTP Solana foundation`, registry 52+14, résilience, Config -> Transport, quatre canaris et smoke Devnet opt-in.
|
||||
|
||||
181
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
Normal file
181
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
Normal file
@@ -0,0 +1,181 @@
|
||||
<!-- file: docs/validation/003-V0_2_1_ONCHAIN_HTTP.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Validation `0.2.1` — HTTP Solana foundation
|
||||
|
||||
## Objet
|
||||
|
||||
Cette matrice synthétise les critères de clôture de `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation` et les preuves opérateur ayant autorisé la publication stable `0.2.1-rel.001`.
|
||||
|
||||
Elle ne remplace ni `docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md` ni les deltas `0.2.1`.
|
||||
|
||||
## Base de clôture
|
||||
|
||||
```text
|
||||
base validée : 0.2.1-pre.6.fix.2
|
||||
candidate finale : 0.2.1-pre.7
|
||||
```
|
||||
|
||||
La base `pre.006-fix.002` a été validée par l'opérateur le 2026-08-17 avec `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, les tests ciblés Transport/Config Desk/Config/Core et `cargo test --workspace`.
|
||||
|
||||
## Revérification officielle Solana
|
||||
|
||||
Revérification de clôture effectuée le 2026-08-17.
|
||||
|
||||
Index HTTP officiel :
|
||||
|
||||
```text
|
||||
https://solana.com/docs/rpc/http
|
||||
```
|
||||
|
||||
L'index courant expose toujours **52 méthodes HTTP** et confirme JSON-RPC 2.0 sur HTTP `POST` avec `Content-Type: application/json`.
|
||||
|
||||
La navigation officielle `Deprecated Methods`, visible depuis les pages deprecated telles que :
|
||||
|
||||
```text
|
||||
https://solana.com/docs/rpc/deprecated/confirmtransaction
|
||||
```
|
||||
|
||||
expose toujours les **14 noms historiques** audités :
|
||||
|
||||
```text
|
||||
confirmTransaction
|
||||
getConfirmedBlock
|
||||
getConfirmedBlocks
|
||||
getConfirmedBlocksWithLimit
|
||||
getConfirmedSignaturesForAddress2
|
||||
getConfirmedTransaction
|
||||
getFeeCalculatorForBlockhash
|
||||
getFeeRateGovernor
|
||||
getFees
|
||||
getRecentBlockhash
|
||||
getSignatureConfirmation
|
||||
getSignatureStatus
|
||||
getSnapshotSlot
|
||||
getStakeActivation
|
||||
```
|
||||
|
||||
KSP les conserve comme `Deprecated / Removed / Historical`; `0.2.1` ne simule pas leur appelabilité runtime.
|
||||
|
||||
## Matrice de clôture
|
||||
|
||||
| Critère | État stable `0.2.1` | Preuve / contrat |
|
||||
|------------------------------------------------------------|---------------------|-----------------------------------------------------------------------|
|
||||
| Crate `ksp-onchain-transport-lib` présente et indépendante | OK | manifest + canary Core `workspace_dependencies` |
|
||||
| Transport -X-> Config/Store/Program | OK | canary de firewall workspace |
|
||||
| Transport -X-> `tracing` direct | OK | ownership Logging + `ksp-logging-lib` |
|
||||
| Features Cargo activées localement par consumer | OK | canary workspace + manifests |
|
||||
| Target Logging explicite possédé par la crate | OK | `src/constants.rs` + canary `workspace_logging` |
|
||||
| URL endpoint redacted dans settings/snapshots/Debug | OK | tests settings/client/pool/public API |
|
||||
| `reqwest::Error` source sans URL sensible | OK | `without_url()` + canary timeout secret |
|
||||
| JSON-RPC 2.0 request/response/id/error | OK | tests `json_rpc` |
|
||||
| Registry HTTP courant | OK | 52 descriptors + canary de release |
|
||||
| Registry historique Deprecated | OK | 14 descriptors `Removed` + canary de release |
|
||||
| Partition typed future | OK | `4 / 22 / 11 / 15` pour `0.2.1`–`0.2.4` |
|
||||
| Quatre canaris typés exacts | OK | `getBalance`, `getGenesisHash`, `getHealth`, `getVersion` |
|
||||
| Pool rôles/capabilities/priorités/fairness | OK | tests pool |
|
||||
| RPS/burst/concurrence/cooldown | OK | tests resilience/pool |
|
||||
| Deadline/timeout/retry borné | OK | tests executor/resilience |
|
||||
| No-resend après dispatch ambigu | OK | descriptor + policy + tests |
|
||||
| 429/Retry-After et statuts temporaires | OK | tests executor |
|
||||
| Config standard Transport | OK | `std.transport.json` + schema + example |
|
||||
| Direction Config -> Transport | OK | adapter `load_resolved_transport_config` |
|
||||
| Sensibilité/provenance/env Transport | OK | tests Config |
|
||||
| Logging Transport dédié | OK | sink dédié `info` dans `std.logging.json` |
|
||||
| Tests réseau par défaut déterministes | OK | fixtures + serveur HTTP local |
|
||||
| Smoke Devnet | OPT-IN | `tests/transport_devnet_smoke.rs`, ignored par défaut |
|
||||
| README durable | OK | `crates/ksp-onchain-transport-lib/README.md` |
|
||||
| USAGE durable | OK | `crates/ksp-onchain-transport-lib/USAGE.md` |
|
||||
| Prompt release suivante | OK | `prompts/007-V0_2_2_START_PROMPT.md` |
|
||||
| Matrice HTTP globale préservée | OK | plan `008`; 48 méthodes restantes restent affectées à `0.2.2`–`0.2.4` |
|
||||
|
||||
## Canaries de complétude finales
|
||||
|
||||
`pre.007` ajoute une intégration publique dédiée qui vérifie :
|
||||
|
||||
```text
|
||||
current == 52
|
||||
historical == 14
|
||||
coverage == 4 / 22 / 11 / 15
|
||||
V0_2_1 == getBalance/getGenesisHash/getHealth/getVersion
|
||||
historical => Deprecated + Removed + NotApplicable
|
||||
```
|
||||
|
||||
Ces checks complètent les tests unitaires existants du registre et protègent la frontière de release depuis l'API publique.
|
||||
|
||||
La candidate validée déclare **80 tests Transport** et **114 tests Config**, dont le smoke Devnet Config unique marqué `ignored`. Cargo a confirmé ces surfaces sur le dépôt canonique avant `rel.001`.
|
||||
|
||||
## Smoke Devnet opt-in
|
||||
|
||||
Le smoke live est temporairement hébergé dans `ksp-config-lib`, car la direction de dépendance actuelle permet d’y prouver la chaîne sans introduire Transport -> Config :
|
||||
|
||||
```text
|
||||
Config
|
||||
-> profile devnet_public
|
||||
-> HttpTransportSettings
|
||||
-> HttpTransportPool
|
||||
-> getHealth
|
||||
-> getGenesisHash
|
||||
-> getVersion
|
||||
-> getBalance(System Program)
|
||||
```
|
||||
|
||||
Transport ne lit donc pas directement l'environnement.
|
||||
|
||||
Exécution explicite :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Ce test dépend d'un service Devnet externe. Il reste ignoré dans les suites déterministes et un incident/rate-limit du RPC public n'est pas assimilé à une régression locale sans analyse. Il a été exécuté explicitement avec succès avant `rel.001`.
|
||||
|
||||
**TODO ownership :** `ksp-config-lib` ne doit jamais devenir la destination générale des smoke tests, y compris pour les futurs scénarios `Config + autre crate`. Dès qu'une surface KSP d'intégration/orchestration appropriée existe, ce smoke doit y migrer ; les futurs smokes cross-crates doivent être créés directement sur cette surface dédiée.
|
||||
|
||||
## Audit `cargo tree` final
|
||||
|
||||
Les graphes ont été exécutés sur le dépôt canonique pour Transport et Config sous les formes normale, duplicates et features. Ils confirment :
|
||||
|
||||
- aucune dépendance Config sous Transport ;
|
||||
- Config dépend de Transport dans le sens autorisé ;
|
||||
- aucune dépendance Store/Program ;
|
||||
- aucune dépendance `tracing` directe de Transport ;
|
||||
- `reqwest/rustls`, `serde/derive`, Tokio Transport et Tokio de test Config restent activés localement par les crates consommatrices ;
|
||||
- les doublons signalés par `cargo tree -d` sont limités à `syn` 2.x/3.x dans les graphes inspectés et proviennent des chaînes proc-macro/transitives, sans duplication d'une stack HTTP/Tokio KSP concurrente.
|
||||
|
||||
## Validations finales opérateur
|
||||
|
||||
Validé le 2026-08-17 sur le dépôt canonique :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-core-lib
|
||||
cargo test -p ksp-app-config-desk
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Le smoke live a ensuite été exécuté explicitement :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Résultat : `1 passed; 0 failed`; les quatre canaris foundation sont donc atteints via le profil Devnet committé.
|
||||
|
||||
## Publication `rel.001`
|
||||
|
||||
`0.2.1-rel.001` ne contient aucune nouvelle capacité HTTP. Il publie :
|
||||
|
||||
```text
|
||||
workspace.package.version -> 0.2.1
|
||||
ROADMAP : 0.2.1 -> [X]
|
||||
CHANGELOG : synthèse stable 0.2.1
|
||||
plan 008 / matrice 003 : statut clôturé et preuves opérateur
|
||||
delta deltas/0.2.1/rel.001.md
|
||||
commit v0.2.1-rel.001
|
||||
tag stable v0.2.1 après validation du commit de release
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: prompts/000-README.md -->
|
||||
<!-- version: 10 -->
|
||||
<!-- version: 11 -->
|
||||
|
||||
# Prompts KSP
|
||||
|
||||
@@ -24,7 +24,7 @@ Le prompt générique `0.1.x` a été affiné pendant `0.0.3` puis remplacé par
|
||||
- [`001-V0_1_1_START_PROMPT.md`](001-V0_1_1_START_PROMPT.md) — prompt historique ouvrant la première release fonctionnelle `0.1.1` après publication stable de `0.0.3` ;
|
||||
- [`002-V0_1_2_START_PROMPT.md`](002-V0_1_2_START_PROMPT.md) — prompt final destiné à ouvrir `0.1.2 — Logging foundation` après publication stable de `0.1.1`.
|
||||
- [`003-V0_1_3_START_PROMPT.md`](003-V0_1_3_START_PROMPT.md) — prompt historique destiné à ouvrir `0.1.3 — Configuration foundation` après publication stable de `0.1.2` ;
|
||||
- [`004-V0_1_4_START_PROMPT.md`](004-V0_1_4_START_PROMPT.md) — prompt final destiné à ouvrir `0.1.4 — ksp-app-config-desk` après publication stable de `0.1.3`.
|
||||
- [`005-V0_2_0_START_PROMPT.md`](005-V0_2_0_START_PROMPT.md) — prompt de reprise préparé à la clôture de `0.1.4`; il ouvre `0.2.0-pre.001`, release intermédiaire d'audit de `khadhroony-bot3`, de comparaison avec KSP et de planification/découpage du reste de `0.2.x`.
|
||||
|
||||
- [`006-V0_2_1_START_PROMPT.md`](006-V0_2_1_START_PROMPT.md) — prompt finalisé par `0.2.0-pre.003`, destiné à ouvrir `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation` après publication stable de `0.2.0`; il impose l'audit exhaustif des surfaces HTTP courantes et deprecated/unstable officiellement documentées, la séparation Config/Transport, les pools/rôles et le gate de sizing « une release = une session ».
|
||||
- [`004-V0_1_4_START_PROMPT.md`](004-V0_1_4_START_PROMPT.md) — prompt final destiné à ouvrir `0.1.4 — ksp-app-config-desk` après publication stable de `0.1.3` ;
|
||||
- [`005-V0_2_0_START_PROMPT.md`](005-V0_2_0_START_PROMPT.md) — prompt de reprise préparé à la clôture de `0.1.4`; il ouvre `0.2.0-pre.001`, release intermédiaire d'audit de `khadhroony-bot3`, de comparaison avec KSP et de planification/découpage du reste de `0.2.x` ;
|
||||
- [`006-V0_2_1_START_PROMPT.md`](006-V0_2_1_START_PROMPT.md) — prompt finalisé par `0.2.0-pre.003`, destiné à ouvrir `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation` après publication stable de `0.2.0`; il impose l'audit exhaustif des surfaces HTTP courantes et deprecated/unstable officiellement documentées, la séparation Config/Transport, les pools/rôles et le gate de sizing « une release = une session » ;
|
||||
- [`007-V0_2_2_START_PROMPT.md`](007-V0_2_2_START_PROMPT.md) — prompt préparé par la dernière prerelease de `0.2.1`, destiné à ouvrir `0.2.2 — HTTP Accounts + Tokens + Cluster` après publication stable de `0.2.1`; il cible les 22 wrappers typés restants de ces familles et impose un nouvel audit officiel/gate de sizing à `pre.001`.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- file: prompts/006-V0_2_1_START_PROMPT.md -->
|
||||
<!-- version: 2 -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# Prompt de démarrage `0.2.1` — `ksp-onchain-transport-lib` HTTP Solana foundation
|
||||
|
||||
> **Statut : finalisé par `0.2.0-pre.003`.** Utiliser ce prompt uniquement après publication stable/tag `v0.2.0`; toute information externe temporelle doit être revérifiée à l'ouverture de `0.2.1-pre.001`.
|
||||
> **Statut : consommé par `0.2.1-pre.001`, puis recalibré par `0.2.1-pre.001-fix.001`.** Le gate de sizing du 2026-08-17 a conclu que le périmètre monolithique décrit ci-dessous n'était pas clôturable raisonnablement dans une seule session. `pre.001` a d'abord réparti la couverture HTTP sur `0.2.1`–`0.2.6`; le fix ramène ce découpage à `0.2.1`–`0.2.4`. Le plan actif `docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md` porte le résultat courant : `0.2.1` devient la foundation + 4 canaris, puis `0.2.2`–`0.2.4` complètent les 48 méthodes restantes. Ces trois releases HTTP complémentaires représentent trois sessions nominales au maximum ; plusieurs releases peuvent être enchaînées dans une même session si chacune est complètement clôturée avant l'ouverture de la suivante et si le sizing restant le permet. Le présent document reste la trace du cahier des charges ayant ouvert l'audit ; ne pas le réutiliser comme prompt d'implémentation monolithique.
|
||||
|
||||
## 1. Contexte de reprise
|
||||
|
||||
@@ -484,12 +484,12 @@ et vérifiant la complétude de la matrice de méthodes si elle peut être autom
|
||||
|
||||
Ne pas ouvrir :
|
||||
|
||||
- WebSocket Solana — `0.2.4` ;
|
||||
- Helius LaserStream WebSocket — `0.2.5` ;
|
||||
- Yellowstone gRPC — `0.2.6` ;
|
||||
- WebSocket Solana — `0.2.7` ;
|
||||
- Helius LaserStream WebSocket — `0.2.8` ;
|
||||
- Yellowstone gRPC — `0.2.9` ;
|
||||
- providers gRPC avancés ;
|
||||
- Wallet — `0.2.2` ;
|
||||
- Wallet Desk — `0.2.3` ;
|
||||
- Wallet — `0.2.5` ;
|
||||
- Wallet Desk — `0.2.6` ;
|
||||
- Store/persistence RAW — `0.3.1` ;
|
||||
- decoders Program ;
|
||||
- `ksp-interface-lib` fonctionnel complet ;
|
||||
@@ -547,60 +547,69 @@ Si la réponse est non ou incertaine, **ne pas commencer la grosse implémentati
|
||||
|
||||
La contrainte de couverture documentaire exhaustive ne doit jamais être contournée en masquant des méthodes pour faire tenir artificiellement la release.
|
||||
|
||||
## 17. Prévision souple initiale des prereleases
|
||||
## 17. Prévision souple recalibrée après le gate `pre.001`
|
||||
|
||||
Cette prévision est un point de départ et doit être recalibrée par `pre.001` à partir de la matrice réelle des méthodes.
|
||||
Le gate a refusé la grosse release HTTP monolithique. La `0.2.1` réduite suit désormais la prévision active du plan `008` :
|
||||
|
||||
### `pre.001` — audit, matrice exhaustive, architecture et sizing
|
||||
|
||||
Aucune grosse implementation.
|
||||
Tranche actuelle, sans grosse implémentation.
|
||||
|
||||
### `pre.002` — crate foundation + settings + JSON-RPC + method descriptors
|
||||
|
||||
Candidat :
|
||||
### `pre.002` — crate foundation + settings + JSON-RPC + descriptors
|
||||
|
||||
- package/workspace ;
|
||||
- contrats settings ;
|
||||
- validation ;
|
||||
- JSON-RPC envelope/error ;
|
||||
- metadata de méthode/status ;
|
||||
- erreurs KSP ;
|
||||
- settings/validation ;
|
||||
- JSON-RPC ;
|
||||
- metadata méthode/statut/runtime/request-form/retry ;
|
||||
- base Logging.
|
||||
|
||||
### Tranches méthodes HTTP
|
||||
### `pre.003` — client/pool/routing
|
||||
|
||||
Répartir les méthodes par familles cohérentes **après inventaire officiel**, par exemple accounts/cluster, blocks/transactions, tokens/economics, write/execution technique ou toute meilleure découpe révélée par la documentation.
|
||||
- endpoint client ;
|
||||
- pool logique ;
|
||||
- rôles/capabilities ;
|
||||
- priorité/fairness/fallback ;
|
||||
- snapshots redacted.
|
||||
|
||||
Aucune de ces tranches ne doit dépasser le budget 15–20 minutes ; ajouter des prereleases si nécessaire **uniquement si la release entière reste clôturable dans la session**.
|
||||
### `pre.004` — résilience
|
||||
|
||||
### Tranche pools/rôles/resilience
|
||||
- rate-limit/burst ;
|
||||
- concurrence ;
|
||||
- cooldown ;
|
||||
- timeout ;
|
||||
- retry/backoff borné ;
|
||||
- classification no-resend des writes futures.
|
||||
|
||||
- pools ;
|
||||
- role matching ;
|
||||
- priority/fallback ;
|
||||
- rate-limit ;
|
||||
- concurrency ;
|
||||
- retry/backoff ;
|
||||
- health/snapshots.
|
||||
### `pre.005` — méthodes canari typées
|
||||
|
||||
### Tranche Config standard
|
||||
- `getBalance` ;
|
||||
- `getGenesisHash` ;
|
||||
- `getHealth` ;
|
||||
- `getVersion` ;
|
||||
- fixtures/tests déterministes.
|
||||
|
||||
- schema/document/example ;
|
||||
- registration/file IDs ;
|
||||
### `pre.006` — Config standard
|
||||
|
||||
- schema/document/example `std.transport` ;
|
||||
- registry/file IDs ;
|
||||
- adapter Config -> Transport ;
|
||||
- env inventory ;
|
||||
- intégration tests.
|
||||
- sensibilité/env ;
|
||||
- integration tests.
|
||||
|
||||
### Dernière prerelease
|
||||
### `pre.007` — clôture
|
||||
|
||||
- matrice de complétude 100 % de la surface ciblée ;
|
||||
- tests ciblés et workspace ;
|
||||
- canaries et matrice 52 current + 14 deprecated historiques ;
|
||||
- tests ciblés/workspace ;
|
||||
- cargo tree audits ;
|
||||
- README/USAGE ;
|
||||
- documentation durable ;
|
||||
- cleanup/TODO ;
|
||||
- prompt `0.2.2` Wallet ;
|
||||
- prompt `0.2.2 — HTTP Accounts + Tokens + Cluster` ;
|
||||
- préparation `rel.001`.
|
||||
|
||||
Les méthodes typées restantes sont réparties sur `0.2.2`–`0.2.4` et restent toutes présentes dans la matrice du plan `008`.
|
||||
|
||||
## 18. Validation de chaque tranche Rust
|
||||
|
||||
Après toute modification Rust :
|
||||
@@ -624,39 +633,49 @@ Exécuter les `cargo tree` pertinents pour les crates modifiées.
|
||||
|
||||
Ne jamais déclarer une commande réussie si elle n'a pas été exécutée.
|
||||
|
||||
## 19. Critères de clôture `0.2.1`
|
||||
## 19. Critères de clôture `0.2.1` après split
|
||||
|
||||
La release ne peut pas être déclarée stable tant que :
|
||||
|
||||
- `ksp-onchain-transport-lib` existe comme crate KSP propre ;
|
||||
- aucune dépendance Transport -> Config/Store/Program n'existe ;
|
||||
- aucune dépendance Transport -> Config/Store/Program/tracing direct n'existe ;
|
||||
- les settings publics sont documentés ;
|
||||
- le document Config Transport + adapter fonctionnent sans inverser l'ownership ;
|
||||
- toutes les méthodes de la surface HTTP Solana normative ciblée sont présentes dans la matrice ;
|
||||
- toutes les méthodes supportables ciblées sont implémentées ;
|
||||
- deprecated/obsolete encore fonctionnel et unstable/experimental émettent le warning KSP prévu ;
|
||||
- JSON-RPC, erreurs et descriptors centraux fonctionnent ;
|
||||
- le registre contient exactement les 52 méthodes courantes et les 14 méthodes Deprecated historiques auditées ;
|
||||
- le statut runtime `Removed` empêche de présenter les 14 anciennes méthodes comme supportées ;
|
||||
- le mécanisme de warning centralisé sait couvrir les contrats deprecated/unstable supportés, notamment les formes legacy documentées ;
|
||||
- pools/rôles/priority/limites/timeouts/retry retenus sont testés ;
|
||||
- les réponses restent transport/raw-compatible et ne produisent pas des modèles Program/Store ;
|
||||
- aucun secret/provider token n'est loggué ;
|
||||
- `getBalance`, `getGenesisHash`, `getHealth` et `getVersion` sont exposés par une API typée et testés ;
|
||||
- le document Config Transport + adapter fonctionnent sans inverser l'ownership ;
|
||||
- les tests ciblés passent ;
|
||||
- les validations workspace finales passent ;
|
||||
- le graphe de dépendances est audité ;
|
||||
- `README.md` et `USAGE.md` sont complets ;
|
||||
- TODO/hors scope sont fermés ou reportés explicitement ;
|
||||
- le prompt final `0.2.2` est prêt ;
|
||||
- le prompt final `0.2.2 — HTTP Accounts + Tokens + Cluster` est prêt ;
|
||||
- les 48 méthodes courantes reportées restent affectées explicitement à `0.2.2`–`0.2.4` ;
|
||||
- la release entière a été clôturée dans la session qui l'a ouverte.
|
||||
|
||||
## 20. Release suivante
|
||||
## 20. Release suivante après split
|
||||
|
||||
La release suivante prévue est :
|
||||
|
||||
```text
|
||||
0.2.2 — ksp-wallet-lib / .kspwallet foundation
|
||||
0.2.2 — ksp-onchain-transport-lib / HTTP Accounts + Tokens + Cluster
|
||||
```
|
||||
|
||||
Elle devra utiliser les fondations N1 mais **ne pas dépendre du transport** pour son cœur cryptographique/format.
|
||||
Elle doit compléter les 5 méthodes Accounts restantes, les 5 méthodes Tokens et les 12 méthodes Cluster restantes, après revérification de la documentation officielle actuelle.
|
||||
|
||||
Le transport `0.2.1` sera ensuite composé avec Wallet dans `0.2.3 — ksp-app-wallet-desk` pour afficher notamment le solde réseau du wallet.
|
||||
La suite HTTP reste :
|
||||
|
||||
```text
|
||||
0.2.3 Transactions
|
||||
0.2.4 Blocks + Economics + compliance finale
|
||||
```
|
||||
|
||||
Wallet passe à `0.2.5` et Wallet Desk à `0.2.6`. Le cœur cryptographique/format de Wallet restera indépendant du transport ; Wallet Desk composera ensuite Wallet et la surface HTTP stabilisée. `0.2.2`, `0.2.3` et `0.2.4` sont trois releases/session nominales ; si l'une est clôturée rapidement, la suivante peut être ouverte dans le même chat, sans fusionner les frontières de version/delta/validation.
|
||||
|
||||
## Instruction d'ouverture
|
||||
|
||||
|
||||
431
prompts/007-V0_2_2_START_PROMPT.md
Normal file
431
prompts/007-V0_2_2_START_PROMPT.md
Normal file
@@ -0,0 +1,431 @@
|
||||
<!-- file: prompts/007-V0_2_2_START_PROMPT.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Prompt de démarrage `0.2.2` — HTTP Accounts + Tokens + Cluster
|
||||
|
||||
## 1. Contexte de reprise
|
||||
|
||||
La base attendue est la release stable :
|
||||
|
||||
```text
|
||||
v0.2.1
|
||||
```
|
||||
|
||||
`0.2.1` a stabilisé `ksp-onchain-transport-lib` comme foundation HTTP Solana : settings runtime, endpoints/pool/rôles, limites et résilience, JSON-RPC 2.0, registry audité, exécution HTTP générique, Config -> Transport et quatre wrappers typés canari.
|
||||
|
||||
Les invariants à préserver sont notamment :
|
||||
|
||||
```text
|
||||
52 méthodes HTTP courantes auditées
|
||||
14 méthodes historiques Deprecated / runtime Removed
|
||||
4 wrappers typés acquis : getBalance/getGenesisHash/getHealth/getVersion
|
||||
partition planifiée : 4 / 22 / 11 / 15 sur 0.2.1–0.2.4
|
||||
Transport -X-> Config/Store/Program/tracing direct
|
||||
Config -> Transport autorisé
|
||||
no-resend après dispatch ambigu pour WriteSubmission
|
||||
URLs/provider credentials absents des diagnostics ordinaires
|
||||
```
|
||||
|
||||
La release à ouvrir est :
|
||||
|
||||
```text
|
||||
0.2.2 — HTTP Accounts + Tokens + Cluster
|
||||
```
|
||||
|
||||
La première tranche est :
|
||||
|
||||
```text
|
||||
0.2.2-pre.001
|
||||
```
|
||||
|
||||
`pre.001` commence par **audit officiel actuel + brainstorming + dimensionnement** avant implémentation lourde.
|
||||
|
||||
## 2. Mission
|
||||
|
||||
Compléter la surface **typée** KSP des familles Accounts, Tokens et Cluster restantes, en réutilisant sans duplication la foundation HTTP de `0.2.1`.
|
||||
|
||||
La cible issue du plan `0.2.1` contient actuellement 22 méthodes :
|
||||
|
||||
### Accounts — 5
|
||||
|
||||
```text
|
||||
getAccountInfo
|
||||
getLargestAccounts
|
||||
getMinimumBalanceForRentExemption
|
||||
getMultipleAccounts
|
||||
getProgramAccounts
|
||||
```
|
||||
|
||||
`getBalance` est déjà acquis en `0.2.1` et ne doit pas être réimplémenté.
|
||||
|
||||
### Tokens — 5
|
||||
|
||||
```text
|
||||
getTokenAccountBalance
|
||||
getTokenAccountsByDelegate
|
||||
getTokenAccountsByOwner
|
||||
getTokenLargestAccounts
|
||||
getTokenSupply
|
||||
```
|
||||
|
||||
### Cluster — 12
|
||||
|
||||
```text
|
||||
getClusterNodes
|
||||
getEpochInfo
|
||||
getEpochSchedule
|
||||
getHighestSnapshotSlot
|
||||
getIdentity
|
||||
getLeaderSchedule
|
||||
getMaxRetransmitSlot
|
||||
getMaxShredInsertSlot
|
||||
getSlot
|
||||
getSlotLeader
|
||||
getSlotLeaders
|
||||
getVoteAccounts
|
||||
```
|
||||
|
||||
`getGenesisHash`, `getHealth` et `getVersion` sont déjà acquis en `0.2.1`.
|
||||
|
||||
Ces 22 noms sont la **partition KSP actuellement planifiée**, pas une vérité externe immuable. `pre.001` doit revérifier la documentation Solana du jour avant de confirmer le périmètre.
|
||||
|
||||
## 3. Sources internes obligatoires
|
||||
|
||||
Relire avant modification :
|
||||
|
||||
```text
|
||||
ROADMAP.md
|
||||
CHANGELOG.md
|
||||
RULES.md
|
||||
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
docs/validation/003-V0_2_1_ONCHAIN_HTTP.md
|
||||
|
||||
docs/architecture/002-LAYERS_AND_DEPENDENCIES.md
|
||||
docs/architecture/003-COMPONENT_CONTRACTS.md
|
||||
docs/architecture/004-COMPONENT_INVENTORY.md
|
||||
docs/architecture/005-DEPENDENCY_GRAPH.md
|
||||
|
||||
docs/rules/RULES_DEPENDENCIES.md
|
||||
docs/rules/RULES_RUST.md
|
||||
docs/rules/RULES_KSP.md
|
||||
docs/rules/FILE_CONTRACTS.md
|
||||
docs/rules/VERSION_WORKFLOW.md
|
||||
docs/rules/PROMPT_STRUCTURE.md
|
||||
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
crates/ksp-onchain-transport-lib/src/
|
||||
crates/ksp-onchain-transport-lib/unit_tests/
|
||||
crates/ksp-onchain-transport-lib/tests/
|
||||
|
||||
crates/ksp-config-lib/src/transport.rs
|
||||
config/std.transport.json
|
||||
config/schemas/std.transport.schema.json
|
||||
```
|
||||
|
||||
Les deltas `0.2.1` servent de trace historique ; ne pas les réécrire.
|
||||
|
||||
## 4. Sources externes normatives
|
||||
|
||||
Au début de `pre.001`, consulter la documentation officielle **actuelle** Solana :
|
||||
|
||||
```text
|
||||
https://solana.com/docs/rpc/http
|
||||
```
|
||||
|
||||
Pour chaque méthode cible, revérifier :
|
||||
|
||||
- nom exact ;
|
||||
- catégorie ;
|
||||
- paramètres et ordre ;
|
||||
- limites de cardinalité ;
|
||||
- objets config ;
|
||||
- commitment/minContextSlot ;
|
||||
- encoding/dataSlice/filters ;
|
||||
- nullable/optional ;
|
||||
- forme exacte du résultat ;
|
||||
- champs versionnés/optionnels ;
|
||||
- erreurs significatives ;
|
||||
- statut stable/deprecated/unstable ;
|
||||
- éventuelle évolution depuis l'audit du 2026-08-17.
|
||||
|
||||
Revérifier également l'index global afin de détecter une méthode ajoutée/supprimée/déplacée depuis `0.2.1`.
|
||||
|
||||
Pour une question technique sur une crate externe, utiliser uniquement sa documentation/source primaire actuelle.
|
||||
|
||||
## 5. Gate de sizing obligatoire
|
||||
|
||||
Avant implémentation, répondre explicitement :
|
||||
|
||||
```text
|
||||
Les 22 wrappers typés + DTOs partagés + tests + documentation peuvent-ils être clôturés proprement dans cette session ?
|
||||
```
|
||||
|
||||
Si NON :
|
||||
|
||||
- ne pas compresser artificiellement la release ;
|
||||
- proposer immédiatement un split cohérent de `0.2.2` avant développement lourd ;
|
||||
- mettre à jour la séquence sans perdre aucune méthode.
|
||||
|
||||
Une prerelease vise environ 15–20 minutes de travail effectif.
|
||||
|
||||
## 6. Architecture à préserver
|
||||
|
||||
Ne pas reconstruire un second transport par famille.
|
||||
|
||||
Le flux reste :
|
||||
|
||||
```text
|
||||
wrapper typé
|
||||
-> descriptor central
|
||||
-> execute_standard_rpc
|
||||
-> pool/admission
|
||||
-> reqwest HTTP
|
||||
-> JSON-RPC validation
|
||||
-> decode typé
|
||||
```
|
||||
|
||||
Réutiliser :
|
||||
|
||||
- `HttpTransportPool` ;
|
||||
- `HttpRpcMethodDescriptor` ;
|
||||
- `HttpRpcCoverageRelease` ;
|
||||
- request kinds ;
|
||||
- retry metadata ;
|
||||
- JSON-RPC KSP ;
|
||||
- erreurs KSP ;
|
||||
- Logging KSP ;
|
||||
- redaction existante.
|
||||
|
||||
Aucun wrapper typé ne doit bypasser le pool, la deadline, le retry ou le contrôle central de statut.
|
||||
|
||||
## 7. DTOs et wire HTTP
|
||||
|
||||
Créer des types KSP dédiés à la surface HTTP lorsque cela améliore réellement le contrat public.
|
||||
|
||||
Principes :
|
||||
|
||||
- utiliser `ksp_core_lib::Pubkey` pour les adresses publiques ;
|
||||
- ne pas ajouter `solana-client` ni SDK RPC haut niveau ;
|
||||
- ne pas dépendre de Store ou Program ;
|
||||
- ne pas introduire `ksp-interface-lib` par anticipation ;
|
||||
- ne pas décoder des programmes/accounts métier dans Transport ;
|
||||
- conserver les valeurs JSON parsed/encoded à un niveau transport approprié ;
|
||||
- ajouter `base64`, `bs58` ou autre dépendance seulement si le contrat typé retenu en a réellement besoin après audit ;
|
||||
- mutualiser les objets réellement communs (`context`, account data/config, token amount, filters, epoch/leader structures) sans créer un « mega DTO » artificiel.
|
||||
|
||||
Les types de résultats doivent conserver les `null` et options documentés au lieu d'inventer des valeurs.
|
||||
|
||||
## 8. Accounts
|
||||
|
||||
Auditer et typer notamment :
|
||||
|
||||
- account absent vs présent ;
|
||||
- `encoding` ;
|
||||
- `dataSlice` ;
|
||||
- `filters` de `getProgramAccounts` ;
|
||||
- `withContext` ;
|
||||
- `sortResults` si toujours documenté ;
|
||||
- limites documentées de `getMultipleAccounts` ;
|
||||
- rent exemption ;
|
||||
- largest accounts et filtres éventuels.
|
||||
|
||||
Ne pas convertir une réponse account en modèle Program/décodé.
|
||||
|
||||
## 9. Tokens
|
||||
|
||||
Les méthodes Token restent du **transport RPC standard Solana**, pas du decoding SPL Program.
|
||||
|
||||
Auditer :
|
||||
|
||||
- `TokenAmount` ;
|
||||
- owner/delegate ;
|
||||
- filtre exclusif `{mint}` ou `{programId}` ;
|
||||
- encodings/dataSlice ;
|
||||
- `minContextSlot` ;
|
||||
- largest accounts ;
|
||||
- supply.
|
||||
|
||||
Aucune dépendance directe à une crate SPL n'est ajoutée uniquement pour représenter le JSON RPC si des DTOs KSP simples suffisent.
|
||||
|
||||
## 10. Cluster
|
||||
|
||||
Auditer précisément les formes de :
|
||||
|
||||
- node contact info ;
|
||||
- epoch info/schedule ;
|
||||
- snapshot slots ;
|
||||
- identity ;
|
||||
- leader schedule et slot leaders ;
|
||||
- max retransmit/shred slots ;
|
||||
- slot ;
|
||||
- vote accounts.
|
||||
|
||||
Conserver les champs optionnels/versionnés documentés et ne pas supposer qu'un provider retourne toujours toutes les extensions.
|
||||
|
||||
## 11. Surface raw vs typed
|
||||
|
||||
`execute_standard_rpc()` reste public et utile, mais :
|
||||
|
||||
> un appel raw/générique ne compte jamais comme couverture typée de `0.2.2`.
|
||||
|
||||
Une méthode cible est clôturée seulement lorsque son wrapper public, ses paramètres/configs, son résultat et ses tests sont présents selon la matrice de la release.
|
||||
|
||||
## 12. Erreurs, retry et sécurité
|
||||
|
||||
Conserver les invariants `0.2.1` :
|
||||
|
||||
- JSON-RPC application error distincte d'une erreur transport ;
|
||||
- timeout/connection/status remappés sans URL sensible ;
|
||||
- `reqwest::Error::without_url()` avant exposition comme source ;
|
||||
- aucune URL/token/body complet dans `Debug`/logs ;
|
||||
- retry uniquement selon descriptor/policy ;
|
||||
- pas de nouvelle hiérarchie d'erreurs locale parallèle à `ksp_core_lib::Error`.
|
||||
|
||||
Toutes les nouvelles méthodes de `0.2.2` sont a priori des reads, mais `pre.001` doit vérifier leur statut/opération actuel au lieu de l'assumer silencieusement.
|
||||
|
||||
## 13. Logging
|
||||
|
||||
Utiliser uniquement :
|
||||
|
||||
```text
|
||||
ksp_logging_lib
|
||||
crate::TRACING_TARGET
|
||||
```
|
||||
|
||||
Le target reste :
|
||||
|
||||
```text
|
||||
ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
Éviter les logs par méthode redondants. Préférer les événements génériques du transport avec metadata sûre : méthode, rôle, endpoint logique, tentative, statut technique.
|
||||
|
||||
Un niveau `debug` ciblé peut être rouvert temporairement pendant le développement, puis doit revenir à `info`/`warn` dans la dernière prerelease conformément aux règles KSP.
|
||||
|
||||
## 14. Config
|
||||
|
||||
Ne modifier `std.transport.json` ou son schema que si une capacité réellement nécessaire de `0.2.2` ne peut pas être exprimée par le contrat existant.
|
||||
|
||||
La direction reste :
|
||||
|
||||
```text
|
||||
ksp-config-lib -> ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
Transport ne lit jamais `.env` ou `std::env::var*`.
|
||||
|
||||
## 15. Tests
|
||||
|
||||
Pour chaque méthode typée, couvrir au minimum :
|
||||
|
||||
- sérialisation de la request ;
|
||||
- résultat success ;
|
||||
- nullable/optional pertinent ;
|
||||
- config/overload pertinent ;
|
||||
- erreur significative ;
|
||||
- invariants de cardinalité/filtre lorsque documentés.
|
||||
|
||||
Utiliser fixtures déterministes et serveur HTTP local ; Internet est interdit aux tests par défaut.
|
||||
|
||||
Conserver/étendre les canaries de release :
|
||||
|
||||
```text
|
||||
current == inventaire officiel confirmé
|
||||
historical == inventaire officiel confirmé
|
||||
partition de couverture exacte
|
||||
0.2.1 canaries inchangés
|
||||
0.2.2 exact method set
|
||||
aucune méthode 0.2.3/0.2.4 déclarée typed-complete prématurément
|
||||
```
|
||||
|
||||
Un smoke Devnet opt-in peut tester un sous-ensemble représentatif, mais il ne remplace pas les fixtures.
|
||||
|
||||
## 16. Dépendances Cargo
|
||||
|
||||
Rappels :
|
||||
|
||||
- versions/default-features communes au root `[workspace.dependencies]` ;
|
||||
- features d'usage activées dans chaque crate consommatrice ;
|
||||
- toute nouvelle dépendance externe commune est d'abord déclarée au root ;
|
||||
- `cargo tree` normal/doublons/features est rejoué lorsqu'une dépendance ou feature change.
|
||||
|
||||
Ne pas ajouter une dépendance uniquement parce que bot3 l'utilisait.
|
||||
|
||||
## 17. Documentation et clôture
|
||||
|
||||
La dernière prerelease de `0.2.2` doit :
|
||||
|
||||
- réauditer la matrice officielle ;
|
||||
- exécuter les canaries finales ;
|
||||
- mettre à jour README/USAGE si la surface publique change ;
|
||||
- mettre à jour la validation durable ;
|
||||
- ramener le logging temporairement élevé à sa baseline ;
|
||||
- préparer le prompt `0.2.3 — HTTP Transactions` ;
|
||||
- préparer un `rel.001` minimal.
|
||||
|
||||
`CHANGELOG.md` reçoit l'entrée `0.2.2` au moment de la publication stable, pas comme journal de prereleases.
|
||||
|
||||
## 18. Prévision souple initiale
|
||||
|
||||
À confirmer par `pre.001` :
|
||||
|
||||
| Tranche | Objectif indicatif |
|
||||
|-----------|----------------------------------------------------------------------------------------|
|
||||
| `pre.001` | re-audit officiel, matrice exacte, DTOs communs, dépendances, sizing |
|
||||
| `pre.002` | primitives/configs/results communs Accounts/Token + fixtures de base |
|
||||
| `pre.003` | 5 méthodes Accounts typées + tests |
|
||||
| `pre.004` | 5 méthodes Tokens typées + tests |
|
||||
| `pre.005` | première moitié des 12 méthodes Cluster + tests |
|
||||
| `pre.006` | seconde moitié Cluster + tests |
|
||||
| `pre.007` | canaries de complétude, smoke opt-in, docs finales, prompt `0.2.3`, préparation stable |
|
||||
|
||||
Ce tableau n'est pas contractuel. Scinder une tranche si elle dépasse le budget ; ne jamais compresser un groupe pour conserver artificiellement un numéro.
|
||||
|
||||
## 19. Validations minimales
|
||||
|
||||
Pendant le développement :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
À la clôture :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-core-lib
|
||||
cargo test --workspace
|
||||
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib -d
|
||||
cargo tree -p ksp-onchain-transport-lib -e features
|
||||
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||
```
|
||||
|
||||
Ne jamais déclarer une commande réussie si elle n'a pas été exécutée.
|
||||
|
||||
## 20. Critères de sortie
|
||||
|
||||
`0.2.2` peut devenir stable seulement si :
|
||||
|
||||
- l'inventaire officiel actuel est revérifié ;
|
||||
- les méthodes ciblées sont précisément celles retenues après cet audit ;
|
||||
- chaque méthode cible possède un contrat public typé et des tests déterministes ;
|
||||
- les quatre canaris `0.2.1` restent inchangés ;
|
||||
- la partition globale ne perd aucune méthode future ;
|
||||
- aucune dépendance/frontière KSP n'est violée ;
|
||||
- redaction/retry/logging restent conformes ;
|
||||
- README/USAGE sont à jour ;
|
||||
- validations Cargo et canaries passent ;
|
||||
- prompt `0.2.3` est prêt ;
|
||||
- `rel.001` ne contient plus de développement fonctionnel nouveau.
|
||||
Reference in New Issue
Block a user