Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fff69ead9c | |||
| c93a833bac | |||
| 05e0e96083 | |||
| b57f796187 | |||
| ecfb9500eb | |||
| e14ce1c36f | |||
| 83cb861e54 | |||
| 1d1bc6a4d6 | |||
| c4636ac8b9 | |||
| f15448ff6e | |||
| bec1f2ec08 | |||
| f625ee5979 | |||
| e68f073505 | |||
| bffb4f9a31 | |||
| 9059a2dc45 | |||
| 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
|
||||
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,10 +1,18 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 4 -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# Changelog KSP
|
||||
|
||||
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
|
||||
|
||||
## 0.2.2 — HTTP Accounts + Tokens + Cluster — 2026-08-18
|
||||
|
||||
`0.2.2` complète la surface HTTP typée de `ksp-onchain-transport-lib` avec 22 wrappers supplémentaires : 5 Accounts, 5 Tokens et 12 Cluster, portant la couverture typée totale à 26 méthodes avec les quatre canaris foundation de `0.2.1`. La release stabilise les DTOs/configs wire communs, préserve les formes Account encodées/`jsonParsed`, les `null` et champs optionnels, les selectors Token exclusifs, les contextes RPC, les structures Cluster/leader/vote et les limites locales auditées (`getMultipleAccounts <= 100`, `getProgramAccounts <= 4` filtres, `memcmp` raw <= 128 octets, `getSlotLeaders` entre 1 et 5000). Les 22 descriptors restent `Read / RetrySafe` et passent exclusivement par le flux central `descriptor -> execute_standard_rpc -> pool/admission -> executor HTTP`. La release n'ajoute aucune dépendance externe, ne crée aucun client HTTP parallèle et conserve le firewall Transport -> Config/Store/Program. Elle ajoute un smoke Devnet pur à Transport avec settings programmatiques couvrant Accounts/Tokens/Cluster, conserve séparément le smoke historique Config -> Transport comme exception transitoire, et valide les deux explicitement avant publication. Les canaries figent toujours 52 méthodes HTTP courantes, 14 historiques Deprecated/Removed et la partition typed `4 / 22 / 11 / 15`. Le prompt `prompts/008-V0_2_3_START_PROMPT.md` ouvre `0.2.3 — HTTP Transactions`.
|
||||
|
||||
## 0.2.1 — HTTP Solana foundation — 2026-08-17
|
||||
|
||||
`0.2.1` stabilise `ksp-onchain-transport-lib` comme foundation HTTP JSON-RPC Solana provider-neutral : settings publics, endpoints/pool/rôles, priorités et fairness, RPS/burst/concurrence/cooldown, deadline commune, retry/backoff, classification no-resend après dispatch ambigu, snapshots sûrs et exécution HTTP réelle via `reqwest`/rustls. La release fige un registre audité de 52 méthodes HTTP courantes et 14 méthodes historiques Deprecated/Removed, avec quatre wrappers typés canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) et une partition explicite des 48 méthodes restantes sur `0.2.2`–`0.2.4`. Elle ajoute `std.transport`, son schema/exemple et l'adapter `ksp-config-lib -> ksp-onchain-transport-lib`, sans dépendance inverse, ainsi que la redaction des URLs/provider credentials, la neutralisation des URLs contenues dans les `reqwest::Error`, un sink Logging Transport dédié à `info`, des fixtures HTTP déterministes, des canaries de complétude et un smoke Devnet opt-in validant la composition Config -> Transport. Le smoke cross-crates reste temporairement hébergé dans Config et doit migrer vers une future surface d'intégration/orchestration ; ce placement n'est pas un modèle pour les futurs smokes `Config + autre crate`. Le prompt `prompts/007-V0_2_2_START_PROMPT.md` ouvre `0.2.2 — HTTP Accounts + Tokens + Cluster`.
|
||||
|
||||
## 0.2.0 — Audit bot3 et planification de la série `0.2.x` — 2026-08-17
|
||||
|
||||
`0.2.0` stabilise le cadrage de la prochaine phase fonctionnelle de KSP après audit de `khadhroony-bot3`. La release fixe l'ordre `0.2.1+` autour du transport HTTP Solana, du Wallet `.kspwallet`, de Wallet Desk, des transports WebSocket/LaserStream/Yellowstone, du transport off-chain de prix, de `ksp-interface-lib` et de `ksp-program-api`; elle impose la couverture exhaustive des surfaces Transport documentées avec warnings KSP pour les opérations deprecated/obsolete encore fonctionnelles et unstable/experimental. Elle stabilise également la progression durable `RAW -> CORE -> DECODE -> SPECIALIZED`, RAW/CORE sans décodage Program, puis des vertical slices complets par groupe à partir de DECODE, avec priorité Solana Core, SPL token/trading, metadata token, Anchor, Meteora/Raydium/Pump/Orca, routing et Market Desk progressive. Le prompt `prompts/006-V0_2_1_START_PROMPT.md` ouvre `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation` avec un gate de sizing imposant qu'une release concrète reste clôturable dans une seule session.
|
||||
|
||||
17
Cargo.toml
17
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 95
|
||||
# version: 119
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
@@ -15,15 +15,16 @@ publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
fs2 = { version = "^0.4" }
|
||||
serde = { version = "^1.0", features = ["derive"] }
|
||||
serde = { version = "^1.0" }
|
||||
serde_json = { version = "^1.0" }
|
||||
jsonschema = { version = "^0.49", default-features = false }
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
solana-pubkey = { version = "^4.3", default-features = false }
|
||||
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] }
|
||||
tracing = { version = "^0.1", default-features = false }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||
tracing-appender = { version = "^0.2", default-features = false }
|
||||
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time"] }
|
||||
chrono = { version = "^0.4", default-features = false, features = ["std", "now"] }
|
||||
tokio = { version = "^1.53", default-features = false }
|
||||
chrono = { version = "^0.4", default-features = false }
|
||||
tauri = { version = "^2.11" }
|
||||
tauri-build = { version = "^2.6" }
|
||||
tauri-plugin-tracing = { version = "^0.3" }
|
||||
|
||||
33
ROADMAP.md
33
ROADMAP.md
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 24 -->
|
||||
<!-- version: 37 -->
|
||||
|
||||
# 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.
|
||||
- [X] `0.2.2` — HTTP Accounts + Tokens + Cluster : 22 wrappers typés (5 Accounts + 5 Tokens + 12 Cluster), canaries de complétude 52+14, smoke Devnet Transport pur et smoke historique Config -> Transport validés, documentation durable et prompt `0.2.3` publiés stables.
|
||||
- [ ] `0.2.3` — Compléter les 11 méthodes HTTP Transactions, y compris write/submission technique avec politique no-resend ambigu.
|
||||
- [ ] `0.2.4` — Compléter les 10 méthodes HTTP Blocks + 5 Economics et exécuter la compliance finale de toute la surface HTTP 52 current + 14 deprecated historiques.
|
||||
- [ ] `0.2.5` — Introduire `ksp-wallet-lib`, le format `.kspwallet`, la gestion sûre des secrets et une architecture d'import/export extensible ; exclure `WalletPolicy`.
|
||||
- [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet.
|
||||
- [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe.
|
||||
- [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.
|
||||
- [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
|
||||
- [ ] `0.2.10` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
||||
- [ ] `0.2.11` — Introduire une petite application desk de visualisation des prix.
|
||||
- [ ] `0.2.12` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
||||
- [ ] `0.2.13` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
||||
|
||||
### Règles Transport pour toute la série
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
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]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
/* Rtl support */
|
||||
[data-simplebar] {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_variables.scss
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
|
||||
@@ -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`.
|
||||
@@ -33,7 +34,9 @@ Le registre par défaut connaît :
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
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
|
||||
141
crates/ksp-onchain-transport-lib/README.md
Normal file
141
crates/ksp-onchain-transport-lib/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# `ksp-onchain-transport-lib`
|
||||
|
||||
`ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Sa première surface est le transport HTTP JSON-RPC ; les extensions WebSocket et gRPC sont introduites séparément lorsque leur release les cible.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
La crate possède :
|
||||
|
||||
- les settings runtime HTTP publics ;
|
||||
- les endpoints nommés et leurs metadata provider/cluster ;
|
||||
- les rôles, capabilities/request kinds et priorités ;
|
||||
- la sélection/fairness/fallback du pool ;
|
||||
- les limites RPS/burst/concurrence et le cooldown ;
|
||||
- les deadlines et timeouts ;
|
||||
- le retry/backoff borné et la règle no-resend après dispatch ambigu ;
|
||||
- les enveloppes JSON-RPC 2.0 et leur validation ;
|
||||
- le registre audité des méthodes Solana HTTP ;
|
||||
- l'exécution générique des méthodes standard supportées ;
|
||||
- les wrappers typés explicitement livrés par KSP ;
|
||||
- les snapshots runtime sûrs ;
|
||||
- l'observabilité Transport via `ksp-logging-lib`.
|
||||
|
||||
La crate ne possède ni documents Config, ni persistence Store, ni modèles Program/métier.
|
||||
|
||||
## Frontières de dépendances
|
||||
|
||||
La direction autorisée est :
|
||||
|
||||
```text
|
||||
ksp-config-lib
|
||||
-> ksp-onchain-transport-lib
|
||||
-> ksp-core-lib
|
||||
-> ksp-logging-lib
|
||||
-> reqwest / tokio / serde
|
||||
```
|
||||
|
||||
La direction inverse est interdite :
|
||||
|
||||
```text
|
||||
ksp-onchain-transport-lib -X-> ksp-config-lib
|
||||
ksp-onchain-transport-lib -X-> Store
|
||||
ksp-onchain-transport-lib -X-> Program
|
||||
ksp-onchain-transport-lib -X-> tracing direct
|
||||
```
|
||||
|
||||
`ksp-config-lib` peut donc charger `std.transport.json` et construire `HttpTransportSettings`, tandis que Transport reste directement utilisable par un consumer qui fournit lui-même ses settings.
|
||||
|
||||
## Surface HTTP standard
|
||||
|
||||
Le registre KSP conserve deux inventaires distincts :
|
||||
|
||||
```text
|
||||
52 méthodes HTTP courantes
|
||||
14 méthodes historiques Deprecated / runtime Removed
|
||||
```
|
||||
|
||||
Le registre porte notamment :
|
||||
|
||||
- catégorie ;
|
||||
- request kind ;
|
||||
- statut documentaire ;
|
||||
- statut runtime ;
|
||||
- forme de requête stable/legacy ;
|
||||
- type d'opération ;
|
||||
- classe de retry ;
|
||||
- remplacement historique éventuel ;
|
||||
- release de couverture typée KSP.
|
||||
|
||||
La surface typée stable après `0.2.2` contient **26 méthodes** :
|
||||
|
||||
```text
|
||||
0.2.1 foundation : 4
|
||||
0.2.2 Accounts : 5
|
||||
0.2.2 Tokens : 5
|
||||
0.2.2 Cluster : 12
|
||||
```
|
||||
|
||||
Les quatre canaris foundation restent `getBalance`, `getGenesisHash`, `getHealth` et `getVersion`. `0.2.2` ajoute les 22 wrappers Accounts/Tokens/Cluster affectés à `HttpRpcCoverageRelease::V0_2_2`. Les 26 méthodes Transactions/Blocks/Economics encore affectées à `0.2.3`–`0.2.4` peuvent déjà passer par l'exécuteur JSON-RPC standard générique lorsqu'un consumer fournit explicitement descriptor et paramètres JSON, mais cette surface raw/générique **ne vaut pas couverture typée**.
|
||||
|
||||
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
|
||||
|
||||
## Résilience
|
||||
|
||||
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
||||
|
||||
1. rôle et capability ;
|
||||
2. priorité croissante ;
|
||||
3. round-robin dans le meilleur tier ;
|
||||
4. RPS/burst ;
|
||||
5. concurrence ;
|
||||
6. cooldown rate-limit ;
|
||||
7. fallback vers les pairs puis les tiers inférieurs ;
|
||||
8. deadline commune à l'opération et ses retries.
|
||||
|
||||
Les retries ne sont autorisés que lorsque la metadata de méthode et l'état de dispatch les rendent sûrs. `WriteSubmission / NeverAfterDispatch` interdit tout resend automatique après un dispatch ambigu.
|
||||
|
||||
Les erreurs JSON-RPC applicatives ne sont pas transformées en retries transport génériques.
|
||||
|
||||
## Sécurité et diagnostics
|
||||
|
||||
Les URLs d'endpoint peuvent contenir des credentials. Elles ne sont donc pas exposées par les `Debug`, snapshots ou logs ordinaires.
|
||||
|
||||
Les `reqwest::Error` attachées comme source sont neutralisées avec `without_url()` avant exposition dans le contrat d'erreur KSP.
|
||||
|
||||
Le target de tracing est possédé explicitement par :
|
||||
|
||||
```text
|
||||
src/constants.rs
|
||||
TRACING_TARGET = "ksp-onchain-transport-lib"
|
||||
```
|
||||
|
||||
La configuration Logging de référence conserve un fichier dédié Transport à niveau `info`. Un niveau `debug`/`trace` ciblé peut être réactivé temporairement via Config lors d'un développement ou diagnostic explicite.
|
||||
|
||||
## Tests
|
||||
|
||||
Les tests par défaut sont déterministes et n'exigent pas Internet : fixtures JSON et serveur HTTP local couvrent requêtes, réponses, retry, 429, timeout, redaction et routing.
|
||||
|
||||
Deux smokes Devnet opt-in sont séparés par responsabilité :
|
||||
|
||||
```text
|
||||
Transport pur : settings programmatiques -> HttpTransportPool
|
||||
-> getAccountInfo/getTokenAccountsByOwner/getEpochInfo/getVoteAccounts
|
||||
|
||||
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
|
||||
-> getHealth/getGenesisHash/getVersion/getBalance
|
||||
```
|
||||
|
||||
Le smoke Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
|
||||
|
||||
Les deux sont `ignored` par défaut. Le smoke Transport appartient durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw, smokes et inspection runtime ;
|
||||
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — foundation HTTP stable ;
|
||||
- [`../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — extension typed Accounts/Tokens/Cluster ;
|
||||
- [`../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée `0.2.2` ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP.
|
||||
174
crates/ksp-onchain-transport-lib/USAGE.md
Normal file
174
crates/ksp-onchain-transport-lib/USAGE.md
Normal file
@@ -0,0 +1,174 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# Utilisation de `ksp-onchain-transport-lib`
|
||||
|
||||
Ce guide présente les surfaces publiques destinées aux consumers. Les notes de release restent dans `CHANGELOG.md` et les deltas.
|
||||
|
||||
## 1. Construction directe du runtime
|
||||
|
||||
Transport peut être utilisé sans Config. Le consumer construit les settings publics puis le pool :
|
||||
|
||||
```rust
|
||||
let url = match ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://api.devnet.solana.com") {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||
true,
|
||||
vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
|
||||
100,
|
||||
ksp_onchain_transport_lib::HttpRoleLimits::new(None, None, None, None),
|
||||
);
|
||||
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||
"solana_devnet_public",
|
||||
true,
|
||||
ksp_onchain_transport_lib::HttpProviderName::new("solana-public"),
|
||||
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||
url,
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(15),
|
||||
Some(8),
|
||||
vec![role],
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||
vec![endpoint],
|
||||
ksp_onchain_transport_lib::HttpRetrySettings::new(
|
||||
2,
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_secs(2),
|
||||
),
|
||||
);
|
||||
let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(settings) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
`HttpTransportSettings::validate()` peut être appelé explicitement avant la construction du pool lorsque le consumer veut séparer validation et initialisation.
|
||||
|
||||
## 2. Construction via `ksp-config-lib`
|
||||
|
||||
Lorsque le consumer utilise Config, la direction reste Config -> Transport :
|
||||
|
||||
```rust
|
||||
let resolved = match engine.load_resolved_transport_config(Some("devnet_public"), &environment) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings()) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
Le document standard peut contenir une URL provenant d'un `KSP_SECRET_*`. La valeur réelle est transmise au runtime, mais les projections sûres et `Debug` restent redacted.
|
||||
|
||||
## 3. Appels typés
|
||||
|
||||
Les wrappers typés se trouvent directement sur `HttpTransportPool`.
|
||||
|
||||
```rust
|
||||
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
|
||||
let health = pool.get_health(&role).await;
|
||||
let genesis_hash = pool.get_genesis_hash(&role).await;
|
||||
let version = pool.get_version(&role).await;
|
||||
let balance = pool
|
||||
.get_balance(
|
||||
&role,
|
||||
&ksp_core_lib::PRGIDPK_SOLANA_SYSTEM,
|
||||
Some(&ksp_onchain_transport_lib::GetBalanceConfig::new(
|
||||
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
None,
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
```
|
||||
|
||||
Les quatre canaris `0.2.1` restent disponibles. `0.2.2` ajoute les wrappers typés Accounts, Tokens et Cluster. Exemples représentatifs :
|
||||
|
||||
```rust
|
||||
let account = pool
|
||||
.get_account_info(&role, &ksp_core_lib::PRGIDPK_SOLANA_SYSTEM, None)
|
||||
.await;
|
||||
let epoch = pool.get_epoch_info(&role, None).await;
|
||||
let vote_accounts = pool.get_vote_accounts(&role, None).await;
|
||||
```
|
||||
|
||||
La surface stable `0.2.2` contient 26 wrappers typés au total : 4 foundation + 5 Accounts + 5 Tokens + 12 Cluster. Les DTOs Transport conservent les `null`, options et formes wire : données Account encodées/`jsonParsed`, `TokenAmount.uiAmount`, contexte RPC, nodes, epoch, leader schedule et vote accounts. Aucun décodage Program/SPL métier n'est effectué ici.
|
||||
|
||||
## 4. Exécution JSON-RPC standard générique
|
||||
|
||||
Une méthode courante auditée peut être appelée via son descriptor :
|
||||
|
||||
```rust
|
||||
if let Some(descriptor) = ksp_onchain_transport_lib::find_http_rpc_method("getSlot") {
|
||||
let _result = pool.execute_standard_rpc(&role, descriptor, vec![]).await;
|
||||
}
|
||||
```
|
||||
|
||||
Cette API retourne un `serde_json::Value`. Elle est utile pour les consumers techniques et pour préparer les futures surfaces typées, mais elle ne remplace pas le wrapper typé d'une méthode dans la matrice de couverture KSP.
|
||||
|
||||
Avant exécution, `ensure_runtime_supported()` est appliqué. Une méthode historique `Removed` retourne `ERROR_CODE_METHOD_REMOVED` au lieu d'émettre un appel réseau fictif.
|
||||
|
||||
## 5. Sélection et admission sans exécuter la requête
|
||||
|
||||
Pour inspecter le routing :
|
||||
|
||||
```rust
|
||||
if let Some(descriptor) = ksp_onchain_transport_lib::find_http_rpc_method("getBalance") {
|
||||
let _selection = pool.select_for_method(&role, descriptor);
|
||||
let _permit = pool.acquire_for_method(&role, descriptor).await;
|
||||
}
|
||||
```
|
||||
|
||||
Dans le même bloc, `acquire_for_method()` réserve réellement la capacité RPS/concurrence sous deadline.
|
||||
|
||||
`HttpRequestPermit` détient la capacité de concurrence jusqu'à sa destruction. Aucun verrou synchrone n'est conservé pendant l'attente réseau.
|
||||
|
||||
## 6. Snapshots runtime
|
||||
|
||||
`HttpTransportPool::snapshot()` fournit une vue sûre des endpoints/rôles : disponibilité, limites, requêtes en vol, cooldown restant et compteurs runtime.
|
||||
|
||||
Les URLs d'endpoint n'y apparaissent jamais.
|
||||
|
||||
## 7. Retry et write submissions
|
||||
|
||||
La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`.
|
||||
|
||||
Les reads/simulations classés `RetrySafe` peuvent être réessayés dans le budget configuré lorsqu'une cause transport est explicitement retryable.
|
||||
|
||||
Pour une opération `WriteSubmission / NeverAfterDispatch`, un timeout ou autre résultat ambigu après dispatch arrête la resoumission automatique. Le consumer métier ne doit pas contourner cette protection avec une boucle de retry externe aveugle.
|
||||
|
||||
## 8. Logging
|
||||
|
||||
Les événements Transport utilisent le target :
|
||||
|
||||
```text
|
||||
ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
Ne jamais journaliser l'URL complète, un token provider, un body massif, une transaction complète ou une réponse complète.
|
||||
|
||||
La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement.
|
||||
|
||||
## 9. Smokes Devnet opt-in
|
||||
|
||||
Le smoke **Transport pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif de `0.2.2` :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Il appelle `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo` et `getVoteAccounts`. La branche Token suit la forme Devnet documentée : owner Pubkey ordinaire de l'exemple officiel, selector `programId` avec l'ID canonique du programme SPL Token, puis config explicite `commitment: finalized` + `encoding: jsonParsed`. Une réponse vide reste acceptable : le smoke valide ainsi la route Token sans dépendre de la persistance d'un mint ou d'un token account Devnet particulier.
|
||||
|
||||
Le smoke historique de **composition Config -> Transport** reste également disponible :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Il valide le profil committé `devnet_public` et les quatre canaris foundation. Il reste transitoirement hébergé dans Config : les futurs smokes cross-crates ne doivent pas faire de Config leur destination générale et devront migrer vers une surface d'intégration/orchestration dédiée lorsqu'elle existera.
|
||||
|
||||
Les endpoints publics Solana sont rate-limités et non destinés à la production. Un échec réseau externe n'est pas assimilé automatiquement à une régression locale ; les fixtures HTTP locales restent les gates reproductibles.
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"lamports":1,"data":"3MN5","owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"space":null},
|
||||
{"lamports":2,"data":["KLUv/Q==","base64+zstd"],"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":1,"space":4},
|
||||
{"lamports":3,"data":{"program":"spl-token","parsed":{"type":"account","info":{"state":"initialized"}},"space":165},"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":2,"space":165}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"pubkey":"11111111111111111111111111111111","featureSet":123,"gossip":"127.0.0.1:8001","pubsub":null,"rpc":"127.0.0.1:8899","serveRepair":"127.0.0.1:8003","shredVersion":456,"tpu":"127.0.0.1:8004","tpuForwards":null,"tpuForwardsQuic":"127.0.0.1:8006","tpuQuic":"127.0.0.1:8005","tpuVote":null,"tvu":"127.0.0.1:8002","version":"4.2.1","clientId":"Agave"}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000003},"value":{"data":["","base64"],"executable":false,"lamports":1,"owner":"not-a-pubkey","rentEpoch":0,"space":0}},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000002},"value":null},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000001},"value":{"data":["AQIDBA==","base64"],"executable":false,"lamports":2039280,"owner":"11111111111111111111111111111111","rentEpoch":18446744073709551615,"space":4}},"id":1}
|
||||
@@ -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":[{"pubkey":"not-a-pubkey","rpc":"127.0.0.1:8899"}],"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":[{"pubkey":"11111111111111111111111111111111","featureSet":3073396398,"gossip":"127.0.0.1:8001","pubsub":null,"rpc":"127.0.0.1:8899","serveRepair":"127.0.0.1:8004","shredVersion":50093,"tpu":"127.0.0.1:8003","tpuForwards":"127.0.0.1:8004","tpuForwardsQuic":"127.0.0.1:8006","tpuQuic":"127.0.0.1:8009","tpuVote":"127.0.0.1:8005","tvu":"127.0.0.1:8000","version":"4.2.1","clientId":"Agave"},{"pubkey":"Stake11111111111111111111111111111111111111"}],"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"absoluteSlot":430000001,"blockHeight":429900000,"epoch":995,"slotIndex":12345,"slotsInEpoch":432000,"transactionCount":null},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"firstNormalEpoch":0,"firstNormalSlot":0,"leaderScheduleSlotOffset":432000,"slotsPerEpoch":432000,"warmup":false},"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","error":{"code":-32008,"message":"No snapshot"},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"full":429990000,"incremental":null},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"identity":"invalid-identity"},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"identity":"ComputeBudget111111111111111111111111111111"},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000005},"value":[{"address":"invalid-address","lamports":1}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000004},"value":[{"address":"11111111111111111111111111111111","lamports":999999999},{"address":"ComputeBudget111111111111111111111111111111","lamports":888888888}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":null,"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"11111111111111111111111111111111":[0,2,4],"ComputeBudget111111111111111111111111111111":[1,3]} ,"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":430000010,"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":430000011,"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param"},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":890880,"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000006},"value":[{"data":["","base64"],"executable":false,"lamports":10,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":0},null,{"data":{"program":"system","parsed":{"type":"nonce"},"space":80},"executable":false,"lamports":20,"owner":"11111111111111111111111111111111","rentEpoch":1,"space":80}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}],"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000007},"value":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":430000020,"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":"not-a-pubkey","id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":"ComputeBudget111111111111111111111111111111","id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":["11111111111111111111111111111111","not-a-pubkey"],"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":["11111111111111111111111111111111","ComputeBudget111111111111111111111111111111"],"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param: could not find account"},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000001},"value":{"amount":"18446744073709551615","decimals":9,"uiAmount":null,"uiAmountString":"18446744073.709551615"}},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000002},"value":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":{"program":"spl-token","parsed":{"type":"account","info":{"tokenAmount":{"amount":"1","decimals":1,"uiAmount":0.1,"uiAmountString":"0.1"}}},"space":165},"executable":false,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":9,"space":165}}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"slot":420000004},"value":[{"pubkey":"not-a-pubkey","account":{"data":["","base64"],"executable":false,"lamports":1,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":0,"space":165}}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":null,"slot":420000003},"value":[{"pubkey":"Stake11111111111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":10,"space":165}}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"slot":420000006},"value":[{"address":"invalid-address","amount":"1","decimals":0,"uiAmount":1.0,"uiAmountString":"1"}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000005},"value":[{"address":"11111111111111111111111111111111","amount":"9000","decimals":2,"uiAmount":90.0,"uiAmountString":"90"},{"address":"ComputeBudget111111111111111111111111111111","amount":"8000","decimals":2,"uiAmount":80.0,"uiAmountString":"80"}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000007},"value":{"amount":"1000000000000000000000000","decimals":6,"uiAmount":1000000000000000000.0,"uiAmountString":"1000000000000000000"}},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"solana-core":"3.1.8","feature-set":2891131721},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"not-a-pubkey","nodePubkey":"11111111111111111111111111111111","activatedStake":1,"commission":5,"epochVoteAccount":true,"epochCredits":[],"lastVote":2,"rootSlot":1}],"delinquent":[]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"11111111111111111111111111111111","nodePubkey":"ComputeBudget111111111111111111111111111111","activatedStake":424242,"commission":8,"inflationRewardsCommissionBps":750,"epochVoteAccount":true,"epochCredits":[[700,100,90],[701,115,100]],"lastVote":999,"rootSlot":990}],"delinquent":[{"votePubkey":"ComputeBudget111111111111111111111111111111","nodePubkey":"11111111111111111111111111111111","activatedStake":1,"commission":5,"epochVoteAccount":false,"epochCredits":[],"lastVote":0,"rootSlot":0}]},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"amount":"18446744073709551615","decimals":9,"uiAmount":null,"uiAmountString":"18446744073.709551615"}
|
||||
@@ -0,0 +1 @@
|
||||
{"votePubkey":"11111111111111111111111111111111","nodePubkey":"11111111111111111111111111111111","activatedStake":424242,"commission":8,"inflationRewardsCommissionBps":750,"epochVoteAccount":true,"epochCredits":[[700,100,90],[701,115,100]],"lastVote":999,"rootSlot":990}
|
||||
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";
|
||||
29
crates/ksp-onchain-transport-lib/src/error.rs
Normal file
29
crates/ksp-onchain-transport-lib/src/error.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
||||
// version: 2
|
||||
|
||||
/// Error code used when HTTP transport runtime settings are invalid.
|
||||
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_settings");
|
||||
/// Error code used when no logical endpoint can satisfy a request.
|
||||
pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
|
||||
/// Error code used when an HTTP connection cannot be established.
|
||||
pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_connection_failed");
|
||||
/// Error code used when an HTTP request fails after a connection exists.
|
||||
pub const ERROR_CODE_HTTP_REQUEST_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_request_failed");
|
||||
/// Error code used when a transport deadline expires.
|
||||
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
||||
/// Error code used when an endpoint or provider rate-limits a request.
|
||||
pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rate_limited");
|
||||
/// Error code used when a JSON-RPC request cannot be encoded.
|
||||
pub const ERROR_CODE_JSON_ENCODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_encode_failed");
|
||||
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
|
||||
pub const ERROR_CODE_JSON_DECODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_decode_failed");
|
||||
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
|
||||
pub const ERROR_CODE_JSON_RPC_PROTOCOL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_rpc_protocol_invalid");
|
||||
/// Error code used when a remote JSON-RPC endpoint returns an application-level RPC error.
|
||||
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
|
||||
/// Error code used when a historically documented RPC method is no longer supported by the targeted runtime.
|
||||
pub const ERROR_CODE_METHOD_REMOVED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "method_removed");
|
||||
/// Error code used when a decoded response cannot satisfy the KSP transport contract expected by the caller.
|
||||
pub const ERROR_CODE_INVALID_RESPONSE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_response");
|
||||
/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract.
|
||||
pub const ERROR_CODE_INVALID_RPC_PARAMETERS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_rpc_parameters");
|
||||
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;
|
||||
219
crates/ksp-onchain-transport-lib/src/lib.rs
Normal file
219
crates/ksp-onchain-transport-lib/src/lib.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
// version: 11
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! KSP-owned Solana on-chain transport foundation.
|
||||
//!
|
||||
//! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains
|
||||
//! independent from `ksp-config-lib`, Store and Program layers. `ksp-config-lib` now constructs these public settings through its one-way Config ->
|
||||
//! Transport adapter without creating a reverse dependency. Logical endpoint clients, priority-aware pools, bounded admission limits and retry/no-resend policy
|
||||
//! are available. The four typed Solana HTTP foundation canaries plus all 22 typed `0.2.2` Accounts, Tokens and Cluster wrappers execute real JSON-RPC
|
||||
//! requests through the shared transport path while the `0.2.3` and `0.2.4` audited families remain staged.
|
||||
|
||||
mod client;
|
||||
mod constants;
|
||||
mod error;
|
||||
mod executor;
|
||||
mod json_rpc;
|
||||
mod pool;
|
||||
mod resilience;
|
||||
mod rpc_accounts;
|
||||
mod rpc_canary;
|
||||
mod rpc_cluster;
|
||||
mod rpc_common;
|
||||
mod rpc_method;
|
||||
mod rpc_tokens;
|
||||
mod settings;
|
||||
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
|
||||
/// Passive runtime availability reported for one logical HTTP endpoint.
|
||||
pub use self::client::HttpEndpointAvailability;
|
||||
/// Shareable logical HTTP endpoint client owned by KSP Transport.
|
||||
pub use self::client::HttpEndpointClient;
|
||||
/// Safe routing snapshot for one configured endpoint role.
|
||||
pub use self::client::HttpEndpointRoleSnapshot;
|
||||
/// Safe metadata snapshot for one logical HTTP endpoint.
|
||||
pub use self::client::HttpEndpointSnapshot;
|
||||
/// Error code used when no logical endpoint can satisfy a request.
|
||||
pub use self::error::ERROR_CODE_ENDPOINT_SELECTION_FAILED;
|
||||
/// Error code used when an HTTP connection cannot be established.
|
||||
pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
|
||||
/// Error code used when an HTTP request fails after connection establishment.
|
||||
pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED;
|
||||
/// Error code used when a decoded response cannot satisfy the expected KSP transport contract.
|
||||
pub use self::error::ERROR_CODE_INVALID_RESPONSE;
|
||||
/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract.
|
||||
pub use self::error::ERROR_CODE_INVALID_RPC_PARAMETERS;
|
||||
/// Error code used when HTTP transport runtime settings are invalid.
|
||||
pub use self::error::ERROR_CODE_INVALID_SETTINGS;
|
||||
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
|
||||
pub use self::error::ERROR_CODE_JSON_DECODE_FAILED;
|
||||
/// Error code used when a JSON-RPC request cannot be encoded.
|
||||
pub use self::error::ERROR_CODE_JSON_ENCODE_FAILED;
|
||||
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
|
||||
pub use self::error::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID;
|
||||
/// Error code used when a historically documented RPC method has been removed from the targeted runtime.
|
||||
pub use self::error::ERROR_CODE_METHOD_REMOVED;
|
||||
/// Error code used when an endpoint or provider rate-limits a request.
|
||||
pub use self::error::ERROR_CODE_RATE_LIMITED;
|
||||
/// Error code used when a remote endpoint returns an application-level JSON-RPC error.
|
||||
pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR;
|
||||
/// Error code used when a transport deadline expires.
|
||||
pub use self::error::ERROR_CODE_TIMEOUT;
|
||||
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
||||
pub use self::json_rpc::JsonRpcErrorObject;
|
||||
/// Validated JSON-RPC 2.0 error response.
|
||||
pub use self::json_rpc::JsonRpcErrorResponse;
|
||||
/// JSON-RPC 2.0 HTTP request envelope emitted by KSP.
|
||||
pub use self::json_rpc::JsonRpcRequest;
|
||||
/// Validated JSON-RPC 2.0 HTTP response.
|
||||
pub use self::json_rpc::JsonRpcResponse;
|
||||
/// Validated JSON-RPC 2.0 success response.
|
||||
pub use self::json_rpc::JsonRpcSuccessResponse;
|
||||
/// Parses and validates a JSON-RPC HTTP response from UTF-8 JSON text.
|
||||
pub use self::json_rpc::parse_json_rpc_response_text;
|
||||
/// Validates a decoded JSON value as one JSON-RPC HTTP response.
|
||||
pub use self::json_rpc::parse_json_rpc_response_value;
|
||||
/// Result of one logical endpoint selection.
|
||||
pub use self::pool::HttpEndpointSelection;
|
||||
/// Runtime admission permit for one HTTP request.
|
||||
pub use self::pool::HttpRequestPermit;
|
||||
/// Shareable logical HTTP endpoint pool with priority routing, admission limits and bounded deadlines.
|
||||
pub use self::pool::HttpTransportPool;
|
||||
/// Safe snapshot of the logical HTTP endpoint pool.
|
||||
pub use self::pool::HttpTransportPoolSnapshot;
|
||||
/// Dispatch knowledge used to prevent ambiguous automatic resubmission.
|
||||
pub use self::resilience::HttpDispatchState;
|
||||
/// Transport-level cause considered by the bounded retry policy.
|
||||
pub use self::resilience::HttpRetryCause;
|
||||
/// Result of evaluating one bounded transport retry opportunity.
|
||||
pub use self::resilience::HttpRetryDecision;
|
||||
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
|
||||
pub use self::resilience::evaluate_transport_retry;
|
||||
/// Typed transport-level Solana account without Program/SPL decoding.
|
||||
pub use self::rpc_accounts::SolanaAccount;
|
||||
/// Address and lamport balance returned by `getLargestAccounts`.
|
||||
pub use self::rpc_accounts::SolanaAccountBalance;
|
||||
/// Wire-preserving account data returned by Solana HTTP account methods.
|
||||
pub use self::rpc_accounts::SolanaAccountData;
|
||||
/// Account-data encoding accepted by Solana HTTP account methods.
|
||||
pub use self::rpc_accounts::SolanaAccountEncoding;
|
||||
/// Shared account configuration used by account-info and token-account list methods.
|
||||
pub use self::rpc_accounts::SolanaAccountInfoConfig;
|
||||
/// Byte range requested from account data without decoding it locally.
|
||||
pub use self::rpc_accounts::SolanaDataSliceConfig;
|
||||
/// One public key plus its account returned by account-list RPC methods.
|
||||
pub use self::rpc_accounts::SolanaKeyedAccount;
|
||||
/// Optional configuration for `getLargestAccounts`.
|
||||
pub use self::rpc_accounts::SolanaLargestAccountsConfig;
|
||||
/// Filter accepted by `getLargestAccounts`.
|
||||
pub use self::rpc_accounts::SolanaLargestAccountsFilter;
|
||||
/// Bytes used by a `memcmp` program-account filter.
|
||||
pub use self::rpc_accounts::SolanaMemcmpBytes;
|
||||
/// One `memcmp` filter applied to account data.
|
||||
pub use self::rpc_accounts::SolanaMemcmpFilter;
|
||||
/// Parsed account payload returned by the RPC node for `jsonParsed` account data.
|
||||
pub use self::rpc_accounts::SolanaParsedAccountData;
|
||||
/// Filter accepted by the current `getProgramAccounts` implementation.
|
||||
pub use self::rpc_accounts::SolanaProgramAccountFilter;
|
||||
/// Configuration for `getProgramAccounts`.
|
||||
pub use self::rpc_accounts::SolanaProgramAccountsConfig;
|
||||
/// Result union returned by `getProgramAccounts` with or without an RPC context.
|
||||
pub use self::rpc_accounts::SolanaProgramAccountsResult;
|
||||
/// Optional typed configuration for the `getBalance` canary.
|
||||
pub use self::rpc_canary::GetBalanceConfig;
|
||||
/// Typed lamport balance returned by the `getBalance` canary.
|
||||
pub use self::rpc_canary::GetBalanceResult;
|
||||
/// Typed genesis hash returned by the `getGenesisHash` canary.
|
||||
pub use self::rpc_canary::SolanaGenesisHash;
|
||||
/// Typed healthy result returned by the `getHealth` canary.
|
||||
pub use self::rpc_canary::SolanaNodeHealth;
|
||||
/// Typed software-version response returned by the `getVersion` canary.
|
||||
pub use self::rpc_canary::SolanaNodeVersion;
|
||||
/// Contact information returned for one cluster node.
|
||||
pub use self::rpc_cluster::SolanaClusterNode;
|
||||
/// Epoch-credit history entry returned by `getVoteAccounts`.
|
||||
pub use self::rpc_cluster::SolanaEpochCredits;
|
||||
/// Epoch information returned by `getEpochInfo`.
|
||||
pub use self::rpc_cluster::SolanaEpochInfo;
|
||||
/// Epoch schedule returned by `getEpochSchedule`.
|
||||
pub use self::rpc_cluster::SolanaEpochSchedule;
|
||||
/// Leader schedule mapping validator identities to relative epoch slot indices.
|
||||
pub use self::rpc_cluster::SolanaLeaderSchedule;
|
||||
/// Optional configuration accepted by `getLeaderSchedule`.
|
||||
pub use self::rpc_cluster::SolanaLeaderScheduleConfig;
|
||||
/// Typed parameter overload for `getLeaderSchedule`.
|
||||
pub use self::rpc_cluster::SolanaLeaderScheduleRequest;
|
||||
/// Highest full and optional incremental snapshot slots returned by `getHighestSnapshotSlot`.
|
||||
pub use self::rpc_cluster::SolanaSnapshotSlotInfo;
|
||||
/// One validator vote-account record returned by `getVoteAccounts`.
|
||||
pub use self::rpc_cluster::SolanaVoteAccountInfo;
|
||||
/// Current and delinquent validator vote-account groups returned by `getVoteAccounts`.
|
||||
pub use self::rpc_cluster::SolanaVoteAccountStatus;
|
||||
/// Configuration accepted by `getVoteAccounts`.
|
||||
pub use self::rpc_cluster::SolanaVoteAccountsConfig;
|
||||
/// Commitment level accepted by typed Solana HTTP RPC adapters.
|
||||
pub use self::rpc_common::SolanaCommitment;
|
||||
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
|
||||
pub use self::rpc_common::SolanaCommitmentConfig;
|
||||
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
|
||||
pub use self::rpc_common::SolanaContextConfig;
|
||||
/// Typed Solana RPC context shared by contextual HTTP responses.
|
||||
pub use self::rpc_common::SolanaRpcContext;
|
||||
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
|
||||
pub use self::rpc_common::SolanaRpcResponse;
|
||||
/// Decodes one private serde wire type into the shared Transport error domain for typed RPC adapters.
|
||||
pub(crate) use self::rpc_common::decode_wire_json;
|
||||
/// Parses a base58 public key without echoing its wire value into diagnostics for typed RPC adapters.
|
||||
pub(crate) use self::rpc_common::parse_wire_pubkey;
|
||||
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
|
||||
pub use self::rpc_method::HttpRpcCategory;
|
||||
/// Release that owns typed KSP coverage for one audited HTTP RPC method.
|
||||
pub use self::rpc_method::HttpRpcCoverageRelease;
|
||||
/// Immutable audited descriptor for one Solana HTTP JSON-RPC method.
|
||||
pub use self::rpc_method::HttpRpcMethodDescriptor;
|
||||
/// Documentation lifecycle status of one audited RPC method.
|
||||
pub use self::rpc_method::RpcDocumentationStatus;
|
||||
/// Technical operation kind used to separate reads, simulations and submissions.
|
||||
pub use self::rpc_method::RpcOperationKind;
|
||||
/// Request-form policy attached to a stable RPC method.
|
||||
pub use self::rpc_method::RpcRequestFormStatus;
|
||||
/// Runtime availability status of one audited RPC method.
|
||||
pub use self::rpc_method::RpcRuntimeStatus;
|
||||
/// HTTP transport retry classification attached to an RPC method descriptor.
|
||||
pub use self::rpc_method::TransportRetryClass;
|
||||
/// Returns all current Solana HTTP RPC method descriptors audited for the `0.2.1`–`0.2.4` coverage sequence.
|
||||
pub use self::rpc_method::current_http_rpc_methods;
|
||||
/// Finds a current or historical standard Solana HTTP RPC descriptor by exact method name.
|
||||
pub use self::rpc_method::find_http_rpc_method;
|
||||
/// Returns historically documented deprecated HTTP RPC descriptors retained for compliance history.
|
||||
pub use self::rpc_method::historical_http_rpc_methods;
|
||||
/// Token-account balance entry returned by `getTokenLargestAccounts`.
|
||||
pub use self::rpc_tokens::SolanaTokenAccountBalance;
|
||||
/// Exclusive selector accepted by token-account list RPC methods.
|
||||
pub use self::rpc_tokens::SolanaTokenAccountSelector;
|
||||
/// Token amount returned by Solana HTTP token RPC methods.
|
||||
pub use self::rpc_tokens::SolanaTokenAmount;
|
||||
/// Open cluster or network descriptor used by HTTP endpoint settings.
|
||||
pub use self::settings::HttpClusterName;
|
||||
/// Runtime settings for one role declared by an HTTP endpoint.
|
||||
pub use self::settings::HttpEndpointRoleSettings;
|
||||
/// Runtime settings for one named Solana HTTP endpoint.
|
||||
pub use self::settings::HttpEndpointSettings;
|
||||
/// Runtime HTTP endpoint URL with redacted diagnostics.
|
||||
pub use self::settings::HttpEndpointUrl;
|
||||
/// Open provider descriptor used by HTTP endpoint settings.
|
||||
pub use self::settings::HttpProviderName;
|
||||
/// Open request-kind descriptor used by logical endpoint capabilities.
|
||||
pub use self::settings::HttpRequestKind;
|
||||
/// Bounded retry settings owned by the HTTP transport runtime.
|
||||
pub use self::settings::HttpRetrySettings;
|
||||
/// Local limits attached to one logical HTTP endpoint role.
|
||||
pub use self::settings::HttpRoleLimits;
|
||||
/// Open logical endpoint role descriptor.
|
||||
pub use self::settings::HttpRoleName;
|
||||
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
|
||||
pub use self::settings::HttpTransportSettings;
|
||||
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;
|
||||
991
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
Normal file
991
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
Normal file
@@ -0,0 +1,991 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
|
||||
// version: 4
|
||||
|
||||
/// Account-data encoding accepted by Solana HTTP account methods.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaAccountEncoding {
|
||||
/// Legacy binary/base58 request encoding.
|
||||
Binary,
|
||||
/// Base58 text encoding.
|
||||
Base58,
|
||||
/// Base64 text encoding.
|
||||
Base64,
|
||||
/// Parsed JSON representation when the RPC node has a parser for the account owner.
|
||||
JsonParsed,
|
||||
/// Base64 text containing zstd-compressed bytes.
|
||||
Base64Zstd,
|
||||
}
|
||||
|
||||
impl SolanaAccountEncoding {
|
||||
/// Returns the Solana JSON-RPC encoding string.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Binary => "binary",
|
||||
Self::Base58 => "base58",
|
||||
Self::Base64 => "base64",
|
||||
Self::JsonParsed => "jsonParsed",
|
||||
Self::Base64Zstd => "base64+zstd",
|
||||
};
|
||||
}
|
||||
|
||||
fn from_wire(value: &str) -> std::option::Option<Self> {
|
||||
return match value {
|
||||
"binary" => std::option::Option::Some(Self::Binary),
|
||||
"base58" => std::option::Option::Some(Self::Base58),
|
||||
"base64" => std::option::Option::Some(Self::Base64),
|
||||
"jsonParsed" => std::option::Option::Some(Self::JsonParsed),
|
||||
"base64+zstd" => std::option::Option::Some(Self::Base64Zstd),
|
||||
_ => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte range requested from account data without decoding it locally.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct SolanaDataSliceConfig {
|
||||
offset: usize,
|
||||
length: usize,
|
||||
}
|
||||
|
||||
impl SolanaDataSliceConfig {
|
||||
/// Creates an account-data slice configuration.
|
||||
#[must_use]
|
||||
pub const fn new(offset: usize, length: usize) -> Self {
|
||||
return Self { offset, length };
|
||||
}
|
||||
|
||||
/// Returns the byte offset.
|
||||
#[must_use]
|
||||
pub const fn offset(&self) -> usize {
|
||||
return self.offset;
|
||||
}
|
||||
|
||||
/// Returns the requested byte length.
|
||||
#[must_use]
|
||||
pub const fn length(&self) -> usize {
|
||||
return self.length;
|
||||
}
|
||||
|
||||
fn to_json_value(self) -> serde_json::Value {
|
||||
return serde_json::json!({"offset": self.offset, "length": self.length});
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared account configuration used by account-info and token-account list methods.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaAccountInfoConfig {
|
||||
encoding: std::option::Option<crate::SolanaAccountEncoding>,
|
||||
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
|
||||
context: crate::SolanaContextConfig,
|
||||
}
|
||||
|
||||
impl SolanaAccountInfoConfig {
|
||||
/// Creates an explicit account-info configuration.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
encoding: std::option::Option<crate::SolanaAccountEncoding>,
|
||||
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
) -> Self {
|
||||
return Self { encoding, data_slice, context: crate::SolanaContextConfig::new(commitment, min_context_slot) };
|
||||
}
|
||||
|
||||
/// Returns the optional account-data encoding.
|
||||
#[must_use]
|
||||
pub const fn encoding(&self) -> std::option::Option<crate::SolanaAccountEncoding> {
|
||||
return self.encoding;
|
||||
}
|
||||
|
||||
/// Returns the optional account-data slice.
|
||||
#[must_use]
|
||||
pub const fn data_slice(&self) -> std::option::Option<crate::SolanaDataSliceConfig> {
|
||||
return self.data_slice;
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.context.commitment();
|
||||
}
|
||||
|
||||
/// Returns the optional minimum context slot.
|
||||
#[must_use]
|
||||
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
|
||||
return self.context.min_context_slot();
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
return self.encoding.is_none() && self.data_slice.is_none() && self.commitment().is_none() && self.min_context_slot().is_none();
|
||||
}
|
||||
|
||||
/// Serializes this config to the Solana JSON-RPC wire object.
|
||||
#[must_use]
|
||||
pub(crate) fn to_json_value(&self) -> serde_json::Value {
|
||||
let context_value = self.context.to_json_value();
|
||||
let mut object = match context_value {
|
||||
serde_json::Value::Object(object) => object,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
if let std::option::Option::Some(encoding) = self.encoding {
|
||||
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(data_slice) = self.data_slice {
|
||||
object.insert("dataSlice".to_owned(), data_slice.to_json_value());
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter accepted by `getLargestAccounts`.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaLargestAccountsFilter {
|
||||
/// Return only circulating accounts.
|
||||
Circulating,
|
||||
/// Return only non-circulating accounts.
|
||||
NonCirculating,
|
||||
}
|
||||
|
||||
impl SolanaLargestAccountsFilter {
|
||||
fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Circulating => "circulating",
|
||||
Self::NonCirculating => "nonCirculating",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional configuration for `getLargestAccounts`.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaLargestAccountsConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
filter: std::option::Option<crate::SolanaLargestAccountsFilter>,
|
||||
sort_results: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
impl SolanaLargestAccountsConfig {
|
||||
/// Creates a largest-accounts configuration.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
filter: std::option::Option<crate::SolanaLargestAccountsFilter>,
|
||||
sort_results: std::option::Option<bool>,
|
||||
) -> Self {
|
||||
return Self { commitment, filter, sort_results };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns the optional circulating-account filter.
|
||||
#[must_use]
|
||||
pub const fn filter(&self) -> std::option::Option<crate::SolanaLargestAccountsFilter> {
|
||||
return self.filter;
|
||||
}
|
||||
|
||||
/// Returns the optional server-side result-sorting request.
|
||||
#[must_use]
|
||||
pub const fn sort_results(&self) -> std::option::Option<bool> {
|
||||
return self.sort_results;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.commitment.is_none() && self.filter.is_none() && self.sort_results.is_none();
|
||||
}
|
||||
|
||||
/// Serializes this config to the Solana JSON-RPC wire object.
|
||||
#[must_use]
|
||||
pub(crate) fn to_json_value(&self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(filter) = self.filter {
|
||||
object.insert("filter".to_owned(), serde_json::Value::String(filter.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(sort_results) = self.sort_results {
|
||||
object.insert("sortResults".to_owned(), serde_json::Value::Bool(sort_results));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes used by a `memcmp` program-account filter.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SolanaMemcmpBytes {
|
||||
/// Base58-encoded bytes.
|
||||
Base58(std::string::String),
|
||||
/// Base64-encoded bytes.
|
||||
Base64(std::string::String),
|
||||
/// Raw byte array.
|
||||
Bytes(std::vec::Vec<u8>),
|
||||
}
|
||||
|
||||
/// One `memcmp` filter applied to account data.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaMemcmpFilter {
|
||||
offset: usize,
|
||||
bytes: crate::SolanaMemcmpBytes,
|
||||
}
|
||||
|
||||
impl SolanaMemcmpFilter {
|
||||
/// Creates a `memcmp` filter without locally decoding encoded string data.
|
||||
#[must_use]
|
||||
pub fn new(offset: usize, bytes: crate::SolanaMemcmpBytes) -> Self {
|
||||
return Self { offset, bytes };
|
||||
}
|
||||
|
||||
/// Returns the account-data byte offset.
|
||||
#[must_use]
|
||||
pub const fn offset(&self) -> usize {
|
||||
return self.offset;
|
||||
}
|
||||
|
||||
/// Returns the encoded or raw bytes.
|
||||
#[must_use]
|
||||
pub const fn bytes(&self) -> &crate::SolanaMemcmpBytes {
|
||||
return &self.bytes;
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
return match &self.bytes {
|
||||
crate::SolanaMemcmpBytes::Base58(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base58"}),
|
||||
crate::SolanaMemcmpBytes::Base64(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base64"}),
|
||||
crate::SolanaMemcmpBytes::Bytes(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "bytes"}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter accepted by the current `getProgramAccounts` implementation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SolanaProgramAccountFilter {
|
||||
/// Require an exact account data size.
|
||||
DataSize(u64),
|
||||
/// Compare bytes at one account-data offset.
|
||||
Memcmp(crate::SolanaMemcmpFilter),
|
||||
/// Require a valid SPL Token account-state layout according to the RPC implementation.
|
||||
TokenAccountState,
|
||||
}
|
||||
|
||||
impl SolanaProgramAccountFilter {
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
return match self {
|
||||
Self::DataSize(size) => serde_json::json!({"dataSize": size}),
|
||||
Self::Memcmp(filter) => serde_json::json!({"memcmp": filter.to_json_value()}),
|
||||
Self::TokenAccountState => serde_json::Value::String("tokenAccountState".to_owned()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for `getProgramAccounts` built from the shared account config plus program filters.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaProgramAccountsConfig {
|
||||
account_config: crate::SolanaAccountInfoConfig,
|
||||
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
|
||||
with_context: std::option::Option<bool>,
|
||||
sort_results: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
impl SolanaProgramAccountsConfig {
|
||||
/// Creates a program-accounts configuration.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
account_config: crate::SolanaAccountInfoConfig,
|
||||
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
|
||||
with_context: std::option::Option<bool>,
|
||||
sort_results: std::option::Option<bool>,
|
||||
) -> Self {
|
||||
return Self { account_config, filters, with_context, sort_results };
|
||||
}
|
||||
|
||||
/// Returns the shared account configuration.
|
||||
#[must_use]
|
||||
pub const fn account_config(&self) -> &crate::SolanaAccountInfoConfig {
|
||||
return &self.account_config;
|
||||
}
|
||||
|
||||
/// Returns the ordered program-account filters.
|
||||
#[must_use]
|
||||
pub fn filters(&self) -> &[crate::SolanaProgramAccountFilter] {
|
||||
return self.filters.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the optional context-wrapper request.
|
||||
#[must_use]
|
||||
pub const fn with_context(&self) -> std::option::Option<bool> {
|
||||
return self.with_context;
|
||||
}
|
||||
|
||||
/// Returns the optional server-side sorting request.
|
||||
#[must_use]
|
||||
pub const fn sort_results(&self) -> std::option::Option<bool> {
|
||||
return self.sort_results;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.account_config.is_empty() && self.filters.is_empty() && self.with_context.is_none() && self.sort_results.is_none();
|
||||
}
|
||||
|
||||
/// Serializes this config to the Solana JSON-RPC wire object.
|
||||
#[must_use]
|
||||
pub(crate) fn to_json_value(&self) -> serde_json::Value {
|
||||
let account_value = self.account_config.to_json_value();
|
||||
let mut object = match account_value {
|
||||
serde_json::Value::Object(object) => object,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
if !self.filters.is_empty() {
|
||||
let values = self.filters.iter().map(crate::SolanaProgramAccountFilter::to_json_value).collect::<std::vec::Vec<_>>();
|
||||
object.insert("filters".to_owned(), serde_json::Value::Array(values));
|
||||
}
|
||||
if let std::option::Option::Some(with_context) = self.with_context {
|
||||
object.insert("withContext".to_owned(), serde_json::Value::Bool(with_context));
|
||||
}
|
||||
if let std::option::Option::Some(sort_results) = self.sort_results {
|
||||
object.insert("sortResults".to_owned(), serde_json::Value::Bool(sort_results));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed account payload returned by the RPC node for `jsonParsed` account data.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaParsedAccountData {
|
||||
program: std::string::String,
|
||||
parsed: serde_json::Value,
|
||||
space: u64,
|
||||
}
|
||||
|
||||
impl SolanaParsedAccountData {
|
||||
/// Returns the parser/program label reported by the RPC node.
|
||||
#[must_use]
|
||||
pub fn program(&self) -> &str {
|
||||
return self.program.as_str();
|
||||
}
|
||||
|
||||
/// Returns the parsed JSON payload without converting it to a Program/SPL domain model.
|
||||
#[must_use]
|
||||
pub const fn parsed(&self) -> &serde_json::Value {
|
||||
return &self.parsed;
|
||||
}
|
||||
|
||||
/// Returns the account-data space reported inside the parsed payload.
|
||||
#[must_use]
|
||||
pub const fn space(&self) -> u64 {
|
||||
return self.space;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-preserving account data returned by Solana HTTP account methods.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaAccountData {
|
||||
/// Legacy single-string binary form retained for backwards compatibility.
|
||||
LegacyBinary(std::string::String),
|
||||
/// Encoded tuple `[data, encoding]`.
|
||||
Encoded {
|
||||
/// Encoded account bytes.
|
||||
data: std::string::String,
|
||||
/// Encoding label returned by the RPC node.
|
||||
encoding: crate::SolanaAccountEncoding,
|
||||
},
|
||||
/// Parsed JSON object returned by the RPC node.
|
||||
JsonParsed(crate::SolanaParsedAccountData),
|
||||
}
|
||||
|
||||
impl SolanaAccountData {
|
||||
fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireAccountData>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return match wire {
|
||||
WireAccountData::LegacyBinary(value) => std::result::Result::Ok(Self::LegacyBinary(value)),
|
||||
WireAccountData::JsonParsed(value) => {
|
||||
std::result::Result::Ok(Self::JsonParsed(crate::SolanaParsedAccountData { program: value.program, parsed: value.parsed, space: value.space }))
|
||||
},
|
||||
WireAccountData::Encoded((data, encoding)) => {
|
||||
let parsed = crate::SolanaAccountEncoding::from_wire(encoding.as_str());
|
||||
let encoding = match parsed {
|
||||
std::option::Option::Some(encoding) => encoding,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "account data tuple uses an unknown encoding")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
},
|
||||
};
|
||||
if encoding == crate::SolanaAccountEncoding::Binary || encoding == crate::SolanaAccountEncoding::JsonParsed {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "account data tuple uses an invalid tuple encoding")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
}
|
||||
std::result::Result::Ok(Self::Encoded { data, encoding })
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed transport-level Solana account without Program/SPL decoding.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaAccount {
|
||||
lamports: u64,
|
||||
data: crate::SolanaAccountData,
|
||||
owner: ksp_core_lib::Pubkey,
|
||||
executable: bool,
|
||||
rent_epoch: u64,
|
||||
space: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl SolanaAccount {
|
||||
/// Returns the account balance in lamports.
|
||||
#[must_use]
|
||||
pub const fn lamports(&self) -> u64 {
|
||||
return self.lamports;
|
||||
}
|
||||
|
||||
/// Returns the wire-preserving account data.
|
||||
#[must_use]
|
||||
pub const fn data(&self) -> &crate::SolanaAccountData {
|
||||
return &self.data;
|
||||
}
|
||||
|
||||
/// Returns the account owner program public key.
|
||||
#[must_use]
|
||||
pub const fn owner(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.owner;
|
||||
}
|
||||
|
||||
/// Returns whether the account is executable.
|
||||
#[must_use]
|
||||
pub const fn executable(&self) -> bool {
|
||||
return self.executable;
|
||||
}
|
||||
|
||||
/// Returns the rent epoch reported by the RPC node.
|
||||
#[must_use]
|
||||
pub const fn rent_epoch(&self) -> u64 {
|
||||
return self.rent_epoch;
|
||||
}
|
||||
|
||||
/// Returns the optional account data-space field.
|
||||
#[must_use]
|
||||
pub const fn space(&self) -> std::option::Option<u64> {
|
||||
return self.space;
|
||||
}
|
||||
|
||||
/// Decodes one account DTO from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireAccount>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let owner = crate::parse_wire_pubkey(method, "owner", wire.owner.as_str());
|
||||
let owner = match owner {
|
||||
std::result::Result::Ok(owner) => owner,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let data = crate::SolanaAccountData::decode_wire(method, wire.data);
|
||||
let data = match data {
|
||||
std::result::Result::Ok(data) => data,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(Self {
|
||||
lamports: wire.lamports,
|
||||
data,
|
||||
owner,
|
||||
executable: wire.executable,
|
||||
rent_epoch: wire.rent_epoch,
|
||||
space: wire.space,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One public key plus its account returned by account-list RPC methods.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaKeyedAccount {
|
||||
pubkey: ksp_core_lib::Pubkey,
|
||||
account: crate::SolanaAccount,
|
||||
}
|
||||
|
||||
impl SolanaKeyedAccount {
|
||||
/// Returns the account public key.
|
||||
#[must_use]
|
||||
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.pubkey;
|
||||
}
|
||||
|
||||
/// Returns the account payload.
|
||||
#[must_use]
|
||||
pub const fn account(&self) -> &crate::SolanaAccount {
|
||||
return &self.account;
|
||||
}
|
||||
|
||||
/// Decodes one keyed account from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireKeyedAccount>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let pubkey = crate::parse_wire_pubkey(method, "pubkey", wire.pubkey.as_str());
|
||||
let pubkey = match pubkey {
|
||||
std::result::Result::Ok(pubkey) => pubkey,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let account = crate::SolanaAccount::decode_wire(method, wire.account);
|
||||
let account = match account {
|
||||
std::result::Result::Ok(account) => account,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(Self { pubkey, account });
|
||||
}
|
||||
}
|
||||
|
||||
/// Address and lamport balance returned by `getLargestAccounts`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaAccountBalance {
|
||||
address: ksp_core_lib::Pubkey,
|
||||
lamports: u64,
|
||||
}
|
||||
|
||||
impl SolanaAccountBalance {
|
||||
/// Returns the account address.
|
||||
#[must_use]
|
||||
pub const fn address(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.address;
|
||||
}
|
||||
|
||||
/// Returns the balance in lamports.
|
||||
#[must_use]
|
||||
pub const fn lamports(&self) -> u64 {
|
||||
return self.lamports;
|
||||
}
|
||||
|
||||
/// Decodes one account-balance entry from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireAccountBalance>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let address = crate::parse_wire_pubkey(method, "address", wire.address.as_str());
|
||||
return match address {
|
||||
std::result::Result::Ok(address) => std::result::Result::Ok(Self { address, lamports: wire.lamports }),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Result union returned by `getProgramAccounts` with or without an RPC context.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaProgramAccountsResult {
|
||||
/// Bare account list returned when `withContext` is false or absent.
|
||||
Accounts(std::vec::Vec<crate::SolanaKeyedAccount>),
|
||||
/// Contextual account list returned when `withContext` is true.
|
||||
Context(crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>),
|
||||
}
|
||||
|
||||
const MAX_MULTIPLE_ACCOUNTS: usize = 100;
|
||||
const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4;
|
||||
const MAX_MEMCMP_BYTES: usize = 128;
|
||||
|
||||
impl crate::HttpTransportPool {
|
||||
/// Executes typed `getAccountInfo` through the common KSP HTTP transport path.
|
||||
pub async fn get_account_info(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::option::Option<crate::SolanaAccount>>> {
|
||||
let method_result = account_descriptor("getAccountInfo");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec![serde_json::Value::String(account.to_string())];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return decode_account_info_response("getAccountInfo", value);
|
||||
}
|
||||
|
||||
/// Executes typed `getLargestAccounts` through the common KSP HTTP transport path.
|
||||
pub async fn get_largest_accounts(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
config: std::option::Option<&crate::SolanaLargestAccountsConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaAccountBalance>>> {
|
||||
let method_result = account_descriptor("getLargestAccounts");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec::Vec::new();
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return decode_largest_accounts_response("getLargestAccounts", value);
|
||||
}
|
||||
|
||||
/// Executes typed `getMinimumBalanceForRentExemption` through the common KSP HTTP transport path.
|
||||
pub async fn get_minimum_balance_for_rent_exemption(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
data_len: usize,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<u64> {
|
||||
let method_result = account_descriptor("getMinimumBalanceForRentExemption");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let data_len_value = serde_json::to_value(data_len);
|
||||
let data_len_value = match data_len_value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_ENCODE_FAILED, "rent-exemption data length could not be encoded")
|
||||
.with_context("rpc_method", "getMinimumBalanceForRentExemption")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut params = std::vec![data_len_value];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& config.commitment().is_some()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decoded = crate::decode_wire_json::<u64>("getMinimumBalanceForRentExemption", value);
|
||||
return match decoded {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes typed `getMultipleAccounts` through the common KSP HTTP transport path.
|
||||
pub async fn get_multiple_accounts(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
accounts: &[ksp_core_lib::Pubkey],
|
||||
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<std::option::Option<crate::SolanaAccount>>>> {
|
||||
if accounts.len() > MAX_MULTIPLE_ACCOUNTS {
|
||||
return invalid_account_parameters("getMultipleAccounts", "getMultipleAccounts accepts at most 100 public keys", "account_count", accounts.len());
|
||||
}
|
||||
let method_result = account_descriptor("getMultipleAccounts");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let addresses = accounts.iter().map(std::string::ToString::to_string).map(serde_json::Value::String).collect::<std::vec::Vec<_>>();
|
||||
let mut params = std::vec![serde_json::Value::Array(addresses)];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return decode_multiple_accounts_response("getMultipleAccounts", value, accounts.len());
|
||||
}
|
||||
|
||||
/// Executes typed `getProgramAccounts` through the common KSP HTTP transport path.
|
||||
pub async fn get_program_accounts(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
program_id: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaProgramAccountsConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaProgramAccountsResult> {
|
||||
if let std::option::Option::Some(config) = config {
|
||||
let validation = validate_program_account_filters(config.filters());
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
let method_result = account_descriptor("getProgramAccounts");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec![serde_json::Value::String(program_id.to_string())];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return decode_program_accounts_response("getProgramAccounts", value);
|
||||
}
|
||||
}
|
||||
|
||||
fn account_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
|
||||
let descriptor = crate::find_http_rpc_method(method);
|
||||
return match descriptor {
|
||||
std::option::Option::Some(descriptor)
|
||||
if descriptor.category() == crate::HttpRpcCategory::Accounts && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
|
||||
{
|
||||
std::result::Result::Ok(descriptor)
|
||||
},
|
||||
_ => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Accounts descriptor is missing from the audited 0.2.2 registry")
|
||||
.with_context("rpc_method", method),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn invalid_account_parameters<T>(method: &str, message: &str, field: &'static str, value: usize) -> ksp_core_lib::Result<T> {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message)
|
||||
.with_context("rpc_method", method)
|
||||
.with_context(field, value.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
fn validate_program_account_filters(filters: &[crate::SolanaProgramAccountFilter]) -> ksp_core_lib::Result<()> {
|
||||
if filters.len() > MAX_PROGRAM_ACCOUNT_FILTERS {
|
||||
return invalid_account_parameters(
|
||||
"getProgramAccounts",
|
||||
"getProgramAccounts accepts at most 4 filters on the targeted Agave runtime",
|
||||
"filter_count",
|
||||
filters.len(),
|
||||
);
|
||||
}
|
||||
for filter in filters {
|
||||
if let crate::SolanaProgramAccountFilter::Memcmp(memcmp) = filter
|
||||
&& let crate::SolanaMemcmpBytes::Bytes(bytes) = memcmp.bytes()
|
||||
&& bytes.len() > MAX_MEMCMP_BYTES
|
||||
{
|
||||
return invalid_account_parameters(
|
||||
"getProgramAccounts",
|
||||
"raw getProgramAccounts memcmp data accepts at most 128 bytes",
|
||||
"memcmp_byte_count",
|
||||
bytes.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn decode_account_info_response(
|
||||
method: &str,
|
||||
value: serde_json::Value,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::option::Option<crate::SolanaAccount>>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse<std::option::Option<serde_json::Value>>>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let account = match wire.value {
|
||||
std::option::Option::Some(value) => {
|
||||
let account = crate::SolanaAccount::decode_wire(method, value);
|
||||
match account {
|
||||
std::result::Result::Ok(account) => std::option::Option::Some(account),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, account));
|
||||
}
|
||||
|
||||
fn decode_largest_accounts_response(
|
||||
method: &str,
|
||||
value: serde_json::Value,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaAccountBalance>>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut values = std::vec::Vec::with_capacity(wire.value.len());
|
||||
for value in wire.value {
|
||||
let decoded = crate::SolanaAccountBalance::decode_wire(method, value);
|
||||
match decoded {
|
||||
std::result::Result::Ok(decoded) => values.push(decoded),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values));
|
||||
}
|
||||
|
||||
fn decode_multiple_accounts_response(
|
||||
method: &str,
|
||||
value: serde_json::Value,
|
||||
expected_count: usize,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<std::option::Option<crate::SolanaAccount>>>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<std::option::Option<serde_json::Value>>>>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut values = std::vec::Vec::with_capacity(wire.value.len());
|
||||
for value in wire.value {
|
||||
match value {
|
||||
std::option::Option::Some(value) => {
|
||||
let decoded = crate::SolanaAccount::decode_wire(method, value);
|
||||
match decoded {
|
||||
std::result::Result::Ok(decoded) => values.push(std::option::Option::Some(decoded)),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
std::option::Option::None => values.push(std::option::Option::None),
|
||||
}
|
||||
}
|
||||
if values.len() != expected_count {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "getMultipleAccounts result count does not match the requested account count")
|
||||
.with_context("rpc_method", method)
|
||||
.with_context("expected_count", expected_count.to_string())
|
||||
.with_context("actual_count", values.len().to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values));
|
||||
}
|
||||
|
||||
fn decode_program_accounts_response(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaProgramAccountsResult> {
|
||||
let decoded = crate::decode_wire_json::<WireProgramAccountsResult>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return match wire {
|
||||
WireProgramAccountsResult::Accounts(values) => {
|
||||
let decoded = decode_keyed_accounts(method, values);
|
||||
match decoded {
|
||||
std::result::Result::Ok(values) => std::result::Result::Ok(crate::SolanaProgramAccountsResult::Accounts(values)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
WireProgramAccountsResult::Context(wire) => {
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let values = decode_keyed_accounts(method, wire.value);
|
||||
match values {
|
||||
std::result::Result::Ok(values) => {
|
||||
std::result::Result::Ok(crate::SolanaProgramAccountsResult::Context(crate::SolanaRpcResponse::new(context, values)))
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn decode_keyed_accounts(method: &str, values: std::vec::Vec<serde_json::Value>) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaKeyedAccount>> {
|
||||
let mut decoded_values = std::vec::Vec::with_capacity(values.len());
|
||||
for value in values {
|
||||
let decoded = crate::SolanaKeyedAccount::decode_wire(method, value);
|
||||
match decoded {
|
||||
std::result::Result::Ok(decoded) => decoded_values.push(decoded),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(decoded_values);
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponse<T> {
|
||||
context: serde_json::Value,
|
||||
value: T,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum WireProgramAccountsResult {
|
||||
Context(WireRpcResponse<std::vec::Vec<serde_json::Value>>),
|
||||
Accounts(std::vec::Vec<serde_json::Value>),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum WireAccountData {
|
||||
LegacyBinary(std::string::String),
|
||||
JsonParsed(WireParsedAccountData),
|
||||
Encoded((std::string::String, std::string::String)),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireParsedAccountData {
|
||||
program: std::string::String,
|
||||
parsed: serde_json::Value,
|
||||
space: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireAccount {
|
||||
lamports: u64,
|
||||
data: serde_json::Value,
|
||||
owner: std::string::String,
|
||||
executable: bool,
|
||||
#[serde(rename = "rentEpoch")]
|
||||
rent_epoch: u64,
|
||||
#[serde(default)]
|
||||
space: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireKeyedAccount {
|
||||
pubkey: std::string::String,
|
||||
account: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireAccountBalance {
|
||||
address: std::string::String,
|
||||
lamports: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/rpc_accounts.rs"]
|
||||
mod tests;
|
||||
242
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
Normal file
242
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_canary.rs
|
||||
// version: 2
|
||||
|
||||
/// Optional typed configuration for `getBalance` retained for the `0.2.1` public canary contract.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct GetBalanceConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl GetBalanceConfig {
|
||||
/// Creates an explicit `getBalance` configuration.
|
||||
#[must_use]
|
||||
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, min_context_slot: std::option::Option<u64>) -> Self {
|
||||
return Self { commitment, min_context_slot };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns the optional minimum context slot.
|
||||
#[must_use]
|
||||
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
|
||||
return self.min_context_slot;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.commitment.is_none() && self.min_context_slot.is_none();
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
return crate::SolanaContextConfig::new(self.commitment, self.min_context_slot).to_json_value();
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed healthy result returned by the `getHealth` canary.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaNodeHealth {
|
||||
/// The RPC node returned the stable `"ok"` health result.
|
||||
Healthy,
|
||||
}
|
||||
|
||||
/// Typed genesis hash returned by the `getGenesisHash` canary.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct SolanaGenesisHash {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl SolanaGenesisHash {
|
||||
/// Returns the base58-encoded genesis hash text.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed software-version response returned by the `getVersion` canary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaNodeVersion {
|
||||
solana_core: std::string::String,
|
||||
feature_set: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
impl SolanaNodeVersion {
|
||||
/// Returns the node software version string from the `solana-core` field.
|
||||
#[must_use]
|
||||
pub fn solana_core(&self) -> &str {
|
||||
return self.solana_core.as_str();
|
||||
}
|
||||
|
||||
/// Returns the optional runtime feature-set identifier.
|
||||
#[must_use]
|
||||
pub const fn feature_set(&self) -> std::option::Option<u32> {
|
||||
return self.feature_set;
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed lamport balance returned by the `getBalance` canary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct GetBalanceResult {
|
||||
context: crate::SolanaRpcContext,
|
||||
value: u64,
|
||||
}
|
||||
|
||||
impl GetBalanceResult {
|
||||
/// Returns the Solana response context.
|
||||
#[must_use]
|
||||
pub const fn context(&self) -> &crate::SolanaRpcContext {
|
||||
return &self.context;
|
||||
}
|
||||
|
||||
/// Returns the account balance in lamports.
|
||||
#[must_use]
|
||||
pub const fn value(&self) -> u64 {
|
||||
return self.value;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HttpTransportPool {
|
||||
/// Executes the typed `getHealth` foundation canary.
|
||||
pub async fn get_health(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeHealth> {
|
||||
let method_result = canary_descriptor("getHealth");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if value.as_str() == std::option::Option::Some("ok") {
|
||||
return std::result::Result::Ok(crate::SolanaNodeHealth::Healthy);
|
||||
}
|
||||
return invalid_canary_response("getHealth", "result must be exactly the string ok");
|
||||
}
|
||||
|
||||
/// Executes the typed `getGenesisHash` foundation canary.
|
||||
pub async fn get_genesis_hash(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaGenesisHash> {
|
||||
let method_result = canary_descriptor("getGenesisHash");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let hash = match value.as_str() {
|
||||
std::option::Option::Some(hash) => hash,
|
||||
std::option::Option::None => return invalid_canary_response("getGenesisHash", "result must be a string"),
|
||||
};
|
||||
if hash.is_empty() || hash.trim() != hash {
|
||||
return invalid_canary_response("getGenesisHash", "result must be a non-empty trimmed string");
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaGenesisHash { value: hash.to_owned() });
|
||||
}
|
||||
|
||||
/// Executes the typed `getVersion` foundation canary.
|
||||
pub async fn get_version(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeVersion> {
|
||||
let method_result = canary_descriptor("getVersion");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decode_result = serde_json::from_value::<WireNodeVersion>(value);
|
||||
let decoded = match decode_result {
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(error) => return invalid_canary_decode("getVersion", error),
|
||||
};
|
||||
if decoded.solana_core.is_empty() || decoded.solana_core.trim() != decoded.solana_core {
|
||||
return invalid_canary_response("getVersion", "solana-core must be a non-empty trimmed string");
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaNodeVersion { solana_core: decoded.solana_core, feature_set: decoded.feature_set });
|
||||
}
|
||||
|
||||
/// Executes the typed `getBalance` foundation canary.
|
||||
pub async fn get_balance(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::GetBalanceConfig>,
|
||||
) -> ksp_core_lib::Result<crate::GetBalanceResult> {
|
||||
let method_result = canary_descriptor("getBalance");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec![serde_json::Value::String(account.to_string())];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decode_result = serde_json::from_value::<WireBalanceResult>(value);
|
||||
let decoded = match decode_result {
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(error) => return invalid_canary_decode("getBalance", error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire("getBalance", decoded.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::GetBalanceResult { context, value: decoded.value });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireNodeVersion {
|
||||
#[serde(rename = "solana-core")]
|
||||
solana_core: std::string::String,
|
||||
#[serde(rename = "feature-set", default)]
|
||||
feature_set: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireBalanceResult {
|
||||
context: serde_json::Value,
|
||||
value: u64,
|
||||
}
|
||||
|
||||
fn canary_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
|
||||
let descriptor = crate::find_http_rpc_method(method);
|
||||
return match descriptor {
|
||||
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed canary descriptor is missing from the audited registry")
|
||||
.with_context("rpc_method", method),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn invalid_canary_decode<T>(method: &str, error: serde_json::Error) -> ksp_core_lib::Result<T> {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP canary response has an invalid shape")
|
||||
.with_context("rpc_method", method)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
|
||||
fn invalid_canary_response<T>(method: &str, message: &str) -> ksp_core_lib::Result<T> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/rpc_canary.rs"]
|
||||
mod tests;
|
||||
943
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
Normal file
943
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
Normal file
@@ -0,0 +1,943 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
|
||||
// version: 4
|
||||
|
||||
const MAX_GET_SLOT_LEADERS: u64 = 5_000;
|
||||
|
||||
/// Contact information returned for one cluster node.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaClusterNode {
|
||||
pubkey: ksp_core_lib::Pubkey,
|
||||
feature_set: std::option::Option<u32>,
|
||||
gossip: std::option::Option<std::string::String>,
|
||||
pubsub: std::option::Option<std::string::String>,
|
||||
rpc: std::option::Option<std::string::String>,
|
||||
serve_repair: std::option::Option<std::string::String>,
|
||||
shred_version: std::option::Option<u16>,
|
||||
tpu: std::option::Option<std::string::String>,
|
||||
tpu_forwards: std::option::Option<std::string::String>,
|
||||
tpu_forwards_quic: std::option::Option<std::string::String>,
|
||||
tpu_quic: std::option::Option<std::string::String>,
|
||||
tpu_vote: std::option::Option<std::string::String>,
|
||||
tvu: std::option::Option<std::string::String>,
|
||||
version: std::option::Option<std::string::String>,
|
||||
client_id: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
impl SolanaClusterNode {
|
||||
/// Returns the node identity public key.
|
||||
#[must_use]
|
||||
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.pubkey;
|
||||
}
|
||||
/// Returns the optional feature-set identifier.
|
||||
#[must_use]
|
||||
pub const fn feature_set(&self) -> std::option::Option<u32> {
|
||||
return self.feature_set;
|
||||
}
|
||||
/// Returns the optional gossip endpoint.
|
||||
#[must_use]
|
||||
pub fn gossip(&self) -> std::option::Option<&str> {
|
||||
return self.gossip.as_deref();
|
||||
}
|
||||
/// Returns the optional PubSub endpoint.
|
||||
#[must_use]
|
||||
pub fn pubsub(&self) -> std::option::Option<&str> {
|
||||
return self.pubsub.as_deref();
|
||||
}
|
||||
/// Returns the optional JSON-RPC endpoint.
|
||||
#[must_use]
|
||||
pub fn rpc(&self) -> std::option::Option<&str> {
|
||||
return self.rpc.as_deref();
|
||||
}
|
||||
/// Returns the optional repair endpoint.
|
||||
#[must_use]
|
||||
pub fn serve_repair(&self) -> std::option::Option<&str> {
|
||||
return self.serve_repair.as_deref();
|
||||
}
|
||||
/// Returns the optional shred version.
|
||||
#[must_use]
|
||||
pub const fn shred_version(&self) -> std::option::Option<u16> {
|
||||
return self.shred_version;
|
||||
}
|
||||
/// Returns the optional TPU endpoint.
|
||||
#[must_use]
|
||||
pub fn tpu(&self) -> std::option::Option<&str> {
|
||||
return self.tpu.as_deref();
|
||||
}
|
||||
/// Returns the optional TPU forwards endpoint.
|
||||
#[must_use]
|
||||
pub fn tpu_forwards(&self) -> std::option::Option<&str> {
|
||||
return self.tpu_forwards.as_deref();
|
||||
}
|
||||
/// Returns the optional TPU forwards QUIC endpoint.
|
||||
#[must_use]
|
||||
pub fn tpu_forwards_quic(&self) -> std::option::Option<&str> {
|
||||
return self.tpu_forwards_quic.as_deref();
|
||||
}
|
||||
/// Returns the optional TPU QUIC endpoint.
|
||||
#[must_use]
|
||||
pub fn tpu_quic(&self) -> std::option::Option<&str> {
|
||||
return self.tpu_quic.as_deref();
|
||||
}
|
||||
/// Returns the optional TPU vote endpoint.
|
||||
#[must_use]
|
||||
pub fn tpu_vote(&self) -> std::option::Option<&str> {
|
||||
return self.tpu_vote.as_deref();
|
||||
}
|
||||
/// Returns the optional TVU endpoint.
|
||||
#[must_use]
|
||||
pub fn tvu(&self) -> std::option::Option<&str> {
|
||||
return self.tvu.as_deref();
|
||||
}
|
||||
/// Returns the optional software-version string.
|
||||
#[must_use]
|
||||
pub fn version(&self) -> std::option::Option<&str> {
|
||||
return self.version.as_deref();
|
||||
}
|
||||
/// Returns the optional Agave client identifier extension.
|
||||
#[must_use]
|
||||
pub fn client_id(&self) -> std::option::Option<&str> {
|
||||
return self.client_id.as_deref();
|
||||
}
|
||||
|
||||
/// Decodes one cluster-node contact record from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireClusterNode>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let pubkey = crate::parse_wire_pubkey(method, "pubkey", wire.pubkey.as_str());
|
||||
let pubkey = match pubkey {
|
||||
std::result::Result::Ok(pubkey) => pubkey,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(Self {
|
||||
pubkey,
|
||||
feature_set: wire.feature_set,
|
||||
gossip: wire.gossip,
|
||||
pubsub: wire.pubsub,
|
||||
rpc: wire.rpc,
|
||||
serve_repair: wire.serve_repair,
|
||||
shred_version: wire.shred_version,
|
||||
tpu: wire.tpu,
|
||||
tpu_forwards: wire.tpu_forwards,
|
||||
tpu_forwards_quic: wire.tpu_forwards_quic,
|
||||
tpu_quic: wire.tpu_quic,
|
||||
tpu_vote: wire.tpu_vote,
|
||||
tvu: wire.tvu,
|
||||
version: wire.version,
|
||||
client_id: wire.client_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Epoch information returned by `getEpochInfo`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaEpochInfo {
|
||||
absolute_slot: u64,
|
||||
block_height: u64,
|
||||
epoch: u64,
|
||||
slot_index: u64,
|
||||
slots_in_epoch: u64,
|
||||
transaction_count: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl SolanaEpochInfo {
|
||||
/// Returns the absolute slot.
|
||||
#[must_use]
|
||||
pub const fn absolute_slot(&self) -> u64 {
|
||||
return self.absolute_slot;
|
||||
}
|
||||
/// Returns the block height.
|
||||
#[must_use]
|
||||
pub const fn block_height(&self) -> u64 {
|
||||
return self.block_height;
|
||||
}
|
||||
/// Returns the epoch number.
|
||||
#[must_use]
|
||||
pub const fn epoch(&self) -> u64 {
|
||||
return self.epoch;
|
||||
}
|
||||
/// Returns the slot index within the epoch.
|
||||
#[must_use]
|
||||
pub const fn slot_index(&self) -> u64 {
|
||||
return self.slot_index;
|
||||
}
|
||||
/// Returns the number of slots in the epoch.
|
||||
#[must_use]
|
||||
pub const fn slots_in_epoch(&self) -> u64 {
|
||||
return self.slots_in_epoch;
|
||||
}
|
||||
/// Returns the nullable transaction count.
|
||||
#[must_use]
|
||||
pub const fn transaction_count(&self) -> std::option::Option<u64> {
|
||||
return self.transaction_count;
|
||||
}
|
||||
|
||||
/// Decodes epoch information from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireEpochInfo>(method, value);
|
||||
return match decoded {
|
||||
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
|
||||
absolute_slot: wire.absolute_slot,
|
||||
block_height: wire.block_height,
|
||||
epoch: wire.epoch,
|
||||
slot_index: wire.slot_index,
|
||||
slots_in_epoch: wire.slots_in_epoch,
|
||||
transaction_count: wire.transaction_count,
|
||||
}),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Epoch schedule returned by `getEpochSchedule`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaEpochSchedule {
|
||||
first_normal_epoch: u64,
|
||||
first_normal_slot: u64,
|
||||
leader_schedule_slot_offset: u64,
|
||||
slots_per_epoch: u64,
|
||||
warmup: bool,
|
||||
}
|
||||
|
||||
impl SolanaEpochSchedule {
|
||||
/// Returns the first normal epoch.
|
||||
#[must_use]
|
||||
pub const fn first_normal_epoch(&self) -> u64 {
|
||||
return self.first_normal_epoch;
|
||||
}
|
||||
/// Returns the first normal slot.
|
||||
#[must_use]
|
||||
pub const fn first_normal_slot(&self) -> u64 {
|
||||
return self.first_normal_slot;
|
||||
}
|
||||
/// Returns the leader-schedule slot offset.
|
||||
#[must_use]
|
||||
pub const fn leader_schedule_slot_offset(&self) -> u64 {
|
||||
return self.leader_schedule_slot_offset;
|
||||
}
|
||||
/// Returns the number of slots per epoch.
|
||||
#[must_use]
|
||||
pub const fn slots_per_epoch(&self) -> u64 {
|
||||
return self.slots_per_epoch;
|
||||
}
|
||||
/// Returns whether epoch warmup is enabled.
|
||||
#[must_use]
|
||||
pub const fn warmup(&self) -> bool {
|
||||
return self.warmup;
|
||||
}
|
||||
|
||||
/// Decodes an epoch schedule from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireEpochSchedule>(method, value);
|
||||
return match decoded {
|
||||
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
|
||||
first_normal_epoch: wire.first_normal_epoch,
|
||||
first_normal_slot: wire.first_normal_slot,
|
||||
leader_schedule_slot_offset: wire.leader_schedule_slot_offset,
|
||||
slots_per_epoch: wire.slots_per_epoch,
|
||||
warmup: wire.warmup,
|
||||
}),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest full and optional incremental snapshot slots returned by `getHighestSnapshotSlot`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaSnapshotSlotInfo {
|
||||
full: u64,
|
||||
incremental: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl SolanaSnapshotSlotInfo {
|
||||
/// Returns the highest full snapshot slot.
|
||||
#[must_use]
|
||||
pub const fn full(&self) -> u64 {
|
||||
return self.full;
|
||||
}
|
||||
/// Returns the optional highest incremental snapshot slot.
|
||||
#[must_use]
|
||||
pub const fn incremental(&self) -> std::option::Option<u64> {
|
||||
return self.incremental;
|
||||
}
|
||||
|
||||
/// Decodes snapshot-slot information from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireSnapshotSlotInfo>(method, value);
|
||||
return match decoded {
|
||||
std::result::Result::Ok(wire) => std::result::Result::Ok(Self { full: wire.full, incremental: wire.incremental }),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional configuration accepted by `getLeaderSchedule`.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaLeaderScheduleConfig {
|
||||
identity: std::option::Option<ksp_core_lib::Pubkey>,
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
}
|
||||
|
||||
impl SolanaLeaderScheduleConfig {
|
||||
/// Creates a leader-schedule configuration.
|
||||
#[must_use]
|
||||
pub const fn new(identity: std::option::Option<ksp_core_lib::Pubkey>, commitment: std::option::Option<crate::SolanaCommitment>) -> Self {
|
||||
return Self { identity, commitment };
|
||||
}
|
||||
/// Returns the optional validator identity filter.
|
||||
#[must_use]
|
||||
pub const fn identity(&self) -> std::option::Option<&ksp_core_lib::Pubkey> {
|
||||
return self.identity.as_ref();
|
||||
}
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
pub(crate) const fn is_empty(&self) -> bool {
|
||||
return self.identity.is_none() && self.commitment.is_none();
|
||||
}
|
||||
pub(crate) fn to_json_value(&self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(identity) = self.identity.as_ref() {
|
||||
object.insert("identity".to_owned(), serde_json::Value::String(identity.to_string()));
|
||||
}
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed parameter overload for `getLeaderSchedule`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SolanaLeaderScheduleRequest {
|
||||
/// Query the current epoch, optionally with a config object.
|
||||
CurrentEpoch(std::option::Option<crate::SolanaLeaderScheduleConfig>),
|
||||
/// Query the epoch containing one slot, optionally with a config object.
|
||||
Slot {
|
||||
/// Slot whose epoch should be queried.
|
||||
slot: u64,
|
||||
/// Optional leader-schedule config sent as the second positional parameter.
|
||||
config: std::option::Option<crate::SolanaLeaderScheduleConfig>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for SolanaLeaderScheduleRequest {
|
||||
fn default() -> Self {
|
||||
return Self::CurrentEpoch(std::option::Option::None);
|
||||
}
|
||||
}
|
||||
|
||||
impl SolanaLeaderScheduleRequest {
|
||||
/// Serializes the typed overload to the exact positional JSON-RPC params.
|
||||
#[must_use]
|
||||
pub(crate) fn to_json_params(&self) -> std::vec::Vec<serde_json::Value> {
|
||||
return match self {
|
||||
Self::CurrentEpoch(std::option::Option::None) => std::vec::Vec::new(),
|
||||
Self::CurrentEpoch(std::option::Option::Some(config)) if config.is_empty() => std::vec::Vec::new(),
|
||||
Self::CurrentEpoch(std::option::Option::Some(config)) => std::vec![config.to_json_value()],
|
||||
Self::Slot { slot, config: std::option::Option::None } => std::vec![serde_json::json!(slot)],
|
||||
Self::Slot { slot, config: std::option::Option::Some(config) } if config.is_empty() => std::vec![serde_json::json!(slot)],
|
||||
Self::Slot { slot, config: std::option::Option::Some(config) } => std::vec![serde_json::json!(slot), config.to_json_value()],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Leader schedule mapping validator identities to relative epoch slot indices.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaLeaderSchedule {
|
||||
entries: std::collections::BTreeMap<ksp_core_lib::Pubkey, std::vec::Vec<usize>>,
|
||||
}
|
||||
|
||||
impl SolanaLeaderSchedule {
|
||||
/// Returns the complete leader schedule map.
|
||||
#[must_use]
|
||||
pub const fn entries(&self) -> &std::collections::BTreeMap<ksp_core_lib::Pubkey, std::vec::Vec<usize>> {
|
||||
return &self.entries;
|
||||
}
|
||||
|
||||
/// Decodes a leader schedule map from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<std::collections::BTreeMap<std::string::String, std::vec::Vec<usize>>>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut entries = std::collections::BTreeMap::new();
|
||||
for (identity, slots) in wire {
|
||||
let pubkey = crate::parse_wire_pubkey(method, "leader_identity", identity.as_str());
|
||||
let pubkey = match pubkey {
|
||||
std::result::Result::Ok(pubkey) => pubkey,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
entries.insert(pubkey, slots);
|
||||
}
|
||||
return std::result::Result::Ok(Self { entries });
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration accepted by `getVoteAccounts`.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaVoteAccountsConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
vote_pubkey: std::option::Option<ksp_core_lib::Pubkey>,
|
||||
keep_unstaked_delinquents: std::option::Option<bool>,
|
||||
delinquent_slot_distance: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl SolanaVoteAccountsConfig {
|
||||
/// Creates a vote-accounts configuration.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
vote_pubkey: std::option::Option<ksp_core_lib::Pubkey>,
|
||||
keep_unstaked_delinquents: std::option::Option<bool>,
|
||||
delinquent_slot_distance: std::option::Option<u64>,
|
||||
) -> Self {
|
||||
return Self { commitment, vote_pubkey, keep_unstaked_delinquents, delinquent_slot_distance };
|
||||
}
|
||||
/// Returns the optional commitment.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
/// Returns the optional vote-account public key filter.
|
||||
#[must_use]
|
||||
pub const fn vote_pubkey(&self) -> std::option::Option<&ksp_core_lib::Pubkey> {
|
||||
return self.vote_pubkey.as_ref();
|
||||
}
|
||||
/// Returns whether unstaked delinquent validators should be kept.
|
||||
#[must_use]
|
||||
pub const fn keep_unstaked_delinquents(&self) -> std::option::Option<bool> {
|
||||
return self.keep_unstaked_delinquents;
|
||||
}
|
||||
/// Returns the optional delinquent slot distance.
|
||||
#[must_use]
|
||||
pub const fn delinquent_slot_distance(&self) -> std::option::Option<u64> {
|
||||
return self.delinquent_slot_distance;
|
||||
}
|
||||
pub(crate) const fn is_empty(&self) -> bool {
|
||||
return self.commitment.is_none() && self.vote_pubkey.is_none() && self.keep_unstaked_delinquents.is_none() && self.delinquent_slot_distance.is_none();
|
||||
}
|
||||
/// Serializes this config to the Solana JSON-RPC wire object.
|
||||
#[must_use]
|
||||
pub(crate) fn to_json_value(&self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(vote_pubkey) = self.vote_pubkey.as_ref() {
|
||||
object.insert("votePubkey".to_owned(), serde_json::Value::String(vote_pubkey.to_string()));
|
||||
}
|
||||
if let std::option::Option::Some(value) = self.keep_unstaked_delinquents {
|
||||
object.insert("keepUnstakedDelinquents".to_owned(), serde_json::Value::Bool(value));
|
||||
}
|
||||
if let std::option::Option::Some(value) = self.delinquent_slot_distance {
|
||||
object.insert("delinquentSlotDistance".to_owned(), serde_json::Value::Number(value.into()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// One epoch-credit history entry returned by `getVoteAccounts`.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct SolanaEpochCredits {
|
||||
epoch: u64,
|
||||
credits: u64,
|
||||
previous_credits: u64,
|
||||
}
|
||||
|
||||
impl SolanaEpochCredits {
|
||||
/// Returns the epoch number.
|
||||
#[must_use]
|
||||
pub const fn epoch(&self) -> u64 {
|
||||
return self.epoch;
|
||||
}
|
||||
/// Returns cumulative credits at the end of the epoch.
|
||||
#[must_use]
|
||||
pub const fn credits(&self) -> u64 {
|
||||
return self.credits;
|
||||
}
|
||||
/// Returns cumulative credits before the epoch.
|
||||
#[must_use]
|
||||
pub const fn previous_credits(&self) -> u64 {
|
||||
return self.previous_credits;
|
||||
}
|
||||
}
|
||||
|
||||
/// One validator vote-account record returned by `getVoteAccounts`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaVoteAccountInfo {
|
||||
vote_pubkey: ksp_core_lib::Pubkey,
|
||||
node_pubkey: ksp_core_lib::Pubkey,
|
||||
activated_stake: u64,
|
||||
commission: u8,
|
||||
inflation_rewards_commission_bps: std::option::Option<u16>,
|
||||
epoch_vote_account: bool,
|
||||
epoch_credits: std::vec::Vec<crate::SolanaEpochCredits>,
|
||||
last_vote: u64,
|
||||
root_slot: u64,
|
||||
}
|
||||
|
||||
impl SolanaVoteAccountInfo {
|
||||
/// Returns the vote account public key.
|
||||
#[must_use]
|
||||
pub const fn vote_pubkey(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.vote_pubkey;
|
||||
}
|
||||
/// Returns the validator identity public key.
|
||||
#[must_use]
|
||||
pub const fn node_pubkey(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.node_pubkey;
|
||||
}
|
||||
/// Returns the activated stake in lamports.
|
||||
#[must_use]
|
||||
pub const fn activated_stake(&self) -> u64 {
|
||||
return self.activated_stake;
|
||||
}
|
||||
/// Returns the legacy/effective percentage commission field.
|
||||
#[must_use]
|
||||
pub const fn commission(&self) -> u8 {
|
||||
return self.commission;
|
||||
}
|
||||
/// Returns the optional raw inflation-rewards commission in basis points.
|
||||
#[must_use]
|
||||
pub const fn inflation_rewards_commission_bps(&self) -> std::option::Option<u16> {
|
||||
return self.inflation_rewards_commission_bps;
|
||||
}
|
||||
/// Returns whether the vote account is staked for the current epoch.
|
||||
#[must_use]
|
||||
pub const fn epoch_vote_account(&self) -> bool {
|
||||
return self.epoch_vote_account;
|
||||
}
|
||||
/// Returns the bounded RPC epoch-credit history.
|
||||
#[must_use]
|
||||
pub fn epoch_credits(&self) -> &[crate::SolanaEpochCredits] {
|
||||
return self.epoch_credits.as_slice();
|
||||
}
|
||||
/// Returns the latest voted slot or zero when no vote exists.
|
||||
#[must_use]
|
||||
pub const fn last_vote(&self) -> u64 {
|
||||
return self.last_vote;
|
||||
}
|
||||
/// Returns the current root slot or zero when no root exists.
|
||||
#[must_use]
|
||||
pub const fn root_slot(&self) -> u64 {
|
||||
return self.root_slot;
|
||||
}
|
||||
|
||||
/// Decodes one vote-account record from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireVoteAccountInfo>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let vote_pubkey = crate::parse_wire_pubkey(method, "votePubkey", wire.vote_pubkey.as_str());
|
||||
let vote_pubkey = match vote_pubkey {
|
||||
std::result::Result::Ok(pubkey) => pubkey,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let node_pubkey = crate::parse_wire_pubkey(method, "nodePubkey", wire.node_pubkey.as_str());
|
||||
let node_pubkey = match node_pubkey {
|
||||
std::result::Result::Ok(pubkey) => pubkey,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut epoch_credits = std::vec::Vec::with_capacity(wire.epoch_credits.len());
|
||||
for entry in wire.epoch_credits {
|
||||
epoch_credits.push(crate::SolanaEpochCredits { epoch: entry[0], credits: entry[1], previous_credits: entry[2] });
|
||||
}
|
||||
return std::result::Result::Ok(Self {
|
||||
vote_pubkey,
|
||||
node_pubkey,
|
||||
activated_stake: wire.activated_stake,
|
||||
commission: wire.commission,
|
||||
inflation_rewards_commission_bps: wire.inflation_rewards_commission_bps,
|
||||
epoch_vote_account: wire.epoch_vote_account,
|
||||
epoch_credits,
|
||||
last_vote: wire.last_vote,
|
||||
root_slot: wire.root_slot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Current and delinquent validator vote-account groups returned by `getVoteAccounts`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaVoteAccountStatus {
|
||||
current: std::vec::Vec<crate::SolanaVoteAccountInfo>,
|
||||
delinquent: std::vec::Vec<crate::SolanaVoteAccountInfo>,
|
||||
}
|
||||
|
||||
impl SolanaVoteAccountStatus {
|
||||
/// Returns current vote accounts.
|
||||
#[must_use]
|
||||
pub fn current(&self) -> &[crate::SolanaVoteAccountInfo] {
|
||||
return self.current.as_slice();
|
||||
}
|
||||
/// Returns delinquent vote accounts.
|
||||
#[must_use]
|
||||
pub fn delinquent(&self) -> &[crate::SolanaVoteAccountInfo] {
|
||||
return self.delinquent.as_slice();
|
||||
}
|
||||
|
||||
/// Decodes the complete vote-account status response from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireVoteAccountStatus>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut current = std::vec::Vec::with_capacity(wire.current.len());
|
||||
for value in wire.current {
|
||||
let decoded = crate::SolanaVoteAccountInfo::decode_wire(method, value);
|
||||
match decoded {
|
||||
std::result::Result::Ok(info) => current.push(info),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
let mut delinquent = std::vec::Vec::with_capacity(wire.delinquent.len());
|
||||
for value in wire.delinquent {
|
||||
let decoded = crate::SolanaVoteAccountInfo::decode_wire(method, value);
|
||||
match decoded {
|
||||
std::result::Result::Ok(info) => delinquent.push(info),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(Self { current, delinquent });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireIdentity {
|
||||
identity: std::string::String,
|
||||
}
|
||||
|
||||
impl crate::HttpTransportPool {
|
||||
/// Executes typed `getClusterNodes` through the common KSP HTTP transport path.
|
||||
pub async fn get_cluster_nodes(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaClusterNode>> {
|
||||
let value = self.execute_cluster_rpc("getClusterNodes", role, std::vec::Vec::new()).await;
|
||||
let value = match value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decoded = crate::decode_wire_json::<std::vec::Vec<serde_json::Value>>("getClusterNodes", value);
|
||||
let values = match decoded {
|
||||
std::result::Result::Ok(values) => values,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut nodes = std::vec::Vec::with_capacity(values.len());
|
||||
for value in values {
|
||||
let node = crate::SolanaClusterNode::decode_wire("getClusterNodes", value);
|
||||
match node {
|
||||
std::result::Result::Ok(node) => nodes.push(node),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(nodes);
|
||||
}
|
||||
|
||||
/// Executes typed `getEpochInfo` through the common KSP HTTP transport path.
|
||||
pub async fn get_epoch_info(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
config: std::option::Option<&crate::SolanaContextConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaEpochInfo> {
|
||||
let mut params = std::vec::Vec::new();
|
||||
push_context_config(&mut params, config);
|
||||
let value = self.execute_cluster_rpc("getEpochInfo", role, params).await;
|
||||
return match value {
|
||||
std::result::Result::Ok(value) => crate::SolanaEpochInfo::decode_wire("getEpochInfo", value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes typed `getEpochSchedule` through the common KSP HTTP transport path.
|
||||
pub async fn get_epoch_schedule(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaEpochSchedule> {
|
||||
let value = self.execute_cluster_rpc("getEpochSchedule", role, std::vec::Vec::new()).await;
|
||||
return match value {
|
||||
std::result::Result::Ok(value) => crate::SolanaEpochSchedule::decode_wire("getEpochSchedule", value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes typed `getHighestSnapshotSlot` through the common KSP HTTP transport path.
|
||||
pub async fn get_highest_snapshot_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaSnapshotSlotInfo> {
|
||||
let value = self.execute_cluster_rpc("getHighestSnapshotSlot", role, std::vec::Vec::new()).await;
|
||||
return match value {
|
||||
std::result::Result::Ok(value) => crate::SolanaSnapshotSlotInfo::decode_wire("getHighestSnapshotSlot", value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes typed `getIdentity` through the common KSP HTTP transport path.
|
||||
pub async fn get_identity(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
|
||||
let value = self.execute_cluster_rpc("getIdentity", role, std::vec::Vec::new()).await;
|
||||
let value = match value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decoded = crate::decode_wire_json::<WireIdentity>("getIdentity", value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::parse_wire_pubkey("getIdentity", "identity", wire.identity.as_str());
|
||||
}
|
||||
|
||||
/// Executes typed `getMaxRetransmitSlot` through the common KSP HTTP transport path.
|
||||
pub async fn get_max_retransmit_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<u64> {
|
||||
return self.get_cluster_simple_slot("getMaxRetransmitSlot", role).await;
|
||||
}
|
||||
|
||||
/// Executes typed `getMaxShredInsertSlot` through the common KSP HTTP transport path.
|
||||
pub async fn get_max_shred_insert_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<u64> {
|
||||
return self.get_cluster_simple_slot("getMaxShredInsertSlot", role).await;
|
||||
}
|
||||
|
||||
/// Executes typed `getLeaderSchedule` through the common KSP HTTP transport path.
|
||||
pub async fn get_leader_schedule(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request: &crate::SolanaLeaderScheduleRequest,
|
||||
) -> ksp_core_lib::Result<std::option::Option<crate::SolanaLeaderSchedule>> {
|
||||
let value = self.execute_cluster_rpc("getLeaderSchedule", role, request.to_json_params()).await;
|
||||
let value = match value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if value.is_null() {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
}
|
||||
let schedule = crate::SolanaLeaderSchedule::decode_wire("getLeaderSchedule", value);
|
||||
return match schedule {
|
||||
std::result::Result::Ok(schedule) => std::result::Result::Ok(std::option::Option::Some(schedule)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes typed `getSlot` through the common KSP HTTP transport path.
|
||||
pub async fn get_slot(&self, role: &crate::HttpRoleName, config: std::option::Option<&crate::SolanaContextConfig>) -> ksp_core_lib::Result<u64> {
|
||||
let mut params = std::vec::Vec::new();
|
||||
push_context_config(&mut params, config);
|
||||
let value = self.execute_cluster_rpc("getSlot", role, params).await;
|
||||
return match value {
|
||||
std::result::Result::Ok(value) => crate::decode_wire_json::<u64>("getSlot", value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes typed `getSlotLeader` through the common KSP HTTP transport path.
|
||||
pub async fn get_slot_leader(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
config: std::option::Option<&crate::SolanaContextConfig>,
|
||||
) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
|
||||
let mut params = std::vec::Vec::new();
|
||||
push_context_config(&mut params, config);
|
||||
let value = self.execute_cluster_rpc("getSlotLeader", role, params).await;
|
||||
let value = match value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decoded = crate::decode_wire_json::<std::string::String>("getSlotLeader", value);
|
||||
let leader = match decoded {
|
||||
std::result::Result::Ok(leader) => leader,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::parse_wire_pubkey("getSlotLeader", "leader", leader.as_str());
|
||||
}
|
||||
|
||||
/// Executes typed `getSlotLeaders` through the common KSP HTTP transport path.
|
||||
pub async fn get_slot_leaders(&self, role: &crate::HttpRoleName, start_slot: u64, limit: u64) -> ksp_core_lib::Result<std::vec::Vec<ksp_core_lib::Pubkey>> {
|
||||
if limit == 0 || limit > MAX_GET_SLOT_LEADERS {
|
||||
return invalid_cluster_parameters("getSlotLeaders", "getSlotLeaders limit must be between 1 and 5000", "limit", limit);
|
||||
}
|
||||
let params = std::vec![serde_json::json!(start_slot), serde_json::json!(limit)];
|
||||
let value = self.execute_cluster_rpc("getSlotLeaders", role, params).await;
|
||||
let value = match value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return decode_pubkey_list("getSlotLeaders", "leader", value);
|
||||
}
|
||||
|
||||
/// Executes typed `getVoteAccounts` through the common KSP HTTP transport path.
|
||||
pub async fn get_vote_accounts(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
config: std::option::Option<&crate::SolanaVoteAccountsConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaVoteAccountStatus> {
|
||||
let mut params = std::vec::Vec::new();
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let value = self.execute_cluster_rpc("getVoteAccounts", role, params).await;
|
||||
return match value {
|
||||
std::result::Result::Ok(value) => crate::SolanaVoteAccountStatus::decode_wire("getVoteAccounts", value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
async fn get_cluster_simple_slot(&self, method_name: &'static str, role: &crate::HttpRoleName) -> ksp_core_lib::Result<u64> {
|
||||
let value = self.execute_cluster_rpc(method_name, role, std::vec::Vec::new()).await;
|
||||
return match value {
|
||||
std::result::Result::Ok(value) => crate::decode_wire_json::<u64>(method_name, value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
async fn execute_cluster_rpc(
|
||||
&self,
|
||||
method_name: &'static str,
|
||||
role: &crate::HttpRoleName,
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
let method = cluster_descriptor(method_name);
|
||||
let method = match method {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return self.execute_standard_rpc(role, method, params).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn push_context_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaContextConfig>) {
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& (config.commitment().is_some() || config.min_context_slot().is_some())
|
||||
{
|
||||
params.push((*config).to_json_value());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fn decode_pubkey_list(method: &str, field: &'static str, value: serde_json::Value) -> ksp_core_lib::Result<std::vec::Vec<ksp_core_lib::Pubkey>> {
|
||||
let decoded = crate::decode_wire_json::<std::vec::Vec<std::string::String>>(method, value);
|
||||
let values = match decoded {
|
||||
std::result::Result::Ok(values) => values,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut pubkeys = std::vec::Vec::with_capacity(values.len());
|
||||
for value in values {
|
||||
let pubkey = crate::parse_wire_pubkey(method, field, value.as_str());
|
||||
match pubkey {
|
||||
std::result::Result::Ok(pubkey) => pubkeys.push(pubkey),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(pubkeys);
|
||||
}
|
||||
|
||||
fn invalid_cluster_parameters<T>(method: &str, message: &str, field: &'static str, value: u64) -> ksp_core_lib::Result<T> {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message)
|
||||
.with_context("rpc_method", method)
|
||||
.with_context(field, value.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
fn cluster_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
|
||||
let descriptor = crate::find_http_rpc_method(method);
|
||||
return match descriptor {
|
||||
std::option::Option::Some(descriptor)
|
||||
if descriptor.category() == crate::HttpRpcCategory::Cluster && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
|
||||
{
|
||||
std::result::Result::Ok(descriptor)
|
||||
},
|
||||
_ => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Cluster descriptor is missing from the audited 0.2.2 registry")
|
||||
.with_context("rpc_method", method),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireClusterNode {
|
||||
pubkey: std::string::String,
|
||||
#[serde(default)]
|
||||
feature_set: std::option::Option<u32>,
|
||||
#[serde(default)]
|
||||
gossip: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
pubsub: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
rpc: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
serve_repair: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
shred_version: std::option::Option<u16>,
|
||||
#[serde(default)]
|
||||
tpu: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
tpu_forwards: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
tpu_forwards_quic: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
tpu_quic: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
tpu_vote: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
tvu: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
version: std::option::Option<std::string::String>,
|
||||
#[serde(default)]
|
||||
client_id: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireEpochInfo {
|
||||
absolute_slot: u64,
|
||||
block_height: u64,
|
||||
epoch: u64,
|
||||
slot_index: u64,
|
||||
slots_in_epoch: u64,
|
||||
transaction_count: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireEpochSchedule {
|
||||
first_normal_epoch: u64,
|
||||
first_normal_slot: u64,
|
||||
leader_schedule_slot_offset: u64,
|
||||
slots_per_epoch: u64,
|
||||
warmup: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireSnapshotSlotInfo {
|
||||
full: u64,
|
||||
incremental: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireVoteAccountInfo {
|
||||
vote_pubkey: std::string::String,
|
||||
node_pubkey: std::string::String,
|
||||
activated_stake: u64,
|
||||
commission: u8,
|
||||
#[serde(default)]
|
||||
inflation_rewards_commission_bps: std::option::Option<u16>,
|
||||
epoch_vote_account: bool,
|
||||
epoch_credits: std::vec::Vec<[u64; 3]>,
|
||||
last_vote: u64,
|
||||
root_slot: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireVoteAccountStatus {
|
||||
current: std::vec::Vec<serde_json::Value>,
|
||||
delinquent: std::vec::Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/rpc_cluster.rs"]
|
||||
mod tests;
|
||||
193
crates/ksp-onchain-transport-lib/src/rpc_common.rs
Normal file
193
crates/ksp-onchain-transport-lib/src/rpc_common.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
|
||||
// version: 4
|
||||
|
||||
/// Commitment level accepted by typed Solana HTTP RPC adapters.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaCommitment {
|
||||
/// Query the most recent processed bank.
|
||||
Processed,
|
||||
/// Query a bank confirmed by cluster vote.
|
||||
Confirmed,
|
||||
/// Query a finalized bank.
|
||||
Finalized,
|
||||
}
|
||||
|
||||
impl SolanaCommitment {
|
||||
/// Returns the Solana JSON-RPC commitment string.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Processed => "processed",
|
||||
Self::Confirmed => "confirmed",
|
||||
Self::Finalized => "finalized",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaCommitmentConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
}
|
||||
|
||||
impl SolanaCommitmentConfig {
|
||||
/// Creates an explicit commitment-only configuration.
|
||||
#[must_use]
|
||||
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>) -> Self {
|
||||
return Self { commitment };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Serializes this config to the Solana JSON-RPC wire object.
|
||||
#[must_use]
|
||||
pub(crate) fn to_json_value(self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaContextConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl SolanaContextConfig {
|
||||
/// Creates an explicit context-aware RPC configuration.
|
||||
#[must_use]
|
||||
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, min_context_slot: std::option::Option<u64>) -> Self {
|
||||
return Self { commitment, min_context_slot };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns the optional minimum context slot.
|
||||
#[must_use]
|
||||
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
|
||||
return self.min_context_slot;
|
||||
}
|
||||
|
||||
/// Serializes this config to the Solana JSON-RPC wire object.
|
||||
#[must_use]
|
||||
pub(crate) fn to_json_value(self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
|
||||
object.insert("minContextSlot".to_owned(), serde_json::Value::Number(min_context_slot.into()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed Solana RPC context shared by contextual HTTP responses.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaRpcContext {
|
||||
slot: u64,
|
||||
api_version: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
impl SolanaRpcContext {
|
||||
/// Returns the context slot reported by the RPC node.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the optional RPC API version reported by the node.
|
||||
#[must_use]
|
||||
pub fn api_version(&self) -> std::option::Option<&str> {
|
||||
return match self.api_version.as_ref() {
|
||||
std::option::Option::Some(value) => std::option::Option::Some(value.as_str()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Decodes one RPC context from a parsed JSON value for typed RPC adapters.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = decode_wire_json::<WireRpcContext>(method, value);
|
||||
let context = match decoded {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(Self { slot: context.slot, api_version: context.api_version });
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaRpcResponse<T> {
|
||||
context: crate::SolanaRpcContext,
|
||||
value: T,
|
||||
}
|
||||
|
||||
impl<T> SolanaRpcResponse<T> {
|
||||
/// Returns the Solana response context.
|
||||
#[must_use]
|
||||
pub const fn context(&self) -> &crate::SolanaRpcContext {
|
||||
return &self.context;
|
||||
}
|
||||
|
||||
/// Returns the typed response value.
|
||||
#[must_use]
|
||||
pub const fn value(&self) -> &T {
|
||||
return &self.value;
|
||||
}
|
||||
|
||||
/// Creates a contextual response after wire decoding and validation.
|
||||
#[must_use]
|
||||
pub(crate) const fn new(context: crate::SolanaRpcContext, value: T) -> Self {
|
||||
return Self { context, value };
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes one private serde wire type and maps shape failures to the shared Transport error domain.
|
||||
pub(crate) fn decode_wire_json<T: serde::de::DeserializeOwned>(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<T> {
|
||||
let decoded = serde_json::from_value::<T>(value);
|
||||
return match decoded {
|
||||
std::result::Result::Ok(decoded) => std::result::Result::Ok(decoded),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response has an invalid wire shape")
|
||||
.with_context("rpc_method", method)
|
||||
.with_source(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Parses a base58 public key from one wire field without echoing its value into diagnostics.
|
||||
pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
|
||||
let parsed = value.parse::<ksp_core_lib::Pubkey>();
|
||||
return match parsed {
|
||||
std::result::Result::Ok(pubkey) => std::result::Result::Ok(pubkey),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response contains an invalid public key")
|
||||
.with_context("rpc_method", method)
|
||||
.with_context("field", field),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcContext {
|
||||
slot: u64,
|
||||
#[serde(rename = "apiVersion", default)]
|
||||
api_version: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/rpc_common.rs"]
|
||||
mod tests;
|
||||
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
354
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
Normal file
354
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
|
||||
// version: 3
|
||||
|
||||
/// Exclusive selector accepted by token-account list RPC methods.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SolanaTokenAccountSelector {
|
||||
/// Select token accounts for one mint.
|
||||
Mint(ksp_core_lib::Pubkey),
|
||||
/// Select token accounts owned by one token program.
|
||||
ProgramId(ksp_core_lib::Pubkey),
|
||||
}
|
||||
|
||||
impl SolanaTokenAccountSelector {
|
||||
/// Serializes the exclusive selector to the Solana JSON-RPC wire object.
|
||||
#[must_use]
|
||||
pub(crate) fn to_json_value(&self) -> serde_json::Value {
|
||||
return match self {
|
||||
Self::Mint(pubkey) => serde_json::json!({"mint": pubkey.to_string()}),
|
||||
Self::ProgramId(pubkey) => serde_json::json!({"programId": pubkey.to_string()}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Token amount returned by Solana HTTP token RPC methods.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaTokenAmount {
|
||||
amount: std::string::String,
|
||||
decimals: u8,
|
||||
ui_amount: std::option::Option<f64>,
|
||||
ui_amount_string: std::string::String,
|
||||
}
|
||||
|
||||
impl SolanaTokenAmount {
|
||||
/// Returns the integer token amount as an exact decimal string.
|
||||
#[must_use]
|
||||
pub fn amount(&self) -> &str {
|
||||
return self.amount.as_str();
|
||||
}
|
||||
|
||||
/// Returns the mint decimal precision.
|
||||
#[must_use]
|
||||
pub const fn decimals(&self) -> u8 {
|
||||
return self.decimals;
|
||||
}
|
||||
|
||||
/// Returns the nullable floating-point UI amount exactly as provided by RPC.
|
||||
#[must_use]
|
||||
pub const fn ui_amount(&self) -> std::option::Option<f64> {
|
||||
return self.ui_amount;
|
||||
}
|
||||
|
||||
/// Returns the exact UI amount string provided by RPC.
|
||||
#[must_use]
|
||||
pub fn ui_amount_string(&self) -> &str {
|
||||
return self.ui_amount_string.as_str();
|
||||
}
|
||||
|
||||
/// Decodes one token amount from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireTokenAmount>(method, value);
|
||||
return match decoded {
|
||||
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
|
||||
amount: wire.amount,
|
||||
decimals: wire.decimals,
|
||||
ui_amount: wire.ui_amount,
|
||||
ui_amount_string: wire.ui_amount_string,
|
||||
}),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Token-account balance entry returned by `getTokenLargestAccounts`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaTokenAccountBalance {
|
||||
address: ksp_core_lib::Pubkey,
|
||||
amount: crate::SolanaTokenAmount,
|
||||
}
|
||||
|
||||
impl SolanaTokenAccountBalance {
|
||||
/// Returns the token account address.
|
||||
#[must_use]
|
||||
pub const fn address(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.address;
|
||||
}
|
||||
|
||||
/// Returns the token amount fields.
|
||||
#[must_use]
|
||||
pub const fn amount(&self) -> &crate::SolanaTokenAmount {
|
||||
return &self.amount;
|
||||
}
|
||||
|
||||
/// Decodes one token-account balance entry from the Solana JSON wire shape.
|
||||
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
|
||||
let decoded = crate::decode_wire_json::<WireTokenAccountBalance>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let address = crate::parse_wire_pubkey(method, "address", wire.address.as_str());
|
||||
let address = match address {
|
||||
std::result::Result::Ok(address) => address,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let amount = crate::SolanaTokenAmount {
|
||||
amount: wire.amount,
|
||||
decimals: wire.decimals,
|
||||
ui_amount: wire.ui_amount,
|
||||
ui_amount_string: wire.ui_amount_string,
|
||||
};
|
||||
return std::result::Result::Ok(Self { address, amount });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireTokenAmount {
|
||||
amount: std::string::String,
|
||||
decimals: u8,
|
||||
#[serde(rename = "uiAmount")]
|
||||
ui_amount: std::option::Option<f64>,
|
||||
#[serde(rename = "uiAmountString")]
|
||||
ui_amount_string: std::string::String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireTokenAccountBalance {
|
||||
address: std::string::String,
|
||||
amount: std::string::String,
|
||||
decimals: u8,
|
||||
#[serde(rename = "uiAmount")]
|
||||
ui_amount: std::option::Option<f64>,
|
||||
#[serde(rename = "uiAmountString")]
|
||||
ui_amount_string: std::string::String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponse<T> {
|
||||
context: serde_json::Value,
|
||||
value: T,
|
||||
}
|
||||
|
||||
impl crate::HttpTransportPool {
|
||||
/// Executes typed `getTokenAccountBalance` through the common KSP HTTP transport path.
|
||||
pub async fn get_token_account_balance(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
token_account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
|
||||
let method_result = token_descriptor("getTokenAccountBalance");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec![serde_json::Value::String(token_account.to_string())];
|
||||
push_commitment_config(&mut params, config);
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return decode_token_amount_response("getTokenAccountBalance", value);
|
||||
}
|
||||
|
||||
/// Executes typed `getTokenAccountsByDelegate` through the common KSP HTTP transport path.
|
||||
pub async fn get_token_accounts_by_delegate(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
delegate: &ksp_core_lib::Pubkey,
|
||||
selector: &crate::SolanaTokenAccountSelector,
|
||||
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
|
||||
return self.get_token_accounts_list("getTokenAccountsByDelegate", role, delegate, selector, config).await;
|
||||
}
|
||||
|
||||
/// Executes typed `getTokenAccountsByOwner` through the common KSP HTTP transport path.
|
||||
pub async fn get_token_accounts_by_owner(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
owner: &ksp_core_lib::Pubkey,
|
||||
selector: &crate::SolanaTokenAccountSelector,
|
||||
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
|
||||
return self.get_token_accounts_list("getTokenAccountsByOwner", role, owner, selector, config).await;
|
||||
}
|
||||
|
||||
/// Executes typed `getTokenLargestAccounts` through the common KSP HTTP transport path.
|
||||
pub async fn get_token_largest_accounts(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
mint: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaTokenAccountBalance>>> {
|
||||
let method_result = token_descriptor("getTokenLargestAccounts");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec![serde_json::Value::String(mint.to_string())];
|
||||
push_commitment_config(&mut params, config);
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return decode_token_account_balances_response("getTokenLargestAccounts", value);
|
||||
}
|
||||
|
||||
/// Executes typed `getTokenSupply` through the common KSP HTTP transport path.
|
||||
pub async fn get_token_supply(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
mint: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
|
||||
let method_result = token_descriptor("getTokenSupply");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec![serde_json::Value::String(mint.to_string())];
|
||||
push_commitment_config(&mut params, config);
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return decode_token_amount_response("getTokenSupply", value);
|
||||
}
|
||||
|
||||
async fn get_token_accounts_list(
|
||||
&self,
|
||||
method_name: &'static str,
|
||||
role: &crate::HttpRoleName,
|
||||
address: &ksp_core_lib::Pubkey,
|
||||
selector: &crate::SolanaTokenAccountSelector,
|
||||
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
|
||||
let method_result = token_descriptor(method_name);
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec![serde_json::Value::String(address.to_string()), selector.to_json_value()];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return decode_keyed_accounts_response(method_name, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn token_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
|
||||
let descriptor = crate::find_http_rpc_method(method);
|
||||
return match descriptor {
|
||||
std::option::Option::Some(descriptor)
|
||||
if descriptor.category() == crate::HttpRpcCategory::Tokens && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
|
||||
{
|
||||
std::result::Result::Ok(descriptor)
|
||||
},
|
||||
_ => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Tokens descriptor is missing from the audited 0.2.2 registry")
|
||||
.with_context("rpc_method", method),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn push_commitment_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaCommitmentConfig>) {
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& config.commitment().is_some()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fn decode_token_amount_response(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse<serde_json::Value>>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let amount = crate::SolanaTokenAmount::decode_wire(method, wire.value);
|
||||
let amount = match amount {
|
||||
std::result::Result::Ok(amount) => amount,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, amount));
|
||||
}
|
||||
|
||||
fn decode_keyed_accounts_response(
|
||||
method: &str,
|
||||
value: serde_json::Value,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut accounts = std::vec::Vec::with_capacity(wire.value.len());
|
||||
for value in wire.value {
|
||||
let account = crate::SolanaKeyedAccount::decode_wire(method, value);
|
||||
match account {
|
||||
std::result::Result::Ok(account) => accounts.push(account),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, accounts));
|
||||
}
|
||||
|
||||
fn decode_token_account_balances_response(
|
||||
method: &str,
|
||||
value: serde_json::Value,
|
||||
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaTokenAccountBalance>>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut accounts = std::vec::Vec::with_capacity(wire.value.len());
|
||||
for value in wire.value {
|
||||
let account = crate::SolanaTokenAccountBalance::decode_wire(method, value);
|
||||
match account {
|
||||
std::result::Result::Ok(account) => accounts.push(account),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, accounts));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/rpc_tokens.rs"]
|
||||
mod tests;
|
||||
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;
|
||||
245
crates/ksp-onchain-transport-lib/tests/public_api.rs
Normal file
245
crates/ksp-onchain-transport-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
// version: 9
|
||||
|
||||
//! 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_002_shared_rpc_types_are_constructible_from_crate_root() {
|
||||
let context = ksp_onchain_transport_lib::SolanaContextConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
|
||||
std::option::Option::Some(123),
|
||||
);
|
||||
assert_eq!(context.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized));
|
||||
assert_eq!(context.min_context_slot(), std::option::Option::Some(123));
|
||||
let account = ksp_onchain_transport_lib::SolanaAccountInfoConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::JsonParsed),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert_eq!(account.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::JsonParsed));
|
||||
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
||||
let selector = ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(pubkey);
|
||||
assert!(matches!(selector, ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(_)));
|
||||
let vote = ksp_onchain_transport_lib::SolanaVoteAccountsConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
|
||||
std::option::Option::Some(pubkey),
|
||||
std::option::Option::Some(true),
|
||||
std::option::Option::Some(128),
|
||||
);
|
||||
assert_eq!(vote.vote_pubkey(), std::option::Option::Some(&pubkey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_003_account_wrappers_are_available_from_crate_root() {
|
||||
let _get_account_info = ksp_onchain_transport_lib::HttpTransportPool::get_account_info;
|
||||
let _get_largest_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_largest_accounts;
|
||||
let _get_minimum_balance_for_rent_exemption = ksp_onchain_transport_lib::HttpTransportPool::get_minimum_balance_for_rent_exemption;
|
||||
let _get_multiple_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_multiple_accounts;
|
||||
let _get_program_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_program_accounts;
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_RPC_PARAMETERS.domain(), "onchain_transport");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_RPC_PARAMETERS.code(), "invalid_rpc_parameters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_004_token_wrappers_are_available_from_crate_root() {
|
||||
let _get_token_account_balance = ksp_onchain_transport_lib::HttpTransportPool::get_token_account_balance;
|
||||
let _get_token_accounts_by_delegate = ksp_onchain_transport_lib::HttpTransportPool::get_token_accounts_by_delegate;
|
||||
let _get_token_accounts_by_owner = ksp_onchain_transport_lib::HttpTransportPool::get_token_accounts_by_owner;
|
||||
let _get_token_largest_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_token_largest_accounts;
|
||||
let _get_token_supply = ksp_onchain_transport_lib::HttpTransportPool::get_token_supply;
|
||||
let mint = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
||||
let selector = ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(mint);
|
||||
assert!(matches!(selector, ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_005_simple_cluster_wrappers_are_available_from_crate_root() {
|
||||
let _get_cluster_nodes = ksp_onchain_transport_lib::HttpTransportPool::get_cluster_nodes;
|
||||
let _get_epoch_info = ksp_onchain_transport_lib::HttpTransportPool::get_epoch_info;
|
||||
let _get_epoch_schedule = ksp_onchain_transport_lib::HttpTransportPool::get_epoch_schedule;
|
||||
let _get_highest_snapshot_slot = ksp_onchain_transport_lib::HttpTransportPool::get_highest_snapshot_slot;
|
||||
let _get_identity = ksp_onchain_transport_lib::HttpTransportPool::get_identity;
|
||||
let _get_max_retransmit_slot = ksp_onchain_transport_lib::HttpTransportPool::get_max_retransmit_slot;
|
||||
let _get_max_shred_insert_slot = ksp_onchain_transport_lib::HttpTransportPool::get_max_shred_insert_slot;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_006_remaining_cluster_wrappers_are_available_from_crate_root() {
|
||||
let _get_leader_schedule = ksp_onchain_transport_lib::HttpTransportPool::get_leader_schedule;
|
||||
let _get_slot = ksp_onchain_transport_lib::HttpTransportPool::get_slot;
|
||||
let _get_slot_leader = ksp_onchain_transport_lib::HttpTransportPool::get_slot_leader;
|
||||
let _get_slot_leaders = ksp_onchain_transport_lib::HttpTransportPool::get_slot_leaders;
|
||||
let _get_vote_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_vote_accounts;
|
||||
let request = ksp_onchain_transport_lib::SolanaLeaderScheduleRequest::default();
|
||||
assert!(matches!(request, ksp_onchain_transport_lib::SolanaLeaderScheduleRequest::CurrentEpoch(std::option::Option::None)));
|
||||
}
|
||||
196
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
Normal file
196
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||
// version: 6
|
||||
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_pre_003_accounts_subset_is_exact_without_advancing_other_v0_2_2_families() {
|
||||
let mut account_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_2
|
||||
&& descriptor.category() == ksp_onchain_transport_lib::HttpRpcCategory::Accounts
|
||||
{
|
||||
account_names.push(descriptor.method());
|
||||
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
|
||||
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
|
||||
}
|
||||
}
|
||||
account_names.sort_unstable();
|
||||
assert_eq!(
|
||||
account_names,
|
||||
std::vec!["getAccountInfo", "getLargestAccounts", "getMinimumBalanceForRentExemption", "getMultipleAccounts", "getProgramAccounts",],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_pre_004_tokens_subset_is_exact_and_retry_safe() {
|
||||
let mut token_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_2
|
||||
&& descriptor.category() == ksp_onchain_transport_lib::HttpRpcCategory::Tokens
|
||||
{
|
||||
token_names.push(descriptor.method());
|
||||
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
|
||||
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
|
||||
}
|
||||
}
|
||||
token_names.sort_unstable();
|
||||
assert_eq!(
|
||||
token_names,
|
||||
std::vec!["getTokenAccountBalance", "getTokenAccountsByDelegate", "getTokenAccountsByOwner", "getTokenLargestAccounts", "getTokenSupply",],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_pre_005_simple_cluster_subset_is_exact_and_retry_safe() {
|
||||
let expected = std::vec![
|
||||
"getClusterNodes",
|
||||
"getEpochInfo",
|
||||
"getEpochSchedule",
|
||||
"getHighestSnapshotSlot",
|
||||
"getIdentity",
|
||||
"getMaxRetransmitSlot",
|
||||
"getMaxShredInsertSlot",
|
||||
];
|
||||
let deferred = std::vec!["getLeaderSchedule", "getSlot", "getSlotLeader", "getSlotLeaders", "getVoteAccounts"];
|
||||
for method_name in &expected {
|
||||
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.005 cluster descriptor must exist");
|
||||
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Cluster);
|
||||
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2);
|
||||
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
|
||||
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
|
||||
}
|
||||
for method_name in &deferred {
|
||||
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.006 cluster descriptor must remain registered");
|
||||
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2);
|
||||
}
|
||||
assert_eq!(expected.len(), 7);
|
||||
assert_eq!(deferred.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_pre_006_completes_cluster_subset_exactly_and_retry_safe() {
|
||||
let mut expected = std::vec![
|
||||
"getClusterNodes",
|
||||
"getEpochInfo",
|
||||
"getEpochSchedule",
|
||||
"getHighestSnapshotSlot",
|
||||
"getIdentity",
|
||||
"getLeaderSchedule",
|
||||
"getMaxRetransmitSlot",
|
||||
"getMaxShredInsertSlot",
|
||||
"getSlot",
|
||||
"getSlotLeader",
|
||||
"getSlotLeaders",
|
||||
"getVoteAccounts",
|
||||
];
|
||||
let mut actual = std::vec::Vec::new();
|
||||
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
|
||||
if descriptor.coverage_release() == ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2
|
||||
&& descriptor.category() == ksp_onchain_transport_lib::HttpRpcCategory::Cluster
|
||||
{
|
||||
actual.push(descriptor.method());
|
||||
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
|
||||
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
|
||||
}
|
||||
}
|
||||
actual.sort_unstable();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_pre_007_v0_2_2_exact_set_is_complete_without_advancing_future_releases() {
|
||||
let mut expected = std::vec![
|
||||
"getAccountInfo",
|
||||
"getClusterNodes",
|
||||
"getEpochInfo",
|
||||
"getEpochSchedule",
|
||||
"getHighestSnapshotSlot",
|
||||
"getIdentity",
|
||||
"getLargestAccounts",
|
||||
"getLeaderSchedule",
|
||||
"getMaxRetransmitSlot",
|
||||
"getMaxShredInsertSlot",
|
||||
"getMinimumBalanceForRentExemption",
|
||||
"getMultipleAccounts",
|
||||
"getProgramAccounts",
|
||||
"getSlot",
|
||||
"getSlotLeader",
|
||||
"getSlotLeaders",
|
||||
"getTokenAccountBalance",
|
||||
"getTokenAccountsByDelegate",
|
||||
"getTokenAccountsByOwner",
|
||||
"getTokenLargestAccounts",
|
||||
"getTokenSupply",
|
||||
"getVoteAccounts",
|
||||
];
|
||||
let mut actual = std::vec::Vec::new();
|
||||
let mut future_transactions = 0_usize;
|
||||
let mut future_blocks_economics = 0_usize;
|
||||
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
|
||||
match descriptor.coverage_release() {
|
||||
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2 => {
|
||||
actual.push(descriptor.method());
|
||||
assert_eq!(descriptor.documentation_status(), ksp_onchain_transport_lib::RpcDocumentationStatus::Stable);
|
||||
assert_eq!(descriptor.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Supported);
|
||||
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
|
||||
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
|
||||
},
|
||||
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3 => future_transactions += 1,
|
||||
ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_4 => future_blocks_economics += 1,
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
actual.sort_unstable();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual.len(), 22);
|
||||
assert_eq!(future_transactions, 11);
|
||||
assert_eq!(future_blocks_economics, 15);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/transport_devnet_smoke.rs
|
||||
// version: 3
|
||||
|
||||
//! Opt-in live Devnet smoke for the pure Transport Accounts/Tokens/Cluster path.
|
||||
|
||||
fn devnet_pool() -> ksp_core_lib::Result<ksp_onchain_transport_lib::HttpTransportPool> {
|
||||
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
|
||||
let role_settings = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
role,
|
||||
true,
|
||||
std::vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
|
||||
0,
|
||||
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
);
|
||||
let endpoint_url_result = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://api.devnet.solana.com");
|
||||
let endpoint_url = match endpoint_url_result {
|
||||
std::result::Result::Ok(endpoint_url) => endpoint_url,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
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"),
|
||||
endpoint_url,
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(20),
|
||||
std::option::Option::Some(4),
|
||||
std::vec![role_settings],
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
ksp_onchain_transport_lib::HttpRetrySettings::new(1, std::time::Duration::from_millis(250), std::time::Duration::from_secs(2)),
|
||||
);
|
||||
return ksp_onchain_transport_lib::HttpTransportPool::new(settings);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "opt-in live Solana Devnet smoke; performs external network requests"]
|
||||
async fn programmatic_devnet_transport_reaches_accounts_tokens_and_cluster_wrappers() {
|
||||
let pool = devnet_pool().expect("programmatic Devnet Transport settings must construct a pool");
|
||||
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
|
||||
|
||||
let account_config = ksp_onchain_transport_lib::SolanaAccountInfoConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
|
||||
std::option::Option::None,
|
||||
);
|
||||
let account = pool
|
||||
.get_account_info(&role, &ksp_core_lib::PRGIDPK_SOLANA_SYSTEM, std::option::Option::Some(&account_config))
|
||||
.await
|
||||
.expect("Devnet getAccountInfo smoke must succeed");
|
||||
assert!(account.context().slot() > 0);
|
||||
assert!(account.value().is_some());
|
||||
|
||||
// Follow the current official Devnet example shape with an ordinary owner, the canonical SPL Token program selector,
|
||||
// and an explicit finalized/jsonParsed config. The owner need not retain any token account; an empty list remains valid.
|
||||
let token_owner = "A1TMhSGzQxMr1TboBKtgixKz1sS6REASMxPo1qsyTSJd"
|
||||
.parse::<ksp_core_lib::Pubkey>()
|
||||
.expect("official Devnet token-owner example must remain a valid Pubkey literal");
|
||||
let token_program = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
||||
.parse::<ksp_core_lib::Pubkey>()
|
||||
.expect("canonical SPL Token program id must remain a valid Pubkey literal");
|
||||
let token_selector = ksp_onchain_transport_lib::SolanaTokenAccountSelector::ProgramId(token_program);
|
||||
let token_config = ksp_onchain_transport_lib::SolanaAccountInfoConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::JsonParsed),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
|
||||
std::option::Option::None,
|
||||
);
|
||||
let token_accounts = pool
|
||||
.get_token_accounts_by_owner(&role, &token_owner, &token_selector, std::option::Option::Some(&token_config))
|
||||
.await
|
||||
.expect("Devnet getTokenAccountsByOwner smoke must succeed with the documented finalized/jsonParsed request shape");
|
||||
assert!(token_accounts.context().slot() > 0);
|
||||
|
||||
let epoch = pool
|
||||
.get_epoch_info(
|
||||
&role,
|
||||
std::option::Option::Some(&ksp_onchain_transport_lib::SolanaContextConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
|
||||
std::option::Option::None,
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("Devnet getEpochInfo smoke must succeed");
|
||||
assert!(epoch.absolute_slot() > 0);
|
||||
|
||||
let vote_accounts = pool.get_vote_accounts(&role, std::option::Option::None).await.expect("Devnet getVoteAccounts smoke must succeed");
|
||||
assert!(!vote_accounts.current().is_empty() || !vote_accounts.delinquent().is_empty());
|
||||
}
|
||||
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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user