Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afaa9c0239 | |||
| 7d456b41dc | |||
| 0edeed1c48 | |||
| be5e3464ee | |||
| 1967e845b0 | |||
| af807afff5 | |||
| 7f567bc1bb | |||
| 09aacc36ab | |||
| 15b129ad04 | |||
| 6ddf2b4995 | |||
| dcabceb52f | |||
| 51bcf870d3 | |||
| c6c5793c61 | |||
| d98ee736be | |||
| 39dbd86f1e | |||
| c938b2409e | |||
| 5b30bb9948 | |||
| 813a45385a | |||
| 21ed5f88da | |||
| 3198cc5666 | |||
| 4568d94a62 | |||
| d32d8de3a5 | |||
| a9fb6a7ac8 | |||
| 433e69272a | |||
| affa18a6e9 | |||
| 05aec88178 | |||
| b32de48c0b | |||
| 15e3402e1c | |||
| dca0a4b809 | |||
| 20320dba7b | |||
| e4bbc78a41 | |||
| feb9befb35 | |||
| a038194679 | |||
| 3172cda241 | |||
| 61fba107ef | |||
| 835de48cb7 | |||
| 21de590aea | |||
| 3081bd1085 | |||
| bdcab1e1a7 | |||
| d70c3a1672 | |||
| 7cdf5e80c9 | |||
| d8bfd7cd2e | |||
| 9c0d4fc197 | |||
| 7c12ec886b | |||
| 7eb6dec809 | |||
| 68f4384c5b | |||
| 9cc140fb84 | |||
| 3b64d1e0ec | |||
| 56b9ce6abc | |||
| 1c8d69778b | |||
| 8e739b9e55 | |||
| 53dbb5bccd | |||
| 92224e5ac6 | |||
| cbb4e7b0de | |||
| 38fd62c256 | |||
| f2a3ec62aa | |||
| b3363073c4 | |||
| c94a54f3e3 | |||
| 0871b85df9 | |||
| 315e7e67e5 | |||
| df95f2f558 | |||
| f0865d5137 | |||
| 4d77b607ea | |||
| 307711f873 | |||
| 5aa7b45840 | |||
| 3c5786f273 | |||
| 3b4d355537 | |||
| 628b4f12f2 | |||
| 66deaf8245 | |||
| 0e256a8ecf | |||
| 9eb0e19d81 | |||
| 74686892e9 | |||
| 1391858972 | |||
| 98bf88e431 | |||
| f0f444bc86 | |||
| d17161234a | |||
| 93199d1856 | |||
| 6fefc64e75 | |||
| 4c540d67a7 | |||
| b67fa89f44 | |||
| 435126f67a | |||
| 8721e54b18 | |||
| 6e3a0fa034 | |||
| 34637848eb | |||
| 778ea58ee1 | |||
| b0461f15ec | |||
| d5df0fe9af | |||
| ab29dc51bb | |||
| b6908cb573 | |||
| b64a799c85 | |||
| cf4b28df2b | |||
| 9e8fd53291 |
26
.env.example
26
.env.example
@@ -1,5 +1,5 @@
|
||||
# file: .env.example
|
||||
# version: 4
|
||||
# version: 9
|
||||
|
||||
# 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.
|
||||
@@ -22,10 +22,34 @@ KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
|
||||
# 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 public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
|
||||
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
|
||||
|
||||
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
|
||||
# The committed Transport document falls back to wss://api.mainnet-beta.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://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
|
||||
|
||||
# PublicNode Mainnet personal token used as secret x-token metadata by the committed Mainnet Yellowstone gRPC profile.
|
||||
# Keep the real value only in the process environment or local .env; do not assume that a Testnet token is authorized on Mainnet.
|
||||
# KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN=replace-me
|
||||
|
||||
# PublicNode Testnet personal token used as secret x-token metadata by the committed Testnet Yellowstone gRPC profile.
|
||||
# Keep the real value only in the process environment or local .env; do not assume that a Mainnet token is authorized on Testnet.
|
||||
# KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN=replace-me
|
||||
|
||||
# OrbitFlare Solana Free License Key used as secret x-token metadata by the committed Devnet Yellowstone gRPC profile.
|
||||
# Keep the real ORBIT-* value only in the process environment or local .env; do not substitute the Customer API X-ORBIT-KEY.
|
||||
# KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN=replace-me
|
||||
|
||||
# Helius API key used by the LaserStream WebSocket endpoint in config/examples/std.transport.example.json.
|
||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||
# KSP_SECRET_HELIUS_API_KEY=replace-me
|
||||
|
||||
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
||||
KSP_DESK_SPLASH_FADE_IN_MS=300
|
||||
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,10 +1,38 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 10 -->
|
||||
<!-- version: 14 -->
|
||||
|
||||
# Changelog KSP
|
||||
|
||||
## 0.2.10 — OrbitFlare Yellowstone gRPC — 2026-08-25
|
||||
|
||||
`0.2.10` ajoute OrbitFlare comme provider Yellowstone gRPC sur le moteur N1 et le standard N2 acquis dans `0.2.9`, sans modifier le moteur gRPC ni créer de façade provider inutile. L'audit et le smoke live ont établi le contrat réel du service Solana Free Devnet : endpoint `http://devnet.rpc.orbitflare.com:10000`, authentification par metadata gRPC secrète `x-token` portant la License Key OrbitFlare, et émission du Ping Yellowstone standard. Config Transport V3 représente ce provider avec `provider = orbitflare`, `cluster = devnet`, `protocol = solana_yellowstone` et `secret_metadata`, tandis que `.env.example` inventorie `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` sans valeur réelle.
|
||||
|
||||
La première tentative live sans metadata a volontairement échoué avec `Unauthenticated`, ce qui a conduit à `pre.002-fix.001` après audit des sources OrbitFlare actuelles. Le smoke corrigé reçoit la License Key uniquement par stdin opérateur, ouvre `Subscribe`, observe un Slot non nul et un `SubscribeUpdate::Ping`, puis ferme la session proprement. Le même smoke est repassé au gate technique final `pre.003`. La clôture conserve donc N1/N2 inchangés, n'ajoute ni heartbeat OrbitFlare, ni SDK provider, ni Config V4, et confirme que la réponse automatique au Ping standard existante suffit.
|
||||
|
||||
Le gate final conserve l'ensemble du workspace vert : audits Rust/Markdown, `cargo check`, Clippy, tests Config et Transport, `cargo test --workspace`, canaris de dépendances et graphes Cargo. Helius LaserStream gRPC, audité comme largement wire-compatible Yellowstone mais non raisonnablement live-testable sans abonnement payant, est reporté dans les TODO provider sans numéro de release. La séquence active passe donc à `0.2.11 — off-chain price transport`; `prompts/016-V0_2_11_START_PROMPT.md` l'ouvre exclusivement depuis le tag stable `v0.2.10` avec audit actuel des sources de prix, de leurs sémantiques et de leur testabilité avant toute implémentation lourde.
|
||||
|
||||
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
|
||||
|
||||
## 0.2.9 — Yellowstone gRPC standard/provider-neutral — 2026-08-24
|
||||
|
||||
`0.2.9` ajoute à `ksp-onchain-transport-lib` un moteur Yellowstone gRPC partagé construit directement sur Tonic/Protobuf, avec `yellowstone-grpc-proto` comme contrat wire upstream et sans `yellowstone-grpc-client` dans le runtime KSP. La surface Solana standard reste provider-neutral et couvre les sept unary retenues (`SubscribeReplayInfo`, `Ping`, `GetLatestBlockhash`, `GetBlockHeight`, `GetSlot`, `IsBlockhashValid`, `GetVersion`), `Subscribe` bidi, les familles accounts/slots/transactions/transaction status/blocks/block meta/entry, les neuf variantes d'update standard retenues, les filtres/common fields actuels, le backpressure, les limites de messages, le half-close/shutdown, ainsi qu'un reconnect/replay prudent depuis le dernier slot observé. Aucune garantie exactly-once, lossless ou d'ordre global gapless n'est introduite ; gaps et duplicates restent observables. `SubscribeDeshred` reste explicitement hors du standard KSP de cette release.
|
||||
|
||||
Config passe à `std.transport` V3, backward-readable avec V1 HTTP et V2 HTTP+WebSocket, et sépare les axes `provider` / `protocol` ainsi que `metadata` / `secret_metadata` sans créer de dépendance Transport -> Config/env. PublicNode constitue la première intégration provider : profils Yellowstone Mainnet et Testnet sur `https://solana-yellowstone-grpc.publicnode.com:443` et `https://solana-testnet-yellowstone-grpc.publicnode.com:443`, avec authentification `x-token` résolue par Config depuis des variables `KSP_SECRET_*`. Le smoke Transport pur reçoit les credentials uniquement par stdin opérateur, n'en journalise aucun et valide en live `Subscribe -> Slot` sur les deux réseaux ; le même personal token a été utilisé avec succès pour Mainnet et Testnet, tout en conservant deux entrées Config distinctes par flexibilité opérationnelle.
|
||||
|
||||
La clôture conserve les surfaces antérieures HTTP (**52 current typed + 14 historiques**), WebSocket Solana standard (**9 familles / 18 opérations**) et Helius LaserStream WebSocket. Le gate final comprend **383/383** tests unit Transport, **49/49** public API, **43/43** release completeness, **4/4** doctests, `cargo test --workspace`, les canaris de dépendances, le smoke PublicNode live **2/2 PASS** et l'inspection des graphes Cargo (`yellowstone-grpc-proto 12.6.0`, `tonic` / `tonic-prost 0.14.6`, `prost` / `prost-types 0.14.4`). `prompts/015-V0_2_10_START_PROMPT.md` ouvre ensuite `0.2.10 — OrbitFlare Yellowstone gRPC` uniquement depuis le tag stable `v0.2.9`, avec audit provider actuel, threat model et sizing en `pre.001` avant toute extension provider lourde.
|
||||
|
||||
## 0.2.8 — Helius LaserStream WebSocket — 2026-08-23
|
||||
|
||||
`0.2.8` étend `ksp-onchain-transport-lib` avec une façade `HeliusLaserStreamWsSession` dédiée qui réutilise le même `WsSession` physique/actor que le WebSocket Solana standard, sans second client, socket, registry ou scheduler. La surface stable Helius réutilise les sept familles standard actuellement retenues (`account`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates`) et ajoute l’extension typée `transactionSubscribe` / `transactionUnsubscribe`; `block` et `vote` restent absents de la façade Helius et `slotsUpdates` conserve son statut unstable. Le heartbeat provider est possédé par l’actor partagé et émet un WebSocket Ping control frame toutes les 60 secondes uniquement pour `WsProtocolKind::HeliusLaserStream`.
|
||||
|
||||
La release ajoute le mapping Config V2 `helius_laserstream`, les profils Helius mainnet/devnet et le secret `KSP_SECRET_HELIUS_API_KEY` avec provenance/redaction segmentaire, sans dépendance inverse Transport -> Config ni lecture directe de l’environnement par Transport. Les canaris couvrent erreurs RPC provider, payload oversized, mismatch de notification, reconnect/remap/unsubscribe races, backpressure isolé et diagnostics sans payload brut. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, **9 familles / 18 opérations WebSocket Solana standard**, et la surface Helius `7 standard + transaction`. Le smoke Helius live cross-crates est volontairement reporté vers une future surface d’intégration/orchestration afin de préserver l’ownership Config du secret. Les graphes Cargo finaux n’introduisent aucun SDK Helius/gRPC ni nouvelle duplication bloquante. `prompts/014-V0_2_9_START_PROMPT.md` ouvre ensuite `0.2.9 — Yellowstone gRPC standard/provider-neutral` uniquement depuis le tag stable `v0.2.8`, avec audit service/proto/crates/licences/MSRV/features et sizing strict en `pre.001` avant toute implémentation lourde.
|
||||
|
||||
## 0.2.7 — WebSocket Solana standard — 2026-08-23
|
||||
|
||||
`0.2.7` stabilise dans `ksp-onchain-transport-lib` le moteur WebSocket Solana standard en complément de la surface HTTP déjà complète. La release couvre exactement les **9 familles subscribe + 9 unsubscribe** de l’inventaire officiel ciblé : `account`, `block`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates` et `vote`. Les wrappers sont typés, les IDs KSP de session/subscription restent locaux et stables, les IDs serveur restent internes/remappables, et plusieurs sessions physiques peuvent coexister explicitement sur la même URL sans introduire de pool/scheduler automatique. Les familles `block`, `slotsUpdates` et `vote` restent identifiées comme unstable selon l’audit normatif courant et utilisent le warning KSP centralisé.
|
||||
|
||||
Le lifecycle WebSocket est borné : actor unique propriétaire du socket, pending JSON-RPC et queues de notifications bornés, `WsSession::close().await`, control frames Ping/Pong/Close, reconnect fini avec backoff borné, resubscribe déterministe par ID local, `continuity_gap_count`, isolation des erreurs applicatives et du backpressure par subscription, nettoyage des late ACK/notifications et terminaison one-shot de `signatureSubscribe`. Config passe à `std.transport` V2 pour composer HTTP + WebSocket tout en gardant la lecture V1 HTTP-only ; la direction reste `Config -> Transport`. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, les canaries Transport (`309` unit, `36` public API, `24` release completeness), le smoke WebSocket Devnet opt-in `slotSubscribe -> notification -> unsubscribe -> close`, les frontières de dépendances et la redaction des URLs/credentials. `prompts/013-V0_2_8_START_PROMPT.md`, renforcé par `0.2.7-pre.014-fix.001`, ouvre ensuite `0.2.8 — Helius LaserStream WebSocket` uniquement depuis le tag stable `v0.2.7`.
|
||||
|
||||
## 0.2.6 — Wallet Desk + `.kspwallet` V2 — 2026-08-22
|
||||
|
||||
`0.2.6` stabilise `ksp-app-wallet-desk` comme seconde application Tauri KSP spécialisée et étend `ksp-wallet-lib` avec le wire binaire `.kspwallet` V2. Wallet Desk compose Config, Wallet, Transport HTTP et Logging sans déplacer leurs responsabilités : inventory root-scoped et symlink-safe, création/import V2 par défaut, ouverture VIEW/OWNER V1/V2, candidats secrets résolus exclusivement par Config, `getBalance` Devnet via Transport, administration alias/notes, rotations OWNER/VIEW, disable/recreate VIEW fort et export Solana CLI JSON/Base58. Les secrets, keypairs, handles, chemins complets import/export et credentials Config restent côté Rust ; le frontend ne reçoit que des projections sûres et utilise des modals Bootstrap pour les opérations privilégiées.
|
||||
|
||||
30
Cargo.toml
30
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 191
|
||||
# version: 268
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.6"
|
||||
version = "0.2.10"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
@@ -15,29 +15,35 @@ publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
argon2 = { version = "^0.5", default-features = false }
|
||||
base64 = { version = "^0.23" }
|
||||
chacha20poly1305 = { version = "^0.11", default-features = false }
|
||||
chrono = { version = "^0.4", default-features = false }
|
||||
directories = { version = "^6.0" }
|
||||
ed25519-dalek = { version = "^3.0", default-features = false }
|
||||
getrandom = { version = "^0.4", default-features = false }
|
||||
base64 = { version = "^0.23" }
|
||||
fs2 = { version = "^0.4" }
|
||||
futures-util = { version = "^0.3", default-features = false }
|
||||
getrandom = { version = "^0.4", default-features = false }
|
||||
http = { version = "^1.5", default-features = false }
|
||||
jsonschema = { version = "^0.51", default-features = false }
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
serde = { version = "^1.0" }
|
||||
serde_json = { version = "^1.0" }
|
||||
jsonschema = { version = "^0.50", default-features = false }
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
solana-keypair = { version = "^3.1", default-features = false }
|
||||
solana-pubkey = { version = "^4.3", default-features = false }
|
||||
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 }
|
||||
tempfile = { version = "^3.27" }
|
||||
chrono = { version = "^0.4", default-features = false }
|
||||
tauri = { version = "^2.11" }
|
||||
tauri-build = { version = "^2.6" }
|
||||
tauri-plugin-dialog = { version = "^2.7" }
|
||||
tauri-plugin-tracing = { version = "^0.3" }
|
||||
tempfile = { version = "^3.27" }
|
||||
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 }
|
||||
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||
tonic = { version = "^0.14", default-features = false }
|
||||
tonic-prost = { version = "^0.14", default-features = false }
|
||||
ts-rs = { version = "^12.0" }
|
||||
yellowstone-grpc-proto = { version = "^12.6", default-features = false }
|
||||
zeroize = { version = "^1.9" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
|
||||
27
ROADMAP.md
27
ROADMAP.md
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 80 -->
|
||||
<!-- version: 87 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -51,13 +51,24 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
- [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication.
|
||||
- [X] `0.2.5` — Wallet foundation stable : `.kspwallet` V1, VIEW/OWNER indépendants, Argon2id/XChaCha20-Poly1305, autorité Ed25519 OWNER, persistence no-clobber, signature, administration/rotations/révocation VIEW forte, import/export Solana CLI JSON + Base58, canaris adversariaux, interop externe et documentation durable publiés. La clôture `pre.010-fix.001`–`fix.003` ajoute `ed25519-dalek 3.0.0` direct, normalise le Rust workspace et installe l’audit structurel Python complémentaire à rustfmt/Clippy. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet.
|
||||
- [X] `0.2.6` — `ksp-app-wallet-desk` + `.kspwallet` V2 stables : composition Config/Wallet/HTTP/Logging, lifecycle VIEW/OWNER, balance, administration/import/export, wire binaire V2, APIs multi-version, migration V1 -> V2 explicite et runtime Tauri packagé user-writable validés ; bundles Linux `.deb`/`.rpm`/`.AppImage` produits avant publication. Plan clôturé : `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`.
|
||||
- [ ] `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/validation des prix offchain, puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation possédée par le composant spécialisé.
|
||||
- [ ] `0.2.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.
|
||||
- [X] `0.2.7` — WebSocket Solana standard stable : 9 familles subscribe/unsubscribe typées (18/18 opérations), sessions physiques multiples explicites, subscriptions logiques typées, lifecycle/reconnect/resubscribe/backpressure/shutdown bornés, Config V2, non-régression HTTP 52+14, compliance finale, smoke WebSocket Devnet et audit de dépendances validés ; publication `rel.001` et prompt `0.2.8` prêts.
|
||||
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur l’actor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
|
||||
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes d’update, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
|
||||
- [X] `0.2.10` — OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
|
||||
- [ ] `0.2.11` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix provider-neutral, au minimum SOL/USD et SOL/EUR, après audit actuel des sémantiques provider et de la testabilité live.
|
||||
- [ ] `0.2.12` — Introduire une petite application desk de visualisation/validation des prix offchain, puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation possédée par le composant spécialisé.
|
||||
- [ ] `0.2.13` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
||||
- [ ] `0.2.14` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
||||
|
||||
### TODO/IDEAS — providers Yellowstone non planifiés
|
||||
|
||||
- [ ] **TODO** — Helius LaserStream gRPC : réauditer lorsque l'accès live gRPC est raisonnablement disponible ; conserver N1/N2 Yellowstone inchangés, vérifier auth/endpoints/Subscribe/Ping/replay/from_slot/erreurs provider et traiter les preprocessed transactions comme extension Helius séparée.
|
||||
- [ ] **TODO** — eRPC : réauditer accès, auth/IP policy, capabilities et produits complémentaires avant toute décision d’implémentation.
|
||||
- [ ] **TODO** — Triton : réauditer la frontière Yellowstone upstream / extensions Triton, notamment Deshred et futures extensions.
|
||||
- [ ] **TODO** — Alchemy : réauditer auth, replay, limites et capabilities Yellowstone avant toute intégration.
|
||||
- [ ] **TODO** — QuickNode : réauditer auth, compression, `from_slot`, filtres et limites de plan.
|
||||
- [ ] **TODO** — Chainstack : réauditer networks, auth, add-on et capabilities Yellowstone.
|
||||
- [ ] **IDEAS** — Tatum, Shyft, Solinfra, NodeFlare et autres providers : conserver comme candidats exploratoires sans numéro de release ni engagement d’implémentation.
|
||||
|
||||
### Règles Transport pour toute la série
|
||||
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"format_version": 2,
|
||||
"retry": {
|
||||
"max_retries": 3,
|
||||
"initial_backoff_ms": 150,
|
||||
"max_backoff_ms": 3000
|
||||
},
|
||||
"ws_defaults": {
|
||||
"command_timeout_ms": 10000,
|
||||
"close_timeout_ms": 5000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"resubscribe": "active_subscriptions",
|
||||
"command_queue_capacity": 128,
|
||||
"notification_queue_capacity": 256,
|
||||
"max_active_subscriptions": 1024,
|
||||
"max_pending_requests": 128,
|
||||
"max_message_size_bytes": 67108864,
|
||||
"max_frame_size_bytes": 16777216,
|
||||
"max_write_buffer_size_bytes": 1048576
|
||||
},
|
||||
"default_profile": "mainnet_mixed",
|
||||
"profiles": [
|
||||
{
|
||||
@@ -23,7 +40,9 @@
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 200,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
@@ -47,7 +66,9 @@
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 20,
|
||||
@@ -58,6 +79,69 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
},
|
||||
{
|
||||
"name": "mainnet_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
||||
"session": {
|
||||
"notification_queue_capacity": 512,
|
||||
"max_active_subscriptions": 2048
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "devnet_helius",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "devnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "devnet_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "devnet",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
{
|
||||
"$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"}
|
||||
"$id": "urn:ksp:schema:std.transport:v3",
|
||||
"title": "KSP standard HTTP + WebSocket + Yellowstone gRPC Transport configuration",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/documentV1"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/documentV2"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/documentV3"
|
||||
}
|
||||
},
|
||||
],
|
||||
"$defs": {
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
@@ -35,50 +33,99 @@
|
||||
"minimum": 1,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"positiveUsize": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"retry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["max_retries", "initial_backoff_ms", "max_backoff_ms"],
|
||||
"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"}
|
||||
"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"}
|
||||
"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"],
|
||||
"required": [
|
||||
"role",
|
||||
"enabled",
|
||||
"request_kinds",
|
||||
"priority",
|
||||
"limits"
|
||||
],
|
||||
"properties": {
|
||||
"role": {"$ref": "#/$defs/descriptor"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"role": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"request_kinds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {"$ref": "#/$defs/descriptor"},
|
||||
"items": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"contains": {"const": "*"}},
|
||||
"then": {"maxItems": 1}
|
||||
"if": {
|
||||
"contains": {
|
||||
"const": "*"
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": {"type": "integer", "minimum": 0, "maximum": 4294967295},
|
||||
"limits": {"$ref": "#/$defs/limits"}
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"limits": {
|
||||
"$ref": "#/$defs/limits"
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"httpEndpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
@@ -92,31 +139,592 @@
|
||||
"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},
|
||||
"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"}
|
||||
"items": {
|
||||
"$ref": "#/$defs/role"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"wsReconnect": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["profile_id", "endpoints"],
|
||||
"required": [
|
||||
"max_retries",
|
||||
"initial_backoff_ms",
|
||||
"max_backoff_ms"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {"$ref": "#/$defs/profileId"},
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsReconnectOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsSession": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"command_timeout_ms",
|
||||
"close_timeout_ms",
|
||||
"reconnect",
|
||||
"resubscribe",
|
||||
"command_queue_capacity",
|
||||
"notification_queue_capacity",
|
||||
"max_active_subscriptions",
|
||||
"max_pending_requests",
|
||||
"max_message_size_bytes",
|
||||
"max_frame_size_bytes",
|
||||
"max_write_buffer_size_bytes"
|
||||
],
|
||||
"properties": {
|
||||
"command_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/wsReconnect"
|
||||
},
|
||||
"resubscribe": {
|
||||
"enum": [
|
||||
"never",
|
||||
"active_subscriptions"
|
||||
]
|
||||
},
|
||||
"command_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"notification_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_active_subscriptions": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_pending_requests": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_message_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_frame_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_write_buffer_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsSessionOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"command_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/wsReconnectOverride"
|
||||
},
|
||||
"resubscribe": {
|
||||
"enum": [
|
||||
"never",
|
||||
"active_subscriptions"
|
||||
]
|
||||
},
|
||||
"command_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"notification_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_active_subscriptions": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_pending_requests": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_message_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_frame_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_write_buffer_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsEndpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"enabled",
|
||||
"provider",
|
||||
"cluster",
|
||||
"kind",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"cluster": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"solana_standard",
|
||||
"helius_laserstream"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/$defs/wsSessionOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"endpoints"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/endpoint"}
|
||||
"items": {
|
||||
"$ref": "#/$defs/httpEndpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"endpoints",
|
||||
"ws_endpoints"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/httpEndpoint"
|
||||
}
|
||||
},
|
||||
"ws_endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/wsEndpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentV1": {
|
||||
"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/profileV1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"retry",
|
||||
"ws_defaults",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 2
|
||||
},
|
||||
"retry": {
|
||||
"$ref": "#/$defs/retry"
|
||||
},
|
||||
"ws_defaults": {
|
||||
"$ref": "#/$defs/wsSession"
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcPositiveMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 300000
|
||||
},
|
||||
"grpcCapacity": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65536
|
||||
},
|
||||
"grpcMessageSize": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 536870912
|
||||
},
|
||||
"grpcReconnect": {
|
||||
"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/grpcPositiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcReconnectOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcSession": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connect_timeout_ms",
|
||||
"unary_timeout_ms",
|
||||
"close_timeout_ms",
|
||||
"reconnect",
|
||||
"request_channel_capacity",
|
||||
"update_channel_capacity",
|
||||
"max_inbound_message_size_bytes",
|
||||
"max_outbound_message_size_bytes"
|
||||
],
|
||||
"properties": {
|
||||
"connect_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"unary_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/grpcReconnect"
|
||||
},
|
||||
"request_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"update_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"max_inbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
},
|
||||
"max_outbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcSessionOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"connect_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"unary_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/grpcReconnectOverride"
|
||||
},
|
||||
"request_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"update_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"max_inbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
},
|
||||
"max_outbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcMetadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[0-9a-z_.-]+$"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 8192
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcEndpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"enabled",
|
||||
"provider",
|
||||
"cluster",
|
||||
"protocol",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"cluster": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"protocol": {
|
||||
"const": "solana_yellowstone"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 8192
|
||||
},
|
||||
"metadata": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/grpcMetadata"
|
||||
}
|
||||
},
|
||||
"secret_metadata": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/grpcMetadata"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/$defs/grpcSessionOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV3": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"endpoints",
|
||||
"ws_endpoints"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/httpEndpoint"
|
||||
}
|
||||
},
|
||||
"ws_endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/wsEndpoint"
|
||||
}
|
||||
},
|
||||
"grpc_endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"$ref": "#/$defs/grpcEndpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentV3": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"retry",
|
||||
"ws_defaults",
|
||||
"grpc_defaults",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 3
|
||||
},
|
||||
"retry": {
|
||||
"$ref": "#/$defs/retry"
|
||||
},
|
||||
"ws_defaults": {
|
||||
"$ref": "#/$defs/wsSession"
|
||||
},
|
||||
"grpc_defaults": {
|
||||
"$ref": "#/$defs/grpcSession"
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"format_version": 3,
|
||||
"retry": {
|
||||
"max_retries": 2,
|
||||
"initial_backoff_ms": 100,
|
||||
"max_backoff_ms": 2000
|
||||
},
|
||||
"ws_defaults": {
|
||||
"command_timeout_ms": 10000,
|
||||
"close_timeout_ms": 5000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"resubscribe": "active_subscriptions",
|
||||
"command_queue_capacity": 128,
|
||||
"notification_queue_capacity": 256,
|
||||
"max_active_subscriptions": 1024,
|
||||
"max_pending_requests": 128,
|
||||
"max_message_size_bytes": 67108864,
|
||||
"max_frame_size_bytes": 16777216,
|
||||
"max_write_buffer_size_bytes": 1048576
|
||||
},
|
||||
"grpc_defaults": {
|
||||
"connect_timeout_ms": 10000,
|
||||
"unary_timeout_ms": 10000,
|
||||
"close_timeout_ms": 5000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"request_channel_capacity": 128,
|
||||
"update_channel_capacity": 256,
|
||||
"max_inbound_message_size_bytes": 67108864,
|
||||
"max_outbound_message_size_bytes": 67108864
|
||||
},
|
||||
"default_profile": "devnet_public",
|
||||
"profiles": [
|
||||
{
|
||||
@@ -23,7 +54,9 @@
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
@@ -34,6 +67,74 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_devnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "orbitflare_devnet",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "solana_devnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_devnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "orbitflare_solana_devnet_yellowstone",
|
||||
"enabled": true,
|
||||
"provider": "orbitflare",
|
||||
"cluster": "devnet",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "http://devnet.rpc.orbitflare.com:10000",
|
||||
"metadata": [],
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-token",
|
||||
"value": "${KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -52,7 +153,9 @@
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
@@ -63,6 +166,130 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "publicnode_mainnet",
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "publicnode_solana_mainnet_yellowstone",
|
||||
"enabled": true,
|
||||
"provider": "publicnode",
|
||||
"cluster": "mainnet-beta",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://solana-yellowstone-grpc.publicnode.com:443",
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-token",
|
||||
"value": "${KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "publicnode_testnet",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "solana_testnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "testnet",
|
||||
"url": "https://api.testnet.solana.com",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_testnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "testnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "wss://api.testnet.solana.com"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "publicnode_solana_testnet_yellowstone",
|
||||
"enabled": true,
|
||||
"provider": "publicnode",
|
||||
"cluster": "testnet",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://solana-testnet-yellowstone-grpc.publicnode.com:443",
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-token",
|
||||
"value": "${KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-app-config-desk/Cargo.toml
|
||||
# version: 8
|
||||
# version: 9
|
||||
|
||||
[package]
|
||||
name = "ksp-app-config-desk"
|
||||
@@ -22,6 +22,7 @@ path = "src/main.rs"
|
||||
tauri-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true, features = ["std", "now"] }
|
||||
fs2.workspace = true
|
||||
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
@@ -30,7 +31,6 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tauri.workspace = true
|
||||
tauri-plugin-tracing.workspace = true
|
||||
chrono = { workspace = true, features = ["std", "now"] }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
ts-rs.workspace = true
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-config-desk/README.md -->
|
||||
<!-- version: 27 -->
|
||||
<!-- version: 28 -->
|
||||
|
||||
# `ksp-app-config-desk`
|
||||
|
||||
@@ -78,7 +78,7 @@ Les DTO Rust applicatifs restent la source de vérité et les bindings généré
|
||||
|
||||
## Robustesse desktop et extensibilité
|
||||
|
||||
`pre.018` rend le fallback de bootstrap Logging testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink.
|
||||
Le fallback de bootstrap Logging est testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink.
|
||||
|
||||
Le frontend possède désormais `shell_registry.ts`. Le registre décrit les vues du shell et les adapters d'éditeurs spécialisés par `file_id`. Le panneau Documents reste générique ; lorsqu'un document possède un adapter enregistré, **Ouvrir l'éditeur spécialisé** déclenche la navigation par événement de registre. `cfg.std.logging -> Logging` est le premier adapter. Ajouter un futur éditeur ne demande donc pas de réécrire le moteur Documents ni la logique générale d'activation des panneaux.
|
||||
|
||||
@@ -123,11 +123,11 @@ main -> ksp-app-config-desk.frontend.main
|
||||
splash -> ksp-app-config-desk.frontend.splash
|
||||
```
|
||||
|
||||
Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit est volontairement hors périmètre de `0.1.4` : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes.
|
||||
Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit reste volontairement hors de la surface actuelle : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes.
|
||||
|
||||
## Panneau Test Logging
|
||||
|
||||
`pre.017` ajoute une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime.
|
||||
Le panneau fournit une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime.
|
||||
|
||||
**Log via bridge frontend** réutilise le bridge déjà installé. Son contrat reste volontairement fixe : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. Cela permet de comparer dans le même panneau le routing backend et le chemin WebView -> Rust -> `ksp-logging-lib`, avant et après modification/hot reload des filtres.
|
||||
|
||||
@@ -171,13 +171,13 @@ La vue **Logging** charge le document standard exclusivement avec `ConfigManagem
|
||||
|
||||
Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. Le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder et appliquer**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Après persistence, Config Desk recharge un `ConfigEnvironment` frais, résout le `default_profile`, puis appelle `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` actif. Le hot reload est immédiat et `logging_generation` avance uniquement après succès. Si l'application runtime échoue, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** ne modifie que le brouillon/source persistée.
|
||||
|
||||
`pre.016` distingue en plus le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés.
|
||||
Le panneau distingue le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés.
|
||||
|
||||
Chaque lancement de Config Desk crée aussi une `LoggingRuntimeIdentity` stable : `application_id` + timestamp UTC de démarrage + PID. `ksp-logging-lib` utilise cette identité pour préfixer les noms des fichiers actifs et la conserve pendant tous les hot reloads du même processus. Deux lancements distincts ne partagent donc plus le même fichier persistant, même avec une rotation `daily`. Avec la configuration de release `info/ksp-info.log`, un prefix effectif peut être `ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log`. Le path Config reste inchangé ; l'identité appartient au runtime, pas au document source.
|
||||
|
||||
## Baseline Logging de release
|
||||
|
||||
La configuration canonique livrée avec `0.1.4` revient à une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors d’un développement ou diagnostic, puis redescendus avant la release suivante.
|
||||
La configuration canonique de release utilise une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors d’un développement ou diagnostic, puis redescendus avant la release suivante.
|
||||
|
||||
## Traçabilité frontend
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
||||
<!-- version: 27 -->
|
||||
<!-- version: 28 -->
|
||||
|
||||
# Utilisation de `ksp-app-config-desk`
|
||||
|
||||
@@ -85,7 +85,7 @@ splash
|
||||
|
||||
Ils sont convertis côté Rust vers des targets KSP statiques `ksp-app-config-desk.frontend*` et émis uniquement via `ksp-logging-lib`. Un niveau différent de `trace`, `debug`, `info`, `warn` ou `error`, ou un `targetId` non whitelisté, est rejeté avec un `CommandErrorDto` sûr.
|
||||
|
||||
`main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit est reporté hors `0.1.4`; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge.
|
||||
`main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit reste hors de la surface actuelle ; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge.
|
||||
|
||||
## Bindings TS-RS
|
||||
|
||||
@@ -287,7 +287,7 @@ Si la résolution ou la préparation du profil explicite échoue, `ksp_logging_l
|
||||
|
||||
## Baseline Logging de release
|
||||
|
||||
Après les tests `debug`/`trace`, la configuration canonique `0.1.4` revient à :
|
||||
Après les tests `debug`/`trace`, la configuration canonique revient à :
|
||||
|
||||
```text
|
||||
default_filter = warn
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
//! Desktop build/shell contract audits for Config Desk.
|
||||
|
||||
@@ -26,6 +26,43 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
||||
let core = match value.split_once('-') {
|
||||
std::option::Option::Some((core, _)) => core,
|
||||
std::option::Option::None => value,
|
||||
};
|
||||
let mut parts = core.split('.');
|
||||
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some((major, minor, patch));
|
||||
}
|
||||
|
||||
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
||||
assert!(value.is_some(), "{field} packaged version must exist");
|
||||
let value = match value {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let parsed = parse_semver_core(value);
|
||||
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
||||
if let std::option::Option::Some(parsed) = parsed {
|
||||
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_and_frontend_build_contracts_remain_explicit() {
|
||||
let root = app_root();
|
||||
@@ -103,11 +140,13 @@ fn pre_014_template_uses_sidebar_navigation_and_kbot_style_splash_contract() {
|
||||
#[test]
|
||||
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
|
||||
let root = app_root();
|
||||
let expected_version = env!("CARGO_PKG_VERSION");
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
assert_eq!(tauri.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
||||
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
assert_eq!(package.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
||||
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(package_version, "package.json");
|
||||
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||
assert!(resources.is_some(), "packaged Config resources map must exist");
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- file: crates/ksp-app-wallet-desk/README.md -->
|
||||
<!-- version: 2 -->
|
||||
<!-- version: 3 -->
|
||||
|
||||
# `ksp-app-wallet-desk`
|
||||
|
||||
`ksp-app-wallet-desk` est l'application desktop spécialisée stable d'administration et de validation des wallets KSP depuis KSP `0.2.6`.
|
||||
`ksp-app-wallet-desk` est l'application desktop spécialisée stable d'administration et de validation des wallets KSP.
|
||||
|
||||
La crate est un package Tauri mixte :
|
||||
|
||||
@@ -29,7 +29,7 @@ Le frontend ne reçoit jamais les keypairs, ciphertexts, passwords Config, paths
|
||||
|
||||
## `.kspwallet` V1/V2
|
||||
|
||||
La release `0.2.6` conserve V1 et ajoute V2 :
|
||||
La surface courante conserve V1 et utilise V2 comme format natif par défaut :
|
||||
|
||||
```text
|
||||
V1 : JSON UTF-8 historique, lecture explicite toujours supportée
|
||||
@@ -45,9 +45,9 @@ Wallet Desk consomme uniquement les APIs non versionnées de `ksp-wallet-lib` :
|
||||
|
||||
`ksp-wallet-lib` conserve parallèlement les APIs explicites `_v1` / `_v2` pour les consumers qui doivent imposer un format. `DEFAULT_WALLET_FORMAT` et `LATEST_SUPPORTED_WALLET_FORMAT` sont des politiques distinctes ; l'apparition future d'un V3 n'impose donc pas de modifier automatiquement le format créé par les APIs génériques.
|
||||
|
||||
La migration V1 -> V2 est une opération explicite OWNER-authentifiée possédée par `ksp-wallet-lib`. Wallet Desk `0.2.6` ne migre jamais silencieusement un wallet lors de sa sélection, inspection ou ouverture.
|
||||
La migration V1 -> V2 est une opération explicite OWNER-authentifiée possédée par `ksp-wallet-lib`. Wallet Desk ne migre jamais silencieusement un wallet lors de sa sélection, inspection ou ouverture.
|
||||
|
||||
## Capacités fonctionnelles `0.2.6`
|
||||
## Capacités fonctionnelles
|
||||
|
||||
La surface validée comprend :
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
||||
// version: 21
|
||||
// version: 22
|
||||
|
||||
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
||||
|
||||
@@ -31,6 +31,43 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
||||
let core = match value.split_once('-') {
|
||||
std::option::Option::Some((core, _)) => core,
|
||||
std::option::Option::None => value,
|
||||
};
|
||||
let mut parts = core.split('.');
|
||||
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some((major, minor, patch));
|
||||
}
|
||||
|
||||
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
||||
assert!(value.is_some(), "{field} packaged version must exist");
|
||||
let value = match value {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let parsed = parse_semver_core(value);
|
||||
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
||||
if let std::option::Option::Some(parsed) = parsed {
|
||||
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() {
|
||||
let root = app_root();
|
||||
@@ -419,13 +456,19 @@ fn pre_017_wallet_desk_open_paths_remain_non_migrating() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_current() {
|
||||
fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_coherent() {
|
||||
let root = app_root();
|
||||
let expected_version = env!("CARGO_PKG_VERSION");
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
assert_eq!(tauri.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
||||
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
assert_eq!(package.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
||||
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(package_version, "package.json");
|
||||
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
||||
let packaged_version = match package_version {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
@@ -440,7 +483,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
|
||||
);
|
||||
}
|
||||
let main = read_text(root.join("frontend/main.html").as_path());
|
||||
assert!(main.contains(expected_version));
|
||||
assert!(main.contains(packaged_version));
|
||||
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
|
||||
assert!(tauri_source.contains("tauri::utils::platform::resource_dir"));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-config-lib/Cargo.toml
|
||||
# version: 7
|
||||
# version: 8
|
||||
|
||||
[package]
|
||||
name = "ksp-config-lib"
|
||||
@@ -9,12 +9,12 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
directories.workspace = true
|
||||
jsonschema.workspace = true
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
jsonschema.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/README.md -->
|
||||
<!-- version: 6 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# ksp-config-lib
|
||||
|
||||
@@ -23,7 +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_*` ;
|
||||
- l'adapter du document Transport V1/V2 vers `HttpTransportSettings` et, en V2, `WsTransportSettings`, 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`.
|
||||
@@ -81,15 +81,15 @@ 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 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. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
|
||||
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport accepte les valeurs secrètes pour les URLs HTTP/WebSocket et, en V3, pour `grpc_endpoints[].secret_metadata[]` : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. Les metadata gRPC publiques et secrètes sont séparées et leur provenance Config est contrôlée avant mapping. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
|
||||
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ;
|
||||
- [`TODO.md`](TODO.md) — points explicitement différés ;
|
||||
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
|
||||
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard HTTP Transport ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport V3 HTTP + WebSocket + Yellowstone gRPC, avec lecture backward des V1/V2 ;
|
||||
- [`../../config/std.wallet.json`](../../config/std.wallet.json) — racine Wallet globale et sous-répertoire optionnel par profil ;
|
||||
- [`../../config/composite.ksp-app-wallet-desk.json`](../../config/composite.ksp-app-wallet-desk.json) — composition Logging/Transport/Wallet de Wallet Desk ;
|
||||
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||
<!-- version: 6 -->
|
||||
<!-- version: 12 -->
|
||||
|
||||
# Utilisation de ksp-config-lib
|
||||
|
||||
@@ -125,9 +125,9 @@ 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
|
||||
### 4.1 Construire le Transport HTTP + WebSocket + Yellowstone gRPC depuis Config
|
||||
|
||||
Config possède également l'adapter du document `std.transport` vers le contrat runtime de `ksp-onchain-transport-lib` :
|
||||
Config possède l'adapter du document `std.transport` vers les contrats runtime de `ksp-onchain-transport-lib` :
|
||||
|
||||
```rust
|
||||
let transport = match engine.load_resolved_transport_config(std::option::Option::None, &environment) {
|
||||
@@ -135,12 +135,32 @@ let transport = match engine.load_resolved_transport_config(std::option::Option:
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let transport_settings = transport.into_settings();
|
||||
let http_settings = transport.http_settings();
|
||||
let ws_settings = transport.ws_settings();
|
||||
let grpc_settings = transport.grpc_settings();
|
||||
let _ = (http_settings, ws_settings, grpc_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.
|
||||
`std.transport` V3 conserve intégralement les shapes V1/V2, ajoute `grpc_defaults` au niveau global et permet `profiles[].grpc_endpoints[]`. Les profils V3 peuvent rester HTTP + WebSocket seulement : l'absence de `grpc_endpoints` mappe vers `None` et n'invente aucun endpoint. La lecture V1 HTTP-only et V2 HTTP + WebSocket reste stricte et backward-compatible.
|
||||
|
||||
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.
|
||||
Un endpoint gRPC V3 sépare explicitement :
|
||||
|
||||
```text
|
||||
provider = description de l'opérateur/exécution, par exemple publicnode
|
||||
protocol = solana_yellowstone
|
||||
metadata = metadata non secrète
|
||||
secret_metadata = metadata dont la valeur doit avoir une provenance KSP_SECRET_*/KSPB_SECRET_*
|
||||
```
|
||||
|
||||
`protocol` n'est pas un nouveau `WsProtocolKind` et ne transforme pas PublicNode en protocole. Config résout les placeholders, vérifie la classe de sensibilité des metadata puis construit `YellowstoneGrpcTransportSettings`. Transport ne lit jamais l'environnement. Les valeurs de `secret_metadata` sont disponibles au runtime mais redacted dans `safe_value` et dans les représentations `Debug`.
|
||||
|
||||
Le provider affiche `solana-yellowstone-grpc.publicnode.com:443` ; le profil versionné `publicnode_mainnet` le représente sous la forme URL TLS requise par Transport, `https://solana-yellowstone-grpc.publicnode.com:443`, sans credential. Aucun hostname Testnet n'est versionné tant que sa valeur exacte n'est pas confirmée depuis une source opérateur/officielle.
|
||||
|
||||
L'accesseur historique `into_transport_settings()` conserve volontairement son tuple `(HTTP, Option<WS>)`. Un consumer ayant besoin des trois backends utilise `into_all_transport_settings()` ou les accesseurs séparés afin de ne pas casser silencieusement les consumers V2.
|
||||
|
||||
Les protocoles WebSocket restent `kind = "solana_standard"` et `kind = "helius_laserstream"`. Helius LaserStream WebSocket conserve ses URLs Config-owned et sa clé `KSP_SECRET_HELIUS_API_KEY`; cette surface est indépendante de Yellowstone gRPC.
|
||||
|
||||
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. La dépendance reste unidirectionnelle : Config connaît les contrats Transport pour les construire ; Transport ne connaît ni Config, ni `.env`, ni les variables KSP.
|
||||
|
||||
### 4.2 Résoudre le répertoire Wallet depuis Config
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/lib.rs
|
||||
// version: 16
|
||||
// version: 18
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -9,8 +9,9 @@
|
||||
//!
|
||||
//! 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. Standard Logging, HTTP Transport and Wallet documents map explicitly to their runtime consumer contracts, while the management surface
|
||||
//! provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
|
||||
//! provenance. Standard Logging, on-chain Transport (HTTP/WebSocket/Yellowstone gRPC) and Wallet documents map explicitly to their runtime consumer
|
||||
//! 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;
|
||||
@@ -154,9 +155,9 @@ 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.
|
||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
||||
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
|
||||
/// Default physical filename for the standard Wallet configuration document.
|
||||
pub use self::registry::DEFAULT_STD_WALLET_FILENAME;
|
||||
@@ -168,13 +169,13 @@ pub use self::registry::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
|
||||
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.
|
||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_WALLET;
|
||||
/// 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.
|
||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
||||
/// Logical file identifier for the standard Wallet configuration document.
|
||||
pub use self::registry::FILE_ID_STD_WALLET;
|
||||
@@ -188,7 +189,7 @@ 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`.
|
||||
/// Effective standard Transport configuration mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime settings.
|
||||
pub use self::transport::ResolvedTransportConfig;
|
||||
/// Effective standard Wallet configuration resolved to validated filesystem roots.
|
||||
pub use self::wallet::ResolvedWalletConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/registry.rs
|
||||
// version: 8
|
||||
// version: 10
|
||||
|
||||
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||
pub const ARG_FILE_MAP: &str = "--filemap";
|
||||
@@ -11,9 +11,9 @@ pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
||||
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.
|
||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
||||
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
|
||||
/// Default physical filename for the standard Wallet configuration document.
|
||||
pub const DEFAULT_STD_WALLET_FILENAME: &str = "std.wallet.json";
|
||||
@@ -25,13 +25,13 @@ pub const FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK: &str = "cfg.composite.ksp-app-w
|
||||
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
||||
/// 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 JSON Schema document.
|
||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet";
|
||||
/// Logical file identifier for the standard Logging configuration document.
|
||||
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
||||
/// Logical file identifier for the standard HTTP Transport configuration document.
|
||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
||||
/// Logical file identifier for the standard Wallet configuration document.
|
||||
pub const FILE_ID_STD_WALLET: &str = "cfg.std.wallet";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-config-lib/src/transport.rs
|
||||
// version: 2
|
||||
// version: 5
|
||||
|
||||
/// Effective standard HTTP Transport configuration resolved from Config and mapped to the Transport runtime contract.
|
||||
/// Effective standard on-chain Transport configuration resolved from Config and mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime contracts.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct ResolvedTransportConfig {
|
||||
file_id: crate::ConfigFileId,
|
||||
@@ -10,6 +10,8 @@ pub struct ResolvedTransportConfig {
|
||||
selection_source: crate::ConfigProfileSelectionSource,
|
||||
effective: crate::ResolvedConfigJson,
|
||||
settings: ksp_onchain_transport_lib::HttpTransportSettings,
|
||||
ws_settings: std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>,
|
||||
grpc_settings: std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings>,
|
||||
}
|
||||
|
||||
impl ResolvedTransportConfig {
|
||||
@@ -47,16 +49,63 @@ impl ResolvedTransportConfig {
|
||||
}
|
||||
|
||||
/// Returns the validated runtime HTTP Transport settings.
|
||||
///
|
||||
/// This compatibility accessor keeps the HTTP contract introduced before Transport V2.
|
||||
#[must_use]
|
||||
pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Returns the validated runtime HTTP Transport settings.
|
||||
#[must_use]
|
||||
pub const fn http_settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Returns validated WebSocket Transport settings when the selected document uses format V2 or V3.
|
||||
///
|
||||
/// Backward-compatible V1 HTTP-only documents return [`std::option::Option::None`].
|
||||
#[must_use]
|
||||
pub fn ws_settings(&self) -> std::option::Option<&ksp_onchain_transport_lib::WsTransportSettings> {
|
||||
return self.ws_settings.as_ref();
|
||||
}
|
||||
|
||||
/// Returns validated Yellowstone gRPC Transport settings when the selected V3 profile declares gRPC endpoints.
|
||||
///
|
||||
/// V1/V2 documents and V3 profiles without `grpc_endpoints` return [`std::option::Option::None`].
|
||||
#[must_use]
|
||||
pub fn grpc_settings(&self) -> std::option::Option<&ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings> {
|
||||
return self.grpc_settings.as_ref();
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Consumes this resolved Config and returns HTTP plus optional WebSocket runtime settings.
|
||||
///
|
||||
/// This compatibility accessor intentionally preserves the V2 tuple shape. Use [`Self::into_all_transport_settings`] when Yellowstone gRPC settings are
|
||||
/// also required.
|
||||
#[must_use]
|
||||
pub fn into_transport_settings(
|
||||
self,
|
||||
) -> (ksp_onchain_transport_lib::HttpTransportSettings, std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>) {
|
||||
return (self.settings, self.ws_settings);
|
||||
}
|
||||
|
||||
/// Consumes this resolved Config and returns HTTP plus optional WebSocket and Yellowstone gRPC runtime settings.
|
||||
#[must_use]
|
||||
pub fn into_all_transport_settings(
|
||||
self,
|
||||
) -> (
|
||||
ksp_onchain_transport_lib::HttpTransportSettings,
|
||||
std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>,
|
||||
std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings>,
|
||||
) {
|
||||
return (self.settings, self.ws_settings, self.grpc_settings);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ResolvedTransportConfig {
|
||||
@@ -68,16 +117,18 @@ impl std::fmt::Debug for ResolvedTransportConfig {
|
||||
.field("profile_id", &self.profile_id)
|
||||
.field("selection_source", &self.selection_source)
|
||||
.field("effective", &self.effective)
|
||||
.field("has_ws_settings", &self.ws_settings.is_some())
|
||||
.field("has_grpc_settings", &self.grpc_settings.is_some())
|
||||
.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.
|
||||
/// Loads the standard Transport document, selects a profile, resolves environment placeholders and maps HTTP plus optional WebSocket and Yellowstone
|
||||
/// gRPC 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.
|
||||
/// because Transport URL wrappers own runtime redaction. V1 remains HTTP-only, V2 adds WebSocket and V3 can additionally map Yellowstone gRPC.
|
||||
pub fn load_resolved_transport_config(
|
||||
&self,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
@@ -96,7 +147,8 @@ impl crate::ConfigDocumentEngine {
|
||||
return resolve_transport_profile(&profile, environment);
|
||||
}
|
||||
|
||||
/// Maps an already resolved standard Transport profile to the runtime HTTP Transport adapter while preserving its selection provenance.
|
||||
/// Maps an already resolved standard Transport profile to HTTP plus optional WebSocket and Yellowstone gRPC runtime adapters while preserving
|
||||
/// selection provenance.
|
||||
///
|
||||
/// This entry point is intended for profiles selected by a composite. The profile must reference `cfg.std.transport`.
|
||||
pub fn resolve_transport_config_profile(
|
||||
@@ -121,7 +173,15 @@ struct EffectiveTransportSource {
|
||||
format_version: u32,
|
||||
profile_id: String,
|
||||
retry: EffectiveRetrySource,
|
||||
#[serde(default)]
|
||||
ws_defaults: std::option::Option<EffectiveWsSessionSource>,
|
||||
#[serde(default)]
|
||||
grpc_defaults: std::option::Option<EffectiveGrpcSessionSource>,
|
||||
endpoints: std::vec::Vec<EffectiveEndpointSource>,
|
||||
#[serde(default)]
|
||||
ws_endpoints: std::option::Option<std::vec::Vec<EffectiveWsEndpointSource>>,
|
||||
#[serde(default)]
|
||||
grpc_endpoints: std::option::Option<std::vec::Vec<EffectiveGrpcEndpointSource>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -165,7 +225,135 @@ struct EffectiveLimitsSource {
|
||||
pause_after_rate_limit_ms: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsSessionSource {
|
||||
command_timeout_ms: u64,
|
||||
close_timeout_ms: u64,
|
||||
reconnect: EffectiveWsReconnectSource,
|
||||
resubscribe: String,
|
||||
command_queue_capacity: usize,
|
||||
notification_queue_capacity: usize,
|
||||
max_active_subscriptions: usize,
|
||||
max_pending_requests: usize,
|
||||
max_message_size_bytes: usize,
|
||||
max_frame_size_bytes: usize,
|
||||
max_write_buffer_size_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsReconnectSource {
|
||||
max_retries: u32,
|
||||
initial_backoff_ms: u64,
|
||||
max_backoff_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsEndpointSource {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
provider: String,
|
||||
cluster: String,
|
||||
kind: String,
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
session: std::option::Option<EffectiveWsSessionOverrideSource>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsSessionOverrideSource {
|
||||
command_timeout_ms: std::option::Option<u64>,
|
||||
close_timeout_ms: std::option::Option<u64>,
|
||||
reconnect: std::option::Option<EffectiveWsReconnectOverrideSource>,
|
||||
resubscribe: std::option::Option<String>,
|
||||
command_queue_capacity: std::option::Option<usize>,
|
||||
notification_queue_capacity: std::option::Option<usize>,
|
||||
max_active_subscriptions: std::option::Option<usize>,
|
||||
max_pending_requests: std::option::Option<usize>,
|
||||
max_message_size_bytes: std::option::Option<usize>,
|
||||
max_frame_size_bytes: std::option::Option<usize>,
|
||||
max_write_buffer_size_bytes: std::option::Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsReconnectOverrideSource {
|
||||
max_retries: std::option::Option<u32>,
|
||||
initial_backoff_ms: std::option::Option<u64>,
|
||||
max_backoff_ms: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcReconnectSource {
|
||||
max_retries: u32,
|
||||
initial_backoff_ms: u64,
|
||||
max_backoff_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcReconnectOverrideSource {
|
||||
max_retries: std::option::Option<u32>,
|
||||
initial_backoff_ms: std::option::Option<u64>,
|
||||
max_backoff_ms: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcSessionSource {
|
||||
connect_timeout_ms: u64,
|
||||
unary_timeout_ms: u64,
|
||||
close_timeout_ms: u64,
|
||||
reconnect: EffectiveGrpcReconnectSource,
|
||||
request_channel_capacity: usize,
|
||||
update_channel_capacity: usize,
|
||||
max_inbound_message_size_bytes: usize,
|
||||
max_outbound_message_size_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcSessionOverrideSource {
|
||||
connect_timeout_ms: std::option::Option<u64>,
|
||||
unary_timeout_ms: std::option::Option<u64>,
|
||||
close_timeout_ms: std::option::Option<u64>,
|
||||
reconnect: std::option::Option<EffectiveGrpcReconnectOverrideSource>,
|
||||
request_channel_capacity: std::option::Option<usize>,
|
||||
update_channel_capacity: std::option::Option<usize>,
|
||||
max_inbound_message_size_bytes: std::option::Option<usize>,
|
||||
max_outbound_message_size_bytes: std::option::Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcMetadataSource {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveGrpcEndpointSource {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
provider: String,
|
||||
cluster: String,
|
||||
protocol: String,
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
metadata: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||
#[serde(default)]
|
||||
secret_metadata: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||
#[serde(default)]
|
||||
session: std::option::Option<EffectiveGrpcSessionOverrideSource>,
|
||||
}
|
||||
|
||||
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapping standard Transport Config profile");
|
||||
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||
let effective = match effective {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -180,12 +368,10 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
||||
);
|
||||
},
|
||||
};
|
||||
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 format_version = source.format_version;
|
||||
let retry = ksp_onchain_transport_lib::HttpRetrySettings::new(
|
||||
source.retry.max_retries,
|
||||
std::time::Duration::from_millis(source.retry.initial_backoff_ms),
|
||||
@@ -197,14 +383,34 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
||||
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));
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(transport_contract_error(profile, "effective HTTP Transport settings fail the Transport runtime contract", &error));
|
||||
}
|
||||
let ws_settings = map_optional_ws_settings(format_version, source.ws_defaults, source.ws_endpoints, profile);
|
||||
let ws_settings = match ws_settings {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let grpc_settings = map_optional_grpc_settings(format_version, source.grpc_defaults, source.grpc_endpoints, &effective, profile);
|
||||
let grpc_settings = match grpc_settings {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let ws_endpoint_count = match ws_settings.as_ref() {
|
||||
std::option::Option::Some(value) => value.endpoints().len(),
|
||||
std::option::Option::None => 0_usize,
|
||||
};
|
||||
let grpc_endpoint_count = match grpc_settings.as_ref() {
|
||||
std::option::Option::Some(value) => value.endpoints().len(),
|
||||
std::option::Option::None => 0_usize,
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
endpoint_count = settings.endpoints().len(),
|
||||
format_version,
|
||||
http_endpoint_count = settings.endpoints().len(),
|
||||
ws_endpoint_count,
|
||||
grpc_endpoint_count,
|
||||
"mapped standard Transport Config to runtime settings"
|
||||
);
|
||||
return std::result::Result::Ok(ResolvedTransportConfig {
|
||||
@@ -214,9 +420,335 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
||||
selection_source: profile.selection_source(),
|
||||
effective,
|
||||
settings,
|
||||
ws_settings,
|
||||
grpc_settings,
|
||||
});
|
||||
}
|
||||
|
||||
fn map_optional_ws_settings(
|
||||
format_version: u32,
|
||||
defaults: std::option::Option<EffectiveWsSessionSource>,
|
||||
sources: std::option::Option<std::vec::Vec<EffectiveWsEndpointSource>>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>> {
|
||||
return match format_version {
|
||||
1 => {
|
||||
if defaults.is_some() || sources.is_some() {
|
||||
std::result::Result::Err(effective_error(profile, "Transport V1 must remain HTTP-only"))
|
||||
} else {
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
"mapped backward-compatible Transport V1 without WebSocket settings"
|
||||
);
|
||||
std::result::Result::Ok(std::option::Option::None)
|
||||
}
|
||||
},
|
||||
2 | 3 => {
|
||||
let defaults = match defaults {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V2/V3 requires ws_defaults")),
|
||||
};
|
||||
let sources = match sources {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V2/V3 profile requires ws_endpoints")),
|
||||
};
|
||||
let endpoints = map_ws_endpoints(sources, &defaults, 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::WsTransportSettings::new(endpoints);
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(transport_contract_error(
|
||||
profile,
|
||||
"effective WebSocket Transport settings fail the Transport runtime contract",
|
||||
&error,
|
||||
));
|
||||
}
|
||||
std::result::Result::Ok(std::option::Option::Some(settings))
|
||||
},
|
||||
_ => std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported")),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_optional_grpc_settings(
|
||||
format_version: u32,
|
||||
defaults: std::option::Option<EffectiveGrpcSessionSource>,
|
||||
sources: std::option::Option<std::vec::Vec<EffectiveGrpcEndpointSource>>,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings>> {
|
||||
return match format_version {
|
||||
1 | 2 => {
|
||||
if defaults.is_some() || sources.is_some() {
|
||||
std::result::Result::Err(effective_error(profile, "Transport V1/V2 must not define Yellowstone gRPC settings"))
|
||||
} else {
|
||||
std::result::Result::Ok(std::option::Option::None)
|
||||
}
|
||||
},
|
||||
3 => {
|
||||
let defaults = match defaults {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Transport V3 requires grpc_defaults")),
|
||||
};
|
||||
let sources = match sources {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let endpoints = map_grpc_endpoints(sources, &defaults, effective, 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::YellowstoneGrpcTransportSettings::new(endpoints);
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(transport_contract_error(
|
||||
profile,
|
||||
"effective Yellowstone gRPC Transport settings fail the Transport runtime contract",
|
||||
&error,
|
||||
));
|
||||
}
|
||||
std::result::Result::Ok(std::option::Option::Some(settings))
|
||||
},
|
||||
_ => std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported")),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_grpc_endpoints(
|
||||
sources: std::vec::Vec<EffectiveGrpcEndpointSource>,
|
||||
defaults: &EffectiveGrpcSessionSource,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings>> {
|
||||
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings>::with_capacity(sources.len());
|
||||
for (endpoint_index, source) in sources.into_iter().enumerate() {
|
||||
let endpoint_name = source.name.clone();
|
||||
if source.protocol != "solana_yellowstone" {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "effective gRPC protocol is unsupported")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("grpc_protocol", source.protocol),
|
||||
);
|
||||
}
|
||||
let url = ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::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 Yellowstone gRPC endpoint URL is invalid", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
let session = map_grpc_session_settings(defaults, source.session.as_ref(), profile, endpoint_name.as_str());
|
||||
let session = match session {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata = map_grpc_metadata(source.metadata, source.secret_metadata, effective, profile, endpoint_name.as_str(), endpoint_index);
|
||||
let metadata = match metadata {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let endpoint = ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||
source.name,
|
||||
source.enabled,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new(source.provider),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new(source.cluster),
|
||||
url,
|
||||
session,
|
||||
);
|
||||
let endpoint = endpoint.with_metadata(metadata);
|
||||
let endpoint = match endpoint {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective Yellowstone gRPC metadata fail the Transport runtime contract", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
return std::result::Result::Ok(endpoints);
|
||||
}
|
||||
|
||||
fn map_grpc_session_settings(
|
||||
defaults: &EffectiveGrpcSessionSource,
|
||||
overrides: std::option::Option<&EffectiveGrpcSessionOverrideSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings> {
|
||||
let mut connect_timeout_ms = defaults.connect_timeout_ms;
|
||||
let mut unary_timeout_ms = defaults.unary_timeout_ms;
|
||||
let mut close_timeout_ms = defaults.close_timeout_ms;
|
||||
let mut reconnect_max_retries = defaults.reconnect.max_retries;
|
||||
let mut reconnect_initial_backoff_ms = defaults.reconnect.initial_backoff_ms;
|
||||
let mut reconnect_max_backoff_ms = defaults.reconnect.max_backoff_ms;
|
||||
let mut request_channel_capacity = defaults.request_channel_capacity;
|
||||
let mut update_channel_capacity = defaults.update_channel_capacity;
|
||||
let mut max_inbound_message_size_bytes = defaults.max_inbound_message_size_bytes;
|
||||
let mut max_outbound_message_size_bytes = defaults.max_outbound_message_size_bytes;
|
||||
if let std::option::Option::Some(overrides) = overrides {
|
||||
if let std::option::Option::Some(value) = overrides.connect_timeout_ms {
|
||||
connect_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.unary_timeout_ms {
|
||||
unary_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.close_timeout_ms {
|
||||
close_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(reconnect) = overrides.reconnect.as_ref() {
|
||||
if let std::option::Option::Some(value) = reconnect.max_retries {
|
||||
reconnect_max_retries = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = reconnect.initial_backoff_ms {
|
||||
reconnect_initial_backoff_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = reconnect.max_backoff_ms {
|
||||
reconnect_max_backoff_ms = value;
|
||||
}
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.request_channel_capacity {
|
||||
request_channel_capacity = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.update_channel_capacity {
|
||||
update_channel_capacity = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_inbound_message_size_bytes {
|
||||
max_inbound_message_size_bytes = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_outbound_message_size_bytes {
|
||||
max_outbound_message_size_bytes = value;
|
||||
}
|
||||
}
|
||||
let reconnect = ksp_onchain_transport_lib::YellowstoneGrpcReconnectSettings::new(
|
||||
reconnect_max_retries,
|
||||
std::time::Duration::from_millis(reconnect_initial_backoff_ms),
|
||||
std::time::Duration::from_millis(reconnect_max_backoff_ms),
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::new(
|
||||
std::time::Duration::from_millis(connect_timeout_ms),
|
||||
std::time::Duration::from_millis(unary_timeout_ms),
|
||||
std::time::Duration::from_millis(close_timeout_ms),
|
||||
reconnect,
|
||||
request_channel_capacity,
|
||||
update_channel_capacity,
|
||||
max_inbound_message_size_bytes,
|
||||
max_outbound_message_size_bytes,
|
||||
);
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective Yellowstone gRPC session settings fail the Transport runtime contract", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(settings);
|
||||
}
|
||||
|
||||
fn map_grpc_metadata(
|
||||
public_sources: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||
secret_sources: std::vec::Vec<EffectiveGrpcMetadataSource>,
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
endpoint_index: usize,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry>> {
|
||||
let mut metadata = std::vec::Vec::<ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry>::with_capacity(public_sources.len() + secret_sources.len());
|
||||
for (metadata_index, source) in public_sources.into_iter().enumerate() {
|
||||
let pointer = format!("/grpc_endpoints/{endpoint_index}/metadata/{metadata_index}/value");
|
||||
if let std::result::Result::Err(error) = validate_grpc_metadata_provenance(effective, pointer.as_str(), false, profile, endpoint_name) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let entry = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::public(source.key, source.value);
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective public Yellowstone gRPC metadata are invalid", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
metadata.push(entry);
|
||||
}
|
||||
for (metadata_index, source) in secret_sources.into_iter().enumerate() {
|
||||
let pointer = format!("/grpc_endpoints/{endpoint_index}/secret_metadata/{metadata_index}/value");
|
||||
if let std::result::Result::Err(error) = validate_grpc_metadata_provenance(effective, pointer.as_str(), true, profile, endpoint_name) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let entry = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret(source.key, source.value);
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective secret Yellowstone gRPC metadata are invalid", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
metadata.push(entry);
|
||||
}
|
||||
return std::result::Result::Ok(metadata);
|
||||
}
|
||||
|
||||
fn validate_grpc_metadata_provenance(
|
||||
effective: &crate::ResolvedConfigJson,
|
||||
pointer: &str,
|
||||
secret_class: bool,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let provenance = match effective.provenance_at(pointer) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "Yellowstone gRPC metadata provenance is unavailable")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut has_secret_environment = false;
|
||||
for item in provenance {
|
||||
let variable_name = match item.variable_name() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if sensitivity.is_secret() {
|
||||
has_secret_environment = true;
|
||||
if !secret_class {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "public Yellowstone gRPC metadata must not derive from a secret environment variable")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
}
|
||||
} else if secret_class {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "secret Yellowstone gRPC metadata may reference only secret environment variables")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
}
|
||||
}
|
||||
if secret_class && !has_secret_environment {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "secret Yellowstone gRPC metadata require secret environment provenance")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("field", pointer),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn map_endpoints(
|
||||
sources: std::vec::Vec<EffectiveEndpointSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
@@ -229,7 +761,7 @@ fn map_endpoints(
|
||||
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),
|
||||
transport_contract_error(profile, "effective HTTP endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -253,6 +785,172 @@ fn map_endpoints(
|
||||
return std::result::Result::Ok(endpoints);
|
||||
}
|
||||
|
||||
fn map_ws_endpoints(
|
||||
sources: std::vec::Vec<EffectiveWsEndpointSource>,
|
||||
defaults: &EffectiveWsSessionSource,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::WsEndpointSettings>> {
|
||||
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::WsEndpointSettings>::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
let endpoint_name = source.name.clone();
|
||||
let protocol = map_ws_protocol_kind(source.kind.as_str(), profile, endpoint_name.as_str());
|
||||
let protocol = match protocol {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let url = ksp_onchain_transport_lib::WsEndpointUrl::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 WebSocket endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
let session = map_ws_session_settings(defaults, source.session.as_ref(), profile, endpoint_name.as_str());
|
||||
let session = match session {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
endpoints.push(ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||
source.name,
|
||||
source.enabled,
|
||||
ksp_onchain_transport_lib::WsProviderName::new(source.provider),
|
||||
ksp_onchain_transport_lib::WsClusterName::new(source.cluster),
|
||||
protocol,
|
||||
url,
|
||||
session,
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(endpoints);
|
||||
}
|
||||
|
||||
fn map_ws_protocol_kind(
|
||||
value: &str,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsProtocolKind> {
|
||||
return match value {
|
||||
"solana_standard" => std::result::Result::Ok(ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard),
|
||||
"helius_laserstream" => std::result::Result::Ok(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream),
|
||||
_ => std::result::Result::Err(
|
||||
effective_error(profile, "effective WebSocket protocol kind is unsupported")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("ws_kind", value),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_ws_session_settings(
|
||||
defaults: &EffectiveWsSessionSource,
|
||||
overrides: std::option::Option<&EffectiveWsSessionOverrideSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsSessionSettings> {
|
||||
let mut command_timeout_ms = defaults.command_timeout_ms;
|
||||
let mut close_timeout_ms = defaults.close_timeout_ms;
|
||||
let mut reconnect_max_retries = defaults.reconnect.max_retries;
|
||||
let mut reconnect_initial_backoff_ms = defaults.reconnect.initial_backoff_ms;
|
||||
let mut reconnect_max_backoff_ms = defaults.reconnect.max_backoff_ms;
|
||||
let mut resubscribe_text = defaults.resubscribe.clone();
|
||||
let mut command_queue_capacity = defaults.command_queue_capacity;
|
||||
let mut notification_queue_capacity = defaults.notification_queue_capacity;
|
||||
let mut max_active_subscriptions = defaults.max_active_subscriptions;
|
||||
let mut max_pending_requests = defaults.max_pending_requests;
|
||||
let mut max_message_size_bytes = defaults.max_message_size_bytes;
|
||||
let mut max_frame_size_bytes = defaults.max_frame_size_bytes;
|
||||
let mut max_write_buffer_size_bytes = defaults.max_write_buffer_size_bytes;
|
||||
if let std::option::Option::Some(overrides) = overrides {
|
||||
if let std::option::Option::Some(value) = overrides.command_timeout_ms {
|
||||
command_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.close_timeout_ms {
|
||||
close_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(reconnect) = overrides.reconnect.as_ref() {
|
||||
if let std::option::Option::Some(value) = reconnect.max_retries {
|
||||
reconnect_max_retries = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = reconnect.initial_backoff_ms {
|
||||
reconnect_initial_backoff_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = reconnect.max_backoff_ms {
|
||||
reconnect_max_backoff_ms = value;
|
||||
}
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.resubscribe.as_ref() {
|
||||
resubscribe_text = value.clone();
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.command_queue_capacity {
|
||||
command_queue_capacity = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.notification_queue_capacity {
|
||||
notification_queue_capacity = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_active_subscriptions {
|
||||
max_active_subscriptions = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_pending_requests {
|
||||
max_pending_requests = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_message_size_bytes {
|
||||
max_message_size_bytes = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_frame_size_bytes {
|
||||
max_frame_size_bytes = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_write_buffer_size_bytes {
|
||||
max_write_buffer_size_bytes = value;
|
||||
}
|
||||
}
|
||||
let reconnect = ksp_onchain_transport_lib::WsReconnectSettings::new(
|
||||
reconnect_max_retries,
|
||||
std::time::Duration::from_millis(reconnect_initial_backoff_ms),
|
||||
std::time::Duration::from_millis(reconnect_max_backoff_ms),
|
||||
);
|
||||
let resubscribe = map_ws_resubscribe_policy(resubscribe_text.as_str(), profile, endpoint_name);
|
||||
let resubscribe = match resubscribe {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let settings = ksp_onchain_transport_lib::WsSessionSettings::new(
|
||||
std::time::Duration::from_millis(command_timeout_ms),
|
||||
std::time::Duration::from_millis(close_timeout_ms),
|
||||
reconnect,
|
||||
resubscribe,
|
||||
command_queue_capacity,
|
||||
notification_queue_capacity,
|
||||
max_active_subscriptions,
|
||||
max_pending_requests,
|
||||
max_message_size_bytes,
|
||||
max_frame_size_bytes,
|
||||
max_write_buffer_size_bytes,
|
||||
);
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective WebSocket session settings fail the Transport runtime contract", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(settings);
|
||||
}
|
||||
|
||||
fn map_ws_resubscribe_policy(
|
||||
value: &str,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsResubscribePolicy> {
|
||||
return match value {
|
||||
"never" => std::result::Result::Ok(ksp_onchain_transport_lib::WsResubscribePolicy::Never),
|
||||
"active_subscriptions" => std::result::Result::Ok(ksp_onchain_transport_lib::WsResubscribePolicy::ActiveSubscriptions),
|
||||
_ => std::result::Result::Err(
|
||||
effective_error(profile, "effective WebSocket resubscribe policy is unsupported")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("resubscribe", value),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_roles(
|
||||
sources: std::vec::Vec<EffectiveRoleSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||
// version: 21
|
||||
// version: 23
|
||||
|
||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
|
||||
//! Logging/Transport adapters and management contracts.
|
||||
@@ -252,6 +252,11 @@ fn transport_adapter_contract_is_available_from_crate_root() {
|
||||
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_transport_config;
|
||||
let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_transport_config_profile;
|
||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedTransportConfig>() > 0);
|
||||
let _http_settings = ksp_config_lib::ResolvedTransportConfig::http_settings;
|
||||
let _ws_settings = ksp_config_lib::ResolvedTransportConfig::ws_settings;
|
||||
let _grpc_settings = ksp_config_lib::ResolvedTransportConfig::grpc_settings;
|
||||
let _into_transport_settings = ksp_config_lib::ResolvedTransportConfig::into_transport_settings;
|
||||
let _into_all_transport_settings = ksp_config_lib::ResolvedTransportConfig::into_all_transport_settings;
|
||||
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");
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"format_version": 2,
|
||||
"retry": {
|
||||
"max_retries": 4,
|
||||
"initial_backoff_ms": 125,
|
||||
"max_backoff_ms": 2500
|
||||
},
|
||||
"ws_defaults": {
|
||||
"command_timeout_ms": 8000,
|
||||
"close_timeout_ms": 4000,
|
||||
"reconnect": {
|
||||
"max_retries": 6,
|
||||
"initial_backoff_ms": 200,
|
||||
"max_backoff_ms": 4000
|
||||
},
|
||||
"resubscribe": "active_subscriptions",
|
||||
"command_queue_capacity": 64,
|
||||
"notification_queue_capacity": 96,
|
||||
"max_active_subscriptions": 256,
|
||||
"max_pending_requests": 48,
|
||||
"max_message_size_bytes": 33554432,
|
||||
"max_frame_size_bytes": 8388608,
|
||||
"max_write_buffer_size_bytes": 524288
|
||||
},
|
||||
"default_profile": "secret_test",
|
||||
"profiles": [
|
||||
{
|
||||
@@ -23,7 +40,9 @@
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 7,
|
||||
"limits": {
|
||||
"requests_per_second": 9,
|
||||
@@ -34,6 +53,74 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "fixture_private_ws",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "fixture-cluster",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_SECRET_TRANSPORT_TEST_WS_URL:-wss://fallback.invalid}",
|
||||
"session": {
|
||||
"command_timeout_ms": 4500,
|
||||
"reconnect": {
|
||||
"max_retries": 7
|
||||
},
|
||||
"resubscribe": "never",
|
||||
"notification_queue_capacity": 48,
|
||||
"max_pending_requests": 24
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fixture_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "helius_devnet",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "fixture_devnet_http",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"url": "https://api.devnet.solana.com",
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "fixture_helius_devnet_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "devnet",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"retry": {
|
||||
"max_retries": 1,
|
||||
"initial_backoff_ms": 90,
|
||||
"max_backoff_ms": 900
|
||||
},
|
||||
"default_profile": "legacy_http",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "legacy_http",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "legacy_http",
|
||||
"enabled": true,
|
||||
"provider": "legacy-provider",
|
||||
"cluster": "devnet",
|
||||
"url": "https://legacy.invalid",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 2200,
|
||||
"max_idle_connections_per_host": 2,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 10,
|
||||
"limits": {
|
||||
"requests_per_second": 2,
|
||||
"burst_capacity": 3,
|
||||
"max_concurrent_requests": 2,
|
||||
"pause_after_rate_limit_ms": 250
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
242
crates/ksp-config-lib/unit_tests/fixtures_v3/std.transport.json
Normal file
242
crates/ksp-config-lib/unit_tests/fixtures_v3/std.transport.json
Normal file
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"format_version": 3,
|
||||
"retry": {
|
||||
"max_retries": 2,
|
||||
"initial_backoff_ms": 100,
|
||||
"max_backoff_ms": 2000
|
||||
},
|
||||
"ws_defaults": {
|
||||
"command_timeout_ms": 8000,
|
||||
"close_timeout_ms": 4000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"resubscribe": "active_subscriptions",
|
||||
"command_queue_capacity": 64,
|
||||
"notification_queue_capacity": 96,
|
||||
"max_active_subscriptions": 256,
|
||||
"max_pending_requests": 48,
|
||||
"max_message_size_bytes": 33554432,
|
||||
"max_frame_size_bytes": 8388608,
|
||||
"max_write_buffer_size_bytes": 524288
|
||||
},
|
||||
"grpc_defaults": {
|
||||
"connect_timeout_ms": 6000,
|
||||
"unary_timeout_ms": 7000,
|
||||
"close_timeout_ms": 4000,
|
||||
"reconnect": {
|
||||
"max_retries": 4,
|
||||
"initial_backoff_ms": 200,
|
||||
"max_backoff_ms": 3000
|
||||
},
|
||||
"request_channel_capacity": 64,
|
||||
"update_channel_capacity": 96,
|
||||
"max_inbound_message_size_bytes": 33554432,
|
||||
"max_outbound_message_size_bytes": 16777216
|
||||
},
|
||||
"default_profile": "grpc_secret_ok",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "grpc_secret_ok",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "fixture_http",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "https://fallback.invalid",
|
||||
"connect_timeout_ms": 1000,
|
||||
"request_timeout_ms": 3000,
|
||||
"max_idle_connections_per_host": 2,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 1,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 5,
|
||||
"max_concurrent_requests": 2,
|
||||
"pause_after_rate_limit_ms": 500
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "fixture_ws",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "wss://fallback.invalid"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "fixture_grpc",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://grpc.invalid",
|
||||
"metadata": [
|
||||
{
|
||||
"key": "x-ksp-label",
|
||||
"value": "${KSP_PUBLIC_GRPC_LABEL:-fixture-public}"
|
||||
}
|
||||
],
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-ksp-token",
|
||||
"value": "Bearer ${KSP_SECRET_GRPC_TOKEN:-fixture-secret}"
|
||||
}
|
||||
],
|
||||
"session": {
|
||||
"connect_timeout_ms": 4500,
|
||||
"reconnect": {
|
||||
"max_retries": 7
|
||||
},
|
||||
"update_channel_capacity": 32
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "grpc_public_from_secret_invalid",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "fixture_http",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "https://fallback.invalid",
|
||||
"connect_timeout_ms": 1000,
|
||||
"request_timeout_ms": 3000,
|
||||
"max_idle_connections_per_host": 2,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 1,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 5,
|
||||
"max_concurrent_requests": 2,
|
||||
"pause_after_rate_limit_ms": 500
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "fixture_ws",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "wss://fallback.invalid"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "fixture_grpc",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://grpc.invalid",
|
||||
"metadata": [
|
||||
{
|
||||
"key": "x-ksp-public",
|
||||
"value": "${KSP_SECRET_GRPC_TOKEN:-fixture-secret}"
|
||||
}
|
||||
],
|
||||
"secret_metadata": [],
|
||||
"session": {
|
||||
"connect_timeout_ms": 4500,
|
||||
"reconnect": {
|
||||
"max_retries": 7
|
||||
},
|
||||
"update_channel_capacity": 32
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "grpc_secret_from_public_invalid",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "fixture_http",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "https://fallback.invalid",
|
||||
"connect_timeout_ms": 1000,
|
||||
"request_timeout_ms": 3000,
|
||||
"max_idle_connections_per_host": 2,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 1,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 5,
|
||||
"max_concurrent_requests": 2,
|
||||
"pause_after_rate_limit_ms": 500
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "fixture_ws",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "wss://fallback.invalid"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "fixture_grpc",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://grpc.invalid",
|
||||
"metadata": [],
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-ksp-secret",
|
||||
"value": "${KSP_PUBLIC_GRPC_LABEL:-fixture-public}"
|
||||
}
|
||||
],
|
||||
"session": {
|
||||
"connect_timeout_ms": 4500,
|
||||
"reconnect": {
|
||||
"max_retries": 7
|
||||
},
|
||||
"update_channel_capacity": 32
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/unit_tests/transport.rs
|
||||
// version: 2
|
||||
// version: 12
|
||||
|
||||
#[test]
|
||||
fn fixture_transport_profile_maps_complete_runtime_contract() {
|
||||
@@ -41,6 +41,58 @@ fn fixture_transport_profile_maps_complete_runtime_contract() {
|
||||
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)));
|
||||
assert!(resolved.grpc_settings().is_none(), "V2 fixture must remain Yellowstone gRPC-free");
|
||||
let ws = resolved.ws_settings();
|
||||
assert!(ws.is_some(), "V2 fixture should expose WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = ws {
|
||||
assert_eq!(ws.endpoints().len(), 2);
|
||||
let endpoint = &ws.endpoints()[0];
|
||||
assert_eq!(endpoint.name(), "fixture_private_ws");
|
||||
assert_eq!(endpoint.provider().as_str(), "fixture-provider");
|
||||
assert_eq!(endpoint.cluster().as_str(), "fixture-cluster");
|
||||
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
|
||||
assert_eq!(endpoint.url().as_str(), "wss://fallback.invalid");
|
||||
assert_eq!(endpoint.session().command_timeout(), std::time::Duration::from_millis(4500));
|
||||
assert_eq!(endpoint.session().close_timeout(), std::time::Duration::from_millis(4000));
|
||||
assert_eq!(endpoint.session().reconnect().max_retries(), 7);
|
||||
assert_eq!(endpoint.session().reconnect().initial_backoff(), std::time::Duration::from_millis(200));
|
||||
assert_eq!(endpoint.session().reconnect().max_backoff(), std::time::Duration::from_millis(4000));
|
||||
assert_eq!(endpoint.session().resubscribe(), ksp_onchain_transport_lib::WsResubscribePolicy::Never);
|
||||
assert_eq!(endpoint.session().command_queue_capacity(), 64);
|
||||
assert_eq!(endpoint.session().notification_queue_capacity(), 48);
|
||||
assert_eq!(endpoint.session().max_active_subscriptions(), 256);
|
||||
assert_eq!(endpoint.session().max_pending_requests(), 24);
|
||||
assert_eq!(endpoint.session().max_message_size_bytes(), 33_554_432);
|
||||
assert_eq!(endpoint.session().max_frame_size_bytes(), 8_388_608);
|
||||
assert_eq!(endpoint.session().max_write_buffer_size_bytes(), 524_288);
|
||||
let helius = &ws.endpoints()[1];
|
||||
assert_eq!(helius.name(), "fixture_helius_ws");
|
||||
assert_eq!(helius.provider().as_str(), "helius");
|
||||
assert_eq!(helius.cluster().as_str(), "mainnet-beta");
|
||||
assert_eq!(helius.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||
assert_eq!(helius.url().as_str(), "wss://mainnet.helius-rpc.com/?api-key=fixture-helius-key");
|
||||
let _connect_future = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(helius.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_transport_fixture_remains_backward_readable_and_http_only() {
|
||||
let engine = v1_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(), "strict Transport V1 fixture should remain readable: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved {
|
||||
assert_eq!(resolved.profile_id(), "legacy_http");
|
||||
assert_eq!(resolved.settings().retry().max_retries(), 1);
|
||||
assert_eq!(resolved.settings().endpoints().len(), 1);
|
||||
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), "https://legacy.invalid");
|
||||
assert!(resolved.ws_settings().is_none(), "V1 must not invent WebSocket runtime settings");
|
||||
assert!(resolved.grpc_settings().is_none(), "V1 must not invent Yellowstone gRPC runtime settings");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -59,12 +111,235 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
|
||||
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");
|
||||
assert!(default.grpc_settings().is_none(), "Devnet profile must not invent a Yellowstone gRPC endpoint");
|
||||
let ws = default.ws_settings();
|
||||
assert!(ws.is_some(), "committed V3 Devnet profile should expose WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = ws {
|
||||
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.devnet.solana.com");
|
||||
assert_eq!(ws.endpoints()[0].protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
|
||||
}
|
||||
}
|
||||
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");
|
||||
assert!(mainnet.grpc_settings().is_none(), "generic Mainnet profile must stay distinct from the PublicNode gRPC profile");
|
||||
let ws = mainnet.ws_settings();
|
||||
assert!(ws.is_some(), "committed V3 Mainnet profile should expose WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = ws {
|
||||
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.mainnet-beta.solana.com");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_v3_orbitflare_devnet_profile_maps_provider_neutral_yellowstone_grpc_with_license_x_token() {
|
||||
let engine = committed_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let canary = "ORBITFLARE-DEVNET-LICENSE-X-TOKEN-CANARY";
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN".to_owned(), canary.to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_transport_config(std::option::Option::Some("orbitflare_devnet"), &environment);
|
||||
assert!(resolved.is_ok(), "committed OrbitFlare Devnet Transport profile should map from its License Key x-token: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved {
|
||||
assert_eq!(resolved.profile_id(), "orbitflare_devnet");
|
||||
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||
assert_eq!(resolved.settings().endpoints()[0].cluster().as_str(), "devnet");
|
||||
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), "https://api.devnet.solana.com");
|
||||
let ws = resolved.ws_settings();
|
||||
assert!(ws.is_some(), "OrbitFlare Devnet profile should retain the standard Solana Devnet WebSocket companion endpoint");
|
||||
if let std::option::Option::Some(ws) = ws {
|
||||
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.devnet.solana.com");
|
||||
assert_eq!(ws.endpoints()[0].protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
|
||||
}
|
||||
let grpc = resolved.grpc_settings();
|
||||
assert!(grpc.is_some(), "OrbitFlare Devnet profile should expose one provider-neutral Yellowstone gRPC endpoint");
|
||||
if let std::option::Option::Some(grpc) = grpc {
|
||||
assert_eq!(grpc.endpoints().len(), 1);
|
||||
let endpoint = &grpc.endpoints()[0];
|
||||
assert_eq!(endpoint.name(), "orbitflare_solana_devnet_yellowstone");
|
||||
assert_eq!(endpoint.provider().as_str(), "orbitflare");
|
||||
assert_eq!(endpoint.cluster().as_str(), "devnet");
|
||||
assert_eq!(endpoint.url().as_str(), "http://devnet.rpc.orbitflare.com:10000");
|
||||
assert!(!endpoint.url().uses_tls(), "OrbitFlare shared Devnet URL must remain explicitly HTTP/2 plaintext");
|
||||
assert_eq!(endpoint.metadata().len(), 1);
|
||||
assert_eq!(endpoint.metadata()[0].key(), "x-token");
|
||||
assert!(endpoint.metadata()[0].is_secret());
|
||||
assert!(grpc.validate().is_ok(), "Config-produced OrbitFlare Yellowstone gRPC settings should satisfy Transport validation");
|
||||
let debug = format!("{grpc:?}");
|
||||
assert!(!debug.contains("orbitflare.com"), "Transport settings Debug must not expose OrbitFlare gRPC endpoint URLs");
|
||||
assert!(!debug.contains(canary), "OrbitFlare License Key x-token must stay redacted from Transport settings Debug");
|
||||
assert!(!debug.contains("X-ORBIT-KEY"), "OrbitFlare Customer API credentials must never enter Yellowstone Transport settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_v3_publicnode_profiles_map_provider_neutral_yellowstone_grpc_with_network_scoped_secret_x_tokens() {
|
||||
let engine = committed_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
for (profile_id, endpoint_name, cluster, url, environment_name, canary) in [
|
||||
(
|
||||
"publicnode_mainnet",
|
||||
"publicnode_solana_mainnet_yellowstone",
|
||||
"mainnet-beta",
|
||||
"https://solana-yellowstone-grpc.publicnode.com:443",
|
||||
"KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN",
|
||||
"PUBLICNODE-MAINNET-GRPC-X-TOKEN-CANARY",
|
||||
),
|
||||
(
|
||||
"publicnode_testnet",
|
||||
"publicnode_solana_testnet_yellowstone",
|
||||
"testnet",
|
||||
"https://solana-testnet-yellowstone-grpc.publicnode.com:443",
|
||||
"KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN",
|
||||
"PUBLICNODE-TESTNET-GRPC-X-TOKEN-CANARY",
|
||||
),
|
||||
] {
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert(environment_name.to_owned(), canary.to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_transport_config(std::option::Option::Some(profile_id), &environment);
|
||||
assert!(resolved.is_ok(), "committed PublicNode V3 profile {profile_id} should map from its network-scoped token: {resolved:?}");
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
assert_eq!(resolved.profile_id(), profile_id);
|
||||
assert!(resolved.ws_settings().is_some());
|
||||
let grpc = resolved.grpc_settings();
|
||||
assert!(grpc.is_some(), "PublicNode profile {profile_id} should expose Yellowstone gRPC settings");
|
||||
if let std::option::Option::Some(grpc) = grpc {
|
||||
assert_eq!(grpc.endpoints().len(), 1);
|
||||
let endpoint = &grpc.endpoints()[0];
|
||||
assert_eq!(endpoint.name(), endpoint_name);
|
||||
assert_eq!(endpoint.provider().as_str(), "publicnode");
|
||||
assert_eq!(endpoint.cluster().as_str(), cluster);
|
||||
assert_eq!(endpoint.url().as_str(), url);
|
||||
assert!(endpoint.url().uses_tls());
|
||||
assert_eq!(endpoint.metadata().len(), 1);
|
||||
assert_eq!(endpoint.metadata()[0].key(), "x-token");
|
||||
assert!(endpoint.metadata()[0].is_secret());
|
||||
let endpoint_debug = format!("{endpoint:?}");
|
||||
assert!(!endpoint_debug.contains(canary), "PublicNode {cluster} x-token must stay redacted from endpoint Debug");
|
||||
assert_eq!(endpoint.session().connect_timeout(), std::time::Duration::from_millis(10_000));
|
||||
assert_eq!(endpoint.session().unary_timeout(), std::time::Duration::from_millis(10_000));
|
||||
assert_eq!(endpoint.session().close_timeout(), std::time::Duration::from_millis(5_000));
|
||||
assert_eq!(endpoint.session().reconnect().max_retries(), 5);
|
||||
assert!(grpc.validate().is_ok(), "Config-produced Yellowstone gRPC settings should satisfy Transport validation");
|
||||
let debug = format!("{grpc:?}");
|
||||
assert!(!debug.contains("publicnode.com"), "Transport settings Debug must not expose gRPC endpoint URLs");
|
||||
assert!(!debug.contains(canary), "Transport settings Debug must not expose the network-scoped PublicNode x-token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_grpc_metadata_separates_public_and_secret_provenance() {
|
||||
let engine = v3_fixture_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let canary = "grpc-secret-canary";
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_GRPC_TOKEN".to_owned(), canary.to_owned());
|
||||
process.insert("KSP_PUBLIC_GRPC_LABEL".to_owned(), "operator-public".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(), "V3 gRPC metadata fixture should map: {resolved:?}");
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let grpc = resolved.grpc_settings();
|
||||
assert!(grpc.is_some());
|
||||
if let std::option::Option::Some(grpc) = grpc {
|
||||
let endpoint = &grpc.endpoints()[0];
|
||||
assert_eq!(endpoint.metadata().len(), 2);
|
||||
assert_eq!(endpoint.metadata()[0].key(), "x-ksp-label");
|
||||
assert!(!endpoint.metadata()[0].is_secret());
|
||||
assert_eq!(endpoint.metadata()[1].key(), "x-ksp-token");
|
||||
assert!(endpoint.metadata()[1].is_secret());
|
||||
assert_eq!(endpoint.session().connect_timeout(), std::time::Duration::from_millis(4_500));
|
||||
assert_eq!(endpoint.session().unary_timeout(), std::time::Duration::from_millis(7_000));
|
||||
assert_eq!(endpoint.session().reconnect().max_retries(), 7);
|
||||
assert_eq!(endpoint.session().update_channel_capacity(), 32);
|
||||
}
|
||||
assert_eq!(
|
||||
resolved.effective().safe_value().pointer("/grpc_endpoints/0/metadata/0/value").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("operator-public")
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.effective().safe_value().pointer("/grpc_endpoints/0/secret_metadata/0/value").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("Bearer ********")
|
||||
);
|
||||
let secret_provenance = resolved.effective().provenance_at("/grpc_endpoints/0/secret_metadata/0/value");
|
||||
assert!(secret_provenance.is_some());
|
||||
if let std::option::Option::Some(provenance) = secret_provenance {
|
||||
assert_eq!(provenance.len(), 2);
|
||||
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_GRPC_TOKEN"));
|
||||
}
|
||||
let debug = format!("{resolved:?}");
|
||||
assert!(!debug.contains(canary));
|
||||
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_grpc_metadata_rejects_crossed_sensitivity_classes() {
|
||||
let engine = v3_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_GRPC_TOKEN".to_owned(), "secret-canary".to_owned());
|
||||
process.insert("KSP_PUBLIC_GRPC_LABEL".to_owned(), "public-canary".to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
let public_from_secret = engine.load_resolved_transport_config(std::option::Option::Some("grpc_public_from_secret_invalid"), &environment);
|
||||
let secret_from_public = engine.load_resolved_transport_config(std::option::Option::Some("grpc_secret_from_public_invalid"), &environment);
|
||||
assert!(public_from_secret.is_err(), "public metadata must reject KSP_SECRET provenance");
|
||||
assert!(secret_from_public.is_err(), "secret_metadata must reject non-secret environment provenance");
|
||||
if let std::result::Result::Err(error) = public_from_secret {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||
assert!(!format!("{error:?}").contains("secret-canary"));
|
||||
}
|
||||
if let std::result::Result::Err(error) = secret_from_public {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||
assert!(!format!("{error:?}").contains("public-canary"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_v3_websocket_endpoint_preserves_v2_compatibility_constructor_without_polling() {
|
||||
let engine = committed_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_transport_config(std::option::Option::Some("devnet_public"), &environment);
|
||||
assert!(resolved.is_ok(), "committed V3 Transport profile should map: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved {
|
||||
let (http, ws) = resolved.into_transport_settings();
|
||||
assert_eq!(http.endpoints().len(), 1);
|
||||
assert!(ws.is_some(), "committed V3 Transport profile should expose WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = ws {
|
||||
assert!(ws.validate().is_ok(), "Config-produced WebSocket settings should satisfy Transport validation");
|
||||
assert_eq!(ws.endpoints().len(), 1);
|
||||
let endpoint = ws.endpoints()[0].clone();
|
||||
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
|
||||
let _connect_future = ksp_onchain_transport_lib::WsSession::connect(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +359,10 @@ fn transport_profile_preserves_global_and_profile_origin() {
|
||||
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("ws_defaults"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||
assert_eq!(profile.origin("grpc_defaults"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||
assert_eq!(profile.origin("endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
|
||||
assert_eq!(profile.origin("ws_endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
|
||||
assert_eq!(profile.origin("format_version"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||
}
|
||||
}
|
||||
@@ -149,6 +427,108 @@ fn secret_transport_url_is_runtime_available_but_safe_projection_is_redacted() {
|
||||
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_websocket_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 = "wss://user:pass@secret-provider.invalid/path?api-key=transport-ws-secret-canary";
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_TRANSPORT_TEST_WS_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 WebSocket endpoint should map without being exposed: {resolved:?}");
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let ws = resolved.ws_settings();
|
||||
assert!(ws.is_some(), "V2 fixture should expose WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = ws {
|
||||
assert_eq!(ws.endpoints()[0].url().as_str(), canary);
|
||||
}
|
||||
let safe_url = resolved.effective().safe_value().pointer("/ws_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-ws-secret-canary"));
|
||||
assert!(!debug.contains("user:pass"));
|
||||
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_laserstream_mainnet_and_devnet_api_key_map_to_protocol_and_safe_redaction() {
|
||||
let engine = fixture_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let canary = "helius-api-key-canary";
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_HELIUS_API_KEY".to_owned(), canary.to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
let mainnet = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||
assert!(mainnet.is_ok(), "Helius mainnet WebSocket endpoint should map without exposing its API key: {mainnet:?}");
|
||||
let mainnet = match mainnet {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mainnet_ws = mainnet.ws_settings();
|
||||
assert!(mainnet_ws.is_some(), "V2 fixture should expose mainnet WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = mainnet_ws {
|
||||
assert_eq!(ws.endpoints().len(), 2);
|
||||
let endpoint = &ws.endpoints()[1];
|
||||
assert_eq!(endpoint.provider().as_str(), "helius");
|
||||
assert_eq!(endpoint.cluster().as_str(), "mainnet-beta");
|
||||
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||
assert_eq!(endpoint.url().as_str(), "wss://mainnet.helius-rpc.com/?api-key=helius-api-key-canary");
|
||||
}
|
||||
let mainnet_safe_url = mainnet.effective().safe_value().pointer("/ws_endpoints/1/url").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(mainnet_safe_url, std::option::Option::Some("wss://mainnet.helius-rpc.com/?api-key=********"));
|
||||
let mainnet_provenance = mainnet.effective().provenance_at("/ws_endpoints/1/url");
|
||||
assert!(mainnet_provenance.is_some(), "Helius mainnet endpoint URL should retain secret environment provenance");
|
||||
if let std::option::Option::Some(provenance) = mainnet_provenance {
|
||||
assert_eq!(provenance.len(), 2);
|
||||
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||
assert_eq!(provenance[1].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_HELIUS_API_KEY"));
|
||||
}
|
||||
let devnet = engine.load_resolved_transport_config(std::option::Option::Some("helius_devnet"), &environment);
|
||||
assert!(devnet.is_ok(), "Helius devnet WebSocket endpoint should map without exposing its API key: {devnet:?}");
|
||||
let devnet = match devnet {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let devnet_ws = devnet.ws_settings();
|
||||
assert!(devnet_ws.is_some(), "V2 fixture should expose devnet WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = devnet_ws {
|
||||
assert_eq!(ws.endpoints().len(), 1);
|
||||
let endpoint = &ws.endpoints()[0];
|
||||
assert_eq!(endpoint.provider().as_str(), "helius");
|
||||
assert_eq!(endpoint.cluster().as_str(), "devnet");
|
||||
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||
assert_eq!(endpoint.url().as_str(), "wss://devnet.helius-rpc.com/?api-key=helius-api-key-canary");
|
||||
let _connect_future = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(endpoint.clone());
|
||||
}
|
||||
let devnet_safe_url = devnet.effective().safe_value().pointer("/ws_endpoints/0/url").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(devnet_safe_url, std::option::Option::Some("wss://devnet.helius-rpc.com/?api-key=********"));
|
||||
let devnet_provenance = devnet.effective().provenance_at("/ws_endpoints/0/url");
|
||||
assert!(devnet_provenance.is_some(), "Helius devnet endpoint URL should retain secret environment provenance");
|
||||
if let std::option::Option::Some(provenance) = devnet_provenance {
|
||||
assert_eq!(provenance.len(), 2);
|
||||
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||
assert_eq!(provenance[1].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_HELIUS_API_KEY"));
|
||||
}
|
||||
let mainnet_debug = format!("{mainnet:?}");
|
||||
let devnet_debug = format!("{devnet:?}");
|
||||
assert!(!mainnet_debug.contains(canary));
|
||||
assert!(!devnet_debug.contains(canary));
|
||||
assert!(mainnet_debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
assert!(devnet_debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_secret_url_provenance_uses_process_and_process_beats_dotenv() {
|
||||
let engine = fixture_engine();
|
||||
@@ -216,6 +596,38 @@ fn fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||
}
|
||||
|
||||
fn v1_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_v1");
|
||||
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 v3_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_v3");
|
||||
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"));
|
||||
|
||||
135
crates/ksp-core-lib/README.md
Normal file
135
crates/ksp-core-lib/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<!-- file: crates/ksp-core-lib/README.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# `ksp-core-lib`
|
||||
|
||||
`ksp-core-lib` porte les contrats fondamentaux partagés par les couches KSP sans dépendre des domaines de plus haut niveau.
|
||||
|
||||
La crate possède actuellement trois responsabilités :
|
||||
|
||||
- le type d'erreur commun KSP ;
|
||||
- le type Solana `Pubkey` réexporté comme primitive d'adresse canonique ;
|
||||
- le registre KSP des Program IDs Solana fondamentaux et leur taxonomie.
|
||||
|
||||
## Frontière architecturale
|
||||
|
||||
Core reste une couche basse. Elle ne possède ni configuration, ni logging runtime, ni transport réseau, ni wallet, ni stockage, ni logique de décodage/exécution.
|
||||
|
||||
Les crates de niveau supérieur peuvent dépendre de Core et réutiliser ses contrats ; Core ne doit pas introduire de dépendance inverse vers ces couches.
|
||||
|
||||
La seule dépendance runtime externe directe actuelle est `solana-pubkey`, utilisée pour la primitive `Pubkey`.
|
||||
|
||||
## Erreur commune KSP
|
||||
|
||||
Le contrat d'erreur public repose sur :
|
||||
|
||||
```text
|
||||
ErrorCode
|
||||
ErrorContext
|
||||
Error
|
||||
Result<T>
|
||||
```
|
||||
|
||||
`ErrorCode` sépare un `domain` stable d'un `code` stable. `Error` ajoute :
|
||||
|
||||
- un message humain ;
|
||||
- des champs de contexte ordonnés ;
|
||||
- une source d'erreur standard optionnelle compatible `Send + Sync`.
|
||||
|
||||
L'affichage d'une erreur reste compact :
|
||||
|
||||
```text
|
||||
<domain>.<code>: <message>
|
||||
```
|
||||
|
||||
Les consommateurs ajoutent uniquement des contextes sûrs. Les secrets, credentials, key material, URLs sensibles ou payloads massifs ne doivent pas être copiés dans le message ou le contexte d'une erreur.
|
||||
|
||||
## `Pubkey`
|
||||
|
||||
La crate réexporte :
|
||||
|
||||
```rust
|
||||
ksp_core_lib::Pubkey
|
||||
```
|
||||
|
||||
Les autres crates KSP utilisent cette primitive lorsqu'un contrat public a besoin d'une adresse Solana générique. Elles évitent ainsi de multiplier les propriétaires de type pour la même notion.
|
||||
|
||||
## Program IDs fondamentaux
|
||||
|
||||
Core possède 18 Program IDs Solana fondamentaux sous deux formes cohérentes :
|
||||
|
||||
```text
|
||||
PRGID_* -> Base58 &str
|
||||
PRGIDPK_* -> Pubkey typé
|
||||
```
|
||||
|
||||
Les deux formes sont déclarées depuis une même valeur grâce à `declare_program_id!`.
|
||||
|
||||
Le registre couvre notamment :
|
||||
|
||||
- System, Vote, Stake, Config et Feature ;
|
||||
- Address Lookup Table et Compute Budget ;
|
||||
- les loaders natif, BPF v1, BPF v2, BPF upgradeable et Loader v4 ;
|
||||
- les precompiles Ed25519, Secp256k1 et Secp256r1 ;
|
||||
- Slashing ;
|
||||
- ZK ElGamal Proof et ZK Token Proof.
|
||||
|
||||
Le registre n'est pas un catalogue général de tous les programmes Solana. Les protocoles, applications et Program IDs de domaines futurs sont ajoutés dans les couches propriétaires appropriées lorsqu'un besoin réel existe.
|
||||
|
||||
## Registre et taxonomie
|
||||
|
||||
Chaque entrée est exposée sous `ProgramIdEntry` avec :
|
||||
|
||||
```text
|
||||
code
|
||||
name
|
||||
program_id
|
||||
pubkey
|
||||
domain
|
||||
family
|
||||
protocol
|
||||
subfamily
|
||||
program_version
|
||||
kind
|
||||
```
|
||||
|
||||
`ProgramIdKind` distingue actuellement :
|
||||
|
||||
```text
|
||||
Program
|
||||
Loader
|
||||
Precompile
|
||||
EnshrinedProgram
|
||||
```
|
||||
|
||||
La lecture du registre s'effectue par :
|
||||
|
||||
```text
|
||||
entries()
|
||||
native_program_ids()
|
||||
program_ids(filter)
|
||||
program_ids_by_domain(...)
|
||||
program_ids_by_family(...)
|
||||
program_ids_by_protocol(...)
|
||||
find_program_id(...)
|
||||
find_program_pubkey(...)
|
||||
```
|
||||
|
||||
`ProgramIdFilter` permet de combiner les axes `domain`, `family`, `protocol`, `subfamily`, `program_version` et `kind`.
|
||||
|
||||
## Garanties validées
|
||||
|
||||
Les tests publics et unitaires verrouillent notamment :
|
||||
|
||||
- la correspondance Base58 / `Pubkey` des constantes ;
|
||||
- l'unicité des codes, Program IDs et pubkeys du registre ;
|
||||
- les 18 entrées fondamentales ;
|
||||
- les recherches textuelles et typées ;
|
||||
- les vues et filtres taxonomiques ;
|
||||
- l'absence des comptes well-known qui ne sont pas des programmes ;
|
||||
- le contrat `Error`, son ordre de contexte et sa chaîne `source()` ;
|
||||
- `Error: Send + Sync`.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — exemples d'utilisation de l'erreur commune, de `Pubkey`, des Program IDs et du registre.
|
||||
265
crates/ksp-core-lib/USAGE.md
Normal file
265
crates/ksp-core-lib/USAGE.md
Normal file
@@ -0,0 +1,265 @@
|
||||
<!-- file: crates/ksp-core-lib/USAGE.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Utilisation de `ksp-core-lib`
|
||||
|
||||
## Utiliser le type d'erreur commun
|
||||
|
||||
Une crate KSP définit des codes stables puis retourne `ksp_core_lib::Result<T>` :
|
||||
|
||||
```rust
|
||||
const ERROR_CODE_LOAD_FAILED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("store", "load_failed");
|
||||
|
||||
fn load_value() -> ksp_core_lib::Result<u64> {
|
||||
let error = ksp_core_lib::Error::new(
|
||||
ERROR_CODE_LOAD_FAILED,
|
||||
"unable to load value",
|
||||
)
|
||||
.with_context("operation", "load_value");
|
||||
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
Le domaine et le code sont accessibles séparément :
|
||||
|
||||
```rust
|
||||
let code = ERROR_CODE_LOAD_FAILED;
|
||||
assert_eq!(code.domain(), "store");
|
||||
assert_eq!(code.code(), "load_failed");
|
||||
```
|
||||
|
||||
Le message et les champs de contexte restent accessibles sans parser `Display` :
|
||||
|
||||
```rust
|
||||
let error = ksp_core_lib::Error::new(
|
||||
ERROR_CODE_LOAD_FAILED,
|
||||
"unable to load value",
|
||||
)
|
||||
.with_context("component", "postgres")
|
||||
.with_context("operation", "load_value");
|
||||
|
||||
assert_eq!(error.code(), ERROR_CODE_LOAD_FAILED);
|
||||
assert_eq!(error.message(), "unable to load value");
|
||||
assert_eq!(error.context()[0].key(), "component");
|
||||
assert_eq!(error.context()[0].value(), "postgres");
|
||||
```
|
||||
|
||||
Un `ErrorContext` peut aussi être construit indépendamment lorsqu’un caller prépare explicitement son contexte :
|
||||
|
||||
```rust
|
||||
let context = ksp_core_lib::ErrorContext::new(
|
||||
"operation",
|
||||
"load_value",
|
||||
);
|
||||
|
||||
assert_eq!(context.key(), "operation");
|
||||
assert_eq!(context.value(), "load_value");
|
||||
```
|
||||
|
||||
Les valeurs de contexte doivent être sûres à exposer. Ne pas y placer de secret, credential, seed, key material, URL contenant une clé API ou payload brut volumineux.
|
||||
|
||||
## Conserver une erreur source
|
||||
|
||||
Une erreur externe compatible `Send + Sync + 'static` peut rester dans la chaîne standard :
|
||||
|
||||
```rust
|
||||
#[derive(Debug)]
|
||||
struct SourceError;
|
||||
|
||||
impl std::fmt::Display for SourceError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("source failure");
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SourceError {}
|
||||
|
||||
let error = ksp_core_lib::Error::new(
|
||||
ERROR_CODE_LOAD_FAILED,
|
||||
"unable to load value",
|
||||
)
|
||||
.with_source(SourceError);
|
||||
|
||||
let source = std::error::Error::source(&error);
|
||||
assert!(source.is_some());
|
||||
```
|
||||
|
||||
La source sert au chaînage d'erreurs ; son contenu ne doit pas être recopié sans contrôle dans un contexte ou un log public.
|
||||
|
||||
## Utiliser `Pubkey`
|
||||
|
||||
Core réexporte la primitive Solana utilisée par les contrats KSP :
|
||||
|
||||
```rust
|
||||
let system = ksp_core_lib::Pubkey::from_str_const(
|
||||
ksp_core_lib::PRGID_SOLANA_SYSTEM,
|
||||
);
|
||||
|
||||
assert_eq!(system, ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
|
||||
```
|
||||
|
||||
Une crate consommatrice peut donc utiliser `ksp_core_lib::Pubkey` dans sa propre API sans dépendre directement de `solana-pubkey` lorsque Core est déjà le propriétaire architectural de cette primitive.
|
||||
|
||||
## Utiliser les constantes Program ID
|
||||
|
||||
Chaque Program ID Core possède une forme texte et une forme typée :
|
||||
|
||||
```rust
|
||||
let system_text: &str = ksp_core_lib::PRGID_SOLANA_SYSTEM;
|
||||
let system_pubkey: ksp_core_lib::Pubkey =
|
||||
ksp_core_lib::PRGIDPK_SOLANA_SYSTEM;
|
||||
|
||||
assert_eq!(
|
||||
system_pubkey,
|
||||
ksp_core_lib::Pubkey::from_str_const(system_text),
|
||||
);
|
||||
```
|
||||
|
||||
Les constantes `PRGID_*` sont utiles pour les wires, diagnostics sûrs ou comparaisons texte. Les constantes `PRGIDPK_*` sont préférées dès qu'un contrat manipule une adresse Solana typée.
|
||||
|
||||
## Déclarer une paire texte / `Pubkey`
|
||||
|
||||
`declare_program_id!` permet à une couche KSP propriétaire d'un Program ID de déclarer les deux représentations depuis une seule valeur Base58 :
|
||||
|
||||
```rust
|
||||
ksp_core_lib::declare_program_id!(
|
||||
PRGID_EXAMPLE,
|
||||
PRGIDPK_EXAMPLE,
|
||||
"11111111111111111111111111111111"
|
||||
);
|
||||
|
||||
assert_eq!(PRGID_EXAMPLE, ksp_core_lib::PRGID_SOLANA_SYSTEM);
|
||||
assert_eq!(PRGIDPK_EXAMPLE, ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
|
||||
```
|
||||
|
||||
La macro ne signifie pas que toute nouvelle constante doit être ajoutée au registre Core. La couche propriétaire du domaine décide où vit le nouveau Program ID.
|
||||
|
||||
## Parcourir le registre canonique
|
||||
|
||||
`entries()` retourne le registre complet en ordre déterministe :
|
||||
|
||||
```rust
|
||||
for entry in ksp_core_lib::entries() {
|
||||
let code = entry.code();
|
||||
let name = entry.name();
|
||||
let text = entry.program_id();
|
||||
let pubkey = entry.pubkey();
|
||||
let domain = entry.domain();
|
||||
let family = entry.family();
|
||||
let protocol = entry.protocol();
|
||||
let subfamily = entry.subfamily();
|
||||
let program_version = entry.program_version();
|
||||
let kind = entry.kind();
|
||||
|
||||
let _ = (
|
||||
code,
|
||||
name,
|
||||
text,
|
||||
pubkey,
|
||||
domain,
|
||||
family,
|
||||
protocol,
|
||||
subfamily,
|
||||
program_version,
|
||||
kind,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`native_program_ids()` fournit la vue des Program IDs Solana fondamentaux actuellement enregistrés :
|
||||
|
||||
```rust
|
||||
let count = ksp_core_lib::native_program_ids().count();
|
||||
assert_eq!(count, 18);
|
||||
```
|
||||
|
||||
## Rechercher une entrée
|
||||
|
||||
Recherche par représentation Base58 :
|
||||
|
||||
```rust
|
||||
let entry = ksp_core_lib::find_program_id(
|
||||
ksp_core_lib::PRGID_SOLANA_SYSTEM,
|
||||
);
|
||||
|
||||
let entry = match entry {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
|
||||
assert_eq!(entry.code(), "solana.system");
|
||||
```
|
||||
|
||||
Recherche par `Pubkey` :
|
||||
|
||||
```rust
|
||||
let entry = ksp_core_lib::find_program_pubkey(
|
||||
&ksp_core_lib::PRGIDPK_SOLANA_VOTE,
|
||||
);
|
||||
|
||||
assert!(entry.is_some());
|
||||
```
|
||||
|
||||
Une recherche inconnue retourne `None`; le registre n'invente pas une entrée générique.
|
||||
|
||||
## Filtrer par axe simple
|
||||
|
||||
Les helpers spécialisés conviennent aux filtres simples :
|
||||
|
||||
```rust
|
||||
let loaders = ksp_core_lib::program_ids_by_family("loader");
|
||||
for loader in loaders {
|
||||
assert_eq!(loader.family(), "loader");
|
||||
}
|
||||
```
|
||||
|
||||
Les vues disponibles peuvent être consommées directement :
|
||||
|
||||
```rust
|
||||
let solana_entries = ksp_core_lib::program_ids_by_domain("solana").count();
|
||||
let loaders = ksp_core_lib::program_ids_by_family("loader").count();
|
||||
let solana_protocol = ksp_core_lib::program_ids_by_protocol("solana").count();
|
||||
|
||||
assert_eq!(solana_entries, 18);
|
||||
assert_eq!(loaders, 5);
|
||||
assert_eq!(solana_protocol, 18);
|
||||
```
|
||||
|
||||
## Combiner plusieurs axes
|
||||
|
||||
`ProgramIdFilter` compose les contraintes sans allocation de collection intermédiaire :
|
||||
|
||||
```rust
|
||||
let filter = ksp_core_lib::ProgramIdFilter::new()
|
||||
.with_domain("solana")
|
||||
.with_family("loader")
|
||||
.with_protocol("solana")
|
||||
.with_subfamily("bpf")
|
||||
.with_program_version("v2")
|
||||
.with_kind(ksp_core_lib::ProgramIdKind::Loader);
|
||||
|
||||
for entry in ksp_core_lib::program_ids(filter) {
|
||||
assert_eq!(entry.domain(), "solana");
|
||||
assert_eq!(entry.family(), "loader");
|
||||
assert_eq!(entry.protocol(), "solana");
|
||||
assert_eq!(entry.subfamily(), std::option::Option::Some("bpf"));
|
||||
assert_eq!(entry.program_version(), std::option::Option::Some("v2"));
|
||||
assert_eq!(entry.kind(), ksp_core_lib::ProgramIdKind::Loader);
|
||||
}
|
||||
```
|
||||
|
||||
Un `ProgramIdFilter::new()` vide correspond à toutes les entrées du registre.
|
||||
|
||||
## Choisir entre texte, `Pubkey` et descriptor
|
||||
|
||||
Utiliser :
|
||||
|
||||
```text
|
||||
PRGID_* / &str pour une représentation Base58 canonique
|
||||
PRGIDPK_* / Pubkey pour un contrat Solana typé
|
||||
ProgramIdEntry lorsqu'il faut aussi la taxonomie KSP
|
||||
```
|
||||
|
||||
Ne pas reparcourir les constantes ou reconstruire une taxonomie parallèle dans une crate consommatrice lorsque le registre Core fournit déjà l'information requise.
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||
// version: 1
|
||||
// version: 7
|
||||
|
||||
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
|
||||
|
||||
@@ -61,8 +61,83 @@ fn transport_manifest_preserves_ksp_dependency_firewall() {
|
||||
}
|
||||
assert!(manifest.contains("ksp-core-lib"));
|
||||
assert!(manifest.contains("ksp-logging-lib"));
|
||||
assert!(manifest.contains("futures-util = { workspace = true, features = [\"sink\", \"std\"] }"));
|
||||
assert!(manifest.contains("http.workspace = true"));
|
||||
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"net\", \"rt\", \"sync\", \"time\"] }"));
|
||||
assert!(manifest.contains("tokio-tungstenite = { workspace = true, features = [\"connect\", \"rustls-tls-webpki-roots\"] }"));
|
||||
assert!(manifest.contains("tonic = { workspace = true, features = [\"channel\", \"tls-aws-lc\", \"tls-webpki-roots\"] }"));
|
||||
assert!(manifest.contains("tonic-prost.workspace = true"));
|
||||
assert!(manifest.contains("yellowstone-grpc-proto.workspace = true"));
|
||||
assert!(manifest.contains("[dev-dependencies]"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"io-util\", \"net\", \"rt\", \"test-util\"] }"));
|
||||
assert!(manifest.contains("tonic = { workspace = true, features = [\"codegen\", \"server\"] }"));
|
||||
assert!(manifest.contains("yellowstone-grpc-proto = { workspace = true, features = [\"tonic\"] }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_manifest_runtime_and_dev_dependency_names_are_exact() {
|
||||
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");
|
||||
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||
assert!(dependencies_tail.is_some(), "transport dependencies section must exist");
|
||||
let dependencies_tail = match dependencies_tail {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let dependencies = match dependencies_tail.split("[dev-dependencies]").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let dependency_names = manifest_dependency_names(dependencies);
|
||||
assert_eq!(
|
||||
dependency_names,
|
||||
std::vec![
|
||||
"futures-util",
|
||||
"http",
|
||||
"ksp-core-lib",
|
||||
"ksp-logging-lib",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
"yellowstone-grpc-proto",
|
||||
]
|
||||
);
|
||||
let dev_dependencies_tail = manifest.split("[dev-dependencies]").nth(1);
|
||||
assert!(dev_dependencies_tail.is_some(), "transport dev-dependencies section must exist");
|
||||
let dev_dependencies_tail = match dev_dependencies_tail {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let dev_dependencies = match dev_dependencies_tail.split("[lints]").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(manifest_dependency_names(dev_dependencies), std::vec!["tokio", "tonic", "yellowstone-grpc-proto"]);
|
||||
}
|
||||
|
||||
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||
let mut names = std::vec::Vec::new();
|
||||
for line in section.lines() {
|
||||
let content = match line.split('#').next() {
|
||||
std::option::Option::Some(value) => value.trim(),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = match content.split('=').next() {
|
||||
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
return names;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-logging-lib/Cargo.toml
|
||||
# version: 5
|
||||
# version: 6
|
||||
|
||||
[package]
|
||||
name = "ksp-logging-lib"
|
||||
@@ -10,8 +10,8 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
tracing = { workspace = true, features = ["std"] }
|
||||
tracing-subscriber = { workspace = true, features = ["fmt", "json", "ansi"] }
|
||||
tracing-appender.workspace = true
|
||||
tracing-subscriber = { workspace = true, features = ["fmt", "json", "ansi"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-logging-lib/USAGE.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 8 -->
|
||||
|
||||
# Utilisation de ksp-logging-lib
|
||||
|
||||
@@ -48,7 +48,7 @@ Les fichiers persistants interdisent `ansi = true`.
|
||||
|
||||
## Runtime multi-output et routing `domain`
|
||||
|
||||
`0.1.3-pre.005` active le multi-sink, les formats et le routing niveau/target ; `0.1.3-pre.006` complète le routing structuré `domain`. Le runtime supporte donc réellement :
|
||||
Le runtime supporte le multi-sink, les formats, le routing niveau/target et le routing structuré `domain` :
|
||||
|
||||
- plusieurs fichiers simultanés ;
|
||||
- les formats `Human`, `Compact`, `Pretty` et `Json` ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-onchain-transport-lib/Cargo.toml
|
||||
# version: 3
|
||||
# version: 9
|
||||
|
||||
[package]
|
||||
name = "ksp-onchain-transport-lib"
|
||||
@@ -8,15 +8,23 @@ edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
futures-util = { workspace = true, features = ["sink", "std"] }
|
||||
http.workspace = true
|
||||
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"] }
|
||||
tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] }
|
||||
tokio-tungstenite = { workspace = true, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||
tonic = { workspace = true, features = ["channel", "tls-aws-lc", "tls-webpki-roots"] }
|
||||
tonic-prost.workspace = true
|
||||
yellowstone-grpc-proto.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt"] }
|
||||
tokio = { workspace = true, features = ["io-util", "net", "rt", "test-util"] }
|
||||
tonic = { workspace = true, features = ["codegen", "server"] }
|
||||
yellowstone-grpc-proto = { workspace = true, features = ["tonic"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 24 -->
|
||||
|
||||
# `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.
|
||||
`ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Elle fournit le transport HTTP JSON-RPC complet, le moteur WebSocket Solana standard et la foundation Yellowstone gRPC standard/provider-neutral. Les extensions provider-specific restent ajoutées séparément lorsqu’une release les cible.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
@@ -19,7 +19,10 @@ La crate possède :
|
||||
- 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 wrappers typés HTTP et WebSocket explicitement livrés par KSP ;
|
||||
- les sessions physiques WebSocket, subscriptions logiques, reconnect/resubscribe et backpressure bornés ;
|
||||
- les settings, channels, unary et sessions `Subscribe` Yellowstone gRPC standard ;
|
||||
- le reconnect/replay Yellowstone prudent avec observabilité de gaps/duplicates sans promesse lossless ;
|
||||
- les snapshots runtime sûrs ;
|
||||
- l'observabilité Transport via `ksp-logging-lib`.
|
||||
|
||||
@@ -35,6 +38,8 @@ ksp-config-lib
|
||||
-> ksp-core-lib
|
||||
-> ksp-logging-lib
|
||||
-> reqwest / tokio / serde
|
||||
-> tokio-tungstenite / futures-util
|
||||
-> tonic / tonic-prost / yellowstone-grpc-proto
|
||||
```
|
||||
|
||||
La direction inverse est interdite :
|
||||
@@ -69,22 +74,179 @@ Le registre porte notamment :
|
||||
- remplacement historique éventuel ;
|
||||
- release de couverture typée KSP.
|
||||
|
||||
La release stable `0.2.4` complète la surface typée des **52 méthodes courantes** :
|
||||
La surface HTTP typée couvre les **52 méthodes courantes** :
|
||||
|
||||
```text
|
||||
0.2.1 foundation : 4
|
||||
0.2.2 Accounts/Tokens/Cluster : 22
|
||||
0.2.3 Transactions : 11
|
||||
0.2.4 Blocks/Economics : 15
|
||||
total : 52
|
||||
foundation : 4
|
||||
Accounts/Tokens/Cluster : 22
|
||||
Transactions : 11
|
||||
Blocks/Economics : 15
|
||||
total : 52
|
||||
```
|
||||
|
||||
`0.2.4` ajoute les dix wrappers Blocks et les cinq wrappers Economics. Les points sensibles restent notamment `getBlock` moderne + bare encoding legacy deprecated, les quatre `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions`, `commissionBps`, les overloads de `getBlocks`, les ranges de production, les valeurs d'inflation/minimum de délégation fournies par le runtime et les `null` positionnels de `getInflationReward`.
|
||||
Les dix wrappers Blocks et les cinq wrappers Economics couvrent notamment `getBlock` moderne + bare encoding legacy deprecated, les quatre `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions`, `commissionBps`, les overloads de `getBlocks`, les ranges de production, les valeurs d'inflation/minimum de délégation fournies par le runtime et les `null` positionnels de `getInflationReward`.
|
||||
|
||||
`KSP-TRANSPORT-007` impose qu'un wrapper typé couvre toutes les possibilités RPC supportées retenues par l'audit : paramètres/options, overloads et formes legacy encore supportées, contraintes déterministes utiles et variantes de réponse pertinentes sans perte. Le réaudit final `0.2.4` agrège le réaudit 37/37 de `0.2.3` avec les 15 nouveaux wrappers et porte la preuve stable à **52/52**.
|
||||
`KSP-TRANSPORT-007` impose qu'un wrapper typé couvre toutes les possibilités RPC supportées retenues par l'audit : paramètres/options, overloads et formes legacy encore supportées, contraintes déterministes utiles et variantes de réponse pertinentes sans perte. Les canaris de release portent la preuve globale à **52/52** méthodes courantes typées.
|
||||
|
||||
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
|
||||
|
||||
## Moteur WebSocket standard
|
||||
|
||||
La première session physique WebSocket est matérialisée sans introduire de pool/scheduler automatique ni de registry de subscriptions anticipé.
|
||||
|
||||
`WsSession::connect(WsEndpointSettings)` :
|
||||
|
||||
- ouvre exactement une connexion physique pour un appel ;
|
||||
- confie le socket à une tâche actor unique ;
|
||||
- sérialise les commandes internes par un canal `mpsc` borné ;
|
||||
- maintient une map bornée de requests JSON-RPC en attente ;
|
||||
- publie `WsSessionSnapshot` via un état compact `watch` ;
|
||||
- applique aux sockets les plafonds KSP de message, frame et write buffer ;
|
||||
- ne projette jamais l'URL dans `Debug`, snapshot, erreurs KSP ou logs ;
|
||||
- répond aux `Ping` reçus et tolère les `Pong`; le lifecycle `Close`/shutdown est borné et explicite.
|
||||
|
||||
Le chemin JSON-RPC générique reste `pub(crate)`. Il sert de primitive au moteur typed de subscriptions et **ne constitue pas une API publique raw provider-extension**. Le registry de subscriptions et le mapping remote/local, le reconnect/resubscribe et le backpressure borné par subscription font partie du moteur standard.
|
||||
|
||||
Les tests déterministes utilisent un serveur WebSocket local et prouvent le handshake, le round-trip JSON-RPC, le dispatch de réponses hors ordre, l'isolation des erreurs RPC applicatives, deux sessions physiques distinctes sur la même URL et la redaction des erreurs de connexion.
|
||||
|
||||
### Limites, control frames et shutdown
|
||||
|
||||
La session physique dispose désormais de `WsSession::close().await`. Le signal de shutdown est distinct de la command queue, passe l'état en `Closing`, annule les requests JSON-RPC en attente, envoie un Close WebSocket best-effort sous `close_timeout`, puis publie `Closed`. Un peer qui ne répond pas au Close ne peut donc pas bloquer indéfiniment le shutdown.
|
||||
|
||||
Les limites `max_message_size`, `max_frame_size`, `max_write_buffer_size` et `max_pending_requests` sont couvertes par des fixtures adversariales locales. Les requests outbound qui dépassent les bornes message/frame sont rejetées avant écriture ; les frames/messages inbound surdimensionnés sont rejetés par Tungstenite avant parse JSON. Les timeouts pending libèrent leur capacité sans faire tomber une session encore saine.
|
||||
|
||||
Ping/Pong/Close sont traités comme control frames : le Pong automatique Tungstenite est flushé et aucun heartbeat applicatif périodique n'est ajouté. Un Close distant inattendu, EOF, erreur I/O/TLS/WebSocket ou violation protocolaire structurelle entre dans le reconnect borné ; `Closed` reste réservé au shutdown local explicite ou à la disparition des handles.
|
||||
|
||||
### Registry de subscriptions
|
||||
|
||||
Le même actor possède maintenant le registre des subscriptions logiques, sans exposer les IDs numériques distants. Chaque subscription reçoit un `WsSubscriptionId` local stable, et le mapping `remote_subscription_id -> WsSubscriptionId` reste strictement runtime/interne.
|
||||
|
||||
La création générique typed reste `pub(crate)` et n'est jamais exposée comme API raw provider-extension. Les wrappers standard publics l’utilisent derrière leurs DTOs et paramètres typés. Le handle public `WsSubscription<T>` expose uniquement :
|
||||
|
||||
- `id()` et `kind()` ;
|
||||
- `state()` ;
|
||||
- `recv()` sur un canal typed borné ;
|
||||
- `unsubscribe()` qui conserve le booléen retourné par l'unsubscribe Solana standard.
|
||||
|
||||
L'ACK de subscribe est traité atomiquement dans l'actor : le remote ID est lié au local ID avant que la notification suivante puisse être dispatchée. Les notifications inconnues/stale sont ignorées avec un diagnostic sûr. Un mismatch de méthode de notification ou un échec de décodage typed termine uniquement la subscription concernée ; la session physique reste `Active`.
|
||||
|
||||
### Reconnect et resubscribe
|
||||
|
||||
Une perte de connexion physique invalide immédiatement les remote subscription IDs et incrémente `continuity_gap_count`. Le runtime utilise `WsReconnectSettings` pour appliquer un nombre fini de tentatives avec backoff exponentiel borné et sans jitter. Le shutdown surveille les phases de backoff et de handshake et interrompt la reprise sans reconnecter uniquement pour nettoyer des subscriptions.
|
||||
|
||||
Avec `WsResubscribePolicy::ActiveSubscriptions`, les subscriptions encore désirées passent en `Resubscribing` et sont restaurées dans l'ordre croissant de leur `WsSubscriptionId`. Les paramètres de subscribe conservés par l'actor sont rejoués, puis chaque nouvel ACK remappe un remote ID sans changer l'identité locale. Le retour à `Active` ne se produit qu'après la fin de cette restauration, ce qui réinitialise alors le budget de reconnect.
|
||||
|
||||
Avec `WsResubscribePolicy::Never`, la session physique peut se reconnecter mais les subscriptions précédentes deviennent terminales. Une cancellation locale reçue pendant reconnect gagne toujours : elle retire la subscription de la restauration ; si un ACK distant arrive après cette cancellation, l'actor envoie un unsubscribe best-effort du nouvel ID sans réactiver le handle local.
|
||||
|
||||
Le compteur de continuity gaps est un signal d'observabilité, pas une garantie de livraison. Transport n'ajoute aucun backfill HTTP et ne promet aucune continuité lossless pendant l'intervalle de déconnexion.
|
||||
|
||||
### Backpressure et libération de capacité
|
||||
|
||||
Chaque subscription dispose de sa propre queue typed bornée par `notification_queue_capacity`. Le runtime ne droppe jamais silencieusement une notification lorsque cette queue est pleine : il incrémente `WsSessionSnapshot::overflow_count()`, fait passer uniquement le handle lent à `Failed`, publie `ERROR_CODE_WS_BACKPRESSURE_OVERFLOW` via `WsSubscription::terminal_error_code()` et programme un `*Unsubscribe` distant best-effort. Les autres subscriptions et la session physique restent utilisables.
|
||||
|
||||
Les autres terminaisons en échec publient également un code KSP sûr sur le handle : erreur protocolaire, timeout, erreur RPC applicative ou perte physique terminale. Les fermetures normales et les unsubscriptions réussis conservent `terminal_error_code() == None`. Aucun payload distant, remote subscription ID ou endpoint URL n'est projeté dans cette cause.
|
||||
|
||||
`max_active_subscriptions` reste une limite d'admission distincte du compteur d'overflow de notifications : un rejet de création ne l'incrémente pas. Lorsqu'une subscription est fermée, échoue ou que son receiver est abandonné puis détecté sur la notification suivante, son entrée runtime et son binding distant sont nettoyés et la capacité locale redevient réutilisable.
|
||||
|
||||
Les fixtures adversariales prouvent l'isolation d'un consumer lent, la survie d'une subscription saine, le cleanup distant best-effort, la réutilisation de capacité après unsubscribe ou abandon du receiver et la conservation du compteur d'overflow à travers les snapshots. Aucune promesse de livraison lossless n'est ajoutée.
|
||||
|
||||
### Wrappers stables : account, program et logs
|
||||
|
||||
Les trois premiers wrappers WebSocket standards sont publics sur `WsSession` :
|
||||
|
||||
```text
|
||||
account_subscribe -> WsSubscription<SolanaRpcResponse<SolanaAccount>>
|
||||
program_subscribe -> WsSubscription<SolanaProgramNotification>
|
||||
logs_subscribe -> WsSubscription<SolanaRpcResponse<SolanaLogsNotification>>
|
||||
```
|
||||
|
||||
`SolanaAccountSubscribeConfig` expose uniquement les options réellement effectives du PubSub audité : `encoding`, `dataSlice` et `commitment`. `minContextSlot` reste volontairement absent car le handler Agave ciblé l'ignore pour `accountSubscribe`; KSP ne transforme donc pas un champ partagé mais inopérant en promesse WebSocket.
|
||||
|
||||
`SolanaProgramSubscribeConfig` réutilise les encodings, slices et commitments account, ajoute les filtres programme et conserve `withContext`. Le décodeur `SolanaProgramNotification` accepte aussi bien le keyed account non contexté que la forme `RpcResponse` contextée afin de préserver les deux formes retenues par l'audit sans perte. `sortResults`, présent sur la surface HTTP `getProgramAccounts`, n'est pas exposé ici car le handler PubSub audité ne le consomme pas.
|
||||
|
||||
`SolanaLogsSubscribeFilter` rend les trois filtres upstream explicites : `All`, `AllWithVotes` et `Mentions(Pubkey)`. La variante `Mentions` encode par construction exactement une adresse. `SolanaLogsNotification` conserve la signature opaque, le `err` nullable et l'ordre des messages `logs`, enveloppés dans `SolanaRpcResponse`.
|
||||
|
||||
L'unsubscribe de ces trois familles passe toujours par `WsSubscription::unsubscribe()`: le caller ne voit ni ne fournit l'ID serveur. Les paramètres initiaux restent conservés par l’actor pour le resubscribe déterministe, et toutes les règles de backpressure/terminal error s’appliquent sans branche spéciale aux DTOs publics.
|
||||
|
||||
### Wrappers stables : signature, slot et root
|
||||
|
||||
Le second lot stable complète les subscriptions standard non instables :
|
||||
|
||||
```text
|
||||
signature_subscribe -> WsSubscription<SolanaRpcResponse<SolanaSignatureNotification>>
|
||||
slot_subscribe -> WsSubscription<SolanaSlotNotification>
|
||||
root_subscribe -> WsSubscription<u64>
|
||||
```
|
||||
|
||||
`SolanaSignatureSubscribeConfig` conserve séparément `commitment` et `enableReceivedNotification`, y compris la différence entre option omise et booléen explicitement faux. `SolanaSignatureNotification` représente les deux formes wire : `ReceivedSignature` pour l'événement précoce optionnel et `Processed { err }` pour la notification terminale. Après livraison de `Processed`, l'actor ferme localement la subscription avec `terminal_error_code() == None`, retire son binding et ne la remet jamais dans le set de resubscribe, conformément au caractère one-shot du serveur Solana.
|
||||
|
||||
Une cancellation effectuée avant cette terminaison continue d'utiliser `WsSubscription::unsubscribe()` et émet `signatureUnsubscribe` avec le remote ID détenu uniquement par l'actor. Après la notification terminale, `unsubscribe()` devient local-only et retourne `false`, puisque la subscription est déjà fermée côté serveur et côté KSP.
|
||||
|
||||
`slot_subscribe()` et `root_subscribe()` n'acceptent aucun paramètre. `SolanaSlotNotification` conserve exactement `slot`, `parent` et `root`; `root_subscribe()` délivre directement le root `u64`. Ces deux subscriptions restent continues et utilisent donc le reconnect/resubscribe standard.
|
||||
|
||||
### Wrappers unstable : block, slotsUpdates et vote
|
||||
|
||||
Les trois familles standard restantes complètent désormais l'inventaire **9/9 subscribe + 9/9 unsubscribe via handles** :
|
||||
|
||||
```text
|
||||
block_subscribe -> WsSubscription<SolanaRpcResponse<SolanaBlockNotification>>
|
||||
slots_updates_subscribe -> WsSubscription<SolanaSlotUpdate>
|
||||
vote_subscribe -> WsSubscription<SolanaVoteNotification>
|
||||
```
|
||||
|
||||
Ces familles restent explicitement **unstable**. Le moteur commun `subscribe_typed_with_completion` émet un warning KSP centralisé pour `Block`, `SlotsUpdates` et `Vote` avant l'ouverture logique, sans recopier filtres, pubkeys, payloads ou URL dans les logs.
|
||||
|
||||
`SolanaBlockSubscribeConfig` conserve `commitment`, `encoding`, `transactionDetails`, `maxSupportedTransactionVersion` et `showRewards`. Le filtre représente `All` ou `MentionsAccountOrProgram(Pubkey)`. Un commitment `processed` explicitement fourni est rejeté avant I/O ; la notification réutilise `SolanaConfirmedBlock` pour le block nullable et conserve l'erreur publication nullable sans interprétation métier. Le numéro de version transaction supporté reste un `u8` générique et n'est pas durci à `0`.
|
||||
|
||||
`SolanaSlotUpdate` représente les sept variantes courantes `firstShredReceived`, `completed`, `createdBank`, `frozen`, `dead`, `optimisticConfirmation` et `root`. Une variante upstream inconnue devient `Unknown { update_type, raw }` au lieu de faire tomber la session. Le `raw` reste borné par `max_message_size_bytes` avant le parse JSON.
|
||||
|
||||
`SolanaVoteNotification` conserve `votePubkey`, `slots`, `hash`, `timestamp` et `signature`. Le timestamp reste optionnel : omission et `null` deviennent `None`, tandis qu'une valeur `i64` est préservée. Transport ne transforme pas ces votes gossip pre-consensus en vérité ledger.
|
||||
|
||||
## Helius LaserStream WebSocket
|
||||
|
||||
La façade `HeliusLaserStreamWsSession` utilise le même actor physique `WsSession` mais expose uniquement la surface provider actuellement retenue par l’audit Helius :
|
||||
|
||||
```text
|
||||
standard réutilisé : account / logs / program / root / signature / slot / slotsUpdates
|
||||
extension Helius : transactionSubscribe / transactionUnsubscribe
|
||||
absent Helius : block / vote
|
||||
```
|
||||
|
||||
`slotsUpdates` reste **unstable** et conserve le warning centralisé du moteur standard. `block` et `vote` restent absents de la façade Helius même s’ils existent sur la façade Solana standard. `transactionSubscribe` reste provider-specific et n’est jamais ajouté à `SolanaStandardWsSession`.
|
||||
|
||||
Les endpoints Helius mainnet/devnet utilisent un `api-key` dans l’URL. KSP recommande de les construire via `ksp-config-lib` et `KSP_SECRET_HELIUS_API_KEY`; la valeur réelle atteint Transport mais les projections sûres, `Debug`, snapshots, erreurs et diagnostics n’exposent pas le credential. Transport ne lit jamais l’environnement et ne dépend jamais de Config.
|
||||
|
||||
Pour Helius, l’actor envoie automatiquement un control frame WebSocket `Ping` toutes les 60 secondes sur une session active. Cette policy est provider-owned, non configurable et ne s’applique pas aux sessions `SolanaStandard`. Une perte physique suit le reconnect/resubscribe borné déjà décrit; aucun replay/lossless n’est promis par la couche WebSocket.
|
||||
|
||||
LaserStream **gRPC** reste un backend distinct, hors de cette façade, de `WsProtocolKind` et de la Config WebSocket `helius_laserstream`.
|
||||
|
||||
## Yellowstone gRPC standard
|
||||
|
||||
La foundation `0.2.9` ajoute un troisième backend réseau distinct de HTTP et WebSocket. Le moteur est KSP-owned : `yellowstone-grpc-proto` fournit le wire publié, tandis que Tonic reste encapsulé derrière les types crate-root KSP. Aucun client Tonic brut ni type protobuf upstream n’est réexporté.
|
||||
|
||||
La surface publique principale comprend :
|
||||
|
||||
```text
|
||||
YellowstoneGrpcEndpointUrl / YellowstoneGrpcEndpointSettings
|
||||
YellowstoneGrpcSessionSettings / YellowstoneGrpcReconnectSettings
|
||||
YellowstoneGrpcTransportSettings
|
||||
YellowstoneGrpcChannel
|
||||
SolanaYellowstoneGrpcUnaryClient
|
||||
YellowstoneSubscribeRequest
|
||||
SolanaYellowstoneGrpcSubscribeSession
|
||||
YellowstoneGrpcSubscribeSnapshot
|
||||
```
|
||||
|
||||
Les sept unary standards retenus sont `SubscribeReplayInfo`, `Ping`, `GetLatestBlockhash`, `GetBlockHeight`, `GetSlot`, `IsBlockhashValid` et `GetVersion`. `Subscribe` couvre accounts, slots, transactions, transaction status, blocks, block metadata et entries, avec `commitment`, `accounts_data_slice`, `ping` et `from_slot`. `SubscribeDeshred` reste hors scope de la foundation standard.
|
||||
|
||||
Le stream bidirectionnel est borné : request/update queues, tailles inbound/outbound, half-close, close timeout et reconnect budget sont explicites. Après reconnect, KSP rejoue la dernière requête complète acceptée et avance prudemment `from_slot` selon le dernier slot observé et `SubscribeReplayInfo.first_available`. Les compteurs de gap et duplicate sont de l’observabilité ; ils ne constituent jamais une garantie exactly-once ou lossless.
|
||||
|
||||
Config Transport V3 peut mapper des `grpc_endpoints` vers ces settings sans inverser la dépendance. `protocol = solana_yellowstone` décrit le wire standard, tandis que `provider` reste un descripteur distinct. Les profils committés `publicnode_mainnet` et `publicnode_testnet` utilisent le standard sans façade PublicNode spécifique. Leur personal token est injecté comme metadata secrète `x-token` depuis Config ; Transport ne lit jamais les variables KSP.
|
||||
|
||||
Le smoke live opt-in ouvre un `Subscribe` slots authentifié sur PublicNode Mainnet et Testnet, attend un update `Slot` non nul puis ferme de manière bornée. Les deux endpoints sont versionnés. Le harness reçoit ses personal tokens sur stdin ; aucun secret n’est committé ni placé dans l’URL. Le même token a été validé par l’opérateur sur les deux réseaux, même si Config conserve deux variables distinctes pour laisser cette policy flexible.
|
||||
|
||||
`0.2.10` ajoute le profil `orbitflare_devnet` sur le même moteur provider-neutral. L’endpoint validé est `http://devnet.rpc.orbitflare.com:10000`; la License Key `ORBIT-*` est fournie par Config via `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` et devient la metadata secrète `x-token`. Le smoke live dédié a validé `Subscribe` slots à commitment confirmed, un `Slot` non nul et un `SubscribeUpdate::Ping` standard. Cette preuve confirme qu’aucune façade OrbitFlare, aucun heartbeat provider et aucune modification N1/N2 ne sont nécessaires.
|
||||
|
||||
## Résilience
|
||||
|
||||
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
||||
@@ -121,31 +283,53 @@ La configuration Logging de référence conserve un fichier dédié Transport à
|
||||
|
||||
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é :
|
||||
Quatre smokes réseau opt-in sont séparés par responsabilité :
|
||||
|
||||
```text
|
||||
Transport pur : settings programmatiques -> HttpTransportPool
|
||||
-> Accounts/Tokens/Cluster représentatifs
|
||||
-> trois reads Transactions
|
||||
-> getBlockHeight
|
||||
-> getInflationRate/getStakeMinimumDelegation
|
||||
Transport HTTP pur : settings programmatiques -> HttpTransportPool
|
||||
-> Accounts/Tokens/Cluster représentatifs
|
||||
-> trois reads Transactions
|
||||
-> getBlockHeight
|
||||
-> getInflationRate/getStakeMinimumDelegation
|
||||
|
||||
Transport WebSocket pur : settings programmatiques -> WsSession
|
||||
-> slotSubscribe
|
||||
-> une slotNotification sous timeout
|
||||
-> slotUnsubscribe
|
||||
-> close
|
||||
|
||||
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
|
||||
-> getHealth/getGenesisHash/getVersion/getBalance
|
||||
|
||||
Transport Yellowstone gRPC PublicNode : settings programmatiques + x-token secret
|
||||
-> Mainnet + Testnet
|
||||
-> TLS -> Subscribe slots -> Slot non nul -> close borné
|
||||
|
||||
Transport Yellowstone gRPC OrbitFlare : settings programmatiques + License Key x-token
|
||||
-> Devnet
|
||||
-> Subscribe slots -> Slot non nul + server Ping -> close borné
|
||||
```
|
||||
|
||||
Le smoke Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
|
||||
Le smoke HTTP Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
|
||||
|
||||
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.
|
||||
Le smoke WebSocket Transport cible uniquement la famille stable `slotSubscribe` sur l'endpoint public Devnet `wss://api.devnet.solana.com`. Il borne connexion, attente de notification, unsubscribe et fermeture ; il ne transforme aucune famille unstable en gate live.
|
||||
|
||||
Les cinq tests sont `ignored` par défaut ; le test PublicNode Yellowstone contient deux cas live Mainnet/Testnet et le test OrbitFlare contient un cas Devnet. Les trois familles de smokes Transport appartiennent durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée. Un rate-limit, refus externe ou incident Devnet n'est pas assimilé automatiquement à une régression locale.
|
||||
|
||||
Aucun smoke Helius live supplémentaire n’est committé en `0.2.8-pre.010`. Un tel test devrait à la fois obtenir `KSP_SECRET_HELIUS_API_KEY` via Config et exercer Transport ; l’ajouter dans Transport violerait l’ownership environnement/secret, tandis que l’ajouter dans Config étendrait l’exception cross-crates que le projet veut au contraire résorber. La première surface KSP d’intégration/orchestration dédiée devra héberger ce smoke. Le scénario live recommandé est alors `helius_devnet -> HeliusLaserStreamWsSession -> slotSubscribe -> notification -> unsubscribe -> close`; `transactionSubscribe` reste un smoke optionnel dépendant des droits provider et ne devient pas un gate stable de release.
|
||||
|
||||
## 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` ;
|
||||
- [`../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé de `0.2.3` ;
|
||||
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée `0.2.3` ;
|
||||
- [`../../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 Accounts/Tokens/Cluster ;
|
||||
- [`../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé Transactions ;
|
||||
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée Transactions ;
|
||||
- [`../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) — plan Blocks/Economics et compliance HTTP finale ;
|
||||
- [`../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée `52/52 + 14/14` et audit `KSP-TRANSPORT-007` global ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP.
|
||||
- [`../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md`](../../docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md) — plan Yellowstone gRPC standard/provider-neutral et PublicNode ;
|
||||
- [`../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md`](../../docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md) — matrice de compliance Yellowstone ;
|
||||
- [`../../docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md`](../../docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md) — composition OrbitFlare Devnet sur le standard Yellowstone ;
|
||||
- [`../../docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md`](../../docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md) — validation finale OrbitFlare `x-token`, `Slot + Ping` et non-régression N1/N2 ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard Transport V3 HTTP + WebSocket + gRPC, avec lecture backward V1/V2.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 23 -->
|
||||
|
||||
# Utilisation de `ksp-onchain-transport-lib`
|
||||
|
||||
@@ -65,7 +65,333 @@ let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into
|
||||
|
||||
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
|
||||
## 3. Session physique WebSocket
|
||||
|
||||
Un consumer peut créer explicitement une session physique WebSocket :
|
||||
|
||||
```rust
|
||||
let ws_url = match ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com") {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let endpoint = ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||
"devnet_public",
|
||||
true,
|
||||
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
|
||||
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
|
||||
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
|
||||
ws_url,
|
||||
ksp_onchain_transport_lib::WsSessionSettings::default(),
|
||||
);
|
||||
let session = match ksp_onchain_transport_lib::WsSession::connect(endpoint).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let snapshot = session.snapshot();
|
||||
```
|
||||
|
||||
Deux appels `WsSession::connect` avec le même endpoint créent volontairement deux connexions physiques distinctes. Il n'existe encore aucun pool de sessions automatique.
|
||||
|
||||
Le socket brut et la primitive JSON-RPC générique ne sont pas publics. Le moteur générique de subscription typed reste `pub(crate)` ; il ne constitue donc pas une escape hatch provider-specific.
|
||||
|
||||
`WsSubscription<T>` est le handle public commun retourné par les wrappers standards. Il porte un `WsSubscriptionId` local stable, jamais le remote ID numérique du serveur. Les notifications arrivent via un receiver typed borné et `unsubscribe().await` exécute le `*Unsubscribe` correspondant en préservant son résultat booléen.
|
||||
|
||||
Le snapshot de session expose les subscriptions actuellement enregistrées via `WsSubscriptionSnapshot`, avec `remote_bound: bool` seulement. Le remote ID réel n'est jamais projeté.
|
||||
|
||||
### Premiers wrappers standards publics
|
||||
|
||||
Trois familles stables peuvent être créées directement sur la session :
|
||||
|
||||
```rust
|
||||
let account = match "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>() {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let account_config = ksp_onchain_transport_lib::SolanaAccountSubscribeConfig::new(
|
||||
Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64),
|
||||
None,
|
||||
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
);
|
||||
let mut account_subscription = match session.account_subscribe(&account, Some(&account_config)).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let next = account_subscription.recv().await;
|
||||
let removed = account_subscription.unsubscribe().await;
|
||||
```
|
||||
|
||||
La même session expose `program_subscribe()` avec `SolanaProgramSubscribeConfig` et `logs_subscribe()` avec `SolanaLogsSubscribeFilter` plus `SolanaCommitmentConfig`. Pour `logsSubscribe`, `Mentions(pubkey)` représente exactement une adresse, conformément à la contrainte upstream retenue par l'audit.
|
||||
|
||||
`accountSubscribe` ne propose pas `minContextSlot`: le champ existe dans un config partagé upstream mais est ignoré par le handler PubSub audité. `programSubscribe` conserve en revanche `withContext`; `SolanaProgramNotification` permet au consumer de traiter explicitement une notification contextée ou non contextée.
|
||||
|
||||
Les trois wrappers retournent le même handle `WsSubscription<T>` : reconnect, resubscribe, overflow, cause terminale et unsubscribe restent donc uniformes. Aucun wrapper public n'accepte un nom de méthode JSON-RPC arbitraire ni un remote subscription ID.
|
||||
|
||||
### Lot stable B : signature, slot et root
|
||||
|
||||
La session expose également les familles stables suivantes :
|
||||
|
||||
```rust
|
||||
let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new(
|
||||
Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
|
||||
Some(true),
|
||||
);
|
||||
let mut signature_subscription = match session.signature_subscribe("<base58-signature>", Some(&signature_config)).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
while let Some(notification) = signature_subscription.recv().await {
|
||||
let notification = match notification {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if notification.value().is_terminal() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Avec `enableReceivedNotification = true`, `ReceivedSignature` peut arriver avant la variante terminale `Processed { err }`. La variante terminale ferme automatiquement le handle KSP, sans `signatureUnsubscribe` supplémentaire et sans resubscribe lors d'une reconnexion ultérieure. Une cancellation explicite avant cette notification terminale reste possible via `unsubscribe().await`.
|
||||
|
||||
`slot_subscribe().await` retourne un `WsSubscription<SolanaSlotNotification>` dont les getters exposent `slot`, `parent` et `root`. `root_subscribe().await` retourne un `WsSubscription<u64>`. Ces deux méthodes n'acceptent aucune configuration ni aucun paramètre RPC.
|
||||
|
||||
### Familles unstable : block, slotsUpdates et vote
|
||||
|
||||
Les trois familles unstable standard sont également typées. Leur utilisation déclenche un warning KSP centralisé :
|
||||
|
||||
```rust
|
||||
let block_config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new(
|
||||
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
|
||||
Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures),
|
||||
Some(0),
|
||||
Some(false),
|
||||
);
|
||||
let mut blocks = match session
|
||||
.block_subscribe(&ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::All, Some(&block_config))
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
`blockSubscribe` requiert un validator qui active la capability upstream correspondante. Une erreur applicative RPC liée à cette capability est renvoyée au caller sans reconnect de la session. `processed` est refusé localement ; `confirmed` et `finalized` sont admis. `maxSupportedTransactionVersion` n'est pas limité artificiellement à `0`.
|
||||
|
||||
`slots_updates_subscribe().await` délivre `SolanaSlotUpdate`. Les sept variantes courantes sont structurées ; une variante inconnue reste consommable via `Unknown` et `unknown_raw()`, sous la borne de taille WebSocket déjà appliquée avant décodage.
|
||||
|
||||
`vote_subscribe().await` délivre `SolanaVoteNotification`. `timestamp()` retourne `Option<i64>` pour conserver omission/null/value. Ce flux reste gossip et pre-consensus : le consumer ne doit pas l'assimiler à une confirmation ledger.
|
||||
|
||||
Les trois familles utilisent le même `WsSubscription::unsubscribe().await`; aucun remote subscription ID n'entre dans l'API publique.
|
||||
|
||||
### Façade Helius LaserStream WebSocket
|
||||
|
||||
Pour un endpoint Config `kind = "helius_laserstream"`, le consumer doit sélectionner l’endpoint WebSocket résolu puis ouvrir la façade Helius, sans reconstruire ni journaliser l’URL contenant l’API key :
|
||||
|
||||
```rust
|
||||
let resolved = match engine.load_resolved_transport_config(Some("helius_devnet"), &environment) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let ws_settings = match resolved.ws_settings() {
|
||||
Some(value) => value,
|
||||
None => return Err(ksp_core_lib::Error::new(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS, "Helius profile requires WebSocket settings")),
|
||||
};
|
||||
let endpoint = match ws_settings
|
||||
.endpoints()
|
||||
.iter()
|
||||
.find(|candidate| candidate.enabled() && candidate.protocol() == ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream)
|
||||
{
|
||||
Some(value) => value.clone(),
|
||||
None => return Err(ksp_core_lib::Error::new(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS, "Helius WebSocket endpoint is unavailable")),
|
||||
};
|
||||
let session = match ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(endpoint).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut slots = match session.slot_subscribe().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let notification = slots.recv().await;
|
||||
let removed = slots.unsubscribe().await;
|
||||
let closed = session.close().await;
|
||||
```
|
||||
|
||||
La façade Helius réutilise `account`, `logs`, `program`, `root`, `signature`, `slot` et `slotsUpdates`. `slotsUpdates` reste unstable. `block` et `vote` ne sont pas exposés. `transaction_subscribe()` prend `HeliusTransactionSubscribeRequest` et retourne `WsSubscription<HeliusTransactionNotification>` ; son unsubscribe reste porté par le handle et produit `transactionUnsubscribe` sans exposer l’ID distant.
|
||||
|
||||
Le heartbeat Helius est automatique : `WsSession` envoie un control frame `Ping` toutes les 60 secondes tant que la session Helius est active. Le consumer ne configure pas un second timer et ne lance pas un task heartbeat parallèle. Cette règle ne vaut pas pour `SolanaStandardWsSession`.
|
||||
|
||||
`KSP_SECRET_HELIUS_API_KEY` appartient à Config. Ne pas lire l’environnement dans Transport, ne pas recopier l’URL résolue dans un log et ne pas ajouter un dev-dependency inverse `Transport -> Config`. LaserStream gRPC reste un backend différent et ne doit pas réutiliser `WsProtocolKind::HeliusLaserStream`.
|
||||
|
||||
### Reconnect automatique borné
|
||||
|
||||
Les settings de session contrôlent le reconnect physique. Une perte de socket publie `Reconnecting { attempt }`, invalide les remote IDs et incrémente `continuity_gap_count`. Avec la policy par défaut `ActiveSubscriptions`, les handles logiques gardent leur `WsSubscriptionId` et passent temporairement en `Resubscribing`; l'actor recrée leurs subscriptions dans l'ordre local avant de republier `Active`.
|
||||
|
||||
`WsResubscribePolicy::Never` reconnecte uniquement la session physique : les subscriptions existantes deviennent terminales et doivent être recréées explicitement par le consumer. Dans les deux modes, les requests applicatives qui étaient en vol lors de la coupure échouent et ne sont pas rejouées implicitement.
|
||||
|
||||
`unsubscribe().await` peut être appelé pendant `Reconnecting` ou `Resubscribing`. La cancellation locale gagne et le handle ne redevient jamais `Active`. Un ACK distant tardif est nettoyé best-effort par l'actor. Aucun backfill HTTP n'est déclenché automatiquement ; le consumer doit traiter `continuity_gap_count` comme un signal de réconciliation éventuelle.
|
||||
|
||||
### Backpressure par subscription
|
||||
|
||||
`WsSessionSettings::notification_queue_capacity()` borne la queue de chaque `WsSubscription<T>`. Le consumer doit donc drainer `recv()` selon son débit métier. Une queue pleine ne bloque pas l'actor et n'affecte pas les autres subscriptions : la subscription lente devient terminale avec `state() == Failed` et `terminal_error_code() == Some(ERROR_CODE_WS_BACKPRESSURE_OVERFLOW)`, tandis que `WsSessionSnapshot::overflow_count()` est incrémenté.
|
||||
|
||||
Un échec terminal non lié à l'overflow expose lui aussi un `ErrorCode` KSP sûr via `terminal_error_code()`. Une fermeture normale conserve `None`. Cette projection ne contient ni payload de notification, ni remote subscription ID, ni URL d'endpoint.
|
||||
|
||||
`max_active_subscriptions` borne séparément le nombre d'entrées logiques enregistrées. Son rejet utilise le même domaine d'erreur de capacité mais n'incrémente pas `overflow_count`, réservé aux queues de notifications saturées. Une subscription fermée ou nettoyée après abandon de son receiver libère sa capacité locale ; l'actor tente aussi de supprimer son binding distant sans rendre ce cleanup bloquant.
|
||||
|
||||
Le consumer doit traiter `overflow_count` et `continuity_gap_count` comme deux signaux distincts : le premier indique une perte locale par saturation d'un consumer, le second une interruption de continuité liée à une reconnexion. Aucun des deux n'implique un replay ou un backfill automatique.
|
||||
|
||||
### Fermeture explicite
|
||||
|
||||
Fermer explicitement la session est la voie normale de shutdown :
|
||||
|
||||
```rust
|
||||
let session = ksp_onchain_transport_lib::WsSession::connect(endpoint).await?;
|
||||
// ... wrappers standard puis recv()/unsubscribe() ...
|
||||
session.close().await?;
|
||||
```
|
||||
|
||||
`close()` agit sur toute la session physique, y compris les clones du handle. Il annule les requests en attente, publie `Closing`, tente le Close WebSocket dans le budget configuré, puis publie `Closed`. Une session `Closed` refuse les nouvelles requests internes.
|
||||
|
||||
Les limites de taille et de capacité sont des policies KSP configurables par `WsSessionSettings`; elles ne doivent pas être interprétées comme des limites protocolaires Solana officielles.
|
||||
|
||||
Le snapshot expose seulement l'identité locale, les metadata logiques de l'endpoint, l'état, les compteurs sûrs et les projections locales de subscriptions. L'URL et les remote subscription IDs ne sont jamais projetés. La disparition de tous les handles de session déclenche le cleanup actor best-effort ; `close().await` reste la voie normale de shutdown.
|
||||
|
||||
## 4. Yellowstone gRPC standard
|
||||
|
||||
### Construction programmatique et unary
|
||||
|
||||
Transport peut ouvrir directement un endpoint Yellowstone sans Config :
|
||||
|
||||
```rust
|
||||
let grpc_url = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse(
|
||||
"https://solana-yellowstone-grpc.publicnode.com:443",
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let x_token_metadata = match ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret(
|
||||
"x-token",
|
||||
x_token,
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let grpc_endpoint = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||
"publicnode_mainnet_yellowstone",
|
||||
true,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new("publicnode"),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new("mainnet-beta"),
|
||||
grpc_url,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::default(),
|
||||
)
|
||||
.with_metadata(vec![x_token_metadata])
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let grpc_channel = match ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(&grpc_endpoint).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let grpc = grpc_channel.standard_unary_client();
|
||||
let version = grpc.get_version().await;
|
||||
let slot = grpc
|
||||
.get_slot(Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed))
|
||||
.await;
|
||||
```
|
||||
|
||||
L’URL reste sensible : `Debug`, erreurs KSP et snapshots n’en exposent pas la valeur. Les metadata publiques/secrètes se construisent avec `YellowstoneGrpcMetadataEntry`; Transport ne lit jamais l’environnement. PublicNode requiert actuellement une metadata secrète `x-token` pour les endpoints Yellowstone validés ; ici `x_token` représente une valeur déjà obtenue par un caller sécurisé. En usage normal, Config construit cette metadata depuis les variables `KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN` ou `KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN`. Les appels unary montrés ci-dessus illustrent la surface KSP standard ; le smoke PublicNode de release ne prétend pas valider leur entitlement provider et gate uniquement `Subscribe` slots.
|
||||
|
||||
### Config Transport V3
|
||||
|
||||
Avec `ksp-config-lib`, un profil V3 peut exposer les trois transports sans casser l’accesseur historique HTTP + WS :
|
||||
|
||||
```rust
|
||||
let resolved = match engine.load_resolved_transport_config(Some("publicnode_mainnet"), &environment) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let grpc_settings = match resolved.grpc_settings() {
|
||||
Some(value) => value,
|
||||
None => return Err(ksp_core_lib::Error::new(
|
||||
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
|
||||
"selected profile has no Yellowstone gRPC endpoint",
|
||||
)),
|
||||
};
|
||||
let endpoint = match grpc_settings.endpoints().iter().find(|candidate| candidate.enabled()) {
|
||||
Some(value) => value,
|
||||
None => return Err(ksp_core_lib::Error::new(
|
||||
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
|
||||
"selected profile has no enabled Yellowstone gRPC endpoint",
|
||||
)),
|
||||
};
|
||||
let channel = ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(endpoint).await;
|
||||
```
|
||||
|
||||
`protocol = solana_yellowstone` est validé par Config et reste distinct du descripteur `provider`. Une valeur provider n’autorise pas Transport à introduire une API provider-specific sans divergence réelle.
|
||||
|
||||
### Profil OrbitFlare Devnet
|
||||
|
||||
Le profil committé `orbitflare_devnet` réutilise exactement le même accès Config -> Transport :
|
||||
|
||||
```rust
|
||||
let resolved = match engine.load_resolved_transport_config(Some("orbitflare_devnet"), &environment) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let grpc_settings = match resolved.grpc_settings() {
|
||||
Some(value) => value,
|
||||
None => return Err(ksp_core_lib::Error::new(
|
||||
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
|
||||
"selected OrbitFlare profile has no Yellowstone gRPC endpoint",
|
||||
)),
|
||||
};
|
||||
let endpoint = match grpc_settings.endpoints().iter().find(|candidate| candidate.enabled()) {
|
||||
Some(value) => value,
|
||||
None => return Err(ksp_core_lib::Error::new(
|
||||
ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS,
|
||||
"selected OrbitFlare profile has no enabled Yellowstone gRPC endpoint",
|
||||
)),
|
||||
};
|
||||
let channel = ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(endpoint).await;
|
||||
```
|
||||
|
||||
Config résout `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` vers la metadata secrète `x-token`. Sa valeur effective est la License Key `ORBIT-*` du produit Solana ; `X-ORBIT-KEY` et le Bearer du Customer API ne doivent pas être utilisés pour Yellowstone. Transport ne lit jamais cette variable lui-même.
|
||||
|
||||
L’endpoint validé par `0.2.10` est `http://devnet.rpc.orbitflare.com:10000`. Il reste volontairement en `http` : KSP ne remplace pas le transport provider par `https` sans endpoint TLS explicitement fourni.
|
||||
|
||||
### Subscribe bidirectionnel
|
||||
|
||||
Une session standard part d’une requête typed complète :
|
||||
|
||||
```rust
|
||||
let mut request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
|
||||
let name = match ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("slots") {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if let Err(error) = request.insert_slot_filter(
|
||||
name,
|
||||
ksp_onchain_transport_lib::YellowstoneSubscribeSlotFilter::new(),
|
||||
) {
|
||||
return Err(error);
|
||||
}
|
||||
request.set_commitment(Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||
let mut stream = match grpc_channel.open_standard_subscribe(request).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let update = stream.next_update().await;
|
||||
let snapshot = stream.snapshot();
|
||||
let closed = stream.close().await;
|
||||
```
|
||||
|
||||
`try_update()` remplace dynamiquement la requête complète tant que la session est `Active`. Une mutation pendant `Reconnecting` est refusée pour éviter une application ambiguë. Le snapshot expose reconnects, replay attempts, gaps, duplicates, dernier `from_slot` demandé et dernier slot observé, sans endpoint ni payload arbitraire.
|
||||
|
||||
Le reconnect réutilise la dernière requête acceptée et peut avancer `from_slot`, mais le consumer doit traiter cette reprise comme best-effort. KSP ne promet ni exactly-once, ni replay historique complet, ni absence de fork/equivocation entre nœuds.
|
||||
|
||||
## 5. Appels typés
|
||||
|
||||
Les wrappers typés se trouvent directement sur `HttpTransportPool`.
|
||||
|
||||
@@ -86,7 +412,7 @@ let balance = pool
|
||||
.await;
|
||||
```
|
||||
|
||||
Les quatre canaris `0.2.1` restent disponibles. `0.2.2` ajoute les wrappers typés Accounts, Tokens et Cluster. Exemples représentatifs :
|
||||
Les wrappers foundation, Accounts, Tokens et Cluster sont disponibles directement sur le pool. Exemples représentatifs :
|
||||
|
||||
```rust
|
||||
let account = pool
|
||||
@@ -96,7 +422,7 @@ let epoch = pool.get_epoch_info(&role, None).await;
|
||||
let vote_accounts = pool.get_vote_accounts(&role, None).await;
|
||||
```
|
||||
|
||||
La release stable `0.2.4` contient les **52 wrappers typés courants** : 4 foundation + 22 Accounts/Tokens/Cluster + 11 Transactions + 10 Blocks + 5 Economics. Les DTOs Transport conservent les `null`, options, overloads et formes wire sans décodage Program/SPL métier.
|
||||
La surface HTTP contient **52 wrappers typés courants** : 4 foundation + 22 Accounts/Tokens/Cluster + 11 Transactions + 10 Blocks + 5 Economics. Les DTOs Transport conservent les `null`, options, overloads et formes wire sans décodage Program/SPL métier.
|
||||
|
||||
Exemples Transaction représentatifs :
|
||||
|
||||
@@ -125,7 +451,7 @@ let stake_minimum = pool.get_stake_minimum_delegation(&role, Some(&context)).awa
|
||||
|
||||
`getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant.
|
||||
|
||||
## 4. Exécution JSON-RPC standard générique
|
||||
## 6. Exécution JSON-RPC standard générique
|
||||
|
||||
Une méthode courante auditée peut être appelée via son descriptor :
|
||||
|
||||
@@ -139,7 +465,7 @@ Cette API retourne un `serde_json::Value`. Elle reste utile pour les extensions
|
||||
|
||||
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
|
||||
## 7. Sélection et admission sans exécuter la requête
|
||||
|
||||
Pour inspecter le routing :
|
||||
|
||||
@@ -154,13 +480,13 @@ Dans le même bloc, `acquire_for_method()` réserve réellement la capacité RPS
|
||||
|
||||
`HttpRequestPermit` détient la capacité de concurrence jusqu'à sa destruction. Aucun verrou synchrone n'est conservé pendant l'attente réseau.
|
||||
|
||||
## 6. Snapshots runtime
|
||||
## 8. 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
|
||||
## 9. Retry et write submissions
|
||||
|
||||
La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`.
|
||||
|
||||
@@ -168,7 +494,7 @@ Les reads/simulations classés `RetrySafe` peuvent être réessayés dans le bud
|
||||
|
||||
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
|
||||
## 10. Logging
|
||||
|
||||
Les événements Transport utilisent le target :
|
||||
|
||||
@@ -180,9 +506,9 @@ Ne jamais journaliser l'URL complète, un token provider, un body massif, une tr
|
||||
|
||||
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
|
||||
## 11. Smokes réseau opt-in
|
||||
|
||||
Le smoke **Transport pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif d'Accounts/Tokens/Cluster, trois reads Transactions, puis des reads Blocks/Economics de la release stable `0.2.4` :
|
||||
Le smoke **Transport HTTP pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif d'Accounts/Tokens/Cluster, trois reads Transactions, puis des reads Blocks/Economics :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||
@@ -190,7 +516,60 @@ cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignor
|
||||
|
||||
Il appelle `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo`, `getVoteAccounts`, puis `getLatestBlockhash`, `isBlockhashValid`, `getTransactionCount`, `getBlockHeight`, `getInflationRate` et `getStakeMinimumDelegation`. 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. La branche Transaction reste read-only : elle ne déclenche ni airdrop ni soumission de transaction et ne remplace pas les fixtures déterministes couvrant les 11 wrappers.
|
||||
|
||||
Le smoke historique de **composition Config -> Transport** reste également disponible :
|
||||
Le smoke **Transport WebSocket pur** utilise l'endpoint public Devnet standard avec des settings programmatiques, ouvre une session physique, crée une subscription stable `slotSubscribe`, attend une notification bornée, vérifie une valeur de slot non nulle, annule la subscription avec son handle puis ferme explicitement la session :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-onchain-transport-lib --test websocket_devnet_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Il n'utilise ni `blockSubscribe`, ni `slotsUpdatesSubscribe`, ni `voteSubscribe` : ces familles restent unstable et leur disponibilité dépend des capabilities du validator. Le smoke live n'est donc pas un gate de disponibilité de ces extensions.
|
||||
|
||||
Le smoke **Transport Yellowstone gRPC PublicNode** reste indépendant de Config mais nécessite un personal token opérateur. Il teste Mainnet et Testnet en ouvrant `Subscribe`, en demandant les updates `slots`, en attendant un `YellowstoneSubscribeUpdate::Slot` non nul puis en fermant la session de manière bornée.
|
||||
|
||||
Pour éviter de placer les secrets dans les arguments ou l'URL, le harness lit deux lignes sur stdin : Mainnet puis Testnet. Elles peuvent contenir la même valeur ; l'opérateur a validé un même personal token sur les deux réseaux.
|
||||
|
||||
```bash
|
||||
read -rsp 'PublicNode Mainnet Yellowstone x-token: ' PUBLICNODE_MAINNET_TOKEN
|
||||
echo
|
||||
read -rsp 'PublicNode Testnet Yellowstone x-token: ' PUBLICNODE_TESTNET_TOKEN
|
||||
echo
|
||||
printf '%s\n%s\n' "$PUBLICNODE_MAINNET_TOKEN" "$PUBLICNODE_TESTNET_TOKEN" \
|
||||
| cargo test -p ksp-onchain-transport-lib \
|
||||
--test yellowstone_publicnode_smoke \
|
||||
-- --ignored --nocapture
|
||||
unset PUBLICNODE_MAINNET_TOKEN PUBLICNODE_TESTNET_TOKEN
|
||||
```
|
||||
|
||||
Endpoints validés :
|
||||
|
||||
```text
|
||||
Mainnet https://solana-yellowstone-grpc.publicnode.com:443
|
||||
Testnet https://solana-testnet-yellowstone-grpc.publicnode.com:443
|
||||
```
|
||||
|
||||
Les profils Config conservent deux variables secrètes distinctes afin d'autoriser des credentials différents si nécessaire ; cette séparation ne signifie pas que PublicNode impose actuellement un token différent par réseau. Un timeout KSP de half-close après réception du slot est accepté par le smoke comme fermeture bornée du provider ; aucune absence de slot ni autre erreur n'est masquée.
|
||||
|
||||
Le smoke **Transport Yellowstone gRPC OrbitFlare** est lui aussi indépendant de Config. Il lit une seule License Key sur stdin, la classe comme metadata secrète `x-token`, ouvre le standard `Subscribe`, demande `slots` à commitment confirmed, attend un Slot non nul et un `SubscribeUpdate::Ping`, puis ferme la session de manière bornée :
|
||||
|
||||
```bash
|
||||
read -rsp 'OrbitFlare License Key: ' ORBITFLARE_LICENSE_KEY
|
||||
echo
|
||||
printf '%s\n' "$ORBITFLARE_LICENSE_KEY" \
|
||||
| cargo test -p ksp-onchain-transport-lib \
|
||||
--test yellowstone_orbitflare_smoke \
|
||||
-- --ignored --nocapture
|
||||
unset ORBITFLARE_LICENSE_KEY
|
||||
```
|
||||
|
||||
Endpoint validé :
|
||||
|
||||
```text
|
||||
Devnet http://devnet.rpc.orbitflare.com:10000
|
||||
```
|
||||
|
||||
Le gate `0.2.10-pre.003` a passé ce scénario en live avec `Slot + Ping`. Le Ping reçu est le message Yellowstone standard auquel N1 sait déjà répondre sans remplacer la dernière requête complète mémorisée ; aucun heartbeat OrbitFlare supplémentaire n’est donc requis.
|
||||
|
||||
Le smoke de **composition Config -> Transport** reste également disponible :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
|
||||
@@ -198,4 +577,32 @@ cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapt
|
||||
|
||||
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.
|
||||
### Smoke Helius live
|
||||
|
||||
Aucun nouveau test Helius live n’est committé en `0.2.8-pre.010`. La raison est architecturale : Transport ne peut pas lire `KSP_SECRET_HELIUS_API_KEY` ni dépendre de Config, et Config ne doit pas devenir la destination générale des futurs smokes `Config + autre crate`. Créer un smoke Helius supplémentaire dans l’une de ces deux crates contournerait donc une frontière déjà documentée.
|
||||
|
||||
Lorsque la surface KSP d’intégration/orchestration dédiée existera, le smoke live minimal recommandé sera :
|
||||
|
||||
```text
|
||||
Config helius_devnet
|
||||
-> endpoint helius_laserstream résolu avec KSP_SECRET_HELIUS_API_KEY
|
||||
-> HeliusLaserStreamWsSession::connect
|
||||
-> slotSubscribe
|
||||
-> une slotNotification sous timeout
|
||||
-> slotUnsubscribe
|
||||
-> close
|
||||
```
|
||||
|
||||
Ce scénario utilise une méthode standard stable sur l’endpoint Helius et teste donc auth + façade provider + actor + unsubscribe sans dépendre d’une entitlement particulière de `transactionSubscribe`. Un smoke `transactionSubscribe` pourra être ajouté séparément comme opt-in provider-specific si l’environnement opérateur possède les droits nécessaires ; il ne doit pas devenir un gate réseau obligatoire de la release.
|
||||
|
||||
Les endpoints publics/provider sont des dépendances externes. Un rate-limit, refus d’auth, entitlement absente ou incident réseau n’est pas assimilé automatiquement à une régression locale ; les fixtures HTTP/WebSocket/gRPC locales et les gates déterministes restent autoritaires.
|
||||
|
||||
Pour auditer les dépendances, inspecter également le graphe effectif après résolution Cargo :
|
||||
|
||||
```bash
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||
cargo tree --duplicates
|
||||
```
|
||||
|
||||
Le premier graphe doit conserver la frontière `Transport -> Core + Logging + crates techniques`; il ne doit introduire aucune dépendance Config, Wallet, Store, Program ou `tracing` directe. Les sorties `--duplicates` sont un diagnostic de résolution transitive : une duplication n'est pas supprimée aveuglément si elle est imposée par des dépendances upstream incompatibles.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/constants.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
//! Transport-owned tracing constants.
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
||||
// version: 3
|
||||
// version: 7
|
||||
|
||||
/// 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 a bounded Yellowstone gRPC request/update queue is exhausted.
|
||||
pub const ERROR_CODE_GRPC_BACKPRESSURE_OVERFLOW: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_backpressure_overflow");
|
||||
/// Error code used when a Yellowstone gRPC channel cannot be prepared safely.
|
||||
pub const ERROR_CODE_GRPC_CHANNEL_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_channel_failed");
|
||||
/// Error code used when a Yellowstone gRPC subscribe session is no longer available to the caller.
|
||||
pub const ERROR_CODE_GRPC_SESSION_CLOSED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_session_closed");
|
||||
/// Error code used when a Yellowstone gRPC endpoint returns a remote gRPC status.
|
||||
pub const ERROR_CODE_GRPC_STATUS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_status");
|
||||
/// 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.
|
||||
@@ -27,3 +35,11 @@ pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::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 transport deadline expires.
|
||||
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
||||
/// Error code used when a bounded WebSocket runtime queue or pending-request capacity is exhausted.
|
||||
pub const ERROR_CODE_WS_BACKPRESSURE_OVERFLOW: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_backpressure_overflow");
|
||||
/// Error code used when a physical WebSocket connection or handshake fails.
|
||||
pub const ERROR_CODE_WS_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_connection_failed");
|
||||
/// Error code used when WebSocket wire data violates the KSP protocol contract.
|
||||
pub const ERROR_CODE_WS_PROTOCOL_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_protocol_error");
|
||||
/// Error code used when a WebSocket session is no longer available to a caller.
|
||||
pub const ERROR_CODE_WS_SESSION_CLOSED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_session_closed");
|
||||
|
||||
191
crates/ksp-onchain-transport-lib/src/grpc_channel.rs
Normal file
191
crates/ksp-onchain-transport-lib/src/grpc_channel.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/grpc_channel.rs
|
||||
// version: 4
|
||||
|
||||
/// Prepared or connected Yellowstone gRPC channel owned by KSP Transport.
|
||||
///
|
||||
/// The underlying Tonic channel, endpoint URL and request metadata remain private. Callers use the KSP-owned typed Yellowstone surfaces layered on this
|
||||
/// physical channel instead of receiving a raw Tonic escape hatch.
|
||||
#[derive(Clone)]
|
||||
pub struct YellowstoneGrpcChannel {
|
||||
endpoint_name: std::string::String,
|
||||
provider: crate::YellowstoneGrpcProviderName,
|
||||
cluster: crate::YellowstoneGrpcClusterName,
|
||||
channel: tonic::transport::Channel,
|
||||
metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>,
|
||||
session: crate::YellowstoneGrpcSessionSettings,
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcChannel {
|
||||
/// Prepares one lazy HTTP/2 channel without establishing a network connection.
|
||||
///
|
||||
/// HTTPS endpoints receive the KSP TLS configuration immediately, so invalid local TLS setup is rejected before a typed client is created. Tonic lazy
|
||||
/// channels require an active Tokio runtime even though no socket is opened yet.
|
||||
pub fn prepare(settings: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if !settings.enabled() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "disabled Yellowstone gRPC endpoint cannot prepare a channel")
|
||||
.with_context("endpoint_name", settings.name()),
|
||||
);
|
||||
}
|
||||
if tokio::runtime::Handle::try_current().is_err() {
|
||||
return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC channel preparation requires an active Tokio runtime"));
|
||||
}
|
||||
let endpoint = match build_tonic_endpoint(settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let channel = endpoint.connect_lazy();
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = settings.name(),
|
||||
provider = settings.provider().as_str(),
|
||||
cluster = settings.cluster().as_str(),
|
||||
tls = settings.url().uses_tls(),
|
||||
metadata_count = settings.metadata().len(),
|
||||
"prepared lazy Yellowstone gRPC channel"
|
||||
);
|
||||
return std::result::Result::Ok(Self::from_parts(settings, channel));
|
||||
}
|
||||
|
||||
/// Establishes one Yellowstone gRPC HTTP/2 channel with bounded connect timeout and configured TLS roots.
|
||||
pub async fn connect(settings: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if !settings.enabled() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "disabled Yellowstone gRPC endpoint cannot connect")
|
||||
.with_context("endpoint_name", settings.name()),
|
||||
);
|
||||
}
|
||||
if tokio::runtime::Handle::try_current().is_err() {
|
||||
return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC connection requires an active Tokio runtime"));
|
||||
}
|
||||
let endpoint = match build_tonic_endpoint(settings) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let channel = match endpoint.connect().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = settings.name(),
|
||||
provider = settings.provider().as_str(),
|
||||
cluster = settings.cluster().as_str(),
|
||||
tls = settings.url().uses_tls(),
|
||||
"Yellowstone gRPC channel connection failed"
|
||||
);
|
||||
return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC channel connection failed"));
|
||||
},
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = settings.name(),
|
||||
provider = settings.provider().as_str(),
|
||||
cluster = settings.cluster().as_str(),
|
||||
tls = settings.url().uses_tls(),
|
||||
"connected Yellowstone gRPC channel"
|
||||
);
|
||||
return std::result::Result::Ok(Self::from_parts(settings, channel));
|
||||
}
|
||||
|
||||
/// Returns the safe logical endpoint name.
|
||||
#[must_use]
|
||||
pub fn endpoint_name(&self) -> &str {
|
||||
return self.endpoint_name.as_str();
|
||||
}
|
||||
|
||||
/// Returns the open provider descriptor.
|
||||
#[must_use]
|
||||
pub const fn provider(&self) -> &crate::YellowstoneGrpcProviderName {
|
||||
return &self.provider;
|
||||
}
|
||||
|
||||
/// Returns the open cluster descriptor.
|
||||
#[must_use]
|
||||
pub const fn cluster(&self) -> &crate::YellowstoneGrpcClusterName {
|
||||
return &self.cluster;
|
||||
}
|
||||
|
||||
/// Creates the standard Solana Yellowstone unary facade over this physical channel.
|
||||
#[must_use]
|
||||
pub fn standard_unary_client(&self) -> crate::SolanaYellowstoneGrpcUnaryClient {
|
||||
return crate::SolanaYellowstoneGrpcUnaryClient::new(self.channel.clone(), self.metadata.clone(), self.session.clone());
|
||||
}
|
||||
|
||||
/// Opens one standard Solana Yellowstone bidirectional `Subscribe` session over this physical channel.
|
||||
pub async fn open_standard_subscribe(
|
||||
&self,
|
||||
initial_request: crate::YellowstoneSubscribeRequest,
|
||||
) -> ksp_core_lib::Result<crate::SolanaYellowstoneGrpcSubscribeSession> {
|
||||
return crate::open_yellowstone_subscribe_session(
|
||||
self.channel.clone(),
|
||||
self.metadata.clone(),
|
||||
self.session.clone(),
|
||||
self.endpoint_name.clone(),
|
||||
self.provider.clone(),
|
||||
self.cluster.clone(),
|
||||
initial_request,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn from_parts(settings: &crate::YellowstoneGrpcEndpointSettings, channel: tonic::transport::Channel) -> Self {
|
||||
return Self {
|
||||
endpoint_name: settings.name().to_owned(),
|
||||
provider: settings.provider().clone(),
|
||||
cluster: settings.cluster().clone(),
|
||||
channel,
|
||||
metadata: settings.metadata().to_vec(),
|
||||
session: settings.session().clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for YellowstoneGrpcChannel {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("YellowstoneGrpcChannel")
|
||||
.field("endpoint_name", &self.endpoint_name)
|
||||
.field("provider", &self.provider)
|
||||
.field("cluster", &self.cluster)
|
||||
.field("metadata_count", &self.metadata.len())
|
||||
.field("channel", &"<private>")
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tonic_endpoint(settings: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result<tonic::transport::Endpoint> {
|
||||
let endpoint = match tonic::transport::Endpoint::from_shared(settings.url().as_str().to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC endpoint URI cannot be prepared by the HTTP/2 transport"));
|
||||
},
|
||||
};
|
||||
let endpoint = endpoint.connect_timeout(settings.session().connect_timeout()).buffer_size(settings.session().request_channel_capacity());
|
||||
if settings.url().uses_tls() {
|
||||
let tls = tonic::transport::ClientTlsConfig::new().with_webpki_roots().timeout(settings.session().connect_timeout());
|
||||
return match endpoint.tls_config(tls) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(_) => {
|
||||
std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC TLS configuration failed before connection"))
|
||||
},
|
||||
};
|
||||
}
|
||||
return std::result::Result::Ok(endpoint);
|
||||
}
|
||||
|
||||
fn grpc_channel_error(settings: &crate::YellowstoneGrpcEndpointSettings, message: &str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, message)
|
||||
.with_context("endpoint_name", settings.name())
|
||||
.with_context("provider", settings.provider().as_str())
|
||||
.with_context("cluster", settings.cluster().as_str());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/grpc_channel.rs"]
|
||||
mod tests;
|
||||
666
crates/ksp-onchain-transport-lib/src/grpc_settings.rs
Normal file
666
crates/ksp-onchain-transport-lib/src/grpc_settings.rs
Normal file
@@ -0,0 +1,666 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/grpc_settings.rs
|
||||
// version: 2
|
||||
|
||||
const DEFAULT_GRPC_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const DEFAULT_GRPC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE_BYTES: usize = 64 * 1024 * 1024;
|
||||
const DEFAULT_GRPC_MAX_OUTBOUND_MESSAGE_SIZE_BYTES: usize = 64 * 1024 * 1024;
|
||||
const DEFAULT_GRPC_RECONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
|
||||
const DEFAULT_GRPC_RECONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const DEFAULT_GRPC_RECONNECT_MAX_RETRIES: u32 = 5;
|
||||
const DEFAULT_GRPC_REQUEST_CHANNEL_CAPACITY: usize = 128;
|
||||
const DEFAULT_GRPC_UNARY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const DEFAULT_GRPC_UPDATE_CHANNEL_CAPACITY: usize = 256;
|
||||
const MAX_GRPC_CHANNEL_CAPACITY: usize = 65_536;
|
||||
const MAX_GRPC_DESCRIPTOR_LENGTH_BYTES: usize = 128;
|
||||
const MAX_GRPC_ENDPOINT_COUNT: usize = 128;
|
||||
const MAX_GRPC_ENDPOINT_URL_LENGTH_BYTES: usize = 8 * 1024;
|
||||
const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 512 * 1024 * 1024;
|
||||
const MAX_GRPC_METADATA_ENTRY_COUNT: usize = 64;
|
||||
const MAX_GRPC_METADATA_KEY_LENGTH_BYTES: usize = 128;
|
||||
const MAX_GRPC_METADATA_VALUE_LENGTH_BYTES: usize = 8 * 1024;
|
||||
const MAX_GRPC_RECONNECT_RETRIES: u32 = 100;
|
||||
const MAX_GRPC_RUNTIME_DURATION: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
/// Runtime Yellowstone gRPC 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 YellowstoneGrpcEndpointUrl {
|
||||
value: std::string::String,
|
||||
uses_tls: bool,
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcEndpointUrl {
|
||||
/// Parses and validates one Yellowstone gRPC endpoint URL.
|
||||
pub fn parse(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating Yellowstone gRPC endpoint URL");
|
||||
let value = value.into();
|
||||
if value.len() > MAX_GRPC_ENDPOINT_URL_LENGTH_BYTES {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC endpoint URL exceeds the KSP length bound")
|
||||
.with_context("field", "grpc_endpoints.url"),
|
||||
);
|
||||
}
|
||||
let parsed = match reqwest::Url::parse(value.as_str()) {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "grpc_endpoints.url", "rejected invalid Yellowstone gRPC endpoint URL");
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC endpoint URL is invalid")
|
||||
.with_context("field", "grpc_endpoints.url")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
field = "grpc_endpoints.url",
|
||||
scheme = parsed.scheme(),
|
||||
"rejected Yellowstone gRPC endpoint URL with unsupported scheme"
|
||||
);
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC endpoint URL must use http or https")
|
||||
.with_context("field", "grpc_endpoints.url")
|
||||
.with_context("scheme", parsed.scheme()),
|
||||
);
|
||||
}
|
||||
if parsed.host_str().is_none() {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "grpc_endpoints.url", "rejected Yellowstone gRPC endpoint URL without host");
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC endpoint URL must contain a host")
|
||||
.with_context("field", "grpc_endpoints.url"),
|
||||
);
|
||||
}
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, scheme = parsed.scheme(), "validated Yellowstone gRPC endpoint URL syntax");
|
||||
return std::result::Result::Ok(Self { value, uses_tls: parsed.scheme() == "https" });
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// Returns whether this endpoint URL requires TLS.
|
||||
#[must_use]
|
||||
pub const fn uses_tls(&self) -> bool {
|
||||
return self.uses_tls;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for YellowstoneGrpcEndpointUrl {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("YellowstoneGrpcEndpointUrl(<redacted>)");
|
||||
}
|
||||
}
|
||||
|
||||
/// One validated ASCII metadata entry attached to Yellowstone gRPC requests.
|
||||
///
|
||||
/// Metadata values are intentionally omitted from [`std::fmt::Debug`] for both public and secret entries. Secret entries are additionally marked sensitive on
|
||||
/// the Tonic metadata value before transmission so the HTTP/2 stack avoids indexing them where supported. Binary `*-bin` metadata is not part of the
|
||||
/// `0.2.9-pre.003` contract.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct YellowstoneGrpcMetadataEntry {
|
||||
key: std::string::String,
|
||||
value: std::string::String,
|
||||
secret: bool,
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcMetadataEntry {
|
||||
/// Creates one non-secret ASCII metadata entry.
|
||||
pub fn public(key: impl std::convert::Into<std::string::String>, value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
return Self::new(key.into(), value.into(), false);
|
||||
}
|
||||
|
||||
/// Creates one secret ASCII metadata entry with redacted diagnostics.
|
||||
pub fn secret(key: impl std::convert::Into<std::string::String>, value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
return Self::new(key.into(), value.into(), true);
|
||||
}
|
||||
|
||||
/// Returns the validated metadata key.
|
||||
#[must_use]
|
||||
pub fn key(&self) -> &str {
|
||||
return self.key.as_str();
|
||||
}
|
||||
|
||||
/// Returns whether the value must be treated as secret by Transport.
|
||||
#[must_use]
|
||||
pub const fn is_secret(&self) -> bool {
|
||||
return self.secret;
|
||||
}
|
||||
|
||||
/// Appends this validated value to an internal Tonic metadata map while preserving its sensitivity marker.
|
||||
pub(crate) fn append_to(&self, metadata: &mut tonic::metadata::MetadataMap) -> ksp_core_lib::Result<()> {
|
||||
let key = match tonic::metadata::MetadataKey::<tonic::metadata::Ascii>::from_bytes(self.key.as_bytes()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata key is invalid")
|
||||
.with_context("field", "grpc_endpoint.metadata.key"),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut value = match tonic::metadata::AsciiMetadataValue::try_from(self.value.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata value is invalid")
|
||||
.with_context("field", "grpc_endpoint.metadata.value")
|
||||
.with_context("metadata_key", self.key.as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
value.set_sensitive(self.secret);
|
||||
metadata.append(key, value);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn new(key: std::string::String, value: std::string::String, secret: bool) -> ksp_core_lib::Result<Self> {
|
||||
if key.is_empty()
|
||||
|| key.len() > MAX_GRPC_METADATA_KEY_LENGTH_BYTES
|
||||
|| key != key.to_ascii_lowercase()
|
||||
|| key.starts_with("grpc-")
|
||||
|| key.ends_with("-bin")
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata key violates the KSP ASCII metadata contract")
|
||||
.with_context("field", "grpc_endpoint.metadata.key"),
|
||||
);
|
||||
}
|
||||
if tonic::metadata::MetadataKey::<tonic::metadata::Ascii>::from_bytes(key.as_bytes()).is_err() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata key is invalid")
|
||||
.with_context("field", "grpc_endpoint.metadata.key"),
|
||||
);
|
||||
}
|
||||
if value.len() > MAX_GRPC_METADATA_VALUE_LENGTH_BYTES || tonic::metadata::AsciiMetadataValue::try_from(value.as_str()).is_err() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata value violates the KSP ASCII metadata contract")
|
||||
.with_context("field", "grpc_endpoint.metadata.value")
|
||||
.with_context("metadata_key", key.as_str()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(Self { key, value, secret });
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for YellowstoneGrpcMetadataEntry {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("YellowstoneGrpcMetadataEntry")
|
||||
.field("key", &self.key)
|
||||
.field("secret", &self.secret)
|
||||
.field("value", &"<redacted>")
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Open provider descriptor used by Yellowstone gRPC endpoint settings.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct YellowstoneGrpcProviderName {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcProviderName {
|
||||
/// Creates an open provider descriptor. Validation is performed by [`YellowstoneGrpcTransportSettings::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 Yellowstone gRPC endpoint settings.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct YellowstoneGrpcClusterName {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcClusterName {
|
||||
/// Creates an open cluster descriptor. Validation is performed by [`YellowstoneGrpcTransportSettings::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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded reconnect settings owned by the Yellowstone gRPC runtime.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneGrpcReconnectSettings {
|
||||
max_retries: u32,
|
||||
initial_backoff: std::time::Duration,
|
||||
max_backoff: std::time::Duration,
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcReconnectSettings {
|
||||
/// Creates bounded reconnect 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 reconnect attempts allowed after one live channel or stream is lost.
|
||||
#[must_use]
|
||||
pub const fn max_retries(&self) -> u32 {
|
||||
return self.max_retries;
|
||||
}
|
||||
|
||||
/// Returns the initial reconnect backoff.
|
||||
#[must_use]
|
||||
pub const fn initial_backoff(&self) -> std::time::Duration {
|
||||
return self.initial_backoff;
|
||||
}
|
||||
|
||||
/// Returns the maximum reconnect backoff.
|
||||
#[must_use]
|
||||
pub const fn max_backoff(&self) -> std::time::Duration {
|
||||
return self.max_backoff;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for YellowstoneGrpcReconnectSettings {
|
||||
fn default() -> Self {
|
||||
return Self::new(DEFAULT_GRPC_RECONNECT_MAX_RETRIES, DEFAULT_GRPC_RECONNECT_INITIAL_BACKOFF, DEFAULT_GRPC_RECONNECT_MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime limits and lifecycle settings for one Yellowstone gRPC physical channel/session path.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneGrpcSessionSettings {
|
||||
connect_timeout: std::time::Duration,
|
||||
unary_timeout: std::time::Duration,
|
||||
close_timeout: std::time::Duration,
|
||||
reconnect: crate::YellowstoneGrpcReconnectSettings,
|
||||
request_channel_capacity: usize,
|
||||
update_channel_capacity: usize,
|
||||
max_inbound_message_size_bytes: usize,
|
||||
max_outbound_message_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcSessionSettings {
|
||||
/// Creates complete runtime settings for one physical Yellowstone gRPC channel/session path.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub const fn new(
|
||||
connect_timeout: std::time::Duration,
|
||||
unary_timeout: std::time::Duration,
|
||||
close_timeout: std::time::Duration,
|
||||
reconnect: crate::YellowstoneGrpcReconnectSettings,
|
||||
request_channel_capacity: usize,
|
||||
update_channel_capacity: usize,
|
||||
max_inbound_message_size_bytes: usize,
|
||||
max_outbound_message_size_bytes: usize,
|
||||
) -> Self {
|
||||
return Self {
|
||||
connect_timeout,
|
||||
unary_timeout,
|
||||
close_timeout,
|
||||
reconnect,
|
||||
request_channel_capacity,
|
||||
update_channel_capacity,
|
||||
max_inbound_message_size_bytes,
|
||||
max_outbound_message_size_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the connection establishment timeout.
|
||||
#[must_use]
|
||||
pub const fn connect_timeout(&self) -> std::time::Duration {
|
||||
return self.connect_timeout;
|
||||
}
|
||||
|
||||
/// Returns the default unary request timeout.
|
||||
#[must_use]
|
||||
pub const fn unary_timeout(&self) -> std::time::Duration {
|
||||
return self.unary_timeout;
|
||||
}
|
||||
|
||||
/// Returns the bounded graceful-close timeout.
|
||||
#[must_use]
|
||||
pub const fn close_timeout(&self) -> std::time::Duration {
|
||||
return self.close_timeout;
|
||||
}
|
||||
|
||||
/// Returns the reconnect policy.
|
||||
#[must_use]
|
||||
pub const fn reconnect(&self) -> &crate::YellowstoneGrpcReconnectSettings {
|
||||
return &self.reconnect;
|
||||
}
|
||||
|
||||
/// Returns the bounded outgoing Subscribe request channel capacity.
|
||||
#[must_use]
|
||||
pub const fn request_channel_capacity(&self) -> usize {
|
||||
return self.request_channel_capacity;
|
||||
}
|
||||
|
||||
/// Returns the bounded incoming update delivery channel capacity.
|
||||
#[must_use]
|
||||
pub const fn update_channel_capacity(&self) -> usize {
|
||||
return self.update_channel_capacity;
|
||||
}
|
||||
|
||||
/// Returns the maximum inbound protobuf message size accepted by KSP.
|
||||
#[must_use]
|
||||
pub const fn max_inbound_message_size_bytes(&self) -> usize {
|
||||
return self.max_inbound_message_size_bytes;
|
||||
}
|
||||
|
||||
/// Returns the maximum outbound protobuf message size accepted by KSP.
|
||||
#[must_use]
|
||||
pub const fn max_outbound_message_size_bytes(&self) -> usize {
|
||||
return self.max_outbound_message_size_bytes;
|
||||
}
|
||||
|
||||
/// Validates KSP-owned runtime bounds without reading Config or environment state.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
if let std::result::Result::Err(error) = validate_grpc_duration(self.connect_timeout, "grpc_session.connect_timeout") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_grpc_duration(self.unary_timeout, "grpc_session.unary_timeout") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_grpc_duration(self.close_timeout, "grpc_session.close_timeout") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if self.reconnect.max_retries() > MAX_GRPC_RECONNECT_RETRIES {
|
||||
return grpc_invalid_settings("Yellowstone gRPC reconnect retry count exceeds the KSP runtime bound", "grpc_session.reconnect.max_retries");
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_grpc_duration(self.reconnect.initial_backoff(), "grpc_session.reconnect.initial_backoff") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_grpc_duration(self.reconnect.max_backoff(), "grpc_session.reconnect.max_backoff") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if self.reconnect.initial_backoff() > self.reconnect.max_backoff() {
|
||||
return grpc_invalid_settings("Yellowstone gRPC reconnect initial backoff must not exceed maximum backoff", "grpc_session.reconnect");
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_grpc_capacity(self.request_channel_capacity, "grpc_session.request_channel_capacity") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_grpc_capacity(self.update_channel_capacity, "grpc_session.update_channel_capacity") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_grpc_message_size(self.max_inbound_message_size_bytes, "grpc_session.max_inbound_message_size_bytes")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_grpc_message_size(self.max_outbound_message_size_bytes, "grpc_session.max_outbound_message_size_bytes")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
request_channel_capacity = self.request_channel_capacity,
|
||||
update_channel_capacity = self.update_channel_capacity,
|
||||
max_inbound_message_size_bytes = self.max_inbound_message_size_bytes,
|
||||
max_outbound_message_size_bytes = self.max_outbound_message_size_bytes,
|
||||
reconnect_max_retries = self.reconnect.max_retries(),
|
||||
"validated Yellowstone gRPC session settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for YellowstoneGrpcSessionSettings {
|
||||
fn default() -> Self {
|
||||
return Self::new(
|
||||
DEFAULT_GRPC_CONNECT_TIMEOUT,
|
||||
DEFAULT_GRPC_UNARY_TIMEOUT,
|
||||
DEFAULT_GRPC_CLOSE_TIMEOUT,
|
||||
crate::YellowstoneGrpcReconnectSettings::default(),
|
||||
DEFAULT_GRPC_REQUEST_CHANNEL_CAPACITY,
|
||||
DEFAULT_GRPC_UPDATE_CHANNEL_CAPACITY,
|
||||
DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE_BYTES,
|
||||
DEFAULT_GRPC_MAX_OUTBOUND_MESSAGE_SIZE_BYTES,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime settings for one named Yellowstone gRPC endpoint.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneGrpcEndpointSettings {
|
||||
name: std::string::String,
|
||||
enabled: bool,
|
||||
provider: crate::YellowstoneGrpcProviderName,
|
||||
cluster: crate::YellowstoneGrpcClusterName,
|
||||
url: crate::YellowstoneGrpcEndpointUrl,
|
||||
session: crate::YellowstoneGrpcSessionSettings,
|
||||
metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>,
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcEndpointSettings {
|
||||
/// Creates explicit settings for one logical Yellowstone gRPC endpoint.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
name: impl std::convert::Into<std::string::String>,
|
||||
enabled: bool,
|
||||
provider: crate::YellowstoneGrpcProviderName,
|
||||
cluster: crate::YellowstoneGrpcClusterName,
|
||||
url: crate::YellowstoneGrpcEndpointUrl,
|
||||
session: crate::YellowstoneGrpcSessionSettings,
|
||||
) -> Self {
|
||||
return Self { name: name.into(), enabled, provider, cluster, url, session, metadata: std::vec::Vec::new() };
|
||||
}
|
||||
|
||||
/// Returns the logical endpoint name.
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
return self.name.as_str();
|
||||
}
|
||||
|
||||
/// Returns whether this endpoint can be used to prepare a physical channel.
|
||||
#[must_use]
|
||||
pub const fn enabled(&self) -> bool {
|
||||
return self.enabled;
|
||||
}
|
||||
|
||||
/// Returns the open provider descriptor.
|
||||
#[must_use]
|
||||
pub const fn provider(&self) -> &crate::YellowstoneGrpcProviderName {
|
||||
return &self.provider;
|
||||
}
|
||||
|
||||
/// Returns the open cluster descriptor.
|
||||
#[must_use]
|
||||
pub const fn cluster(&self) -> &crate::YellowstoneGrpcClusterName {
|
||||
return &self.cluster;
|
||||
}
|
||||
|
||||
/// Returns the sensitive Yellowstone gRPC endpoint URL wrapper.
|
||||
#[must_use]
|
||||
pub const fn url(&self) -> &crate::YellowstoneGrpcEndpointUrl {
|
||||
return &self.url;
|
||||
}
|
||||
|
||||
/// Returns the effective runtime settings for the channel/session path.
|
||||
#[must_use]
|
||||
pub const fn session(&self) -> &crate::YellowstoneGrpcSessionSettings {
|
||||
return &self.session;
|
||||
}
|
||||
|
||||
/// Returns metadata entries in declaration order without exposing their values.
|
||||
#[must_use]
|
||||
pub fn metadata(&self) -> &[crate::YellowstoneGrpcMetadataEntry] {
|
||||
return self.metadata.as_slice();
|
||||
}
|
||||
|
||||
/// Replaces request metadata after validating KSP bounds and ASCII metadata rules.
|
||||
pub fn with_metadata(mut self, metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>) -> ksp_core_lib::Result<Self> {
|
||||
if metadata.len() > MAX_GRPC_METADATA_ENTRY_COUNT {
|
||||
return grpc_invalid_settings_value("Yellowstone gRPC metadata entry count exceeds the KSP bound", "grpc_endpoint.metadata");
|
||||
}
|
||||
self.metadata = metadata;
|
||||
if let std::result::Result::Err(error) = self.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(self);
|
||||
}
|
||||
|
||||
/// Validates this endpoint without performing network I/O.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
return validate_grpc_endpoint(self, "grpc_endpoint");
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete runtime settings consumed by the KSP Yellowstone gRPC transport engine.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneGrpcTransportSettings {
|
||||
endpoints: std::vec::Vec<crate::YellowstoneGrpcEndpointSettings>,
|
||||
}
|
||||
|
||||
impl YellowstoneGrpcTransportSettings {
|
||||
/// Creates complete Yellowstone gRPC runtime settings.
|
||||
#[must_use]
|
||||
pub fn new(endpoints: std::vec::Vec<crate::YellowstoneGrpcEndpointSettings>) -> Self {
|
||||
return Self { endpoints };
|
||||
}
|
||||
|
||||
/// Returns configured Yellowstone gRPC endpoints in declaration order.
|
||||
#[must_use]
|
||||
pub fn endpoints(&self) -> &[crate::YellowstoneGrpcEndpointSettings] {
|
||||
return self.endpoints.as_slice();
|
||||
}
|
||||
|
||||
/// Validates structural runtime invariants without reading Config or environment state.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, endpoint_count = self.endpoints.len(), "validating Yellowstone gRPC transport settings");
|
||||
if self.endpoints.is_empty() {
|
||||
return grpc_invalid_settings("at least one Yellowstone gRPC endpoint must be configured", "grpc_endpoints");
|
||||
}
|
||||
if self.endpoints.len() > MAX_GRPC_ENDPOINT_COUNT {
|
||||
return grpc_invalid_settings("Yellowstone gRPC endpoint count exceeds the KSP bound", "grpc_endpoints");
|
||||
}
|
||||
let mut enabled_endpoint_count = 0_usize;
|
||||
for (endpoint_index, endpoint) in self.endpoints.iter().enumerate() {
|
||||
let field_prefix = format!("grpc_endpoints[{endpoint_index}]");
|
||||
if let std::result::Result::Err(error) = validate_grpc_endpoint(endpoint, field_prefix.as_str()) {
|
||||
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, "Yellowstone gRPC endpoint names must be unique")
|
||||
.with_context("field", format!("grpc_endpoints[{endpoint_index}].name"))
|
||||
.with_context("endpoint_name", endpoint.name()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if enabled_endpoint_count == 0 {
|
||||
return grpc_invalid_settings("at least one Yellowstone gRPC endpoint must be enabled", "grpc_endpoints.enabled");
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_count = self.endpoints.len(),
|
||||
enabled_endpoint_count,
|
||||
"validated Yellowstone gRPC transport settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_grpc_endpoint(endpoint: &crate::YellowstoneGrpcEndpointSettings, field_prefix: &str) -> ksp_core_lib::Result<()> {
|
||||
let name_field = format!("{field_prefix}.name");
|
||||
if let std::result::Result::Err(error) = validate_grpc_descriptor(endpoint.name(), name_field.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let provider_field = format!("{field_prefix}.provider");
|
||||
if let std::result::Result::Err(error) = validate_grpc_descriptor(endpoint.provider().as_str(), provider_field.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let cluster_field = format!("{field_prefix}.cluster");
|
||||
if let std::result::Result::Err(error) = validate_grpc_descriptor(endpoint.cluster().as_str(), cluster_field.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = endpoint.session().validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if endpoint.metadata().len() > MAX_GRPC_METADATA_ENTRY_COUNT {
|
||||
return grpc_invalid_settings("Yellowstone gRPC metadata entry count exceeds the KSP bound", "grpc_endpoint.metadata");
|
||||
}
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = endpoint.name(),
|
||||
provider = endpoint.provider().as_str(),
|
||||
cluster = endpoint.cluster().as_str(),
|
||||
enabled = endpoint.enabled(),
|
||||
"validated Yellowstone gRPC endpoint settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_grpc_descriptor(value: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return grpc_invalid_settings("Yellowstone gRPC transport descriptor must not be empty", field);
|
||||
}
|
||||
if value.trim() != value {
|
||||
return grpc_invalid_settings("Yellowstone gRPC transport descriptor must not contain leading or trailing whitespace", field);
|
||||
}
|
||||
if value.len() > MAX_GRPC_DESCRIPTOR_LENGTH_BYTES {
|
||||
return grpc_invalid_settings("Yellowstone gRPC transport descriptor exceeds the KSP length bound", field);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_grpc_duration(value: std::time::Duration, field: &str) -> ksp_core_lib::Result<()> {
|
||||
if value.is_zero() {
|
||||
return grpc_invalid_settings("Yellowstone gRPC runtime duration must be greater than zero", field);
|
||||
}
|
||||
if value > MAX_GRPC_RUNTIME_DURATION {
|
||||
return grpc_invalid_settings("Yellowstone gRPC runtime duration exceeds the KSP bound", field);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_grpc_capacity(value: usize, field: &str) -> ksp_core_lib::Result<()> {
|
||||
if value == 0 {
|
||||
return grpc_invalid_settings("Yellowstone gRPC channel capacity must be greater than zero", field);
|
||||
}
|
||||
if value > MAX_GRPC_CHANNEL_CAPACITY {
|
||||
return grpc_invalid_settings("Yellowstone gRPC channel capacity exceeds the KSP bound", field);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_grpc_message_size(value: usize, field: &str) -> ksp_core_lib::Result<()> {
|
||||
if value == 0 {
|
||||
return grpc_invalid_settings("Yellowstone gRPC message size must be greater than zero", field);
|
||||
}
|
||||
if value > MAX_GRPC_MESSAGE_SIZE_BYTES {
|
||||
return grpc_invalid_settings("Yellowstone gRPC message size exceeds the KSP bound", field);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn grpc_invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, reason = message, "rejected Yellowstone gRPC transport settings");
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field));
|
||||
}
|
||||
|
||||
fn grpc_invalid_settings_value<T>(message: &str, field: &str) -> ksp_core_lib::Result<T> {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, reason = message, "rejected Yellowstone gRPC transport settings");
|
||||
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/grpc_settings.rs"]
|
||||
mod tests;
|
||||
1146
crates/ksp-onchain-transport-lib/src/grpc_stream.rs
Normal file
1146
crates/ksp-onchain-transport-lib/src/grpc_stream.rs
Normal file
File diff suppressed because it is too large
Load Diff
3740
crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs
Normal file
3740
crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs
Normal file
File diff suppressed because it is too large
Load Diff
398
crates/ksp-onchain-transport-lib/src/grpc_unary.rs
Normal file
398
crates/ksp-onchain-transport-lib/src/grpc_unary.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/grpc_unary.rs
|
||||
// version: 1
|
||||
|
||||
const MAX_YELLOWSTONE_BLOCKHASH_LENGTH_BYTES: usize = 256;
|
||||
const MAX_YELLOWSTONE_VERSION_LENGTH_BYTES: usize = 512;
|
||||
const PATH_GET_BLOCK_HEIGHT: &str = "/geyser.Geyser/GetBlockHeight";
|
||||
const PATH_GET_LATEST_BLOCKHASH: &str = "/geyser.Geyser/GetLatestBlockhash";
|
||||
const PATH_GET_SLOT: &str = "/geyser.Geyser/GetSlot";
|
||||
const PATH_GET_VERSION: &str = "/geyser.Geyser/GetVersion";
|
||||
const PATH_IS_BLOCKHASH_VALID: &str = "/geyser.Geyser/IsBlockhashValid";
|
||||
const PATH_PING: &str = "/geyser.Geyser/Ping";
|
||||
const PATH_SUBSCRIBE_REPLAY_INFO: &str = "/geyser.Geyser/SubscribeReplayInfo";
|
||||
|
||||
/// Standard Solana Yellowstone unary client layered on one KSP-owned physical gRPC channel.
|
||||
///
|
||||
/// This N2 facade deliberately exposes no raw Tonic client and no upstream protobuf types. Streaming `Subscribe` is not part of `0.2.9-pre.003`.
|
||||
#[derive(Clone)]
|
||||
pub struct SolanaYellowstoneGrpcUnaryClient {
|
||||
channel: tonic::transport::Channel,
|
||||
metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>,
|
||||
unary_timeout: std::time::Duration,
|
||||
max_inbound_message_size_bytes: usize,
|
||||
max_outbound_message_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl SolanaYellowstoneGrpcUnaryClient {
|
||||
/// Creates the internal unary facade state from a KSP-owned physical channel and validated endpoint settings.
|
||||
pub(crate) fn new(
|
||||
channel: tonic::transport::Channel,
|
||||
metadata: std::vec::Vec<crate::YellowstoneGrpcMetadataEntry>,
|
||||
settings: crate::YellowstoneGrpcSessionSettings,
|
||||
) -> Self {
|
||||
return Self {
|
||||
channel,
|
||||
metadata,
|
||||
unary_timeout: settings.unary_timeout(),
|
||||
max_inbound_message_size_bytes: settings.max_inbound_message_size_bytes(),
|
||||
max_outbound_message_size_bytes: settings.max_outbound_message_size_bytes(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the first slot retained by the endpoint for replay when advertised.
|
||||
pub async fn subscribe_replay_info(&self) -> ksp_core_lib::Result<crate::YellowstoneReplayInfo> {
|
||||
let response: yellowstone_grpc_proto::geyser::SubscribeReplayInfoResponse =
|
||||
match self.unary("SubscribeReplayInfo", PATH_SUBSCRIBE_REPLAY_INFO, yellowstone_grpc_proto::geyser::SubscribeReplayInfoRequest {}).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::YellowstoneReplayInfo::new(response.first_available));
|
||||
}
|
||||
|
||||
/// Executes the standard Yellowstone unary `Ping` RPC and verifies the echoed count.
|
||||
pub async fn ping(&self, count: i32) -> ksp_core_lib::Result<crate::YellowstonePong> {
|
||||
let response: yellowstone_grpc_proto::geyser::PongResponse =
|
||||
match self.unary("Ping", PATH_PING, yellowstone_grpc_proto::geyser::PingRequest { count }).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if response.count != count {
|
||||
return invalid_unary_response("Ping", "Yellowstone Ping response count does not match the request");
|
||||
}
|
||||
return std::result::Result::Ok(crate::YellowstonePong::new(response.count));
|
||||
}
|
||||
|
||||
/// Returns the latest blockhash advertised by the Yellowstone endpoint at an optional commitment.
|
||||
pub async fn get_latest_blockhash(
|
||||
&self,
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
) -> ksp_core_lib::Result<crate::YellowstoneLatestBlockhash> {
|
||||
let response: yellowstone_grpc_proto::geyser::GetLatestBlockhashResponse = match self
|
||||
.unary(
|
||||
"GetLatestBlockhash",
|
||||
PATH_GET_LATEST_BLOCKHASH,
|
||||
yellowstone_grpc_proto::geyser::GetLatestBlockhashRequest { commitment: commitment_to_wire(commitment) },
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if response.blockhash.is_empty() || response.blockhash.len() > MAX_YELLOWSTONE_BLOCKHASH_LENGTH_BYTES {
|
||||
return invalid_unary_response("GetLatestBlockhash", "Yellowstone latest blockhash response violates the KSP text bound");
|
||||
}
|
||||
return std::result::Result::Ok(crate::YellowstoneLatestBlockhash::new(response.slot, response.blockhash, response.last_valid_block_height));
|
||||
}
|
||||
|
||||
/// Returns the current block height at an optional commitment.
|
||||
pub async fn get_block_height(&self, commitment: std::option::Option<crate::SolanaCommitment>) -> ksp_core_lib::Result<crate::YellowstoneBlockHeight> {
|
||||
let response: yellowstone_grpc_proto::geyser::GetBlockHeightResponse = match self
|
||||
.unary(
|
||||
"GetBlockHeight",
|
||||
PATH_GET_BLOCK_HEIGHT,
|
||||
yellowstone_grpc_proto::geyser::GetBlockHeightRequest { commitment: commitment_to_wire(commitment) },
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::YellowstoneBlockHeight::new(response.block_height));
|
||||
}
|
||||
|
||||
/// Returns the current slot at an optional commitment.
|
||||
pub async fn get_slot(&self, commitment: std::option::Option<crate::SolanaCommitment>) -> ksp_core_lib::Result<crate::YellowstoneSlot> {
|
||||
let response: yellowstone_grpc_proto::geyser::GetSlotResponse =
|
||||
match self.unary("GetSlot", PATH_GET_SLOT, yellowstone_grpc_proto::geyser::GetSlotRequest { commitment: commitment_to_wire(commitment) }).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::YellowstoneSlot::new(response.slot));
|
||||
}
|
||||
|
||||
/// Checks one opaque Solana blockhash at an optional commitment.
|
||||
pub async fn is_blockhash_valid(
|
||||
&self,
|
||||
blockhash: impl std::convert::Into<std::string::String>,
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
) -> ksp_core_lib::Result<crate::YellowstoneBlockhashValidity> {
|
||||
let blockhash = blockhash.into();
|
||||
if blockhash.is_empty() || blockhash.len() > MAX_YELLOWSTONE_BLOCKHASH_LENGTH_BYTES || blockhash.trim() != blockhash {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "Yellowstone blockhash must be non-empty, bounded and untrimmed")
|
||||
.with_context("grpc_operation", "IsBlockhashValid")
|
||||
.with_context("field", "blockhash"),
|
||||
);
|
||||
}
|
||||
let response: yellowstone_grpc_proto::geyser::IsBlockhashValidResponse = match self
|
||||
.unary(
|
||||
"IsBlockhashValid",
|
||||
PATH_IS_BLOCKHASH_VALID,
|
||||
yellowstone_grpc_proto::geyser::IsBlockhashValidRequest { blockhash, commitment: commitment_to_wire(commitment) },
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::YellowstoneBlockhashValidity::new(response.slot, response.valid));
|
||||
}
|
||||
|
||||
/// Returns the endpoint's Yellowstone/validator version string after applying a deterministic KSP text bound.
|
||||
pub async fn get_version(&self) -> ksp_core_lib::Result<crate::YellowstoneVersionInfo> {
|
||||
let response: yellowstone_grpc_proto::geyser::GetVersionResponse =
|
||||
match self.unary("GetVersion", PATH_GET_VERSION, yellowstone_grpc_proto::geyser::GetVersionRequest {}).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if response.version.is_empty() || response.version.len() > MAX_YELLOWSTONE_VERSION_LENGTH_BYTES {
|
||||
return invalid_unary_response("GetVersion", "Yellowstone version response violates the KSP text bound");
|
||||
}
|
||||
return std::result::Result::Ok(crate::YellowstoneVersionInfo::new(response.version));
|
||||
}
|
||||
|
||||
async fn unary<Request, Response>(&self, operation: &'static str, path: &'static str, message: Request) -> ksp_core_lib::Result<Response>
|
||||
where
|
||||
Request: tonic_prost::prost::Message + Send + Sync + 'static,
|
||||
Response: tonic_prost::prost::Message + std::default::Default + Send + Sync + 'static,
|
||||
{
|
||||
let path = match path.parse::<http::uri::PathAndQuery>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "internal Yellowstone gRPC method path is invalid")
|
||||
.with_context("grpc_operation", operation),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut grpc = tonic::client::Grpc::new(self.channel.clone())
|
||||
.max_decoding_message_size(self.max_inbound_message_size_bytes)
|
||||
.max_encoding_message_size(self.max_outbound_message_size_bytes);
|
||||
let future = async {
|
||||
if grpc.ready().await.is_err() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "Yellowstone gRPC channel is not ready for unary dispatch")
|
||||
.with_context("grpc_operation", operation),
|
||||
);
|
||||
}
|
||||
let mut request = tonic::Request::new(message);
|
||||
for entry in &self.metadata {
|
||||
if let std::result::Result::Err(error) = entry.append_to(request.metadata_mut()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
let response = match grpc.unary(request, path, tonic_prost::ProstCodec::<Request, Response>::default()).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(status) => return std::result::Result::Err(grpc_status_error(operation, status)),
|
||||
};
|
||||
return std::result::Result::Ok(response.into_inner());
|
||||
};
|
||||
return match tokio::time::timeout(self.unary_timeout, future).await {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, "Yellowstone gRPC unary operation exceeded the KSP deadline")
|
||||
.with_context("grpc_operation", operation),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SolanaYellowstoneGrpcUnaryClient {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("SolanaYellowstoneGrpcUnaryClient")
|
||||
.field("metadata_count", &self.metadata.len())
|
||||
.field("unary_timeout", &self.unary_timeout)
|
||||
.field("max_inbound_message_size_bytes", &self.max_inbound_message_size_bytes)
|
||||
.field("max_outbound_message_size_bytes", &self.max_outbound_message_size_bytes)
|
||||
.field("channel", &"<private>")
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay availability advertised by `SubscribeReplayInfo`.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneReplayInfo {
|
||||
first_available: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl YellowstoneReplayInfo {
|
||||
/// Creates a replay-info projection.
|
||||
#[must_use]
|
||||
pub const fn new(first_available: std::option::Option<u64>) -> Self {
|
||||
return Self { first_available };
|
||||
}
|
||||
|
||||
/// Returns the first replayable slot when the endpoint advertises one.
|
||||
#[must_use]
|
||||
pub const fn first_available(&self) -> std::option::Option<u64> {
|
||||
return self.first_available;
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo returned by the Yellowstone unary `Ping` RPC.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstonePong {
|
||||
count: i32,
|
||||
}
|
||||
|
||||
impl YellowstonePong {
|
||||
/// Creates a ping response projection.
|
||||
#[must_use]
|
||||
pub const fn new(count: i32) -> Self {
|
||||
return Self { count };
|
||||
}
|
||||
|
||||
/// Returns the echoed ping count.
|
||||
#[must_use]
|
||||
pub const fn count(&self) -> i32 {
|
||||
return self.count;
|
||||
}
|
||||
}
|
||||
|
||||
/// Latest blockhash response returned by Yellowstone.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneLatestBlockhash {
|
||||
slot: u64,
|
||||
blockhash: std::string::String,
|
||||
last_valid_block_height: u64,
|
||||
}
|
||||
|
||||
impl YellowstoneLatestBlockhash {
|
||||
/// Creates a latest-blockhash projection.
|
||||
#[must_use]
|
||||
pub fn new(slot: u64, blockhash: std::string::String, last_valid_block_height: u64) -> Self {
|
||||
return Self { slot, blockhash, last_valid_block_height };
|
||||
}
|
||||
|
||||
/// Returns the response slot.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the opaque Solana blockhash text.
|
||||
#[must_use]
|
||||
pub fn blockhash(&self) -> &str {
|
||||
return self.blockhash.as_str();
|
||||
}
|
||||
|
||||
/// Returns the last valid block height associated with the blockhash.
|
||||
#[must_use]
|
||||
pub const fn last_valid_block_height(&self) -> u64 {
|
||||
return self.last_valid_block_height;
|
||||
}
|
||||
}
|
||||
|
||||
/// Current block height returned by Yellowstone.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneBlockHeight {
|
||||
block_height: u64,
|
||||
}
|
||||
|
||||
impl YellowstoneBlockHeight {
|
||||
/// Creates a block-height projection.
|
||||
#[must_use]
|
||||
pub const fn new(block_height: u64) -> Self {
|
||||
return Self { block_height };
|
||||
}
|
||||
|
||||
/// Returns the block height.
|
||||
#[must_use]
|
||||
pub const fn block_height(&self) -> u64 {
|
||||
return self.block_height;
|
||||
}
|
||||
}
|
||||
|
||||
/// Current slot returned by Yellowstone.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneSlot {
|
||||
slot: u64,
|
||||
}
|
||||
|
||||
impl YellowstoneSlot {
|
||||
/// Creates a slot projection.
|
||||
#[must_use]
|
||||
pub const fn new(slot: u64) -> Self {
|
||||
return Self { slot };
|
||||
}
|
||||
|
||||
/// Returns the slot.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of checking one blockhash with Yellowstone.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneBlockhashValidity {
|
||||
slot: u64,
|
||||
valid: bool,
|
||||
}
|
||||
|
||||
impl YellowstoneBlockhashValidity {
|
||||
/// Creates a blockhash-validity projection.
|
||||
#[must_use]
|
||||
pub const fn new(slot: u64, valid: bool) -> Self {
|
||||
return Self { slot, valid };
|
||||
}
|
||||
|
||||
/// Returns the response slot.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns whether the blockhash is valid at the requested commitment.
|
||||
#[must_use]
|
||||
pub const fn valid(&self) -> bool {
|
||||
return self.valid;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded Yellowstone endpoint version information.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct YellowstoneVersionInfo {
|
||||
version: std::string::String,
|
||||
}
|
||||
|
||||
impl YellowstoneVersionInfo {
|
||||
/// Creates a version projection.
|
||||
#[must_use]
|
||||
pub fn new(version: std::string::String) -> Self {
|
||||
return Self { version };
|
||||
}
|
||||
|
||||
/// Returns the bounded endpoint version string.
|
||||
#[must_use]
|
||||
pub fn version(&self) -> &str {
|
||||
return self.version.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
fn commitment_to_wire(commitment: std::option::Option<crate::SolanaCommitment>) -> std::option::Option<i32> {
|
||||
return commitment.map(|value| {
|
||||
return match value {
|
||||
crate::SolanaCommitment::Processed => yellowstone_grpc_proto::geyser::CommitmentLevel::Processed as i32,
|
||||
crate::SolanaCommitment::Confirmed => yellowstone_grpc_proto::geyser::CommitmentLevel::Confirmed as i32,
|
||||
crate::SolanaCommitment::Finalized => yellowstone_grpc_proto::geyser::CommitmentLevel::Finalized as i32,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fn grpc_status_error(operation: &'static str, status: tonic::Status) -> ksp_core_lib::Error {
|
||||
let code = status.code().to_string();
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, grpc_operation = operation, grpc_code = code.as_str(), "Yellowstone gRPC unary endpoint returned a status");
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_STATUS, "Yellowstone gRPC endpoint returned a gRPC status")
|
||||
.with_context("grpc_operation", operation)
|
||||
.with_context("grpc_code", code);
|
||||
}
|
||||
|
||||
fn invalid_unary_response<T>(operation: &'static 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("grpc_operation", operation));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/grpc_unary.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/client.rs
|
||||
// version: 7
|
||||
// file: crates/ksp-onchain-transport-lib/src/http_client.rs
|
||||
// version: 8
|
||||
|
||||
/// Passive runtime availability reported for one logical HTTP endpoint or role.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -510,5 +510,5 @@ fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/client.rs"]
|
||||
#[path = "../unit_tests/http_client.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/executor.rs
|
||||
// version: 3
|
||||
// file: crates/ksp-onchain-transport-lib/src/http_executor.rs
|
||||
// version: 4
|
||||
|
||||
const HTTP_BAD_GATEWAY: u16 = 502;
|
||||
const HTTP_GATEWAY_TIMEOUT: u16 = 504;
|
||||
@@ -228,5 +228,5 @@ fn http_status_error(method: &crate::HttpRpcMethodDescriptor, status: u16) -> ks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/executor.rs"]
|
||||
#[path = "../unit_tests/http_executor.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/pool.rs
|
||||
// version: 7
|
||||
// file: crates/ksp-onchain-transport-lib/src/http_pool.rs
|
||||
// version: 8
|
||||
|
||||
/// Safe snapshot of the logical HTTP endpoint pool.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -616,5 +616,5 @@ fn request_timeout(role: &crate::HttpRoleName, request_kind: &crate::HttpRequest
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/pool.rs"]
|
||||
#[path = "../unit_tests/http_pool.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/resilience.rs
|
||||
// version: 3
|
||||
// file: crates/ksp-onchain-transport-lib/src/http_resilience.rs
|
||||
// version: 4
|
||||
|
||||
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);
|
||||
@@ -401,5 +401,5 @@ fn retry_backoff(settings: &crate::HttpRetrySettings, retry_number: u32) -> std:
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/resilience.rs"]
|
||||
#[path = "../unit_tests/http_resilience.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/settings.rs
|
||||
// version: 6
|
||||
// file: crates/ksp-onchain-transport-lib/src/http_settings.rs
|
||||
// version: 7
|
||||
|
||||
/// Runtime HTTP endpoint URL owned by Transport.
|
||||
///
|
||||
@@ -582,5 +582,5 @@ fn invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/settings.rs"]
|
||||
#[path = "../unit_tests/http_settings.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
// version: 21
|
||||
// version: 44
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -16,14 +16,49 @@
|
||||
//! modern/legacy `getTransaction` coverage. `0.2.4` completes the HTTP surface with all ten Blocks and five Economics wrappers, including complete
|
||||
//! modern/legacy `getBlock`, positional inflation rewards, runtime-provided economics values and the final `KSP-TRANSPORT-007` compliance target.
|
||||
//! The candidate surface therefore exposes typed wrappers for all 52 current audited Solana HTTP methods while retaining 14 removed historical descriptors.
|
||||
//! `0.2.7-pre.002` adds the provider-neutral WebSocket settings foundation, redacted endpoint URLs, explicit protocol-family discrimination, local session and
|
||||
//! subscription identities, observable lifecycle states and safe snapshots. `0.2.7-pre.004` adds the first physical WebSocket runtime with one
|
||||
//! actor-owned socket,
|
||||
//! bounded handshake, command/pending JSON-RPC flow and deterministic local-server fixtures. `0.2.7-pre.005` adds explicit bounded shutdown, adversarial
|
||||
//! request/frame/message limits and control-frame handling. `0.2.7-pre.006` adds the typed subscription registry with stable local IDs and internal remote-ID
|
||||
//! routing. `0.2.7-pre.007` adds finite reconnect, deterministic resubscribe and continuity-gap tracking. `0.2.7-pre.008` makes per-subscription notification
|
||||
//! backpressure terminal and observable, preserves safe terminal error codes, performs best-effort remote cleanup and proves bounded capacity reuse.
|
||||
//! `0.2.7-pre.009` opens the first stable typed WebSocket wrappers for account, program-account and transaction-log subscriptions without exposing a raw
|
||||
//! provider-extension subscription API. `0.2.8-pre.002` adds a Helius LaserStream WebSocket protocol discriminator and two typed protocol facades while
|
||||
//! keeping the `WsSession` actor/socket implementation unique and the historical generic constructor standard-only.
|
||||
//! `0.2.8-pre.003` initially exposed the six standard families unambiguously supported by the audited Helius pages; `0.2.8-pre.009` reconciles the current
|
||||
//! Helius documentation and adds the now-documented unstable `slotsUpdatesSubscribe` pair while keeping explicitly unsupported block/vote pairs absent.
|
||||
//! `0.2.8-pre.005` adds the typed Helius `transactionSubscribe` request contract and provider filter/options validation. `0.2.8-pre.006` integrates the live
|
||||
//! transaction handle and typed `transactionNotification` union into the same actor-owned registry, remote-ID remap, unsubscribe-race handling and
|
||||
//! per-subscription backpressure path.
|
||||
//! `0.2.9-pre.002` opens the Yellowstone gRPC N1 engine foundation with Transport-owned redacted settings, bounded reconnect/channel/message policies, the
|
||||
//! published Yellowstone protobuf dependency and a lazy Tonic HTTP/2 channel wrapper that exposes no raw Tonic or upstream protobuf types.
|
||||
//! `0.2.9-pre.003` adds bounded TLS/WebPKI connection establishment, generic redacted ASCII request metadata and the seven standard Yellowstone unary RPCs
|
||||
//! through KSP-owned DTOs.
|
||||
//! `0.2.9-pre.004` materializes the provider-neutral standard `SubscribeRequest` foundation: all seven named filter maps, global filter-name bounds/uniqueness,
|
||||
//! commitment, ordered account-data slices, ping and `from_slot`. Family-specific account/slot filters land in `pre.005`; transaction/block filters remain
|
||||
//! staged for `pre.007–008`.
|
||||
//! `0.2.9-pre.006` normalizes the five unambiguously HTTP-owned private implementation modules with an `http_` prefix while preserving shared `rpc_*`,
|
||||
//! JSON-RPC, error and constants modules.
|
||||
//! `0.2.9-pre.007` completes the standard transaction/transaction-status filters and storage-wire projections; `pre.008` completes Blocks, block-meta and
|
||||
//! entry projections. `0.2.9-pre.009` promotes those protobuf bridges into runtime and opens one KSP-owned bounded bidirectional `Subscribe` session with
|
||||
//! request mutation, automatic server-Ping reply, observable Pong, normal server half-close, terminal backpressure and bounded graceful shutdown.
|
||||
//! `0.2.9-pre.010` activates the bounded KSP-owned reconnect policy, deterministic replay from the latest accepted request and highest observed slot,
|
||||
//! conservative ReplayInfo-based continuity-gap observability and bounded duplicate observation without claiming exactly-once or lossless delivery.
|
||||
|
||||
mod client;
|
||||
mod constants;
|
||||
mod error;
|
||||
mod executor;
|
||||
mod grpc_channel;
|
||||
mod grpc_settings;
|
||||
mod grpc_stream;
|
||||
mod grpc_subscribe;
|
||||
mod grpc_unary;
|
||||
mod http_client;
|
||||
mod http_executor;
|
||||
mod http_pool;
|
||||
mod http_resilience;
|
||||
mod http_settings;
|
||||
mod json_rpc;
|
||||
mod pool;
|
||||
mod resilience;
|
||||
mod rpc_accounts;
|
||||
mod rpc_blocks;
|
||||
mod rpc_canary;
|
||||
@@ -33,18 +68,27 @@ mod rpc_economics;
|
||||
mod rpc_method;
|
||||
mod rpc_tokens;
|
||||
mod rpc_transactions;
|
||||
mod settings;
|
||||
mod ws_accounts;
|
||||
mod ws_blocks;
|
||||
mod ws_cluster;
|
||||
mod ws_helius_transactions;
|
||||
mod ws_lifecycle;
|
||||
mod ws_protocol_session;
|
||||
mod ws_session;
|
||||
mod ws_settings;
|
||||
mod ws_subscription;
|
||||
mod ws_transactions;
|
||||
|
||||
/// 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 bounded Yellowstone gRPC runtime capacity is exhausted.
|
||||
pub use self::error::ERROR_CODE_GRPC_BACKPRESSURE_OVERFLOW;
|
||||
/// Error code used when a Yellowstone gRPC channel cannot be prepared safely.
|
||||
pub use self::error::ERROR_CODE_GRPC_CHANNEL_FAILED;
|
||||
/// Error code used when a Yellowstone gRPC subscribe session is no longer available.
|
||||
pub use self::error::ERROR_CODE_GRPC_SESSION_CLOSED;
|
||||
/// Error code used when a Yellowstone gRPC endpoint returns a remote status.
|
||||
pub use self::error::ERROR_CODE_GRPC_STATUS;
|
||||
/// 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.
|
||||
@@ -69,6 +113,200 @@ pub use self::error::ERROR_CODE_RATE_LIMITED;
|
||||
pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR;
|
||||
/// Error code used when a transport deadline expires.
|
||||
pub use self::error::ERROR_CODE_TIMEOUT;
|
||||
/// Error code used when bounded WebSocket runtime capacity is exhausted.
|
||||
pub use self::error::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW;
|
||||
/// Error code used when a physical WebSocket connection or handshake fails.
|
||||
pub use self::error::ERROR_CODE_WS_CONNECTION_FAILED;
|
||||
/// Error code used when WebSocket wire data violates protocol invariants.
|
||||
pub use self::error::ERROR_CODE_WS_PROTOCOL_ERROR;
|
||||
/// Error code used when a WebSocket session is no longer available.
|
||||
pub use self::error::ERROR_CODE_WS_SESSION_CLOSED;
|
||||
/// Yellowstone gRPC channel owned by KSP Transport.
|
||||
pub use self::grpc_channel::YellowstoneGrpcChannel;
|
||||
/// Open cluster or network descriptor used by Yellowstone gRPC endpoint settings.
|
||||
pub use self::grpc_settings::YellowstoneGrpcClusterName;
|
||||
/// Runtime settings for one named Yellowstone gRPC endpoint.
|
||||
pub use self::grpc_settings::YellowstoneGrpcEndpointSettings;
|
||||
/// Runtime Yellowstone gRPC endpoint URL with redacted diagnostics.
|
||||
pub use self::grpc_settings::YellowstoneGrpcEndpointUrl;
|
||||
/// Validated public or secret ASCII metadata attached to Yellowstone gRPC requests.
|
||||
pub use self::grpc_settings::YellowstoneGrpcMetadataEntry;
|
||||
/// Open provider descriptor used by Yellowstone gRPC endpoint settings.
|
||||
pub use self::grpc_settings::YellowstoneGrpcProviderName;
|
||||
/// Bounded reconnect settings owned by the Yellowstone gRPC runtime.
|
||||
pub use self::grpc_settings::YellowstoneGrpcReconnectSettings;
|
||||
/// Runtime limits and lifecycle settings for one Yellowstone gRPC channel/session path.
|
||||
pub use self::grpc_settings::YellowstoneGrpcSessionSettings;
|
||||
/// Complete runtime settings consumed by the KSP Yellowstone gRPC transport engine.
|
||||
pub use self::grpc_settings::YellowstoneGrpcTransportSettings;
|
||||
/// Standard Yellowstone bidirectional Subscribe session.
|
||||
pub use self::grpc_stream::SolanaYellowstoneGrpcSubscribeSession;
|
||||
/// Safe Yellowstone reconnect/replay continuity snapshot.
|
||||
pub use self::grpc_stream::YellowstoneGrpcSubscribeSnapshot;
|
||||
/// Safe Yellowstone bidirectional Subscribe lifecycle state.
|
||||
pub use self::grpc_stream::YellowstoneGrpcSubscribeState;
|
||||
/// One validated standard Yellowstone account predicate.
|
||||
pub use self::grpc_subscribe::YellowstoneAccountFilterPredicate;
|
||||
/// Typed account payload carried by one standard Yellowstone account update.
|
||||
pub use self::grpc_subscribe::YellowstoneAccountInfo;
|
||||
/// Lamport comparison used by standard Yellowstone account filters.
|
||||
pub use self::grpc_subscribe::YellowstoneAccountLamportsFilter;
|
||||
/// Validated standard Yellowstone account memcmp predicate.
|
||||
pub use self::grpc_subscribe::YellowstoneAccountMemcmp;
|
||||
/// Encoding selected by one Yellowstone account memcmp predicate.
|
||||
pub use self::grpc_subscribe::YellowstoneAccountMemcmpEncoding;
|
||||
/// Standard Yellowstone account-update projection owned by KSP.
|
||||
pub use self::grpc_subscribe::YellowstoneAccountUpdate;
|
||||
/// One standard Yellowstone account-data slice.
|
||||
pub use self::grpc_subscribe::YellowstoneAccountsDataSlice;
|
||||
/// Metadata-only standard Yellowstone block update.
|
||||
pub use self::grpc_subscribe::YellowstoneBlockMetaUpdate;
|
||||
/// Rewards container carried by Yellowstone block and block-meta updates.
|
||||
pub use self::grpc_subscribe::YellowstoneBlockRewards;
|
||||
/// Full standard Yellowstone block update.
|
||||
pub use self::grpc_subscribe::YellowstoneBlockUpdate;
|
||||
/// One compiled instruction from the Yellowstone Solana-storage transaction wire.
|
||||
pub use self::grpc_subscribe::YellowstoneCompiledInstruction;
|
||||
/// Wire-preserving KSP representation of a standard Yellowstone Cuckoo filter.
|
||||
pub use self::grpc_subscribe::YellowstoneCuckooFilter;
|
||||
/// Hash algorithm carried by a standard Yellowstone Cuckoo filter.
|
||||
pub use self::grpc_subscribe::YellowstoneCuckooHashAlgorithm;
|
||||
/// One Yellowstone block-entry payload reused by block and standalone entry updates.
|
||||
pub use self::grpc_subscribe::YellowstoneEntryInfo;
|
||||
/// Standalone standard Yellowstone entry update.
|
||||
pub use self::grpc_subscribe::YellowstoneEntryUpdate;
|
||||
/// Fixed-width 32-byte hash from the Yellowstone Solana-storage transaction wire.
|
||||
pub use self::grpc_subscribe::YellowstoneHashBytes;
|
||||
/// One inner instruction from Yellowstone transaction status metadata.
|
||||
pub use self::grpc_subscribe::YellowstoneInnerInstruction;
|
||||
/// One indexed inner-instruction group from Yellowstone transaction status metadata.
|
||||
pub use self::grpc_subscribe::YellowstoneInnerInstructions;
|
||||
/// One address-table lookup from a Yellowstone transaction message.
|
||||
pub use self::grpc_subscribe::YellowstoneMessageAddressTableLookup;
|
||||
/// Return-data payload from Yellowstone transaction status metadata.
|
||||
pub use self::grpc_subscribe::YellowstoneReturnData;
|
||||
/// One reward entry from Yellowstone transaction status metadata.
|
||||
pub use self::grpc_subscribe::YellowstoneReward;
|
||||
/// Reward classification from the Yellowstone Solana-storage wire.
|
||||
pub use self::grpc_subscribe::YellowstoneRewardType;
|
||||
/// Current standard Yellowstone slot status.
|
||||
pub use self::grpc_subscribe::YellowstoneSlotStatus;
|
||||
/// Standard Yellowstone slot-update projection owned by KSP.
|
||||
pub use self::grpc_subscribe::YellowstoneSlotUpdate;
|
||||
/// Solana transaction body carried by Yellowstone storage protobuf messages.
|
||||
pub use self::grpc_subscribe::YellowstoneStoredTransaction;
|
||||
/// Complete account-family filter group for standard Yellowstone Subscribe.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeAccountFilter;
|
||||
/// Complete block-family filter group for standard Yellowstone Subscribe.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeBlockFilter;
|
||||
/// Empty filter marker activating the standard Yellowstone blocks-meta family.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeBlocksMetaFilter;
|
||||
/// Empty filter marker activating the standard Yellowstone entry family.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeEntryFilter;
|
||||
/// Validated globally unique logical filter name for standard Yellowstone Subscribe maps.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeFilterName;
|
||||
/// Optional ping mutation carried by the standard Yellowstone Subscribe request.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribePing;
|
||||
/// Standard Yellowstone server Ping update.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribePingUpdate;
|
||||
/// Standard Yellowstone server Pong update.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribePongUpdate;
|
||||
/// Provider-neutral standard Yellowstone Subscribe request.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeRequest;
|
||||
/// Complete slot-family filter group for standard Yellowstone Subscribe.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeSlotFilter;
|
||||
/// Complete transaction-family filter shared by transactions and transaction-status maps.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeTransactionFilter;
|
||||
/// Any standard Yellowstone Subscribe update.
|
||||
pub use self::grpc_subscribe::YellowstoneSubscribeUpdate;
|
||||
/// Optional token-account owner expansion for current Yellowstone transaction filters.
|
||||
pub use self::grpc_subscribe::YellowstoneTokenAccountExpansion;
|
||||
/// One pre/post token balance from Yellowstone transaction status metadata.
|
||||
pub use self::grpc_subscribe::YellowstoneTokenBalance;
|
||||
/// Optional Transaction V1 inline budget configuration from Yellowstone Solana-storage.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionConfig;
|
||||
/// Opaque runtime transaction error bytes from Yellowstone Solana-storage.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionError;
|
||||
/// Complete transaction info carried by Yellowstone transaction and block updates.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionInfo;
|
||||
/// Complete current Yellowstone transaction message.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionMessage;
|
||||
/// Solana transaction message header from Yellowstone Solana-storage.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionMessageHeader;
|
||||
/// Fixed-width transaction signature attached to Yellowstone updates.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionSignature;
|
||||
/// Validated base58 transaction-signature selector for Yellowstone transaction filters.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionSignatureSelector;
|
||||
/// Complete Yellowstone transaction status metadata.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionStatusMeta;
|
||||
/// Lightweight Yellowstone transaction-status update.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionStatusUpdate;
|
||||
/// Full Yellowstone transaction update.
|
||||
pub use self::grpc_subscribe::YellowstoneTransactionUpdate;
|
||||
/// UI token amount from Yellowstone transaction status metadata.
|
||||
pub use self::grpc_subscribe::YellowstoneUiTokenAmount;
|
||||
/// Timestamp attached to standard Yellowstone update envelopes.
|
||||
pub use self::grpc_subscribe::YellowstoneUpdateTimestamp;
|
||||
/// Standard Solana Yellowstone unary facade over one KSP-owned physical gRPC channel.
|
||||
pub use self::grpc_unary::SolanaYellowstoneGrpcUnaryClient;
|
||||
/// Block height returned by the standard Yellowstone unary surface.
|
||||
pub use self::grpc_unary::YellowstoneBlockHeight;
|
||||
/// Result of a standard Yellowstone blockhash-validity check.
|
||||
pub use self::grpc_unary::YellowstoneBlockhashValidity;
|
||||
/// Latest blockhash returned by the standard Yellowstone unary surface.
|
||||
pub use self::grpc_unary::YellowstoneLatestBlockhash;
|
||||
/// Echo returned by the standard Yellowstone unary Ping RPC.
|
||||
pub use self::grpc_unary::YellowstonePong;
|
||||
/// Replay availability advertised by the standard Yellowstone unary surface.
|
||||
pub use self::grpc_unary::YellowstoneReplayInfo;
|
||||
/// Current slot returned by the standard Yellowstone unary surface.
|
||||
pub use self::grpc_unary::YellowstoneSlot;
|
||||
/// Bounded endpoint version returned by the standard Yellowstone unary surface.
|
||||
pub use self::grpc_unary::YellowstoneVersionInfo;
|
||||
/// Passive runtime availability reported for one logical HTTP endpoint.
|
||||
pub use self::http_client::HttpEndpointAvailability;
|
||||
/// Shareable logical HTTP endpoint client owned by KSP Transport.
|
||||
pub use self::http_client::HttpEndpointClient;
|
||||
/// Safe routing snapshot for one configured endpoint role.
|
||||
pub use self::http_client::HttpEndpointRoleSnapshot;
|
||||
/// Safe metadata snapshot for one logical HTTP endpoint.
|
||||
pub use self::http_client::HttpEndpointSnapshot;
|
||||
/// Result of one logical endpoint selection.
|
||||
pub use self::http_pool::HttpEndpointSelection;
|
||||
/// Runtime admission permit for one HTTP request.
|
||||
pub use self::http_pool::HttpRequestPermit;
|
||||
/// Shareable logical HTTP endpoint pool with priority routing, admission limits and bounded deadlines.
|
||||
pub use self::http_pool::HttpTransportPool;
|
||||
/// Safe snapshot of the logical HTTP endpoint pool.
|
||||
pub use self::http_pool::HttpTransportPoolSnapshot;
|
||||
/// Dispatch knowledge used to prevent ambiguous automatic resubmission.
|
||||
pub use self::http_resilience::HttpDispatchState;
|
||||
/// Transport-level cause considered by the bounded retry policy.
|
||||
pub use self::http_resilience::HttpRetryCause;
|
||||
/// Result of evaluating one bounded transport retry opportunity.
|
||||
pub use self::http_resilience::HttpRetryDecision;
|
||||
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
|
||||
pub use self::http_resilience::evaluate_transport_retry;
|
||||
/// Open cluster or network descriptor used by HTTP endpoint settings.
|
||||
pub use self::http_settings::HttpClusterName;
|
||||
/// Runtime settings for one role declared by an HTTP endpoint.
|
||||
pub use self::http_settings::HttpEndpointRoleSettings;
|
||||
/// Runtime settings for one named Solana HTTP endpoint.
|
||||
pub use self::http_settings::HttpEndpointSettings;
|
||||
/// Runtime HTTP endpoint URL with redacted diagnostics.
|
||||
pub use self::http_settings::HttpEndpointUrl;
|
||||
/// Open provider descriptor used by HTTP endpoint settings.
|
||||
pub use self::http_settings::HttpProviderName;
|
||||
/// Open request-kind descriptor used by logical endpoint capabilities.
|
||||
pub use self::http_settings::HttpRequestKind;
|
||||
/// Bounded retry settings owned by the HTTP transport runtime.
|
||||
pub use self::http_settings::HttpRetrySettings;
|
||||
/// Local limits attached to one logical HTTP endpoint role.
|
||||
pub use self::http_settings::HttpRoleLimits;
|
||||
/// Open logical endpoint role descriptor.
|
||||
pub use self::http_settings::HttpRoleName;
|
||||
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
|
||||
pub use self::http_settings::HttpTransportSettings;
|
||||
/// 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.
|
||||
@@ -83,29 +321,13 @@ pub use self::json_rpc::JsonRpcSuccessResponse;
|
||||
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.
|
||||
/// Wire-preserving account data returned by Solana account HTTP and WebSocket methods.
|
||||
pub use self::rpc_accounts::SolanaAccountData;
|
||||
/// Account-data encoding accepted by Solana HTTP account methods.
|
||||
/// Account-data encoding accepted by Solana account HTTP and WebSocket 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;
|
||||
@@ -183,15 +405,15 @@ pub use self::rpc_cluster::SolanaVoteAccountInfo;
|
||||
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.
|
||||
/// Commitment level accepted by typed Solana HTTP and WebSocket adapters.
|
||||
pub use self::rpc_common::SolanaCommitment;
|
||||
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
|
||||
/// Optional commitment-only configuration shared by typed Solana 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.
|
||||
/// Typed Solana RPC context shared by contextual HTTP and WebSocket responses.
|
||||
pub use self::rpc_common::SolanaRpcContext;
|
||||
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
|
||||
/// Generic contextual result returned by typed Solana HTTP and WebSocket adapters.
|
||||
pub use self::rpc_common::SolanaRpcResponse;
|
||||
/// Inflation-governor values returned by `getInflationGovernor`.
|
||||
pub use self::rpc_economics::SolanaInflationGovernor;
|
||||
@@ -271,38 +493,120 @@ pub use self::rpc_transactions::SolanaTransactionEncoding;
|
||||
pub use self::rpc_transactions::SolanaTransactionVersion;
|
||||
/// Three-state wire field used when Solana distinguishes omission from an explicit JSON `null`.
|
||||
pub use self::rpc_transactions::SolanaWireField;
|
||||
/// 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;
|
||||
/// Configuration accepted by the standard Solana `accountSubscribe` WebSocket method.
|
||||
pub use self::ws_accounts::SolanaAccountSubscribeConfig;
|
||||
/// One `programNotification` payload preserving contextual and non-contextual upstream forms.
|
||||
pub use self::ws_accounts::SolanaProgramNotification;
|
||||
/// Configuration accepted by the standard Solana `programSubscribe` WebSocket method.
|
||||
pub use self::ws_accounts::SolanaProgramSubscribeConfig;
|
||||
/// Typed value carried inside an unstable Solana `blockNotification` response.
|
||||
pub use self::ws_blocks::SolanaBlockNotification;
|
||||
/// Optional configuration accepted by unstable Solana `blockSubscribe`.
|
||||
pub use self::ws_blocks::SolanaBlockSubscribeConfig;
|
||||
/// Filter accepted by unstable Solana `blockSubscribe`.
|
||||
pub use self::ws_blocks::SolanaBlockSubscribeFilter;
|
||||
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
|
||||
pub use self::ws_cluster::SolanaSlotNotification;
|
||||
/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback.
|
||||
pub use self::ws_cluster::SolanaSlotUpdate;
|
||||
/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates.
|
||||
pub use self::ws_cluster::SolanaSlotUpdateStats;
|
||||
/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`.
|
||||
pub use self::ws_cluster::SolanaVoteNotification;
|
||||
/// Full/accounts-mode notification delivered by Helius `transactionSubscribe`.
|
||||
pub use self::ws_helius_transactions::HeliusFullTransactionNotification;
|
||||
/// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`.
|
||||
pub use self::ws_helius_transactions::HeliusTokenAccountsFilter;
|
||||
/// Typed Helius `transactionNotification` payload union.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionNotification;
|
||||
/// Signatures-mode notification delivered by Helius `transactionSubscribe`.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSignatureNotification;
|
||||
/// Transaction encoding accepted by Helius `transactionSubscribe`.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSubscribeEncoding;
|
||||
/// Helius-specific filter object accepted as the first `transactionSubscribe` parameter.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSubscribeFilter;
|
||||
/// Optional Helius `transactionSubscribe` result-shaping configuration.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSubscribeOptions;
|
||||
/// Complete typed request contract for Helius `transactionSubscribe` before actor registration.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSubscribeRequest;
|
||||
/// Stable local identity assigned to one physical WebSocket session.
|
||||
pub use self::ws_lifecycle::WsSessionId;
|
||||
/// Safe runtime snapshot for one physical WebSocket session.
|
||||
pub use self::ws_lifecycle::WsSessionSnapshot;
|
||||
/// Observable lifecycle state of one physical WebSocket session.
|
||||
pub use self::ws_lifecycle::WsSessionState;
|
||||
/// Stable local identity assigned to one logical WebSocket subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionId;
|
||||
/// WebSocket subscription family represented by one logical subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionKind;
|
||||
/// Safe lifecycle projection for one logical WebSocket subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionSnapshot;
|
||||
/// Observable lifecycle state of one logical WebSocket subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionState;
|
||||
/// Typed facade for one Helius LaserStream WebSocket physical session.
|
||||
pub use self::ws_protocol_session::HeliusLaserStreamWsSession;
|
||||
/// Typed facade for one standard Solana WebSocket physical session.
|
||||
pub use self::ws_protocol_session::SolanaStandardWsSession;
|
||||
/// Shareable compatibility handle for one explicitly created standard Solana physical WebSocket session.
|
||||
pub use self::ws_session::WsSession;
|
||||
/// Open cluster or network descriptor used by WebSocket endpoint settings.
|
||||
pub use self::ws_settings::WsClusterName;
|
||||
/// Runtime settings for one named WebSocket endpoint.
|
||||
pub use self::ws_settings::WsEndpointSettings;
|
||||
/// Runtime WebSocket endpoint URL with redacted diagnostics.
|
||||
pub use self::ws_settings::WsEndpointUrl;
|
||||
/// WebSocket protocol family understood by KSP Transport.
|
||||
pub use self::ws_settings::WsProtocolKind;
|
||||
/// Open provider descriptor used by WebSocket endpoint settings.
|
||||
pub use self::ws_settings::WsProviderName;
|
||||
/// Bounded reconnect settings owned by the WebSocket transport runtime.
|
||||
pub use self::ws_settings::WsReconnectSettings;
|
||||
/// Policy controlling logical resubscription after reconnect.
|
||||
pub use self::ws_settings::WsResubscribePolicy;
|
||||
/// Runtime limits and lifecycle settings for one physical WebSocket session.
|
||||
pub use self::ws_settings::WsSessionSettings;
|
||||
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
|
||||
pub use self::ws_settings::WsTransportSettings;
|
||||
/// Typed handle for one logical WebSocket subscription.
|
||||
pub use self::ws_subscription::WsSubscription;
|
||||
/// Typed value carried by a contextual Solana `logsNotification`.
|
||||
pub use self::ws_transactions::SolanaLogsNotification;
|
||||
/// Filter accepted by the standard Solana `logsSubscribe` WebSocket method.
|
||||
pub use self::ws_transactions::SolanaLogsSubscribeFilter;
|
||||
/// Typed value carried by standard Solana `signatureNotification` messages.
|
||||
pub use self::ws_transactions::SolanaSignatureNotification;
|
||||
/// Optional configuration accepted by standard Solana `signatureSubscribe`.
|
||||
pub use self::ws_transactions::SolanaSignatureSubscribeConfig;
|
||||
|
||||
/// Owning tracing target for events emitted by the on-chain transport crate.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Internal Yellowstone Subscribe session opener used by the physical channel.
|
||||
pub(crate) use self::grpc_stream::open_yellowstone_subscribe_session;
|
||||
/// Internal Yellowstone Subscribe request wire conversion shared with the stream engine.
|
||||
pub(crate) use self::grpc_subscribe::yellowstone_subscribe_request_to_wire;
|
||||
/// Internal Yellowstone Subscribe update decoder shared with the stream engine.
|
||||
pub(crate) use self::grpc_subscribe::yellowstone_subscribe_update_from_wire;
|
||||
/// Crate-internal `HttpConcurrencyPermit` state shared across the owning crate.
|
||||
pub(crate) use self::resilience::HttpConcurrencyPermit;
|
||||
pub(crate) use self::http_resilience::HttpConcurrencyPermit;
|
||||
/// Crate-internal `HttpRoleRuntime` state shared across the owning crate.
|
||||
pub(crate) use self::resilience::HttpRoleRuntime;
|
||||
pub(crate) use self::http_resilience::HttpRoleRuntime;
|
||||
/// Crate-internal `RoleAdmissionAttempt` variants used by the owning crate.
|
||||
pub(crate) use self::resilience::RoleAdmissionAttempt;
|
||||
pub(crate) use self::http_resilience::RoleAdmissionAttempt;
|
||||
/// Validates endpoint settings.
|
||||
pub(crate) use self::http_settings::validate_endpoint_settings;
|
||||
/// 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;
|
||||
/// Validates endpoint settings.
|
||||
pub(crate) use self::settings::validate_endpoint_settings;
|
||||
/// Crate-internal command surface shared by the physical session and typed subscription handle.
|
||||
pub(crate) use self::ws_session::WsSessionCommand;
|
||||
/// Crate-internal notification dispatch result.
|
||||
pub(crate) use self::ws_subscription::WsNotificationDispatchOutcome;
|
||||
/// Crate-internal type-erased notification dispatcher.
|
||||
pub(crate) use self::ws_subscription::WsNotificationDispatcher;
|
||||
/// Crate-internal actor registration returned after subscribe acknowledgement.
|
||||
pub(crate) use self::ws_subscription::WsSubscriptionRegistration;
|
||||
/// Crate-internal actor-owned logical subscription runtime entry.
|
||||
pub(crate) use self::ws_subscription::WsSubscriptionRuntime;
|
||||
/// Crate-internal constructor for bounded typed notification channels with terminal-value classification.
|
||||
pub(crate) use self::ws_subscription::typed_notification_channel_with_completion;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
const MAX_MEMCMP_BYTES: usize = 128;
|
||||
const MAX_MULTIPLE_ACCOUNTS: usize = 100;
|
||||
const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4;
|
||||
|
||||
/// Account-data encoding accepted by Solana HTTP account methods.
|
||||
/// Account-data encoding accepted by Solana account HTTP and WebSocket methods.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaAccountEncoding {
|
||||
/// Legacy binary/base58 request encoding.
|
||||
@@ -71,7 +71,8 @@ impl SolanaDataSliceConfig {
|
||||
return self.length;
|
||||
}
|
||||
|
||||
fn to_json_value(self) -> serde_json::Value {
|
||||
/// Serializes this data-slice configuration to the Solana JSON-RPC wire object.
|
||||
pub(crate) fn to_json_value(self) -> serde_json::Value {
|
||||
return serde_json::json!({"offset": self.offset, "length": self.length});
|
||||
}
|
||||
}
|
||||
@@ -277,7 +278,8 @@ pub enum SolanaProgramAccountFilter {
|
||||
}
|
||||
|
||||
impl SolanaProgramAccountFilter {
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
/// Serializes this program-account filter to the Solana JSON-RPC wire representation.
|
||||
pub(crate) 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()}),
|
||||
@@ -385,7 +387,7 @@ impl SolanaParsedAccountData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-preserving account data returned by Solana HTTP account methods.
|
||||
/// Wire-preserving account data returned by Solana account HTTP and WebSocket methods.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaAccountData {
|
||||
/// Legacy single-string binary form retained for backwards compatibility.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
|
||||
// version: 5
|
||||
// version: 7
|
||||
|
||||
/// Commitment level accepted by typed Solana HTTP RPC adapters.
|
||||
/// Commitment level accepted by typed Solana HTTP, WebSocket and Yellowstone gRPC adapters.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaCommitment {
|
||||
/// Query the most recent processed bank.
|
||||
@@ -24,7 +24,7 @@ impl SolanaCommitment {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
|
||||
/// Optional commitment-only configuration shared by typed Solana RPC methods.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaCommitmentConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
@@ -94,7 +94,7 @@ impl SolanaContextConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed Solana RPC context shared by contextual HTTP responses.
|
||||
/// Typed Solana RPC context shared by contextual HTTP and WebSocket responses.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaRpcContext {
|
||||
slot: u64,
|
||||
@@ -128,7 +128,7 @@ impl SolanaRpcContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
|
||||
/// Generic contextual result returned by typed Solana HTTP and WebSocket adapters.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaRpcResponse<T> {
|
||||
context: crate::SolanaRpcContext,
|
||||
@@ -168,7 +168,7 @@ pub(crate) fn decode_wire_json<T: serde::de::DeserializeOwned>(method: &str, val
|
||||
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")
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana RPC response has an invalid wire shape")
|
||||
.with_context("rpc_method", method)
|
||||
.with_source(error),
|
||||
),
|
||||
@@ -181,7 +181,7 @@ pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_c
|
||||
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")
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana RPC response contains an invalid public key")
|
||||
.with_context("rpc_method", method)
|
||||
.with_context("field", field),
|
||||
),
|
||||
|
||||
322
crates/ksp-onchain-transport-lib/src/ws_accounts.rs
Normal file
322
crates/ksp-onchain-transport-lib/src/ws_accounts.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_accounts.rs
|
||||
// version: 3
|
||||
|
||||
const MAX_PROGRAM_SUBSCRIBE_FILTERS: usize = 4;
|
||||
const MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES: usize = 128;
|
||||
|
||||
/// Configuration accepted by the standard Solana `accountSubscribe` WebSocket method.
|
||||
///
|
||||
/// `minContextSlot` is deliberately absent: Agave `v4.2.1` carries that field in the shared account config but the PubSub handler ignores it, so KSP does
|
||||
/// not expose it as an effective WebSocket option.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaAccountSubscribeConfig {
|
||||
encoding: std::option::Option<crate::SolanaAccountEncoding>,
|
||||
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
}
|
||||
|
||||
impl SolanaAccountSubscribeConfig {
|
||||
/// Creates an explicit `accountSubscribe` 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>,
|
||||
) -> Self {
|
||||
return Self { encoding, data_slice, commitment };
|
||||
}
|
||||
|
||||
/// 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.commitment;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.encoding.is_none() && self.data_slice.is_none() && self.commitment.is_none();
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
let mut 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());
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration accepted by the standard Solana `programSubscribe` WebSocket method.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaProgramSubscribeConfig {
|
||||
account: crate::SolanaAccountSubscribeConfig,
|
||||
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
|
||||
with_context: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
impl SolanaProgramSubscribeConfig {
|
||||
/// Creates an explicit `programSubscribe` configuration.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
account: crate::SolanaAccountSubscribeConfig,
|
||||
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
|
||||
with_context: std::option::Option<bool>,
|
||||
) -> Self {
|
||||
return Self { account, filters, with_context };
|
||||
}
|
||||
|
||||
/// Returns the shared WebSocket account configuration.
|
||||
#[must_use]
|
||||
pub const fn account(&self) -> &crate::SolanaAccountSubscribeConfig {
|
||||
return &self.account;
|
||||
}
|
||||
|
||||
/// Returns the ordered program-account filters.
|
||||
#[must_use]
|
||||
pub fn filters(&self) -> &[crate::SolanaProgramAccountFilter] {
|
||||
return self.filters.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the optional `withContext` request; omission uses the upstream default `false`.
|
||||
#[must_use]
|
||||
pub const fn with_context(&self) -> std::option::Option<bool> {
|
||||
return self.with_context;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.account.is_empty() && self.filters.is_empty() && self.with_context.is_none();
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
let account = self.account.to_json_value();
|
||||
let mut object = match account {
|
||||
serde_json::Value::Object(object) => object,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
if !self.filters.is_empty() {
|
||||
let filters = self.filters.iter().map(crate::SolanaProgramAccountFilter::to_json_value).collect::<std::vec::Vec<_>>();
|
||||
object.insert("filters".to_owned(), serde_json::Value::Array(filters));
|
||||
}
|
||||
if let std::option::Option::Some(with_context) = self.with_context {
|
||||
object.insert("withContext".to_owned(), serde_json::Value::Bool(with_context));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// One `programNotification` payload, preserving whether the upstream wire result was contextualized.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaProgramNotification {
|
||||
/// Program account payload without a surrounding RPC context.
|
||||
Account(crate::SolanaKeyedAccount),
|
||||
/// Program account payload wrapped in an RPC context.
|
||||
Context(crate::SolanaRpcResponse<crate::SolanaKeyedAccount>),
|
||||
}
|
||||
|
||||
impl SolanaProgramNotification {
|
||||
/// Returns the program account regardless of the upstream context-wrapper form.
|
||||
#[must_use]
|
||||
pub const fn account(&self) -> &crate::SolanaKeyedAccount {
|
||||
return match self {
|
||||
Self::Account(account) => account,
|
||||
Self::Context(response) => response.value(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the RPC context when the upstream notification included one.
|
||||
#[must_use]
|
||||
pub const fn context(&self) -> std::option::Option<&crate::SolanaRpcContext> {
|
||||
return match self {
|
||||
Self::Account(_) => std::option::Option::None,
|
||||
Self::Context(response) => std::option::Option::Some(response.context()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WsSession {
|
||||
/// Subscribes to changes for one Solana account through standard `accountSubscribe`.
|
||||
pub async fn account_subscribe(
|
||||
&self,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
|
||||
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());
|
||||
}
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Account, params, |value| return decode_account_notification("accountSubscribe", value))
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to account changes owned by one Solana program through standard `programSubscribe`.
|
||||
pub async fn program_subscribe(
|
||||
&self,
|
||||
program_id: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
|
||||
if let std::option::Option::Some(config) = config {
|
||||
let validation = validate_program_subscribe_filters(config.filters());
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
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());
|
||||
}
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Program, params, |value| return decode_program_notification("programSubscribe", value))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponse {
|
||||
context: serde_json::Value,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum WireProgramNotification {
|
||||
Context(WireRpcResponse),
|
||||
Account(serde_json::Value),
|
||||
}
|
||||
|
||||
fn decode_account_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaAccount>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse>(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 = crate::SolanaAccount::decode_wire(method, wire.value);
|
||||
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(crate::SolanaRpcResponse::new(context, account));
|
||||
}
|
||||
|
||||
fn decode_program_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaProgramNotification> {
|
||||
let decoded = crate::decode_wire_json::<WireProgramNotification>(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 {
|
||||
WireProgramNotification::Account(value) => {
|
||||
let account = crate::SolanaKeyedAccount::decode_wire(method, value);
|
||||
match account {
|
||||
std::result::Result::Ok(account) => std::result::Result::Ok(crate::SolanaProgramNotification::Account(account)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
WireProgramNotification::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 account = crate::SolanaKeyedAccount::decode_wire(method, wire.value);
|
||||
let account = match account {
|
||||
std::result::Result::Ok(account) => account,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
std::result::Result::Ok(crate::SolanaProgramNotification::Context(crate::SolanaRpcResponse::new(context, account)))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_program_subscribe_filters(filters: &[crate::SolanaProgramAccountFilter]) -> ksp_core_lib::Result<()> {
|
||||
if filters.len() > MAX_PROGRAM_SUBSCRIBE_FILTERS {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "programSubscribe accepts at most 4 filters on the targeted Agave runtime")
|
||||
.with_context("rpc_method", "programSubscribe")
|
||||
.with_context("filter_count", filters.len().to_string()),
|
||||
);
|
||||
}
|
||||
for filter in filters {
|
||||
if let crate::SolanaProgramAccountFilter::Memcmp(memcmp) = filter
|
||||
&& let crate::SolanaMemcmpBytes::Bytes(bytes) = memcmp.bytes()
|
||||
&& bytes.len() > MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "raw programSubscribe memcmp data accepts at most 128 bytes")
|
||||
.with_context("rpc_method", "programSubscribe")
|
||||
.with_context("memcmp_byte_count", bytes.len().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
impl crate::SolanaStandardWsSession {
|
||||
/// Subscribes to changes for one Solana account through standard `accountSubscribe`.
|
||||
pub async fn account_subscribe(
|
||||
&self,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
|
||||
return self.physical_session().account_subscribe(account, config).await;
|
||||
}
|
||||
|
||||
/// Subscribes to account changes owned by one Solana program through standard `programSubscribe`.
|
||||
pub async fn program_subscribe(
|
||||
&self,
|
||||
program_id: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
|
||||
return self.physical_session().program_subscribe(program_id, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HeliusLaserStreamWsSession {
|
||||
/// Subscribes to account changes through the standard `accountSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn account_subscribe(
|
||||
&self,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
|
||||
return self.physical_session().account_subscribe(account, config).await;
|
||||
}
|
||||
|
||||
/// Subscribes to program-owned account changes through the standard `programSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn program_subscribe(
|
||||
&self,
|
||||
program_id: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
|
||||
return self.physical_session().program_subscribe(program_id, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_accounts.rs"]
|
||||
mod tests;
|
||||
226
crates/ksp-onchain-transport-lib/src/ws_blocks.rs
Normal file
226
crates/ksp-onchain-transport-lib/src/ws_blocks.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_blocks.rs
|
||||
// version: 2
|
||||
|
||||
/// Filter accepted by unstable Solana `blockSubscribe`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SolanaBlockSubscribeFilter {
|
||||
/// Subscribe to every block that reaches the configured commitment.
|
||||
All,
|
||||
/// Subscribe only to blocks containing a transaction that mentions the account or program.
|
||||
MentionsAccountOrProgram(ksp_core_lib::Pubkey),
|
||||
}
|
||||
|
||||
impl SolanaBlockSubscribeFilter {
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
return match self {
|
||||
Self::All => serde_json::Value::String("all".to_owned()),
|
||||
Self::MentionsAccountOrProgram(pubkey) => serde_json::json!({"mentionsAccountOrProgram": pubkey.to_string()}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional configuration accepted by unstable Solana `blockSubscribe`.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaBlockSubscribeConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
encoding: std::option::Option<crate::SolanaTransactionEncoding>,
|
||||
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
|
||||
max_supported_transaction_version: std::option::Option<u8>,
|
||||
show_rewards: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
impl SolanaBlockSubscribeConfig {
|
||||
/// Creates an explicit unstable block-subscription configuration.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
encoding: std::option::Option<crate::SolanaTransactionEncoding>,
|
||||
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
|
||||
max_supported_transaction_version: std::option::Option<u8>,
|
||||
show_rewards: std::option::Option<bool>,
|
||||
) -> Self {
|
||||
return Self { commitment, encoding, transaction_details, max_supported_transaction_version, show_rewards };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns the optional transaction encoding.
|
||||
#[must_use]
|
||||
pub const fn encoding(&self) -> std::option::Option<crate::SolanaTransactionEncoding> {
|
||||
return self.encoding;
|
||||
}
|
||||
|
||||
/// Returns the optional transaction detail level.
|
||||
#[must_use]
|
||||
pub const fn transaction_details(&self) -> std::option::Option<crate::SolanaTransactionDetails> {
|
||||
return self.transaction_details;
|
||||
}
|
||||
|
||||
/// Returns the highest transaction version the caller declares it can consume.
|
||||
#[must_use]
|
||||
pub const fn max_supported_transaction_version(&self) -> std::option::Option<u8> {
|
||||
return self.max_supported_transaction_version;
|
||||
}
|
||||
|
||||
/// Returns whether rewards were explicitly requested for block notifications.
|
||||
#[must_use]
|
||||
pub const fn show_rewards(&self) -> std::option::Option<bool> {
|
||||
return self.show_rewards;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.commitment.is_none()
|
||||
&& self.encoding.is_none()
|
||||
&& self.transaction_details.is_none()
|
||||
&& self.max_supported_transaction_version.is_none()
|
||||
&& self.show_rewards.is_none();
|
||||
}
|
||||
|
||||
fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
if self.commitment == std::option::Option::Some(crate::SolanaCommitment::Processed) {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
|
||||
"blockSubscribe commitment must be confirmed or finalized when explicitly provided",
|
||||
)
|
||||
.with_context("rpc_method", "blockSubscribe")
|
||||
.with_context("commitment", "processed"),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
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(encoding) = self.encoding {
|
||||
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(transaction_details) = self.transaction_details {
|
||||
object.insert("transactionDetails".to_owned(), serde_json::Value::String(transaction_details.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(version) = self.max_supported_transaction_version {
|
||||
object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into()));
|
||||
}
|
||||
if let std::option::Option::Some(show_rewards) = self.show_rewards {
|
||||
object.insert("showRewards".to_owned(), serde_json::Value::Bool(show_rewards));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed value carried inside an unstable Solana `blockNotification` response.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaBlockNotification {
|
||||
slot: u64,
|
||||
block: std::option::Option<crate::SolanaConfirmedBlock>,
|
||||
err: std::option::Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl SolanaBlockNotification {
|
||||
/// Returns the slot associated with this block update.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the decoded block when the unstable notification contains one.
|
||||
#[must_use]
|
||||
pub const fn block(&self) -> std::option::Option<&crate::SolanaConfirmedBlock> {
|
||||
return self.block.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the nullable publication error without interpreting its unstable wire shape.
|
||||
#[must_use]
|
||||
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
|
||||
return self.err.as_ref();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WsSession {
|
||||
/// Subscribes to unstable standard Solana block notifications through `blockSubscribe`.
|
||||
///
|
||||
/// Solana documents this method as unstable and requires validator-side block-subscription support. KSP emits a warning through its logging facade when
|
||||
/// this family is requested. An explicitly supplied commitment must be `confirmed` or `finalized`.
|
||||
pub async fn block_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaBlockSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaBlockSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaBlockNotification>>> {
|
||||
if let std::option::Option::Some(config) = config {
|
||||
let validated = config.validate();
|
||||
if let std::result::Result::Err(error) = validated {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
let mut params = std::vec![filter.to_json_value()];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push((*config).to_json_value());
|
||||
}
|
||||
return self.subscribe_typed(crate::WsSubscriptionKind::Block, params, |value| return decode_block_notification("blockSubscribe", value)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireBlockNotification {
|
||||
slot: u64,
|
||||
block: std::option::Option<serde_json::Value>,
|
||||
err: serde_json::Value,
|
||||
}
|
||||
|
||||
fn decode_block_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaBlockNotification>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse>(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 block = match wire.value.block {
|
||||
std::option::Option::Some(value) => {
|
||||
let decoded = crate::SolanaConfirmedBlock::decode_wire(method, value);
|
||||
match decoded {
|
||||
std::result::Result::Ok(block) => std::option::Option::Some(block),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
let err = match wire.value.err {
|
||||
serde_json::Value::Null => std::option::Option::None,
|
||||
value => std::option::Option::Some(value),
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, crate::SolanaBlockNotification { slot: wire.value.slot, block, err }));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponse {
|
||||
context: serde_json::Value,
|
||||
value: WireBlockNotification,
|
||||
}
|
||||
|
||||
impl crate::SolanaStandardWsSession {
|
||||
/// Subscribes to unstable standard Solana block notifications through `blockSubscribe`.
|
||||
pub async fn block_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaBlockSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaBlockSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaBlockNotification>>> {
|
||||
return self.physical_session().block_subscribe(filter, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_blocks.rs"]
|
||||
mod tests;
|
||||
446
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
Normal file
446
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
Normal file
@@ -0,0 +1,446 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs
|
||||
// version: 5
|
||||
|
||||
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaSlotNotification {
|
||||
slot: u64,
|
||||
parent: u64,
|
||||
root: u64,
|
||||
}
|
||||
|
||||
impl SolanaSlotNotification {
|
||||
/// Returns the newly processed slot.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the parent slot reported by the validator.
|
||||
#[must_use]
|
||||
pub const fn parent(&self) -> u64 {
|
||||
return self.parent;
|
||||
}
|
||||
|
||||
/// Returns the current root slot reported alongside this slot update.
|
||||
#[must_use]
|
||||
pub const fn root(&self) -> u64 {
|
||||
return self.root;
|
||||
}
|
||||
}
|
||||
|
||||
/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaSlotUpdateStats {
|
||||
max_transactions_per_entry: u64,
|
||||
num_failed_transactions: u64,
|
||||
num_successful_transactions: u64,
|
||||
num_transaction_entries: u64,
|
||||
}
|
||||
|
||||
impl SolanaSlotUpdateStats {
|
||||
/// Returns the maximum transactions per entry observed for the frozen bank.
|
||||
#[must_use]
|
||||
pub const fn max_transactions_per_entry(&self) -> u64 {
|
||||
return self.max_transactions_per_entry;
|
||||
}
|
||||
|
||||
/// Returns the failed transaction count.
|
||||
#[must_use]
|
||||
pub const fn num_failed_transactions(&self) -> u64 {
|
||||
return self.num_failed_transactions;
|
||||
}
|
||||
|
||||
/// Returns the successful transaction count.
|
||||
#[must_use]
|
||||
pub const fn num_successful_transactions(&self) -> u64 {
|
||||
return self.num_successful_transactions;
|
||||
}
|
||||
|
||||
/// Returns the transaction-entry count.
|
||||
#[must_use]
|
||||
pub const fn num_transaction_entries(&self) -> u64 {
|
||||
return self.num_transaction_entries;
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaSlotUpdate {
|
||||
/// The first shred for a slot was received.
|
||||
FirstShredReceived {
|
||||
/// Slot whose first shred was observed.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
},
|
||||
/// All shreds for a slot were received.
|
||||
Completed {
|
||||
/// Slot whose shred set completed.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
},
|
||||
/// A bank was created for the slot.
|
||||
CreatedBank {
|
||||
/// Slot whose bank was created.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
/// Parent slot used to create the bank.
|
||||
parent: u64,
|
||||
},
|
||||
/// A bank was frozen and execution statistics are available.
|
||||
Frozen {
|
||||
/// Slot whose bank was frozen.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
/// Execution statistics reported for the frozen bank.
|
||||
stats: crate::SolanaSlotUpdateStats,
|
||||
},
|
||||
/// The slot was marked dead.
|
||||
Dead {
|
||||
/// Slot marked dead by the validator.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
/// Upstream diagnostic string explaining why the slot was marked dead.
|
||||
error: std::string::String,
|
||||
},
|
||||
/// The slot reached the current unstable optimistic-confirmation marker.
|
||||
OptimisticConfirmation {
|
||||
/// Slot that reached optimistic confirmation.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
},
|
||||
/// The slot became root.
|
||||
Root {
|
||||
/// Slot that became root.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
},
|
||||
/// A future upstream variant that KSP does not yet interpret.
|
||||
///
|
||||
/// `raw` is bounded by the physical session's configured inbound WebSocket message limit before JSON decoding.
|
||||
Unknown {
|
||||
/// Upstream `type` discriminator that KSP does not yet recognize.
|
||||
update_type: std::string::String,
|
||||
/// Complete bounded JSON object preserved for forward-compatible inspection.
|
||||
raw: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl SolanaSlotUpdate {
|
||||
/// Returns the slot for known variants, or the optional slot found in an unknown raw variant.
|
||||
#[must_use]
|
||||
pub fn slot(&self) -> std::option::Option<u64> {
|
||||
return match self {
|
||||
Self::FirstShredReceived { slot, .. }
|
||||
| Self::Completed { slot, .. }
|
||||
| Self::CreatedBank { slot, .. }
|
||||
| Self::Frozen { slot, .. }
|
||||
| Self::Dead { slot, .. }
|
||||
| Self::OptimisticConfirmation { slot, .. }
|
||||
| Self::Root { slot, .. } => std::option::Option::Some(*slot),
|
||||
Self::Unknown { raw, .. } => raw.get("slot").and_then(serde_json::Value::as_u64),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the millisecond Unix timestamp for known variants, or an optional timestamp from an unknown raw variant.
|
||||
#[must_use]
|
||||
pub fn timestamp(&self) -> std::option::Option<i64> {
|
||||
return match self {
|
||||
Self::FirstShredReceived { timestamp, .. }
|
||||
| Self::Completed { timestamp, .. }
|
||||
| Self::CreatedBank { timestamp, .. }
|
||||
| Self::Frozen { timestamp, .. }
|
||||
| Self::Dead { timestamp, .. }
|
||||
| Self::OptimisticConfirmation { timestamp, .. }
|
||||
| Self::Root { timestamp, .. } => std::option::Option::Some(*timestamp),
|
||||
Self::Unknown { raw, .. } => raw.get("timestamp").and_then(serde_json::Value::as_i64),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the upstream `type` string, including unknown future values.
|
||||
#[must_use]
|
||||
pub fn update_type(&self) -> &str {
|
||||
return match self {
|
||||
Self::FirstShredReceived { .. } => "firstShredReceived",
|
||||
Self::Completed { .. } => "completed",
|
||||
Self::CreatedBank { .. } => "createdBank",
|
||||
Self::Frozen { .. } => "frozen",
|
||||
Self::Dead { .. } => "dead",
|
||||
Self::OptimisticConfirmation { .. } => "optimisticConfirmation",
|
||||
Self::Root { .. } => "root",
|
||||
Self::Unknown { update_type, .. } => update_type.as_str(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the bounded raw object only for an unknown future upstream variant.
|
||||
#[must_use]
|
||||
pub const fn unknown_raw(&self) -> std::option::Option<&serde_json::Value> {
|
||||
return match self {
|
||||
Self::Unknown { raw, .. } => std::option::Option::Some(raw),
|
||||
_ => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaVoteNotification {
|
||||
vote_pubkey: ksp_core_lib::Pubkey,
|
||||
slots: std::vec::Vec<u64>,
|
||||
hash: std::string::String,
|
||||
timestamp: std::option::Option<i64>,
|
||||
signature: std::string::String,
|
||||
}
|
||||
|
||||
impl SolanaVoteNotification {
|
||||
/// Returns the vote-account public key.
|
||||
#[must_use]
|
||||
pub const fn vote_pubkey(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.vote_pubkey;
|
||||
}
|
||||
|
||||
/// Returns the ordered slots covered by the observed vote.
|
||||
#[must_use]
|
||||
pub fn slots(&self) -> &[u64] {
|
||||
return self.slots.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the vote hash exactly as reported by the unstable upstream wire.
|
||||
#[must_use]
|
||||
pub fn hash(&self) -> &str {
|
||||
return self.hash.as_str();
|
||||
}
|
||||
|
||||
/// Returns the optional vote timestamp, preserving omitted and explicit-null wire forms as `None`.
|
||||
#[must_use]
|
||||
pub const fn timestamp(&self) -> std::option::Option<i64> {
|
||||
return self.timestamp;
|
||||
}
|
||||
|
||||
/// Returns the vote transaction signature exactly as reported by the unstable upstream wire.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> &str {
|
||||
return self.signature.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WsSession {
|
||||
/// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`.
|
||||
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| {
|
||||
return decode_slot_notification("slotSubscribe", value);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to standard Solana root-slot notifications through `rootSubscribe`.
|
||||
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
|
||||
return crate::decode_wire_json::<u64>("rootSubscribe", value);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable standard Solana slot-lifecycle notifications through `slotsUpdatesSubscribe`.
|
||||
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::SlotsUpdates, std::vec::Vec::new(), |value| {
|
||||
return decode_slots_update_notification("slotsUpdatesSubscribe", value);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable pre-consensus gossip vote notifications through `voteSubscribe`.
|
||||
pub async fn vote_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaVoteNotification>> {
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Vote, std::vec::Vec::new(), |value| {
|
||||
return decode_vote_notification("voteSubscribe", value);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireSlotNotification {
|
||||
slot: u64,
|
||||
parent: u64,
|
||||
root: u64,
|
||||
}
|
||||
|
||||
fn decode_slot_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaSlotNotification> {
|
||||
let decoded = crate::decode_wire_json::<WireSlotNotification>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaSlotNotification { slot: wire.slot, parent: wire.parent, root: wire.root });
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireSlotUpdateStats {
|
||||
max_transactions_per_entry: u64,
|
||||
num_failed_transactions: u64,
|
||||
num_successful_transactions: u64,
|
||||
num_transaction_entries: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireVoteNotification {
|
||||
#[serde(rename = "votePubkey")]
|
||||
vote_pubkey: std::string::String,
|
||||
slots: std::vec::Vec<u64>,
|
||||
hash: std::string::String,
|
||||
#[serde(default)]
|
||||
timestamp: std::option::Option<i64>,
|
||||
signature: std::string::String,
|
||||
}
|
||||
|
||||
fn decode_slots_update_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaSlotUpdate> {
|
||||
let object = match value.as_object() {
|
||||
std::option::Option::Some(object) => object,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification must be an object")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
},
|
||||
};
|
||||
let update_type = match object.get("type").and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(update_type) => update_type,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification is missing a string type")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
},
|
||||
};
|
||||
if !matches!(update_type, "firstShredReceived" | "completed" | "createdBank" | "frozen" | "dead" | "optimisticConfirmation" | "root") {
|
||||
return std::result::Result::Ok(crate::SolanaSlotUpdate::Unknown { update_type: update_type.to_owned(), raw: value.clone() });
|
||||
}
|
||||
let slot = match object.get("slot").and_then(serde_json::Value::as_u64) {
|
||||
std::option::Option::Some(slot) => slot,
|
||||
std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric slot"),
|
||||
};
|
||||
let timestamp = match object.get("timestamp").and_then(serde_json::Value::as_i64) {
|
||||
std::option::Option::Some(timestamp) => timestamp,
|
||||
std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric timestamp"),
|
||||
};
|
||||
return match update_type {
|
||||
"firstShredReceived" => std::result::Result::Ok(crate::SolanaSlotUpdate::FirstShredReceived { slot, timestamp }),
|
||||
"completed" => std::result::Result::Ok(crate::SolanaSlotUpdate::Completed { slot, timestamp }),
|
||||
"createdBank" => match object.get("parent").and_then(serde_json::Value::as_u64) {
|
||||
std::option::Option::Some(parent) => std::result::Result::Ok(crate::SolanaSlotUpdate::CreatedBank { slot, timestamp, parent }),
|
||||
std::option::Option::None => invalid_slots_update(method, "createdBank update is missing numeric parent"),
|
||||
},
|
||||
"frozen" => {
|
||||
let stats = match object.get("stats") {
|
||||
std::option::Option::Some(stats) => crate::decode_wire_json::<WireSlotUpdateStats>(method, stats.clone()),
|
||||
std::option::Option::None => return invalid_slots_update(method, "frozen update is missing stats"),
|
||||
};
|
||||
let stats = match stats {
|
||||
std::result::Result::Ok(stats) => stats,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
std::result::Result::Ok(crate::SolanaSlotUpdate::Frozen {
|
||||
slot,
|
||||
timestamp,
|
||||
stats: crate::SolanaSlotUpdateStats {
|
||||
max_transactions_per_entry: stats.max_transactions_per_entry,
|
||||
num_failed_transactions: stats.num_failed_transactions,
|
||||
num_successful_transactions: stats.num_successful_transactions,
|
||||
num_transaction_entries: stats.num_transaction_entries,
|
||||
},
|
||||
})
|
||||
},
|
||||
"dead" => match object.get("err").and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(error) => std::result::Result::Ok(crate::SolanaSlotUpdate::Dead { slot, timestamp, error: error.to_owned() }),
|
||||
std::option::Option::None => invalid_slots_update(method, "dead update is missing string err"),
|
||||
},
|
||||
"optimisticConfirmation" => std::result::Result::Ok(crate::SolanaSlotUpdate::OptimisticConfirmation { slot, timestamp }),
|
||||
"root" => std::result::Result::Ok(crate::SolanaSlotUpdate::Root { slot, timestamp }),
|
||||
_ => invalid_slots_update(method, "known slots update type dispatch failed"),
|
||||
};
|
||||
}
|
||||
|
||||
fn invalid_slots_update(method: &str, message: &'static str) -> ksp_core_lib::Result<crate::SolanaSlotUpdate> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method));
|
||||
}
|
||||
|
||||
fn decode_vote_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaVoteNotification> {
|
||||
let decoded = crate::decode_wire_json::<WireVoteNotification>(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 = wire.vote_pubkey.parse::<ksp_core_lib::Pubkey>();
|
||||
let vote_pubkey = match vote_pubkey {
|
||||
std::result::Result::Ok(vote_pubkey) => vote_pubkey,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "voteSubscribe notification contains an invalid votePubkey")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaVoteNotification {
|
||||
vote_pubkey,
|
||||
slots: wire.slots,
|
||||
hash: wire.hash,
|
||||
timestamp: wire.timestamp,
|
||||
signature: wire.signature,
|
||||
});
|
||||
}
|
||||
|
||||
impl crate::SolanaStandardWsSession {
|
||||
/// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`.
|
||||
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
|
||||
return self.physical_session().slot_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to standard Solana root-slot notifications through `rootSubscribe`.
|
||||
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
|
||||
return self.physical_session().root_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable standard Solana slot-lifecycle notifications through `slotsUpdatesSubscribe`.
|
||||
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
|
||||
return self.physical_session().slots_updates_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable pre-consensus gossip vote notifications through `voteSubscribe`.
|
||||
pub async fn vote_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaVoteNotification>> {
|
||||
return self.physical_session().vote_subscribe().await;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HeliusLaserStreamWsSession {
|
||||
/// Subscribes to slot-processing notifications through the standard `slotSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
|
||||
return self.physical_session().slot_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to root-slot notifications through the standard `rootSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
|
||||
return self.physical_session().root_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable slot-lifecycle notifications through the standard `slotsUpdatesSubscribe` wire currently documented by Helius LaserStream
|
||||
/// WebSocket.
|
||||
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
|
||||
return self.physical_session().slots_updates_subscribe().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_cluster.rs"]
|
||||
mod tests;
|
||||
608
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
Normal file
608
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
Normal file
@@ -0,0 +1,608 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
|
||||
// version: 5
|
||||
|
||||
const MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS: usize = 50_000;
|
||||
|
||||
/// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum HeliusTokenAccountsFilter {
|
||||
/// Disable token-account owner expansion explicitly; equivalent to omitting `tokenAccounts`.
|
||||
None,
|
||||
/// Match transactions where a token balance owned by an included account changes or its token account closes.
|
||||
BalanceChanged,
|
||||
/// Match transactions referencing any token account owned by an included account, even if the balance does not change.
|
||||
All,
|
||||
}
|
||||
|
||||
impl HeliusTokenAccountsFilter {
|
||||
/// Returns the exact Helius WebSocket wire string.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::None => "none",
|
||||
Self::BalanceChanged => "balanceChanged",
|
||||
Self::All => "all",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Transaction encoding accepted by Helius `transactionSubscribe`.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum HeliusTransactionSubscribeEncoding {
|
||||
/// Base58 encoded transaction bytes.
|
||||
Base58,
|
||||
/// Base64 encoded transaction bytes.
|
||||
Base64,
|
||||
/// Parsed JSON transaction representation.
|
||||
JsonParsed,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSubscribeEncoding {
|
||||
/// Returns the exact Helius WebSocket wire string.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Base58 => "base58",
|
||||
Self::Base64 => "base64",
|
||||
Self::JsonParsed => "jsonParsed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Helius-specific filter object accepted as the first `transactionSubscribe` parameter.
|
||||
///
|
||||
/// Debug output intentionally exposes only filter presence, modes and account counts. Transaction signatures and account values are omitted so routine
|
||||
/// diagnostics cannot accidentally disclose the caller's complete provider filter payload.
|
||||
#[derive(Clone, Default, Eq, PartialEq)]
|
||||
pub struct HeliusTransactionSubscribeFilter {
|
||||
vote: std::option::Option<bool>,
|
||||
failed: std::option::Option<bool>,
|
||||
signature: std::option::Option<std::string::String>,
|
||||
account_include: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
account_exclude: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
account_required: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
token_accounts: std::option::Option<crate::HeliusTokenAccountsFilter>,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSubscribeFilter {
|
||||
/// Creates a complete Helius transaction filter while preserving omitted versus explicitly empty account arrays.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
vote: std::option::Option<bool>,
|
||||
failed: std::option::Option<bool>,
|
||||
signature: std::option::Option<std::string::String>,
|
||||
account_include: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
account_exclude: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
account_required: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
token_accounts: std::option::Option<crate::HeliusTokenAccountsFilter>,
|
||||
) -> Self {
|
||||
return Self { vote, failed, signature, account_include, account_exclude, account_required, token_accounts };
|
||||
}
|
||||
|
||||
/// Returns the optional vote-transaction filter flag.
|
||||
#[must_use]
|
||||
pub const fn vote(&self) -> std::option::Option<bool> {
|
||||
return self.vote;
|
||||
}
|
||||
|
||||
/// Returns the optional failed-transaction filter flag.
|
||||
#[must_use]
|
||||
pub const fn failed(&self) -> std::option::Option<bool> {
|
||||
return self.failed;
|
||||
}
|
||||
|
||||
/// Returns the optional exact transaction signature filter.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> std::option::Option<&str> {
|
||||
return match self.signature.as_ref() {
|
||||
std::option::Option::Some(signature) => std::option::Option::Some(signature.as_str()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the optional OR-style account inclusion list.
|
||||
#[must_use]
|
||||
pub fn account_include(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> {
|
||||
return match self.account_include.as_ref() {
|
||||
std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the optional account exclusion list.
|
||||
#[must_use]
|
||||
pub fn account_exclude(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> {
|
||||
return match self.account_exclude.as_ref() {
|
||||
std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the optional AND-style required-account list.
|
||||
#[must_use]
|
||||
pub fn account_required(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> {
|
||||
return match self.account_required.as_ref() {
|
||||
std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the optional Helius token-account owner-expansion mode.
|
||||
#[must_use]
|
||||
pub const fn token_accounts(&self) -> std::option::Option<crate::HeliusTokenAccountsFilter> {
|
||||
return self.token_accounts;
|
||||
}
|
||||
|
||||
fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
let include = validate_account_list("accountInclude", self.account_include.as_deref());
|
||||
if let std::result::Result::Err(error) = include {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let exclude = validate_account_list("accountExclude", self.account_exclude.as_deref());
|
||||
if let std::result::Result::Err(error) = exclude {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let required = validate_account_list("accountRequired", self.account_required.as_deref());
|
||||
if let std::result::Result::Err(error) = required {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(vote) = self.vote {
|
||||
object.insert("vote".to_owned(), serde_json::Value::Bool(vote));
|
||||
}
|
||||
if let std::option::Option::Some(failed) = self.failed {
|
||||
object.insert("failed".to_owned(), serde_json::Value::Bool(failed));
|
||||
}
|
||||
if let std::option::Option::Some(signature) = self.signature.as_ref() {
|
||||
object.insert("signature".to_owned(), serde_json::Value::String(signature.clone()));
|
||||
}
|
||||
insert_account_list(&mut object, "accountInclude", self.account_include.as_deref());
|
||||
insert_account_list(&mut object, "accountExclude", self.account_exclude.as_deref());
|
||||
insert_account_list(&mut object, "accountRequired", self.account_required.as_deref());
|
||||
if let std::option::Option::Some(token_accounts) = self.token_accounts {
|
||||
object.insert("tokenAccounts".to_owned(), serde_json::Value::String(token_accounts.as_str().to_owned()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusTransactionSubscribeFilter {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("HeliusTransactionSubscribeFilter")
|
||||
.field("vote", &self.vote)
|
||||
.field("failed", &self.failed)
|
||||
.field("signature_present", &self.signature.is_some())
|
||||
.field("account_include_count", &self.account_include.as_ref().map(std::vec::Vec::len))
|
||||
.field("account_exclude_count", &self.account_exclude.as_ref().map(std::vec::Vec::len))
|
||||
.field("account_required_count", &self.account_required.as_ref().map(std::vec::Vec::len))
|
||||
.field("token_accounts", &self.token_accounts)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional Helius `transactionSubscribe` result-shaping configuration.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct HeliusTransactionSubscribeOptions {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
encoding: std::option::Option<crate::HeliusTransactionSubscribeEncoding>,
|
||||
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
|
||||
show_rewards: std::option::Option<bool>,
|
||||
max_supported_transaction_version: std::option::Option<u8>,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSubscribeOptions {
|
||||
/// Creates a complete optional Helius transaction-subscription configuration.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
encoding: std::option::Option<crate::HeliusTransactionSubscribeEncoding>,
|
||||
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
|
||||
show_rewards: std::option::Option<bool>,
|
||||
max_supported_transaction_version: std::option::Option<u8>,
|
||||
) -> Self {
|
||||
return Self { commitment, encoding, transaction_details, show_rewards, max_supported_transaction_version };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns the optional Helius transaction encoding.
|
||||
#[must_use]
|
||||
pub const fn encoding(&self) -> std::option::Option<crate::HeliusTransactionSubscribeEncoding> {
|
||||
return self.encoding;
|
||||
}
|
||||
|
||||
/// Returns the optional transaction detail level.
|
||||
#[must_use]
|
||||
pub const fn transaction_details(&self) -> std::option::Option<crate::SolanaTransactionDetails> {
|
||||
return self.transaction_details;
|
||||
}
|
||||
|
||||
/// Returns whether rewards were explicitly requested.
|
||||
#[must_use]
|
||||
pub const fn show_rewards(&self) -> std::option::Option<bool> {
|
||||
return self.show_rewards;
|
||||
}
|
||||
|
||||
/// Returns the highest transaction version the caller declares it can consume.
|
||||
#[must_use]
|
||||
pub const fn max_supported_transaction_version(&self) -> std::option::Option<u8> {
|
||||
return self.max_supported_transaction_version;
|
||||
}
|
||||
|
||||
fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
let requires_version =
|
||||
matches!(self.transaction_details, std::option::Option::Some(crate::SolanaTransactionDetails::Full | crate::SolanaTransactionDetails::Accounts));
|
||||
if requires_version && self.max_supported_transaction_version.is_none() {
|
||||
let detail = match self.transaction_details {
|
||||
std::option::Option::Some(detail) => detail.as_str(),
|
||||
std::option::Option::None => "omitted",
|
||||
};
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
|
||||
"Helius transactionSubscribe requires maxSupportedTransactionVersion for full or accounts transaction details",
|
||||
)
|
||||
.with_context("rpc_method", "transactionSubscribe")
|
||||
.with_context("field", "maxSupportedTransactionVersion")
|
||||
.with_context("transaction_details", detail),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
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(encoding) = self.encoding {
|
||||
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(transaction_details) = self.transaction_details {
|
||||
object.insert("transactionDetails".to_owned(), serde_json::Value::String(transaction_details.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(show_rewards) = self.show_rewards {
|
||||
object.insert("showRewards".to_owned(), serde_json::Value::Bool(show_rewards));
|
||||
}
|
||||
if let std::option::Option::Some(version) = self.max_supported_transaction_version {
|
||||
object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete typed request contract for Helius `transactionSubscribe`.
|
||||
///
|
||||
/// The request owns the exact provider filter and optional result-shaping object. Validation and serialization occur before actor registration so deterministic
|
||||
/// provider constraints fail without WebSocket I/O.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct HeliusTransactionSubscribeRequest {
|
||||
filter: crate::HeliusTransactionSubscribeFilter,
|
||||
options: std::option::Option<crate::HeliusTransactionSubscribeOptions>,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSubscribeRequest {
|
||||
/// Creates one typed Helius transaction-subscription request.
|
||||
#[must_use]
|
||||
pub fn new(filter: crate::HeliusTransactionSubscribeFilter, options: std::option::Option<crate::HeliusTransactionSubscribeOptions>) -> Self {
|
||||
return Self { filter, options };
|
||||
}
|
||||
|
||||
/// Returns the provider transaction filter.
|
||||
#[must_use]
|
||||
pub const fn filter(&self) -> &crate::HeliusTransactionSubscribeFilter {
|
||||
return &self.filter;
|
||||
}
|
||||
|
||||
/// Returns the optional provider result-shaping configuration.
|
||||
#[must_use]
|
||||
pub const fn options(&self) -> std::option::Option<&crate::HeliusTransactionSubscribeOptions> {
|
||||
return self.options.as_ref();
|
||||
}
|
||||
|
||||
/// Validates deterministic Helius request constraints before any WebSocket I/O.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
let filter = self.filter.validate();
|
||||
if let std::result::Result::Err(error) = filter {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::option::Option::Some(options) = self.options {
|
||||
let options = options.validate();
|
||||
if let std::result::Result::Err(error) = options {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusTransactionSubscribeRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("HeliusTransactionSubscribeRequest").field("filter", &self.filter).field("options", &self.options).finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn helius_transaction_subscribe_params(request: &crate::HeliusTransactionSubscribeRequest) -> ksp_core_lib::Result<std::vec::Vec<serde_json::Value>> {
|
||||
let validation = request.validate();
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mut params = std::vec![request.filter.to_json_value()];
|
||||
if let std::option::Option::Some(options) = request.options {
|
||||
params.push(options.to_json_value());
|
||||
}
|
||||
return std::result::Result::Ok(params);
|
||||
}
|
||||
|
||||
/// Full/accounts-mode notification delivered by Helius `transactionSubscribe`.
|
||||
///
|
||||
/// The nested transaction payload is deliberately retained as JSON because its exact Solana wire representation depends on the requested encoding and detail
|
||||
/// mode. KSP types the stable provider envelope while preserving the full nested payload without Program-specific decoding.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct HeliusFullTransactionNotification {
|
||||
transaction: serde_json::Value,
|
||||
signature: std::string::String,
|
||||
slot: u64,
|
||||
transaction_index: u64,
|
||||
}
|
||||
|
||||
impl HeliusFullTransactionNotification {
|
||||
/// Returns the provider transaction/status payload without interpreting Program-specific contents.
|
||||
#[must_use]
|
||||
pub const fn transaction(&self) -> &serde_json::Value {
|
||||
return &self.transaction;
|
||||
}
|
||||
|
||||
/// Returns the base58 transaction signature reported by Helius.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> &str {
|
||||
return self.signature.as_str();
|
||||
}
|
||||
|
||||
/// Returns the slot in which the transaction was processed.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the zero-based transaction position within the block.
|
||||
#[must_use]
|
||||
pub const fn transaction_index(&self) -> u64 {
|
||||
return self.transaction_index;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusFullTransactionNotification {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("HeliusFullTransactionNotification")
|
||||
.field("transaction", &"<omitted>")
|
||||
.field("signature", &"<omitted>")
|
||||
.field("slot", &self.slot)
|
||||
.field("transaction_index", &self.transaction_index)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Signatures-mode notification delivered by Helius `transactionSubscribe`.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct HeliusTransactionSignatureNotification {
|
||||
signature: std::string::String,
|
||||
slot: u64,
|
||||
transaction_index: u64,
|
||||
err: crate::SolanaWireField<serde_json::Value>,
|
||||
memo: crate::SolanaWireField<std::string::String>,
|
||||
block_time: crate::SolanaWireField<i64>,
|
||||
confirmation_status: crate::SolanaWireField<std::string::String>,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSignatureNotification {
|
||||
/// Returns the base58 transaction signature reported by Helius.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> &str {
|
||||
return self.signature.as_str();
|
||||
}
|
||||
|
||||
/// Returns the slot in which the transaction was processed.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the zero-based transaction position within the block.
|
||||
#[must_use]
|
||||
pub const fn transaction_index(&self) -> u64 {
|
||||
return self.transaction_index;
|
||||
}
|
||||
|
||||
/// Returns the optional transaction error while preserving omitted/null/value wire states.
|
||||
#[must_use]
|
||||
pub const fn err(&self) -> &crate::SolanaWireField<serde_json::Value> {
|
||||
return &self.err;
|
||||
}
|
||||
|
||||
/// Returns the optional memo while preserving omitted/null/value wire states.
|
||||
#[must_use]
|
||||
pub const fn memo(&self) -> &crate::SolanaWireField<std::string::String> {
|
||||
return &self.memo;
|
||||
}
|
||||
|
||||
/// Returns the optional block time while preserving omitted/null/value wire states.
|
||||
#[must_use]
|
||||
pub const fn block_time(&self) -> &crate::SolanaWireField<i64> {
|
||||
return &self.block_time;
|
||||
}
|
||||
|
||||
/// Returns the optional confirmation-status label while preserving omitted/null/value wire states.
|
||||
#[must_use]
|
||||
pub const fn confirmation_status(&self) -> &crate::SolanaWireField<std::string::String> {
|
||||
return &self.confirmation_status;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusTransactionSignatureNotification {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("HeliusTransactionSignatureNotification")
|
||||
.field("signature", &"<omitted>")
|
||||
.field("slot", &self.slot)
|
||||
.field("transaction_index", &self.transaction_index)
|
||||
.field("err", &wire_field_debug_state(&self.err))
|
||||
.field("memo", &wire_field_debug_state(&self.memo))
|
||||
.field("block_time", &wire_field_debug_state(&self.block_time))
|
||||
.field("confirmation_status", &wire_field_debug_state(&self.confirmation_status))
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_field_debug_state<T>(field: &crate::SolanaWireField<T>) -> &'static str {
|
||||
if field.is_omitted() {
|
||||
return "omitted";
|
||||
}
|
||||
if field.is_null() {
|
||||
return "null";
|
||||
}
|
||||
return "value";
|
||||
}
|
||||
|
||||
/// Typed Helius `transactionNotification` payload union.
|
||||
///
|
||||
/// `Full` also covers the provider `accounts` detail mode because both contain the nested `transaction` member. `Signature` covers the lightweight
|
||||
/// signatures mode. `Unknown` preserves `none` mode and forward-compatible provider shapes instead of failing the logical subscription.
|
||||
#[derive(Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum HeliusTransactionNotification {
|
||||
/// Full/accounts notification carrying the nested transaction payload.
|
||||
Full(crate::HeliusFullTransactionNotification),
|
||||
/// Lightweight signatures notification.
|
||||
Signature(crate::HeliusTransactionSignatureNotification),
|
||||
/// Provider shape not currently typed by KSP, preserved losslessly.
|
||||
Unknown(serde_json::Value),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusTransactionNotification {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return match self {
|
||||
Self::Full(notification) => formatter.debug_tuple("Full").field(notification).finish(),
|
||||
Self::Signature(notification) => formatter.debug_tuple("Signature").field(notification).finish(),
|
||||
Self::Unknown(_) => formatter.debug_tuple("Unknown").field(&"<omitted>").finish(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HeliusLaserStreamWsSession {
|
||||
/// Opens one Helius `transactionSubscribe` logical subscription through the shared physical actor.
|
||||
///
|
||||
/// The returned handle keeps a stable local identity across physical reconnects. Helius remote subscription IDs stay actor-private and are remapped after
|
||||
/// resubscribe. Calling [`crate::WsSubscription::unsubscribe`] removes the remote mapping before sending `transactionUnsubscribe`, so provider messages
|
||||
/// already in flight after cancellation are ignored without reactivating the logical subscription.
|
||||
pub async fn transaction_subscribe(
|
||||
&self,
|
||||
request: &crate::HeliusTransactionSubscribeRequest,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::HeliusTransactionNotification>> {
|
||||
let params = helius_transaction_subscribe_params(request);
|
||||
let params = match params {
|
||||
std::result::Result::Ok(params) => params,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return self
|
||||
.physical_session()
|
||||
.subscribe_typed(crate::WsSubscriptionKind::HeliusTransaction, params, |value| return decode_helius_transaction_notification(value))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireHeliusFullTransactionNotification {
|
||||
transaction: serde_json::Value,
|
||||
signature: std::string::String,
|
||||
slot: u64,
|
||||
transaction_index: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireHeliusTransactionSignatureNotification {
|
||||
signature: std::string::String,
|
||||
slot: u64,
|
||||
transaction_index: u64,
|
||||
#[serde(default)]
|
||||
err: crate::SolanaWireField<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
memo: crate::SolanaWireField<std::string::String>,
|
||||
#[serde(default)]
|
||||
block_time: crate::SolanaWireField<i64>,
|
||||
#[serde(default)]
|
||||
confirmation_status: crate::SolanaWireField<std::string::String>,
|
||||
}
|
||||
|
||||
fn decode_helius_transaction_notification(value: serde_json::Value) -> ksp_core_lib::Result<crate::HeliusTransactionNotification> {
|
||||
if value.get("transaction").is_some() {
|
||||
let decoded = crate::decode_wire_json::<WireHeliusFullTransactionNotification>("transactionNotification", value.clone());
|
||||
if let std::result::Result::Ok(decoded) = decoded {
|
||||
return std::result::Result::Ok(crate::HeliusTransactionNotification::Full(crate::HeliusFullTransactionNotification {
|
||||
transaction: decoded.transaction,
|
||||
signature: decoded.signature,
|
||||
slot: decoded.slot,
|
||||
transaction_index: decoded.transaction_index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if value.get("signature").is_some() && value.get("slot").is_some() && value.get("transactionIndex").is_some() {
|
||||
let decoded = crate::decode_wire_json::<WireHeliusTransactionSignatureNotification>("transactionNotification", value.clone());
|
||||
if let std::result::Result::Ok(decoded) = decoded {
|
||||
return std::result::Result::Ok(crate::HeliusTransactionNotification::Signature(crate::HeliusTransactionSignatureNotification {
|
||||
signature: decoded.signature,
|
||||
slot: decoded.slot,
|
||||
transaction_index: decoded.transaction_index,
|
||||
err: decoded.err,
|
||||
memo: decoded.memo,
|
||||
block_time: decoded.block_time,
|
||||
confirmation_status: decoded.confirmation_status,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(crate::HeliusTransactionNotification::Unknown(value));
|
||||
}
|
||||
|
||||
fn validate_account_list(field: &'static str, accounts: std::option::Option<&[ksp_core_lib::Pubkey]>) -> ksp_core_lib::Result<()> {
|
||||
if let std::option::Option::Some(accounts) = accounts
|
||||
&& accounts.len() > MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "Helius transactionSubscribe account filter exceeds the provider limit")
|
||||
.with_context("rpc_method", "transactionSubscribe")
|
||||
.with_context("field", field)
|
||||
.with_context("actual_count", accounts.len().to_string())
|
||||
.with_context("max_count", MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn insert_account_list(
|
||||
object: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
||||
field: &'static str,
|
||||
accounts: std::option::Option<&[ksp_core_lib::Pubkey]>,
|
||||
) {
|
||||
if let std::option::Option::Some(accounts) = accounts {
|
||||
let values = accounts.iter().map(|account| return serde_json::Value::String(account.to_string())).collect::<std::vec::Vec<_>>();
|
||||
object.insert(field.to_owned(), serde_json::Value::Array(values));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_helius_transactions.rs"]
|
||||
mod tests;
|
||||
366
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
Normal file
366
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||
// version: 7
|
||||
|
||||
/// Stable local identity assigned to one physical WebSocket session.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WsSessionId(std::num::NonZeroU64);
|
||||
|
||||
impl WsSessionId {
|
||||
/// Creates a session identity from a non-zero local value.
|
||||
#[must_use]
|
||||
pub const fn new(value: std::num::NonZeroU64) -> Self {
|
||||
return Self(value);
|
||||
}
|
||||
|
||||
/// Returns the stable local numeric value.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
return self.0.get();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable local identity assigned to one logical WebSocket subscription.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WsSubscriptionId(std::num::NonZeroU64);
|
||||
|
||||
impl WsSubscriptionId {
|
||||
/// Creates a subscription identity from a non-zero local value.
|
||||
#[must_use]
|
||||
pub const fn new(value: std::num::NonZeroU64) -> Self {
|
||||
return Self(value);
|
||||
}
|
||||
|
||||
/// Returns the stable local numeric value.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
return self.0.get();
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable lifecycle state of one physical WebSocket session.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WsSessionState {
|
||||
/// No physical connection is currently active and no connection attempt is running.
|
||||
Disconnected,
|
||||
/// The actor is establishing the physical connection.
|
||||
Connecting,
|
||||
/// The physical connection is active.
|
||||
Active,
|
||||
/// The actor is reconnecting after an unexpected physical disconnect.
|
||||
Reconnecting {
|
||||
/// One-based reconnect attempt currently in progress or waiting for backoff.
|
||||
attempt: u32,
|
||||
},
|
||||
/// Explicit shutdown has started and new subscriptions are refused.
|
||||
Closing,
|
||||
/// Explicit shutdown completed.
|
||||
Closed,
|
||||
/// The session reached a terminal failure state.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Observable lifecycle state of one logical WebSocket subscription.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WsSubscriptionState {
|
||||
/// The local subscription exists but its initial subscribe request has not completed.
|
||||
Requested,
|
||||
/// The logical subscription is bound to an active remote subscription.
|
||||
Active,
|
||||
/// The logical subscription is being restored after reconnect.
|
||||
Resubscribing,
|
||||
/// Local cancellation has won and remote cleanup is in progress when possible.
|
||||
Cancelling,
|
||||
/// The logical subscription reached a non-error terminal state.
|
||||
Closed,
|
||||
/// The logical subscription reached a terminal failure state.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// WebSocket subscription family represented by one logical subscription.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WsSubscriptionKind {
|
||||
/// `accountSubscribe` family.
|
||||
Account,
|
||||
/// `blockSubscribe` family.
|
||||
Block,
|
||||
/// `logsSubscribe` family.
|
||||
Logs,
|
||||
/// `programSubscribe` family.
|
||||
Program,
|
||||
/// `rootSubscribe` family.
|
||||
Root,
|
||||
/// `signatureSubscribe` family.
|
||||
Signature,
|
||||
/// `slotSubscribe` family.
|
||||
Slot,
|
||||
/// `slotsUpdatesSubscribe` family.
|
||||
SlotsUpdates,
|
||||
/// `voteSubscribe` family.
|
||||
Vote,
|
||||
/// Helius LaserStream WebSocket `transactionSubscribe` extension family.
|
||||
HeliusTransaction,
|
||||
}
|
||||
|
||||
impl WsSubscriptionKind {
|
||||
/// Returns the stable KSP descriptor for this WebSocket subscription family.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "account",
|
||||
Self::Block => "block",
|
||||
Self::Logs => "logs",
|
||||
Self::Program => "program",
|
||||
Self::Root => "root",
|
||||
Self::Signature => "signature",
|
||||
Self::Slot => "slot",
|
||||
Self::SlotsUpdates => "slots_updates",
|
||||
Self::Vote => "vote",
|
||||
Self::HeliusTransaction => "helius_transaction",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact subscribe JSON-RPC method for this family.
|
||||
pub(crate) const fn subscribe_method(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "accountSubscribe",
|
||||
Self::Block => "blockSubscribe",
|
||||
Self::Logs => "logsSubscribe",
|
||||
Self::Program => "programSubscribe",
|
||||
Self::Root => "rootSubscribe",
|
||||
Self::Signature => "signatureSubscribe",
|
||||
Self::Slot => "slotSubscribe",
|
||||
Self::SlotsUpdates => "slotsUpdatesSubscribe",
|
||||
Self::Vote => "voteSubscribe",
|
||||
Self::HeliusTransaction => "transactionSubscribe",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact unsubscribe JSON-RPC method for this family.
|
||||
pub(crate) const fn unsubscribe_method(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "accountUnsubscribe",
|
||||
Self::Block => "blockUnsubscribe",
|
||||
Self::Logs => "logsUnsubscribe",
|
||||
Self::Program => "programUnsubscribe",
|
||||
Self::Root => "rootUnsubscribe",
|
||||
Self::Signature => "signatureUnsubscribe",
|
||||
Self::Slot => "slotUnsubscribe",
|
||||
Self::SlotsUpdates => "slotsUpdatesUnsubscribe",
|
||||
Self::Vote => "voteUnsubscribe",
|
||||
Self::HeliusTransaction => "transactionUnsubscribe",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact notification method emitted for this family.
|
||||
pub(crate) const fn notification_method(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "accountNotification",
|
||||
Self::Block => "blockNotification",
|
||||
Self::Logs => "logsNotification",
|
||||
Self::Program => "programNotification",
|
||||
Self::Root => "rootNotification",
|
||||
Self::Signature => "signatureNotification",
|
||||
Self::Slot => "slotNotification",
|
||||
Self::SlotsUpdates => "slotsUpdatesNotification",
|
||||
Self::Vote => "voteNotification",
|
||||
Self::HeliusTransaction => "transactionNotification",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns whether Solana documents this standard subscription family as unstable.
|
||||
///
|
||||
/// Provider extensions are stable here unless explicitly classified otherwise.
|
||||
pub(crate) const fn is_unstable(self) -> bool {
|
||||
return matches!(self, Self::Block | Self::SlotsUpdates | Self::Vote);
|
||||
}
|
||||
|
||||
/// Emits the centralized KSP warning required before opening an unstable standard subscription.
|
||||
pub(crate) fn warn_if_unstable(self) {
|
||||
if self.is_unstable() {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
rpc_method = self.subscribe_method(),
|
||||
subscription_kind = self.as_str(),
|
||||
documentation_status = "unstable",
|
||||
"unstable Solana WebSocket subscription requested"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe lifecycle projection for one logical WebSocket subscription.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsSubscriptionSnapshot {
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state: crate::WsSubscriptionState,
|
||||
remote_bound: bool,
|
||||
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
}
|
||||
|
||||
impl WsSubscriptionSnapshot {
|
||||
/// Creates one safe subscription lifecycle projection for Transport runtime internals.
|
||||
#[must_use]
|
||||
pub(crate) const fn new(
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state: crate::WsSubscriptionState,
|
||||
remote_bound: bool,
|
||||
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
) -> Self {
|
||||
return Self { id, kind, state, remote_bound, terminal_error_code };
|
||||
}
|
||||
|
||||
/// Returns the stable local subscription identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSubscriptionId {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the logical WebSocket subscription family.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> crate::WsSubscriptionKind {
|
||||
return self.kind;
|
||||
}
|
||||
|
||||
/// Returns the current logical lifecycle state.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> crate::WsSubscriptionState {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
/// Returns whether a current remote subscription ID is bound internally.
|
||||
///
|
||||
/// The remote ID itself is deliberately absent because it is ephemeral across reconnects.
|
||||
#[must_use]
|
||||
pub const fn remote_bound(&self) -> bool {
|
||||
return self.remote_bound;
|
||||
}
|
||||
|
||||
/// Returns the safe terminal error code when this subscription ended because of a failure.
|
||||
#[must_use]
|
||||
pub const fn terminal_error_code(&self) -> std::option::Option<ksp_core_lib::ErrorCode> {
|
||||
return self.terminal_error_code;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe runtime snapshot for one physical WebSocket session.
|
||||
///
|
||||
/// The snapshot deliberately contains logical endpoint metadata and local identities only. It never stores the endpoint URL, credentials, request payloads or
|
||||
/// raw notifications.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsSessionSnapshot {
|
||||
id: crate::WsSessionId,
|
||||
endpoint_name: std::string::String,
|
||||
provider: crate::WsProviderName,
|
||||
cluster: crate::WsClusterName,
|
||||
protocol: crate::WsProtocolKind,
|
||||
state: crate::WsSessionState,
|
||||
pending_request_count: usize,
|
||||
continuity_gap_count: u64,
|
||||
overflow_count: u64,
|
||||
subscriptions: std::vec::Vec<crate::WsSubscriptionSnapshot>,
|
||||
}
|
||||
|
||||
impl WsSessionSnapshot {
|
||||
/// Creates one safe session projection for Transport runtime internals.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
id: crate::WsSessionId,
|
||||
endpoint_name: impl std::convert::Into<std::string::String>,
|
||||
provider: crate::WsProviderName,
|
||||
cluster: crate::WsClusterName,
|
||||
protocol: crate::WsProtocolKind,
|
||||
state: crate::WsSessionState,
|
||||
pending_request_count: usize,
|
||||
continuity_gap_count: u64,
|
||||
overflow_count: u64,
|
||||
subscriptions: std::vec::Vec<crate::WsSubscriptionSnapshot>,
|
||||
) -> Self {
|
||||
return Self {
|
||||
id,
|
||||
endpoint_name: endpoint_name.into(),
|
||||
provider,
|
||||
cluster,
|
||||
protocol,
|
||||
state,
|
||||
pending_request_count,
|
||||
continuity_gap_count,
|
||||
overflow_count,
|
||||
subscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the stable local session identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSessionId {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the safe logical endpoint name.
|
||||
#[must_use]
|
||||
pub fn endpoint_name(&self) -> &str {
|
||||
return self.endpoint_name.as_str();
|
||||
}
|
||||
|
||||
/// Returns the provider descriptor without endpoint credentials.
|
||||
#[must_use]
|
||||
pub const fn provider(&self) -> &crate::WsProviderName {
|
||||
return &self.provider;
|
||||
}
|
||||
|
||||
/// Returns the cluster descriptor.
|
||||
#[must_use]
|
||||
pub const fn cluster(&self) -> &crate::WsClusterName {
|
||||
return &self.cluster;
|
||||
}
|
||||
|
||||
/// Returns the WebSocket protocol family.
|
||||
#[must_use]
|
||||
pub const fn protocol(&self) -> crate::WsProtocolKind {
|
||||
return self.protocol;
|
||||
}
|
||||
|
||||
/// Returns the current physical session lifecycle state.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> crate::WsSessionState {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
/// Returns the number of JSON-RPC requests currently awaiting responses.
|
||||
#[must_use]
|
||||
pub const fn pending_request_count(&self) -> usize {
|
||||
return self.pending_request_count;
|
||||
}
|
||||
|
||||
/// Returns the number of observed physical continuity gaps for this session.
|
||||
#[must_use]
|
||||
pub const fn continuity_gap_count(&self) -> u64 {
|
||||
return self.continuity_gap_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative number of notification queue overflows observed by this session.
|
||||
#[must_use]
|
||||
pub const fn overflow_count(&self) -> u64 {
|
||||
return self.overflow_count;
|
||||
}
|
||||
|
||||
/// Returns safe lifecycle projections for logical subscriptions owned by this session.
|
||||
#[must_use]
|
||||
pub fn subscriptions(&self) -> &[crate::WsSubscriptionSnapshot] {
|
||||
return self.subscriptions.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the number of logical subscriptions currently projected by the session.
|
||||
#[must_use]
|
||||
pub fn subscription_count(&self) -> usize {
|
||||
return self.subscriptions.len();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_lifecycle.rs"]
|
||||
mod tests;
|
||||
149
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
Normal file
149
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
|
||||
// version: 6
|
||||
|
||||
/// Typed facade for one standard Solana WebSocket physical session.
|
||||
///
|
||||
/// The facade delegates to the same [`crate::WsSession`] actor used by the compatibility API. It owns no socket, registry, reconnect loop or queue of its
|
||||
/// own and therefore does not duplicate the physical WebSocket runtime. Subscription wrappers are implemented beside their wire owners in the
|
||||
/// `ws_accounts`, `ws_blocks`, `ws_cluster` and `ws_transactions` modules.
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// async fn unsupported_helius_transaction(
|
||||
/// session: &ksp_onchain_transport_lib::SolanaStandardWsSession,
|
||||
/// request: &ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest,
|
||||
/// ) {
|
||||
/// let _ = session.transaction_subscribe(request).await;
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct SolanaStandardWsSession {
|
||||
inner: crate::WsSession,
|
||||
}
|
||||
|
||||
impl SolanaStandardWsSession {
|
||||
/// Opens one standard Solana WebSocket session through the shared physical actor.
|
||||
pub async fn connect(endpoint: crate::WsEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||
let connected = crate::WsSession::connect_for_protocol(endpoint, crate::WsProtocolKind::SolanaStandard).await;
|
||||
return match connected {
|
||||
std::result::Result::Ok(inner) => std::result::Result::Ok(Self { inner }),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the stable local session identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSessionId {
|
||||
return self.inner.id();
|
||||
}
|
||||
|
||||
/// Returns the latest safe runtime snapshot published by the shared actor.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> crate::WsSessionSnapshot {
|
||||
return self.inner.snapshot();
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
return self.inner.state();
|
||||
}
|
||||
|
||||
/// Explicitly closes the shared physical session under its configured close timeout.
|
||||
pub async fn close(&self) -> ksp_core_lib::Result<()> {
|
||||
return self.inner.close().await;
|
||||
}
|
||||
|
||||
/// Returns the crate-private shared physical session used by domain-specific facade wrappers.
|
||||
pub(crate) fn physical_session(&self) -> &crate::WsSession {
|
||||
return &self.inner;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SolanaStandardWsSession {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("SolanaStandardWsSession").field("id", &self.id()).field("snapshot", &self.snapshot()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed facade for one Helius LaserStream WebSocket physical session.
|
||||
///
|
||||
/// The facade exposes the seven standard Solana subscription families that the current Helius method pages support or document as available plus the
|
||||
/// Helius-specific typed
|
||||
/// `transactionSubscribe` lifecycle. Transaction notifications, reconnect/resubscribe, unsubscribe races and bounded backpressure all delegate to the same
|
||||
/// shared [`crate::WsSession`] actor; the facade owns no second socket, registry or queue. No public inner handle is exposed, so callers cannot bypass the
|
||||
/// provider-specific surface by recovering a generic [`crate::WsSession`].
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// async fn unsupported_block(session: &ksp_onchain_transport_lib::HeliusLaserStreamWsSession) {
|
||||
/// let _ = session.block_subscribe().await;
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// async fn unsupported_vote(session: &ksp_onchain_transport_lib::HeliusLaserStreamWsSession) {
|
||||
/// let _ = session.vote_subscribe().await;
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// fn no_escape_hatch(session: ksp_onchain_transport_lib::HeliusLaserStreamWsSession) {
|
||||
/// let _ = session.into_inner();
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct HeliusLaserStreamWsSession {
|
||||
inner: crate::WsSession,
|
||||
}
|
||||
|
||||
impl HeliusLaserStreamWsSession {
|
||||
/// Opens one Helius LaserStream WebSocket session through the shared physical actor.
|
||||
pub async fn connect(endpoint: crate::WsEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||
let connected = crate::WsSession::connect_for_protocol(endpoint, crate::WsProtocolKind::HeliusLaserStream).await;
|
||||
return match connected {
|
||||
std::result::Result::Ok(inner) => std::result::Result::Ok(Self { inner }),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the stable local session identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSessionId {
|
||||
return self.inner.id();
|
||||
}
|
||||
|
||||
/// Returns the latest safe runtime snapshot published by the shared actor.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> crate::WsSessionSnapshot {
|
||||
return self.inner.snapshot();
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
return self.inner.state();
|
||||
}
|
||||
|
||||
/// Explicitly closes the shared physical session under its configured close timeout.
|
||||
pub async fn close(&self) -> ksp_core_lib::Result<()> {
|
||||
return self.inner.close().await;
|
||||
}
|
||||
|
||||
/// Returns the crate-private shared physical session used by domain-specific facade wrappers.
|
||||
pub(crate) fn physical_session(&self) -> &crate::WsSession {
|
||||
return &self.inner;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusLaserStreamWsSession {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("HeliusLaserStreamWsSession").field("id", &self.id()).field("snapshot", &self.snapshot()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_helius_standard.rs"]
|
||||
mod helius_standard_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_protocol_session.rs"]
|
||||
mod tests;
|
||||
2427
crates/ksp-onchain-transport-lib/src/ws_session.rs
Normal file
2427
crates/ksp-onchain-transport-lib/src/ws_session.rs
Normal file
File diff suppressed because it is too large
Load Diff
574
crates/ksp-onchain-transport-lib/src/ws_settings.rs
Normal file
574
crates/ksp-onchain-transport-lib/src/ws_settings.rs
Normal file
@@ -0,0 +1,574 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_settings.rs
|
||||
// version: 4
|
||||
|
||||
const DEFAULT_WS_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const DEFAULT_WS_COMMAND_QUEUE_CAPACITY: usize = 128;
|
||||
const DEFAULT_WS_COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const DEFAULT_WS_MAX_ACTIVE_SUBSCRIPTIONS: usize = 1_024;
|
||||
const DEFAULT_WS_MAX_FRAME_SIZE_BYTES: usize = 16 * 1024 * 1024;
|
||||
const DEFAULT_WS_MAX_MESSAGE_SIZE_BYTES: usize = 64 * 1024 * 1024;
|
||||
const DEFAULT_WS_MAX_PENDING_REQUESTS: usize = 128;
|
||||
const DEFAULT_WS_MAX_WRITE_BUFFER_SIZE_BYTES: usize = 1024 * 1024;
|
||||
const DEFAULT_WS_NOTIFICATION_QUEUE_CAPACITY: usize = 256;
|
||||
const DEFAULT_WS_RECONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
|
||||
const DEFAULT_WS_RECONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const DEFAULT_WS_RECONNECT_MAX_RETRIES: u32 = 5;
|
||||
|
||||
/// Runtime WebSocket 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 WsEndpointUrl {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl WsEndpointUrl {
|
||||
/// Parses and validates one WebSocket endpoint URL.
|
||||
pub fn parse(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating WebSocket endpoint URL");
|
||||
let value = value.into();
|
||||
let parsed = match reqwest::Url::parse(value.as_str()) {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "ws_endpoints.url", "rejected invalid WebSocket endpoint URL");
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL is invalid")
|
||||
.with_context("field", "ws_endpoints.url")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if parsed.scheme() != "ws" && parsed.scheme() != "wss" {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
field = "ws_endpoints.url",
|
||||
scheme = parsed.scheme(),
|
||||
"rejected WebSocket endpoint URL with unsupported scheme"
|
||||
);
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL must use ws or wss")
|
||||
.with_context("field", "ws_endpoints.url")
|
||||
.with_context("scheme", parsed.scheme()),
|
||||
);
|
||||
}
|
||||
if parsed.host_str().is_none() {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "ws_endpoints.url", "rejected WebSocket endpoint URL without host");
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL must contain a host")
|
||||
.with_context("field", "ws_endpoints.url"),
|
||||
);
|
||||
}
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, scheme = parsed.scheme(), "validated WebSocket endpoint URL syntax");
|
||||
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 WsEndpointUrl {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("WsEndpointUrl(<redacted>)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Open provider descriptor used by WebSocket endpoint settings.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct WsProviderName {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl WsProviderName {
|
||||
/// Creates an open provider descriptor. Validation is performed by [`WsTransportSettings::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 WebSocket endpoint settings.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct WsClusterName {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl WsClusterName {
|
||||
/// Creates an open cluster descriptor. Validation is performed by [`WsTransportSettings::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();
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket protocol family understood by KSP Transport.
|
||||
///
|
||||
/// The protocol discriminator belongs specifically to the WebSocket runtime. Provider products using another transport, including a future Helius
|
||||
/// LaserStream gRPC backend, require a distinct transport-owned descriptor instead of reusing this enum.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WsProtocolKind {
|
||||
/// Standard Solana JSON-RPC WebSocket PubSub.
|
||||
SolanaStandard,
|
||||
/// Helius LaserStream WebSocket protocol surface.
|
||||
HeliusLaserStream,
|
||||
}
|
||||
|
||||
impl WsProtocolKind {
|
||||
/// Returns the stable KSP descriptor for this protocol family.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::SolanaStandard => "solana_standard",
|
||||
Self::HeliusLaserStream => "helius_laserstream",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded reconnect settings owned by the WebSocket transport runtime.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsReconnectSettings {
|
||||
max_retries: u32,
|
||||
initial_backoff: std::time::Duration,
|
||||
max_backoff: std::time::Duration,
|
||||
}
|
||||
|
||||
impl WsReconnectSettings {
|
||||
/// Creates bounded reconnect 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 reconnect attempts allowed after the connection is lost.
|
||||
#[must_use]
|
||||
pub const fn max_retries(&self) -> u32 {
|
||||
return self.max_retries;
|
||||
}
|
||||
|
||||
/// Returns the initial reconnect backoff.
|
||||
#[must_use]
|
||||
pub const fn initial_backoff(&self) -> std::time::Duration {
|
||||
return self.initial_backoff;
|
||||
}
|
||||
|
||||
/// Returns the maximum reconnect backoff.
|
||||
#[must_use]
|
||||
pub const fn max_backoff(&self) -> std::time::Duration {
|
||||
return self.max_backoff;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for WsReconnectSettings {
|
||||
fn default() -> Self {
|
||||
return Self::new(DEFAULT_WS_RECONNECT_MAX_RETRIES, DEFAULT_WS_RECONNECT_INITIAL_BACKOFF, DEFAULT_WS_RECONNECT_MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
|
||||
/// Policy controlling whether logical subscriptions are restored after a successful reconnect.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
|
||||
pub enum WsResubscribePolicy {
|
||||
/// Never restore subscriptions automatically after the physical connection is replaced.
|
||||
Never,
|
||||
/// Restore subscriptions that are still logically desired when reconnect completes.
|
||||
#[default]
|
||||
ActiveSubscriptions,
|
||||
}
|
||||
|
||||
/// Runtime limits and lifecycle settings for one physical WebSocket session.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsSessionSettings {
|
||||
command_timeout: std::time::Duration,
|
||||
close_timeout: std::time::Duration,
|
||||
reconnect: crate::WsReconnectSettings,
|
||||
resubscribe: crate::WsResubscribePolicy,
|
||||
command_queue_capacity: usize,
|
||||
notification_queue_capacity: usize,
|
||||
max_active_subscriptions: usize,
|
||||
max_pending_requests: usize,
|
||||
max_message_size_bytes: usize,
|
||||
max_frame_size_bytes: usize,
|
||||
max_write_buffer_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl WsSessionSettings {
|
||||
/// Creates complete runtime settings for one physical WebSocket session.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub const fn new(
|
||||
command_timeout: std::time::Duration,
|
||||
close_timeout: std::time::Duration,
|
||||
reconnect: crate::WsReconnectSettings,
|
||||
resubscribe: crate::WsResubscribePolicy,
|
||||
command_queue_capacity: usize,
|
||||
notification_queue_capacity: usize,
|
||||
max_active_subscriptions: usize,
|
||||
max_pending_requests: usize,
|
||||
max_message_size_bytes: usize,
|
||||
max_frame_size_bytes: usize,
|
||||
max_write_buffer_size_bytes: usize,
|
||||
) -> Self {
|
||||
return Self {
|
||||
command_timeout,
|
||||
close_timeout,
|
||||
reconnect,
|
||||
resubscribe,
|
||||
command_queue_capacity,
|
||||
notification_queue_capacity,
|
||||
max_active_subscriptions,
|
||||
max_pending_requests,
|
||||
max_message_size_bytes,
|
||||
max_frame_size_bytes,
|
||||
max_write_buffer_size_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the deadline applied to bounded session commands and JSON-RPC control requests.
|
||||
#[must_use]
|
||||
pub const fn command_timeout(&self) -> std::time::Duration {
|
||||
return self.command_timeout;
|
||||
}
|
||||
|
||||
/// Returns the total bounded close/shutdown deadline.
|
||||
#[must_use]
|
||||
pub const fn close_timeout(&self) -> std::time::Duration {
|
||||
return self.close_timeout;
|
||||
}
|
||||
|
||||
/// Returns the reconnect policy.
|
||||
#[must_use]
|
||||
pub const fn reconnect(&self) -> &crate::WsReconnectSettings {
|
||||
return &self.reconnect;
|
||||
}
|
||||
|
||||
/// Returns the resubscribe policy.
|
||||
#[must_use]
|
||||
pub const fn resubscribe(&self) -> crate::WsResubscribePolicy {
|
||||
return self.resubscribe;
|
||||
}
|
||||
|
||||
/// Returns the bounded session-command queue capacity.
|
||||
#[must_use]
|
||||
pub const fn command_queue_capacity(&self) -> usize {
|
||||
return self.command_queue_capacity;
|
||||
}
|
||||
|
||||
/// Returns the bounded notification queue capacity allocated per logical subscription.
|
||||
#[must_use]
|
||||
pub const fn notification_queue_capacity(&self) -> usize {
|
||||
return self.notification_queue_capacity;
|
||||
}
|
||||
|
||||
/// Returns the maximum number of logical subscriptions allowed on one physical session.
|
||||
#[must_use]
|
||||
pub const fn max_active_subscriptions(&self) -> usize {
|
||||
return self.max_active_subscriptions;
|
||||
}
|
||||
|
||||
/// Returns the maximum number of JSON-RPC requests allowed to await responses concurrently.
|
||||
#[must_use]
|
||||
pub const fn max_pending_requests(&self) -> usize {
|
||||
return self.max_pending_requests;
|
||||
}
|
||||
|
||||
/// Returns the maximum accepted complete WebSocket message size in bytes.
|
||||
#[must_use]
|
||||
pub const fn max_message_size_bytes(&self) -> usize {
|
||||
return self.max_message_size_bytes;
|
||||
}
|
||||
|
||||
/// Returns the maximum accepted WebSocket frame size in bytes.
|
||||
#[must_use]
|
||||
pub const fn max_frame_size_bytes(&self) -> usize {
|
||||
return self.max_frame_size_bytes;
|
||||
}
|
||||
|
||||
/// Returns the maximum WebSocket write-buffer size in bytes.
|
||||
#[must_use]
|
||||
pub const fn max_write_buffer_size_bytes(&self) -> usize {
|
||||
return self.max_write_buffer_size_bytes;
|
||||
}
|
||||
|
||||
/// Validates runtime bounds without reading Config or environment state.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating WebSocket session settings");
|
||||
if self.command_timeout.is_zero() {
|
||||
return ws_invalid_settings("WebSocket command timeout must be greater than zero", "ws_session.command_timeout");
|
||||
}
|
||||
if self.close_timeout.is_zero() {
|
||||
return ws_invalid_settings("WebSocket close timeout must be greater than zero", "ws_session.close_timeout");
|
||||
}
|
||||
if self.reconnect.initial_backoff().is_zero() {
|
||||
return ws_invalid_settings("initial WebSocket reconnect backoff must be greater than zero", "ws_session.reconnect.initial_backoff");
|
||||
}
|
||||
if self.reconnect.max_backoff().is_zero() {
|
||||
return ws_invalid_settings("maximum WebSocket reconnect backoff must be greater than zero", "ws_session.reconnect.max_backoff");
|
||||
}
|
||||
if self.reconnect.max_backoff() < self.reconnect.initial_backoff() {
|
||||
return ws_invalid_settings(
|
||||
"maximum WebSocket reconnect backoff must not be lower than initial reconnect backoff",
|
||||
"ws_session.reconnect.max_backoff",
|
||||
);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.command_queue_capacity, "ws_session.command_queue_capacity") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.notification_queue_capacity, "ws_session.notification_queue_capacity") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_active_subscriptions, "ws_session.max_active_subscriptions") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_pending_requests, "ws_session.max_pending_requests") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_message_size_bytes, "ws_session.max_message_size_bytes") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_frame_size_bytes, "ws_session.max_frame_size_bytes") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_write_buffer_size_bytes, "ws_session.max_write_buffer_size_bytes") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
command_queue_capacity = self.command_queue_capacity,
|
||||
notification_queue_capacity = self.notification_queue_capacity,
|
||||
max_active_subscriptions = self.max_active_subscriptions,
|
||||
max_pending_requests = self.max_pending_requests,
|
||||
max_message_size_bytes = self.max_message_size_bytes,
|
||||
max_frame_size_bytes = self.max_frame_size_bytes,
|
||||
max_write_buffer_size_bytes = self.max_write_buffer_size_bytes,
|
||||
reconnect_max_retries = self.reconnect.max_retries(),
|
||||
resubscribe = self.resubscribe.as_str(),
|
||||
"validated WebSocket session settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for WsSessionSettings {
|
||||
fn default() -> Self {
|
||||
return Self::new(
|
||||
DEFAULT_WS_COMMAND_TIMEOUT,
|
||||
DEFAULT_WS_CLOSE_TIMEOUT,
|
||||
crate::WsReconnectSettings::default(),
|
||||
crate::WsResubscribePolicy::default(),
|
||||
DEFAULT_WS_COMMAND_QUEUE_CAPACITY,
|
||||
DEFAULT_WS_NOTIFICATION_QUEUE_CAPACITY,
|
||||
DEFAULT_WS_MAX_ACTIVE_SUBSCRIPTIONS,
|
||||
DEFAULT_WS_MAX_PENDING_REQUESTS,
|
||||
DEFAULT_WS_MAX_MESSAGE_SIZE_BYTES,
|
||||
DEFAULT_WS_MAX_FRAME_SIZE_BYTES,
|
||||
DEFAULT_WS_MAX_WRITE_BUFFER_SIZE_BYTES,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl WsResubscribePolicy {
|
||||
/// Returns the stable KSP descriptor for this policy.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Never => "never",
|
||||
Self::ActiveSubscriptions => "active_subscriptions",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime settings for one named WebSocket endpoint.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsEndpointSettings {
|
||||
name: std::string::String,
|
||||
enabled: bool,
|
||||
provider: crate::WsProviderName,
|
||||
cluster: crate::WsClusterName,
|
||||
protocol: crate::WsProtocolKind,
|
||||
url: crate::WsEndpointUrl,
|
||||
session: crate::WsSessionSettings,
|
||||
}
|
||||
|
||||
impl WsEndpointSettings {
|
||||
/// Creates explicit settings for one logical WebSocket endpoint.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
name: impl std::convert::Into<std::string::String>,
|
||||
enabled: bool,
|
||||
provider: crate::WsProviderName,
|
||||
cluster: crate::WsClusterName,
|
||||
protocol: crate::WsProtocolKind,
|
||||
url: crate::WsEndpointUrl,
|
||||
session: crate::WsSessionSettings,
|
||||
) -> Self {
|
||||
return Self { name: name.into(), enabled, provider, cluster, protocol, url, session };
|
||||
}
|
||||
|
||||
/// Returns the logical endpoint name.
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
return self.name.as_str();
|
||||
}
|
||||
|
||||
/// Returns whether this endpoint can be used to create physical sessions.
|
||||
#[must_use]
|
||||
pub const fn enabled(&self) -> bool {
|
||||
return self.enabled;
|
||||
}
|
||||
|
||||
/// Returns the open provider descriptor.
|
||||
#[must_use]
|
||||
pub const fn provider(&self) -> &crate::WsProviderName {
|
||||
return &self.provider;
|
||||
}
|
||||
|
||||
/// Returns the open cluster descriptor.
|
||||
#[must_use]
|
||||
pub const fn cluster(&self) -> &crate::WsClusterName {
|
||||
return &self.cluster;
|
||||
}
|
||||
|
||||
/// Returns the WebSocket protocol family.
|
||||
#[must_use]
|
||||
pub const fn protocol(&self) -> crate::WsProtocolKind {
|
||||
return self.protocol;
|
||||
}
|
||||
|
||||
/// Returns the sensitive WebSocket endpoint URL wrapper.
|
||||
#[must_use]
|
||||
pub const fn url(&self) -> &crate::WsEndpointUrl {
|
||||
return &self.url;
|
||||
}
|
||||
|
||||
/// Returns the effective settings applied to every physical session explicitly created from this endpoint.
|
||||
#[must_use]
|
||||
pub const fn session(&self) -> &crate::WsSessionSettings {
|
||||
return &self.session;
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsTransportSettings {
|
||||
endpoints: std::vec::Vec<crate::WsEndpointSettings>,
|
||||
}
|
||||
|
||||
impl WsTransportSettings {
|
||||
/// Creates complete WebSocket transport runtime settings.
|
||||
#[must_use]
|
||||
pub fn new(endpoints: std::vec::Vec<crate::WsEndpointSettings>) -> Self {
|
||||
return Self { endpoints };
|
||||
}
|
||||
|
||||
/// Returns configured WebSocket endpoints in declaration order.
|
||||
#[must_use]
|
||||
pub fn endpoints(&self) -> &[crate::WsEndpointSettings] {
|
||||
return self.endpoints.as_slice();
|
||||
}
|
||||
|
||||
/// Validates structural runtime invariants without reading Config or environment state.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, endpoint_count = self.endpoints.len(), "validating WebSocket transport settings");
|
||||
if self.endpoints.is_empty() {
|
||||
return ws_invalid_settings("at least one WebSocket endpoint must be configured", "ws_endpoints");
|
||||
}
|
||||
let mut enabled_endpoint_count = 0_usize;
|
||||
for (endpoint_index, endpoint) in self.endpoints.iter().enumerate() {
|
||||
if let std::result::Result::Err(error) = validate_ws_endpoint(endpoint, endpoint_index) {
|
||||
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, "WebSocket endpoint names must be unique")
|
||||
.with_context("field", format!("ws_endpoints[{endpoint_index}].name"))
|
||||
.with_context("endpoint_name", endpoint.name()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if enabled_endpoint_count == 0 {
|
||||
return ws_invalid_settings("at least one WebSocket endpoint must be enabled", "ws_endpoints.enabled");
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_count = self.endpoints.len(),
|
||||
enabled_endpoint_count,
|
||||
"validated WebSocket transport settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ws_endpoint(endpoint: &crate::WsEndpointSettings, endpoint_index: usize) -> ksp_core_lib::Result<()> {
|
||||
let name_field = format!("ws_endpoints[{endpoint_index}].name");
|
||||
if let std::result::Result::Err(error) = validate_ws_descriptor(endpoint.name(), name_field.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let provider_field = format!("ws_endpoints[{endpoint_index}].provider");
|
||||
if let std::result::Result::Err(error) = validate_ws_descriptor(endpoint.provider().as_str(), provider_field.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let cluster_field = format!("ws_endpoints[{endpoint_index}].cluster");
|
||||
if let std::result::Result::Err(error) = validate_ws_descriptor(endpoint.cluster().as_str(), cluster_field.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = endpoint.session().validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = endpoint.name(),
|
||||
provider = endpoint.provider().as_str(),
|
||||
cluster = endpoint.cluster().as_str(),
|
||||
protocol = endpoint.protocol().as_str(),
|
||||
enabled = endpoint.enabled(),
|
||||
"validated WebSocket endpoint settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_ws_descriptor(value: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return ws_invalid_settings("WebSocket transport descriptor must not be empty", field);
|
||||
}
|
||||
if value.trim() != value {
|
||||
return ws_invalid_settings("WebSocket transport descriptor must not contain leading or trailing whitespace", field);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_non_zero_bound(value: usize, field: &str) -> ksp_core_lib::Result<()> {
|
||||
if value == 0 {
|
||||
return ws_invalid_settings("WebSocket runtime bound must be greater than zero", field);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn ws_invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, reason = message, "rejected WebSocket transport settings");
|
||||
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/ws_settings.rs"]
|
||||
mod tests;
|
||||
246
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
Normal file
246
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs
|
||||
// version: 6
|
||||
|
||||
/// Typed handle for one logical WebSocket subscription.
|
||||
///
|
||||
/// The handle owns the bounded typed notification receiver while the physical session actor owns the remote subscription identity and socket. The remote
|
||||
/// numeric subscription identifier is intentionally never exposed because it is transient and is remapped by the session actor after reconnect.
|
||||
pub struct WsSubscription<T> {
|
||||
session_id: crate::WsSessionId,
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
notification_rx: tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>,
|
||||
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
|
||||
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
|
||||
command_tx: tokio::sync::mpsc::Sender<crate::WsSessionCommand>,
|
||||
command_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl<T> WsSubscription<T> {
|
||||
/// Creates a typed subscription handle from one actor registration.
|
||||
pub(crate) fn new(
|
||||
session_id: crate::WsSessionId,
|
||||
registration: WsSubscriptionRegistration,
|
||||
notification_rx: tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>,
|
||||
command_tx: tokio::sync::mpsc::Sender<crate::WsSessionCommand>,
|
||||
command_timeout: std::time::Duration,
|
||||
) -> Self {
|
||||
return Self {
|
||||
session_id,
|
||||
id: registration.id,
|
||||
kind: registration.kind,
|
||||
notification_rx,
|
||||
state_rx: registration.state_rx,
|
||||
terminal_error_rx: registration.terminal_error_rx,
|
||||
command_tx,
|
||||
command_timeout,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the stable local logical subscription identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSubscriptionId {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the logical WebSocket subscription family.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> crate::WsSubscriptionKind {
|
||||
return self.kind;
|
||||
}
|
||||
|
||||
/// Returns the latest observable lifecycle state for this logical subscription.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSubscriptionState {
|
||||
return *self.state_rx.borrow();
|
||||
}
|
||||
|
||||
/// Returns the safe terminal error code when this logical subscription failed.
|
||||
///
|
||||
/// Successful local cancellation and normal completion use `None`. The value never contains remote payloads or endpoint credentials.
|
||||
#[must_use]
|
||||
pub fn terminal_error_code(&self) -> std::option::Option<ksp_core_lib::ErrorCode> {
|
||||
return *self.terminal_error_rx.borrow();
|
||||
}
|
||||
|
||||
/// Receives the next typed notification or terminal typed-decoding error.
|
||||
///
|
||||
/// The underlying queue is bounded by `WsSessionSettings::notification_queue_capacity`. `None` means the actor closed this logical subscription and no
|
||||
/// further notifications can arrive.
|
||||
pub async fn recv(&mut self) -> std::option::Option<ksp_core_lib::Result<T>> {
|
||||
return self.notification_rx.recv().await;
|
||||
}
|
||||
|
||||
/// Cancels this logical subscription and sends the matching protocol unsubscribe request when a remote binding still exists.
|
||||
///
|
||||
/// The returned boolean preserves the standard Solana unsubscribe result when the current remote binding is reachable. Local cancellation is terminal
|
||||
/// for this handle; during reconnect it wins before resubscribe selection, and a late remote acknowledgement is cleaned up best-effort without
|
||||
/// reactivation.
|
||||
pub async fn unsubscribe(&mut self) -> ksp_core_lib::Result<bool> {
|
||||
match self.state() {
|
||||
crate::WsSubscriptionState::Closed => return std::result::Result::Ok(false),
|
||||
crate::WsSubscriptionState::Failed => {
|
||||
return std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket subscription is already failed"));
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
let command = crate::WsSessionCommand::Unsubscribe { subscription_id: self.id, response_tx };
|
||||
let send_wait = tokio::time::timeout(self.command_timeout, self.command_tx.send(command)).await;
|
||||
match send_wait {
|
||||
std::result::Result::Ok(std::result::Result::Ok(())) => {},
|
||||
std::result::Result::Ok(std::result::Result::Err(_)) => {
|
||||
return std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket session command channel is closed"));
|
||||
},
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(subscription_timeout_error(
|
||||
self.session_id,
|
||||
self.id,
|
||||
"WebSocket unsubscribe command queue remained unavailable until timeout",
|
||||
));
|
||||
},
|
||||
}
|
||||
return match response_rx.await {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => {
|
||||
std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket session ended before unsubscribe completion"))
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for WsSubscription<T> {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("WsSubscription")
|
||||
.field("session_id", &self.session_id)
|
||||
.field("id", &self.id)
|
||||
.field("kind", &self.kind)
|
||||
.field("state", &self.state())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal result of dispatching one decoded wire notification into a bounded typed channel.
|
||||
pub(crate) enum WsNotificationDispatchOutcome {
|
||||
Delivered,
|
||||
DeliveredTerminal,
|
||||
ReceiverClosed,
|
||||
QueueFull,
|
||||
DecodeFailed { code: ksp_core_lib::ErrorCode },
|
||||
}
|
||||
|
||||
/// Type-erased actor-owned dispatcher for one heterogeneous typed notification channel.
|
||||
pub(crate) type WsNotificationDispatcher = std::boxed::Box<dyn Fn(serde_json::Value) -> WsNotificationDispatchOutcome + std::marker::Send + std::marker::Sync>;
|
||||
|
||||
/// Creates one bounded typed notification receiver whose dispatcher can mark a successfully delivered value as terminal.
|
||||
pub(crate) fn typed_notification_channel_with_completion<T, F, C>(
|
||||
capacity: usize,
|
||||
decoder: F,
|
||||
is_terminal: C,
|
||||
) -> (WsNotificationDispatcher, tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>)
|
||||
where
|
||||
T: std::marker::Send + 'static,
|
||||
F: Fn(serde_json::Value) -> ksp_core_lib::Result<T> + std::marker::Send + std::marker::Sync + 'static,
|
||||
C: Fn(&T) -> bool + std::marker::Send + std::marker::Sync + 'static,
|
||||
{
|
||||
let (notification_tx, notification_rx) = tokio::sync::mpsc::channel(capacity);
|
||||
let dispatcher = move |value: serde_json::Value| -> WsNotificationDispatchOutcome {
|
||||
let permit = match notification_tx.try_reserve() {
|
||||
std::result::Result::Ok(permit) => permit,
|
||||
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
|
||||
return WsNotificationDispatchOutcome::ReceiverClosed;
|
||||
},
|
||||
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
|
||||
return WsNotificationDispatchOutcome::QueueFull;
|
||||
},
|
||||
};
|
||||
return match decoder(value) {
|
||||
std::result::Result::Ok(notification) => {
|
||||
let terminal = is_terminal(¬ification);
|
||||
permit.send(std::result::Result::Ok(notification));
|
||||
if terminal {
|
||||
return WsNotificationDispatchOutcome::DeliveredTerminal;
|
||||
}
|
||||
WsNotificationDispatchOutcome::Delivered
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
let code = error.code();
|
||||
permit.send(std::result::Result::Err(error));
|
||||
WsNotificationDispatchOutcome::DecodeFailed { code }
|
||||
},
|
||||
};
|
||||
};
|
||||
return (std::boxed::Box::new(dispatcher), notification_rx);
|
||||
}
|
||||
|
||||
/// Internal registration returned after a remote subscribe acknowledgement becomes atomically bound.
|
||||
pub(crate) struct WsSubscriptionRegistration {
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
|
||||
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
|
||||
}
|
||||
|
||||
impl WsSubscriptionRegistration {
|
||||
/// Creates one successful actor registration without exposing the transient remote identifier.
|
||||
pub(crate) fn new(
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
|
||||
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
|
||||
) -> Self {
|
||||
return Self { id, kind, state_rx, terminal_error_rx };
|
||||
}
|
||||
}
|
||||
|
||||
/// Actor-owned runtime entry for one local logical WebSocket subscription.
|
||||
pub(crate) struct WsSubscriptionRuntime {
|
||||
/// Stable local identity.
|
||||
pub(crate) id: crate::WsSubscriptionId,
|
||||
/// WebSocket subscription family.
|
||||
pub(crate) kind: crate::WsSubscriptionKind,
|
||||
/// Current logical lifecycle state.
|
||||
pub(crate) state: crate::WsSubscriptionState,
|
||||
/// Original subscribe parameters retained internally for deterministic resubscribe.
|
||||
pub(crate) params: std::vec::Vec<serde_json::Value>,
|
||||
/// Current transient remote subscription identity when bound.
|
||||
pub(crate) remote_id: std::option::Option<u64>,
|
||||
/// Lifecycle publisher observed by the public typed handle.
|
||||
pub(crate) state_tx: tokio::sync::watch::Sender<crate::WsSubscriptionState>,
|
||||
/// Safe terminal failure code publisher observed by the public typed handle.
|
||||
pub(crate) terminal_error_tx: tokio::sync::watch::Sender<std::option::Option<ksp_core_lib::ErrorCode>>,
|
||||
/// Type-erased dispatcher into the bounded typed notification channel.
|
||||
pub(crate) dispatcher: WsNotificationDispatcher,
|
||||
}
|
||||
|
||||
impl WsSubscriptionRuntime {
|
||||
/// Builds the safe session-snapshot projection for this runtime entry.
|
||||
pub(crate) fn snapshot(&self) -> crate::WsSubscriptionSnapshot {
|
||||
return crate::WsSubscriptionSnapshot::new(self.id, self.kind, self.state, self.remote_id.is_some(), *self.terminal_error_tx.borrow());
|
||||
}
|
||||
|
||||
/// Updates the runtime state and publishes it to the typed handle.
|
||||
pub(crate) fn set_state(&mut self, state: crate::WsSubscriptionState) {
|
||||
self.state = state;
|
||||
self.state_tx.send_replace(state);
|
||||
}
|
||||
|
||||
/// Publishes a terminal failure code before moving the logical subscription to `Failed`.
|
||||
pub(crate) fn fail_with_code(&mut self, code: ksp_core_lib::ErrorCode) {
|
||||
self.terminal_error_tx.send_replace(std::option::Option::Some(code));
|
||||
self.set_state(crate::WsSubscriptionState::Failed);
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription_closed_error(session_id: crate::WsSessionId, subscription_id: crate::WsSubscriptionId, message: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_WS_SESSION_CLOSED, message)
|
||||
.with_context("session_id", session_id.get().to_string())
|
||||
.with_context("subscription_id", subscription_id.get().to_string());
|
||||
}
|
||||
|
||||
fn subscription_timeout_error(session_id: crate::WsSessionId, subscription_id: crate::WsSubscriptionId, message: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message)
|
||||
.with_context("session_id", session_id.get().to_string())
|
||||
.with_context("subscription_id", subscription_id.get().to_string());
|
||||
}
|
||||
291
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
Normal file
291
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_transactions.rs
|
||||
// version: 4
|
||||
|
||||
/// Optional configuration accepted by standard Solana `signatureSubscribe`.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaSignatureSubscribeConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
enable_received_notification: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
impl SolanaSignatureSubscribeConfig {
|
||||
/// Creates an explicit signature-subscription configuration.
|
||||
#[must_use]
|
||||
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, enable_received_notification: std::option::Option<bool>) -> Self {
|
||||
return Self { commitment, enable_received_notification };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns whether the server should emit the early `receivedSignature` notification when explicitly configured.
|
||||
#[must_use]
|
||||
pub const fn enable_received_notification(&self) -> std::option::Option<bool> {
|
||||
return self.enable_received_notification;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.commitment.is_none() && self.enable_received_notification.is_none();
|
||||
}
|
||||
|
||||
fn to_json_value(self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(enable_received_notification) = self.enable_received_notification {
|
||||
object.insert("enableReceivedNotification".to_owned(), serde_json::Value::Bool(enable_received_notification));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed value carried by standard Solana `signatureNotification` messages.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaSignatureNotification {
|
||||
/// Early notification emitted when the RPC node first receives the signature and `enableReceivedNotification` is enabled.
|
||||
ReceivedSignature,
|
||||
/// Terminal processing notification emitted when the configured commitment is reached.
|
||||
Processed {
|
||||
/// Nullable transaction-error wire value; `None` means the transaction succeeded at the requested commitment.
|
||||
err: std::option::Option<serde_json::Value>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SolanaSignatureNotification {
|
||||
/// Returns whether this notification terminates the server-side one-shot subscription.
|
||||
#[must_use]
|
||||
pub const fn is_terminal(&self) -> bool {
|
||||
return match self {
|
||||
Self::ReceivedSignature => false,
|
||||
Self::Processed { .. } => true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the transaction-error wire value for a terminal processing notification when present.
|
||||
#[must_use]
|
||||
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
|
||||
return match self {
|
||||
Self::ReceivedSignature | Self::Processed { err: std::option::Option::None } => std::option::Option::None,
|
||||
Self::Processed { err: std::option::Option::Some(err) } => std::option::Option::Some(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter accepted by the standard Solana `logsSubscribe` WebSocket method.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SolanaLogsSubscribeFilter {
|
||||
/// Subscribe to all transactions except simple vote transactions.
|
||||
All,
|
||||
/// Subscribe to all transactions including simple vote transactions.
|
||||
AllWithVotes,
|
||||
/// Subscribe only to transactions mentioning exactly one public key.
|
||||
Mentions(ksp_core_lib::Pubkey),
|
||||
}
|
||||
|
||||
impl SolanaLogsSubscribeFilter {
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
return match self {
|
||||
Self::All => serde_json::Value::String("all".to_owned()),
|
||||
Self::AllWithVotes => serde_json::Value::String("allWithVotes".to_owned()),
|
||||
Self::Mentions(pubkey) => serde_json::json!({"mentions": [pubkey.to_string()]}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed value carried by a contextual Solana `logsNotification`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaLogsNotification {
|
||||
signature: std::string::String,
|
||||
err: std::option::Option<serde_json::Value>,
|
||||
logs: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
impl SolanaLogsNotification {
|
||||
/// Returns the base58 transaction signature exactly as reported by the RPC node.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> &str {
|
||||
return self.signature.as_str();
|
||||
}
|
||||
|
||||
/// Returns the nullable transaction-error wire value without interpreting Program/runtime error semantics.
|
||||
#[must_use]
|
||||
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
|
||||
return self.err.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the ordered transaction log messages.
|
||||
#[must_use]
|
||||
pub fn logs(&self) -> &[std::string::String] {
|
||||
return self.logs.as_slice();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WsSession {
|
||||
/// Subscribes to one Solana transaction signature through standard `signatureSubscribe`.
|
||||
///
|
||||
/// The server automatically terminates this subscription after the terminal processed notification. When
|
||||
/// `enableReceivedNotification` is enabled, an earlier `ReceivedSignature` value may be delivered first without closing the logical handle.
|
||||
pub async fn signature_subscribe(
|
||||
&self,
|
||||
signature: &str,
|
||||
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
|
||||
let mut params = std::vec![serde_json::Value::String(signature.to_owned())];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push((*config).to_json_value());
|
||||
}
|
||||
return self
|
||||
.subscribe_typed_with_completion(
|
||||
crate::WsSubscriptionKind::Signature,
|
||||
params,
|
||||
|value| return decode_signature_notification("signatureSubscribe", value),
|
||||
|notification| return notification.value().is_terminal(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to Solana transaction logs through standard `logsSubscribe`.
|
||||
pub async fn logs_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaLogsSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaLogsNotification>>> {
|
||||
let mut params = std::vec![filter.to_json_value()];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& config.commitment().is_some()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
return self.subscribe_typed(crate::WsSubscriptionKind::Logs, params, |value| return decode_logs_notification("logsSubscribe", value)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum WireSignatureNotification {
|
||||
Received(std::string::String),
|
||||
Processed(WireSignatureProcessed),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireSignatureProcessed {
|
||||
err: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponseSignature {
|
||||
context: serde_json::Value,
|
||||
value: WireSignatureNotification,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponse {
|
||||
context: serde_json::Value,
|
||||
value: WireLogsNotification,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireLogsNotification {
|
||||
signature: std::string::String,
|
||||
err: serde_json::Value,
|
||||
logs: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
fn decode_signature_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponseSignature>(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 notification = match wire.value {
|
||||
WireSignatureNotification::Received(value) if value == "receivedSignature" => crate::SolanaSignatureNotification::ReceivedSignature,
|
||||
WireSignatureNotification::Received(_) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "signatureSubscribe notification contains an unknown string variant")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
},
|
||||
WireSignatureNotification::Processed(processed) => {
|
||||
let err = match processed.err {
|
||||
serde_json::Value::Null => std::option::Option::None,
|
||||
value => std::option::Option::Some(value),
|
||||
};
|
||||
crate::SolanaSignatureNotification::Processed { err }
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, notification));
|
||||
}
|
||||
|
||||
fn decode_logs_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaLogsNotification>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse>(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 err = match wire.value.err {
|
||||
serde_json::Value::Null => std::option::Option::None,
|
||||
value => std::option::Option::Some(value),
|
||||
};
|
||||
let notification = crate::SolanaLogsNotification { signature: wire.value.signature, err, logs: wire.value.logs };
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, notification));
|
||||
}
|
||||
|
||||
impl crate::SolanaStandardWsSession {
|
||||
/// Subscribes to one Solana transaction signature through standard `signatureSubscribe`.
|
||||
pub async fn signature_subscribe(
|
||||
&self,
|
||||
signature: &str,
|
||||
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
|
||||
return self.physical_session().signature_subscribe(signature, config).await;
|
||||
}
|
||||
|
||||
/// Subscribes to Solana transaction logs through standard `logsSubscribe`.
|
||||
pub async fn logs_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaLogsSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaLogsNotification>>> {
|
||||
return self.physical_session().logs_subscribe(filter, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HeliusLaserStreamWsSession {
|
||||
/// Subscribes to one transaction signature through the standard `signatureSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn signature_subscribe(
|
||||
&self,
|
||||
signature: &str,
|
||||
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
|
||||
return self.physical_session().signature_subscribe(signature, config).await;
|
||||
}
|
||||
|
||||
/// Subscribes to transaction logs through the standard `logsSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn logs_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaLogsSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaLogsNotification>>> {
|
||||
return self.physical_session().logs_subscribe(filter, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_transactions.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
// version: 24
|
||||
// version: 48
|
||||
|
||||
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||
|
||||
@@ -521,3 +521,494 @@ fn public_v0_2_4_pre_009_all_52_current_typed_wrappers_and_legacy_forms_are_avai
|
||||
let _get_block_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_block_legacy;
|
||||
let _get_transaction_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_legacy;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_002_websocket_settings_and_lifecycle_contracts_are_available_from_crate_root() {
|
||||
let url = ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com").expect("public WebSocket URL fixture must parse");
|
||||
let session_settings = ksp_onchain_transport_lib::WsSessionSettings::default();
|
||||
let endpoint = ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||
"devnet_public",
|
||||
true,
|
||||
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
|
||||
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
|
||||
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
|
||||
url,
|
||||
session_settings,
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::WsTransportSettings::new(std::vec![endpoint]);
|
||||
assert!(settings.validate().is_ok());
|
||||
assert_eq!(settings.endpoints()[0].protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
|
||||
let session_id = ksp_onchain_transport_lib::WsSessionId::new(std::num::NonZeroU64::new(1).expect("public test ID must be non-zero"));
|
||||
let subscription_id = ksp_onchain_transport_lib::WsSubscriptionId::new(std::num::NonZeroU64::new(2).expect("public test ID must be non-zero"));
|
||||
assert_eq!(session_id.get(), 1);
|
||||
assert_eq!(subscription_id.get(), 2);
|
||||
assert_eq!(
|
||||
ksp_onchain_transport_lib::WsSessionState::Reconnecting { attempt: 1 },
|
||||
ksp_onchain_transport_lib::WsSessionState::Reconnecting { attempt: 1 }
|
||||
);
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::Slot.as_str(), "slot");
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionState::Requested, ksp_onchain_transport_lib::WsSubscriptionState::Requested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_004_physical_websocket_session_contract_is_available_from_crate_root() {
|
||||
let type_name = std::any::type_name::<ksp_onchain_transport_lib::WsSession>();
|
||||
assert!(type_name.ends_with("WsSession"));
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW.code(), "ws_backpressure_overflow");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_CONNECTION_FAILED.code(), "ws_connection_failed");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_PROTOCOL_ERROR.code(), "ws_protocol_error");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_SESSION_CLOSED.code(), "ws_session_closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_005_bounded_websocket_close_contract_is_available_from_crate_root() {
|
||||
let _close = ksp_onchain_transport_lib::WsSession::close;
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSessionState::Closing, ksp_onchain_transport_lib::WsSessionState::Closing);
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSessionState::Closed, ksp_onchain_transport_lib::WsSessionState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_006_typed_websocket_subscription_handle_is_available_from_crate_root() {
|
||||
let type_name = std::any::type_name::<ksp_onchain_transport_lib::WsSubscription<serde_json::Value>>();
|
||||
assert!(type_name.contains("WsSubscription"));
|
||||
let _id = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::id;
|
||||
let _kind = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::kind;
|
||||
let _state = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::state;
|
||||
let _recv = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::recv;
|
||||
let _unsubscribe = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::unsubscribe;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_008_backpressure_observability_contract_is_available_from_crate_root() {
|
||||
let _terminal_error = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::terminal_error_code;
|
||||
let _snapshot_terminal_error = ksp_onchain_transport_lib::WsSubscriptionSnapshot::terminal_error_code;
|
||||
let _overflow_count = ksp_onchain_transport_lib::WsSessionSnapshot::overflow_count;
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW.code(), "ws_backpressure_overflow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_009_stable_websocket_lot_a_wrappers_and_dtos_are_available_from_crate_root() {
|
||||
let _account_subscribe = ksp_onchain_transport_lib::WsSession::account_subscribe;
|
||||
let _program_subscribe = ksp_onchain_transport_lib::WsSession::program_subscribe;
|
||||
let _logs_subscribe = ksp_onchain_transport_lib::WsSession::logs_subscribe;
|
||||
let account_config = ksp_onchain_transport_lib::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaDataSliceConfig::new(0, 32)),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
);
|
||||
assert_eq!(account_config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||
let program_config = ksp_onchain_transport_lib::SolanaProgramSubscribeConfig::new(
|
||||
account_config,
|
||||
std::vec![ksp_onchain_transport_lib::SolanaProgramAccountFilter::DataSize(80)],
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
assert_eq!(program_config.with_context(), std::option::Option::Some(true));
|
||||
let mention = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("public logs mention fixture must parse");
|
||||
let filter = ksp_onchain_transport_lib::SolanaLogsSubscribeFilter::Mentions(mention);
|
||||
let filter_name = std::any::type_name_of_val(&filter);
|
||||
assert!(filter_name.ends_with("SolanaLogsSubscribeFilter"));
|
||||
let _program_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaProgramNotification>();
|
||||
let _logs_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaLogsNotification>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_010_stable_websocket_lot_b_wrappers_and_dtos_are_available_from_crate_root() {
|
||||
let _signature_subscribe = ksp_onchain_transport_lib::WsSession::signature_subscribe;
|
||||
let _slot_subscribe = ksp_onchain_transport_lib::WsSession::slot_subscribe;
|
||||
let _root_subscribe = ksp_onchain_transport_lib::WsSession::root_subscribe;
|
||||
let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
assert_eq!(signature_config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized));
|
||||
assert_eq!(signature_config.enable_received_notification(), std::option::Option::Some(true));
|
||||
let terminal = ksp_onchain_transport_lib::SolanaSignatureNotification::Processed { err: std::option::Option::None };
|
||||
assert!(terminal.is_terminal());
|
||||
assert!(terminal.err().is_none());
|
||||
let _slot_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotNotification>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_011_unstable_websocket_wrappers_and_dtos_are_available_from_crate_root() {
|
||||
let _block_subscribe = ksp_onchain_transport_lib::WsSession::block_subscribe;
|
||||
let _slots_updates_subscribe = ksp_onchain_transport_lib::WsSession::slots_updates_subscribe;
|
||||
let _vote_subscribe = ksp_onchain_transport_lib::WsSession::vote_subscribe;
|
||||
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
||||
let filter = ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(pubkey);
|
||||
assert!(matches!(filter, ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(_)));
|
||||
let config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(0),
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.show_rewards(), std::option::Option::Some(true));
|
||||
let _block_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaBlockNotification>();
|
||||
let _slot_update = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotUpdate>();
|
||||
let _slot_update_stats = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotUpdateStats>();
|
||||
let _vote_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaVoteNotification>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_012_complete_standard_websocket_surface_is_available_from_crate_root() {
|
||||
let _account = ksp_onchain_transport_lib::WsSession::account_subscribe;
|
||||
let _block = ksp_onchain_transport_lib::WsSession::block_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::WsSession::logs_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::WsSession::program_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::WsSession::root_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::WsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::WsSession::slot_subscribe;
|
||||
let _slots_updates = ksp_onchain_transport_lib::WsSession::slots_updates_subscribe;
|
||||
let _vote = ksp_onchain_transport_lib::WsSession::vote_subscribe;
|
||||
let _unsubscribe = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::unsubscribe;
|
||||
let kinds = [
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
|
||||
];
|
||||
assert_eq!(kinds.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_8_pre_002_protocol_facades_are_available_without_replacing_the_standard_session_contract() {
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
|
||||
let _historical_connect = ksp_onchain_transport_lib::WsSession::connect;
|
||||
let _standard_connect = ksp_onchain_transport_lib::SolanaStandardWsSession::connect;
|
||||
let _standard_close = ksp_onchain_transport_lib::SolanaStandardWsSession::close;
|
||||
let _standard_snapshot = ksp_onchain_transport_lib::SolanaStandardWsSession::snapshot;
|
||||
let _standard_account = ksp_onchain_transport_lib::SolanaStandardWsSession::account_subscribe;
|
||||
let _standard_block = ksp_onchain_transport_lib::SolanaStandardWsSession::block_subscribe;
|
||||
let _standard_logs = ksp_onchain_transport_lib::SolanaStandardWsSession::logs_subscribe;
|
||||
let _standard_program = ksp_onchain_transport_lib::SolanaStandardWsSession::program_subscribe;
|
||||
let _standard_root = ksp_onchain_transport_lib::SolanaStandardWsSession::root_subscribe;
|
||||
let _standard_signature = ksp_onchain_transport_lib::SolanaStandardWsSession::signature_subscribe;
|
||||
let _standard_slot = ksp_onchain_transport_lib::SolanaStandardWsSession::slot_subscribe;
|
||||
let _standard_slots_updates = ksp_onchain_transport_lib::SolanaStandardWsSession::slots_updates_subscribe;
|
||||
let _standard_vote = ksp_onchain_transport_lib::SolanaStandardWsSession::vote_subscribe;
|
||||
let _helius_connect = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect;
|
||||
let _helius_close = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::close;
|
||||
let _helius_snapshot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::snapshot;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_8_pre_003_helius_standard_surface_reuses_shared_typed_contracts() {
|
||||
let _account = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::account_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::program_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::logs_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slot_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::root_subscribe;
|
||||
let _shared_account_config = std::any::type_name::<ksp_onchain_transport_lib::SolanaAccountSubscribeConfig>();
|
||||
let _shared_program_config = std::any::type_name::<ksp_onchain_transport_lib::SolanaProgramSubscribeConfig>();
|
||||
let _shared_logs_filter = std::any::type_name::<ksp_onchain_transport_lib::SolanaLogsSubscribeFilter>();
|
||||
let _shared_signature_config = std::any::type_name::<ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig>();
|
||||
let _shared_slot_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotNotification>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_8_pre_005_helius_transaction_request_contract_is_available_without_live_handle() {
|
||||
let account = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
||||
let filter = ksp_onchain_transport_lib::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some("fixture-signature".to_owned()),
|
||||
std::option::Option::Some(std::vec![account]),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged),
|
||||
);
|
||||
let options = ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::JsonParsed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some(0),
|
||||
);
|
||||
let request = ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
|
||||
assert!(request.validate().is_ok());
|
||||
assert_eq!(request.filter().token_accounts(), std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged));
|
||||
assert_eq!(
|
||||
request.options().and_then(ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::encoding),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::JsonParsed)
|
||||
);
|
||||
assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::None.as_str(), "none");
|
||||
assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged.as_str(), "balanceChanged");
|
||||
assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::All.as_str(), "all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_8_pre_006_helius_transaction_live_handle_and_notification_types_are_available() {
|
||||
let _subscribe = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe;
|
||||
let _notification = std::any::type_name::<ksp_onchain_transport_lib::HeliusTransactionNotification>();
|
||||
let _full = std::any::type_name::<ksp_onchain_transport_lib::HeliusFullTransactionNotification>();
|
||||
let _signature = std::any::type_name::<ksp_onchain_transport_lib::HeliusTransactionSignatureNotification>();
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_8_pre_009_helius_slots_updates_surface_reuses_shared_typed_contract() {
|
||||
let _slots_updates = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slots_updates_subscribe;
|
||||
let _shared_slot_update = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotUpdate>();
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates.as_str(), "slots_updates");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn public_v0_2_9_pre_002_yellowstone_engine_settings_and_lazy_channel_are_available_from_crate_root() {
|
||||
let url = ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse("http://127.0.0.1:10000")
|
||||
.expect("public Yellowstone gRPC URL parser must accept HTTP fixture endpoint");
|
||||
let session = ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::default();
|
||||
assert!(session.validate().is_ok());
|
||||
let endpoint = ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||
"fixture",
|
||||
true,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new("fixture-provider"),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new("devnet"),
|
||||
url,
|
||||
session,
|
||||
);
|
||||
let settings = ksp_onchain_transport_lib::YellowstoneGrpcTransportSettings::new(std::vec![endpoint.clone()]);
|
||||
assert!(settings.validate().is_ok());
|
||||
let channel = ksp_onchain_transport_lib::YellowstoneGrpcChannel::prepare(&endpoint);
|
||||
assert!(channel.is_ok());
|
||||
let channel = match channel {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(channel.endpoint_name(), "fixture");
|
||||
assert_eq!(channel.provider().as_str(), "fixture-provider");
|
||||
assert_eq!(channel.cluster().as_str(), "devnet");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_CHANNEL_FAILED.domain(), "onchain_transport");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_CHANNEL_FAILED.code(), "grpc_channel_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_9_pre_003_yellowstone_metadata_and_seven_unary_contracts_are_available_from_crate_root() {
|
||||
let public = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::public("x-ksp-public", "fixture");
|
||||
assert!(public.is_ok());
|
||||
let secret = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret("x-ksp-token", "secret");
|
||||
assert!(secret.is_ok());
|
||||
let replay = ksp_onchain_transport_lib::YellowstoneReplayInfo::new(std::option::Option::Some(1));
|
||||
assert_eq!(replay.first_available(), std::option::Option::Some(1));
|
||||
let pong = ksp_onchain_transport_lib::YellowstonePong::new(2);
|
||||
assert_eq!(pong.count(), 2);
|
||||
let latest = ksp_onchain_transport_lib::YellowstoneLatestBlockhash::new(3, "hash".to_owned(), 4);
|
||||
assert_eq!(latest.slot(), 3);
|
||||
assert_eq!(latest.blockhash(), "hash");
|
||||
assert_eq!(latest.last_valid_block_height(), 4);
|
||||
assert_eq!(ksp_onchain_transport_lib::YellowstoneBlockHeight::new(5).block_height(), 5);
|
||||
assert_eq!(ksp_onchain_transport_lib::YellowstoneSlot::new(6).slot(), 6);
|
||||
let validity = ksp_onchain_transport_lib::YellowstoneBlockhashValidity::new(7, true);
|
||||
assert_eq!(validity.slot(), 7);
|
||||
assert!(validity.valid());
|
||||
assert_eq!(ksp_onchain_transport_lib::YellowstoneVersionInfo::new("v".to_owned()).version(), "v");
|
||||
let _replay_info = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::subscribe_replay_info;
|
||||
let _ping = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::ping;
|
||||
let _latest_blockhash = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::get_latest_blockhash;
|
||||
let _block_height = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::get_block_height;
|
||||
let _slot = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::get_slot;
|
||||
async fn call_is_blockhash_valid(
|
||||
client: &ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneBlockhashValidity> {
|
||||
return client.is_blockhash_valid("fixture-blockhash", std::option::Option::None).await;
|
||||
}
|
||||
let _is_blockhash_valid = call_is_blockhash_valid;
|
||||
let _version = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::get_version;
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_STATUS.domain(), "onchain_transport");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_STATUS.code(), "grpc_status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_9_pre_004_yellowstone_subscribe_common_contract_is_available_from_crate_root() {
|
||||
let mut request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
|
||||
let account_name = ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("accounts").expect("account filter name must validate");
|
||||
let slot_name = ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("slots").expect("slot filter name must validate");
|
||||
let transaction_name = ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("transactions").expect("transaction filter name must validate");
|
||||
let transaction_status_name =
|
||||
ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("transaction-status").expect("transaction-status filter name must validate");
|
||||
let block_name = ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("blocks").expect("block filter name must validate");
|
||||
let block_meta_name = ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("blocks-meta").expect("block-meta filter name must validate");
|
||||
let entry_name = ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("entry").expect("entry filter name must validate");
|
||||
assert!(request.insert_account_filter(account_name, ksp_onchain_transport_lib::YellowstoneSubscribeAccountFilter::new()).is_ok());
|
||||
assert!(request.insert_slot_filter(slot_name, ksp_onchain_transport_lib::YellowstoneSubscribeSlotFilter::new()).is_ok());
|
||||
assert!(request.insert_transaction_filter(transaction_name, ksp_onchain_transport_lib::YellowstoneSubscribeTransactionFilter::new()).is_ok());
|
||||
assert!(
|
||||
request
|
||||
.insert_transaction_status_filter(transaction_status_name, ksp_onchain_transport_lib::YellowstoneSubscribeTransactionFilter::new())
|
||||
.is_ok()
|
||||
);
|
||||
assert!(request.insert_block_filter(block_name, ksp_onchain_transport_lib::YellowstoneSubscribeBlockFilter::new()).is_ok());
|
||||
assert!(request.insert_blocks_meta_filter(block_meta_name, ksp_onchain_transport_lib::YellowstoneSubscribeBlocksMetaFilter::new()).is_ok());
|
||||
assert!(request.insert_entry_filter(entry_name, ksp_onchain_transport_lib::YellowstoneSubscribeEntryFilter::new()).is_ok());
|
||||
request.set_commitment(std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||
let slice = ksp_onchain_transport_lib::YellowstoneAccountsDataSlice::new(4, 32).expect("account-data slice must validate");
|
||||
assert!(request.push_accounts_data_slice(slice).is_ok());
|
||||
request.set_ping(std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneSubscribePing::new(7)));
|
||||
request.set_from_slot(std::option::Option::Some(99));
|
||||
assert!(request.validate().is_ok());
|
||||
assert_eq!(request.account_filter_count(), 1);
|
||||
assert_eq!(request.slot_filter_count(), 1);
|
||||
assert_eq!(request.transaction_filter_count(), 1);
|
||||
assert_eq!(request.transaction_status_filter_count(), 1);
|
||||
assert_eq!(request.block_filter_count(), 1);
|
||||
assert_eq!(request.blocks_meta_filter_count(), 1);
|
||||
assert_eq!(request.entry_filter_count(), 1);
|
||||
assert_eq!(request.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||
assert_eq!(request.accounts_data_slices(), &[slice]);
|
||||
assert_eq!(request.ping(), std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneSubscribePing::new(7)));
|
||||
assert_eq!(request.from_slot(), std::option::Option::Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_9_pre_005_yellowstone_accounts_slots_contract_is_available_from_crate_root() {
|
||||
let account = ksp_core_lib::Pubkey::new_from_array([1_u8; 32]);
|
||||
let owner = ksp_core_lib::Pubkey::new_from_array([2_u8; 32]);
|
||||
let mut accounts = ksp_onchain_transport_lib::YellowstoneSubscribeAccountFilter::new();
|
||||
assert!(accounts.push_account(account).is_ok());
|
||||
assert!(accounts.push_owner(owner).is_ok());
|
||||
let memcmp = ksp_onchain_transport_lib::YellowstoneAccountMemcmp::bytes(0, vec![1_u8, 2, 3]).expect("public memcmp must validate");
|
||||
assert!(accounts.push_filter(ksp_onchain_transport_lib::YellowstoneAccountFilterPredicate::Memcmp(memcmp)).is_ok());
|
||||
accounts.set_nonempty_txn_signature(std::option::Option::Some(true));
|
||||
let cuckoo =
|
||||
ksp_onchain_transport_lib::YellowstoneCuckooFilter::new(vec![0_u8; 16], 4, 4, 8, 1, ksp_onchain_transport_lib::YellowstoneCuckooHashAlgorithm::SipHash)
|
||||
.expect("public cuckoo filter must validate");
|
||||
accounts.set_cuckoo_accounts_filter(std::option::Option::Some(cuckoo));
|
||||
let mut slots = ksp_onchain_transport_lib::YellowstoneSubscribeSlotFilter::new();
|
||||
slots.set_filter_by_commitment(std::option::Option::Some(true));
|
||||
slots.set_interslot_updates(std::option::Option::Some(true));
|
||||
let timestamp = ksp_onchain_transport_lib::YellowstoneUpdateTimestamp::new(1, 2).expect("public timestamp must validate");
|
||||
let signature = ksp_onchain_transport_lib::YellowstoneTransactionSignature::new([3_u8; 64]);
|
||||
assert_eq!(timestamp.nanos(), 2);
|
||||
assert_eq!(signature.as_bytes(), &[3_u8; 64]);
|
||||
assert_eq!(slots.filter_by_commitment(), std::option::Option::Some(true));
|
||||
let _account_info = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneAccountInfo>();
|
||||
let _account_update = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneAccountUpdate>();
|
||||
let _slot_update = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSlotUpdate>();
|
||||
let _slot_status = ksp_onchain_transport_lib::YellowstoneSlotStatus::FirstShredReceived;
|
||||
let _lamports = ksp_onchain_transport_lib::YellowstoneAccountLamportsFilter::Gt(10);
|
||||
let _encoding = ksp_onchain_transport_lib::YellowstoneAccountMemcmpEncoding::Bytes;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_9_pre_007_yellowstone_transactions_contract_is_available_from_crate_root() {
|
||||
let mut filter = ksp_onchain_transport_lib::YellowstoneSubscribeTransactionFilter::new();
|
||||
filter.set_vote(std::option::Option::Some(false));
|
||||
filter.set_failed(std::option::Option::Some(true));
|
||||
let selector = ksp_onchain_transport_lib::YellowstoneTransactionSignatureSelector::new("1".repeat(64)).expect("signature selector must validate");
|
||||
filter.set_signature(std::option::Option::Some(selector));
|
||||
assert!(filter.push_account_include(ksp_core_lib::Pubkey::new_from_array([1_u8; 32])).is_ok());
|
||||
assert!(filter.push_account_exclude(ksp_core_lib::Pubkey::new_from_array([2_u8; 32])).is_ok());
|
||||
assert!(filter.push_account_required(ksp_core_lib::Pubkey::new_from_array([3_u8; 32])).is_ok());
|
||||
filter.set_token_accounts(std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneTokenAccountExpansion::All));
|
||||
assert_eq!(filter.token_accounts(), std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneTokenAccountExpansion::All));
|
||||
let _hash = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneHashBytes>();
|
||||
let _instruction = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneCompiledInstruction>();
|
||||
let _lookup = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneMessageAddressTableLookup>();
|
||||
let _config = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTransactionConfig>();
|
||||
let _message = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTransactionMessage>();
|
||||
let _stored = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneStoredTransaction>();
|
||||
let _meta = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTransactionStatusMeta>();
|
||||
let _info = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTransactionInfo>();
|
||||
let _update = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTransactionUpdate>();
|
||||
let _status = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTransactionStatusUpdate>();
|
||||
let _error = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTransactionError>();
|
||||
let _inner = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneInnerInstructions>();
|
||||
let _token = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTokenBalance>();
|
||||
let _return_data = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneReturnData>();
|
||||
let _reward = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneReward>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_9_pre_008_yellowstone_blocks_contract_is_available_from_crate_root() {
|
||||
let account = ksp_core_lib::Pubkey::new_from_array([71_u8; 32]);
|
||||
let mut block = ksp_onchain_transport_lib::YellowstoneSubscribeBlockFilter::new();
|
||||
assert!(block.push_account_include(account).is_ok());
|
||||
block.set_include_transactions(std::option::Option::Some(true));
|
||||
block.set_include_accounts(std::option::Option::Some(false));
|
||||
block.set_include_entries(std::option::Option::Some(true));
|
||||
assert_eq!(block.account_include(), &[account]);
|
||||
assert_eq!(block.include_transactions(), std::option::Option::Some(true));
|
||||
assert_eq!(block.include_accounts(), std::option::Option::Some(false));
|
||||
assert_eq!(block.include_entries(), std::option::Option::Some(true));
|
||||
let mut request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
|
||||
assert!(
|
||||
request
|
||||
.insert_block_filter(ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("blocks").expect("filter name must validate"), block,)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
.insert_blocks_meta_filter(
|
||||
ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("block-meta").expect("filter name must validate"),
|
||||
ksp_onchain_transport_lib::YellowstoneSubscribeBlocksMetaFilter::new(),
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
.insert_entry_filter(
|
||||
ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("entry").expect("filter name must validate"),
|
||||
ksp_onchain_transport_lib::YellowstoneSubscribeEntryFilter::new(),
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(request.block_filter_count(), 1);
|
||||
assert_eq!(request.blocks_meta_filter_count(), 1);
|
||||
assert_eq!(request.entry_filter_count(), 1);
|
||||
let _block_update = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneBlockUpdate>();
|
||||
let _block_meta = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneBlockMetaUpdate>();
|
||||
let _block_rewards = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneBlockRewards>();
|
||||
let _entry_info = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneEntryInfo>();
|
||||
let _entry_update = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneEntryUpdate>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_9_pre_009_yellowstone_bidi_session_contract_is_available_from_crate_root() {
|
||||
fn assert_send<T: Send>() {
|
||||
let _ = std::marker::PhantomData::<T>;
|
||||
return;
|
||||
}
|
||||
assert_send::<ksp_onchain_transport_lib::SolanaYellowstoneGrpcSubscribeSession>();
|
||||
assert_send::<ksp_onchain_transport_lib::YellowstoneSubscribeUpdate>();
|
||||
let _open = ksp_onchain_transport_lib::YellowstoneGrpcChannel::open_standard_subscribe;
|
||||
let _session = std::any::type_name::<ksp_onchain_transport_lib::SolanaYellowstoneGrpcSubscribeSession>();
|
||||
let _ping = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribePingUpdate>();
|
||||
let _pong = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribePongUpdate>();
|
||||
let _states = [
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Active,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Closing,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Closed,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Failed,
|
||||
];
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_BACKPRESSURE_OVERFLOW.domain(), "onchain_transport");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_BACKPRESSURE_OVERFLOW.code(), "grpc_backpressure_overflow");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_SESSION_CLOSED.domain(), "onchain_transport");
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_SESSION_CLOSED.code(), "grpc_session_closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_9_pre_010_yellowstone_reconnect_snapshot_is_available_from_crate_root() {
|
||||
fn assert_copy<T: Copy>() {
|
||||
let _ = std::marker::PhantomData::<T>;
|
||||
return;
|
||||
}
|
||||
assert_copy::<ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot>();
|
||||
let _snapshot = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot>();
|
||||
let _state = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Reconnecting;
|
||||
let _session_snapshot = ksp_onchain_transport_lib::SolanaYellowstoneGrpcSubscribeSession::snapshot;
|
||||
let _reconnect_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::reconnect_count;
|
||||
let _gap_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::continuity_gap_count;
|
||||
let _duplicate_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::duplicate_update_count;
|
||||
let _replay_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::replay_attempt_count;
|
||||
let _requested = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::last_requested_from_slot;
|
||||
let _observed = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::last_observed_slot;
|
||||
let _terminal = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::terminal_error_code;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||
// version: 22
|
||||
// version: 41
|
||||
|
||||
//! Release-level completeness canaries for the staged HTTP wrapper sequence.
|
||||
//! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage.
|
||||
|
||||
#[test]
|
||||
fn release_registry_partition_matches_the_audited_http_plan() {
|
||||
@@ -730,3 +730,617 @@ fn release_v0_2_4_pre_009_final_http_inventory_and_coverage_partition_are_exact(
|
||||
assert_eq!((v0_2_1, v0_2_2, v0_2_3, v0_2_4), (4, 22, 11, 15));
|
||||
assert_eq!(historical_current, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_7_pre_012_websocket_surface_accounts_for_all_nine_standard_pairs() {
|
||||
let kinds = [
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
|
||||
];
|
||||
let expected_names = ["account", "block", "logs", "program", "root", "signature", "slot", "slots_updates", "vote"];
|
||||
let actual_names = kinds.map(ksp_onchain_transport_lib::WsSubscriptionKind::as_str);
|
||||
assert_eq!(actual_names, expected_names);
|
||||
assert_eq!(kinds.len() * 2, 18);
|
||||
let _account = ksp_onchain_transport_lib::WsSession::account_subscribe;
|
||||
let _block = ksp_onchain_transport_lib::WsSession::block_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::WsSession::logs_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::WsSession::program_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::WsSession::root_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::WsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::WsSession::slot_subscribe;
|
||||
let _slots_updates = ksp_onchain_transport_lib::WsSession::slots_updates_subscribe;
|
||||
let _vote = ksp_onchain_transport_lib::WsSession::vote_subscribe;
|
||||
let _unsubscribe = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::unsubscribe;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_7_pre_012_http_inventory_remains_52_current_plus_14_historical() {
|
||||
let current = ksp_onchain_transport_lib::current_http_rpc_methods();
|
||||
let historical = ksp_onchain_transport_lib::historical_http_rpc_methods();
|
||||
assert_eq!(current.len(), 52);
|
||||
assert_eq!(historical.len(), 14);
|
||||
assert!(current.iter().all(|descriptor| return descriptor.runtime_status() == ksp_onchain_transport_lib::RpcRuntimeStatus::Supported));
|
||||
assert!(historical.iter().all(|descriptor| return descriptor.runtime_status() == ksp_onchain_transport_lib::RpcRuntimeStatus::Removed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_002_protocol_facades_preserve_the_standard_partition() {
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
|
||||
let standard_kinds = [
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
|
||||
];
|
||||
assert_eq!(standard_kinds.len(), 9);
|
||||
assert_eq!(
|
||||
std::any::type_name::<ksp_onchain_transport_lib::SolanaStandardWsSession>().rsplit("::").next(),
|
||||
std::option::Option::Some("SolanaStandardWsSession")
|
||||
);
|
||||
assert_eq!(
|
||||
std::any::type_name::<ksp_onchain_transport_lib::HeliusLaserStreamWsSession>().rsplit("::").next(),
|
||||
std::option::Option::Some("HeliusLaserStreamWsSession")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_003_original_six_standard_families_remain_available_after_provider_evolution() {
|
||||
let _account = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::account_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::program_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::logs_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slot_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::root_subscribe;
|
||||
let source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(source.contains("unsupported_block"));
|
||||
assert!(source.contains("unsupported_vote"));
|
||||
assert!(!source.contains("pub async fn transaction_subscribe"));
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_005_helius_transaction_request_surface_is_typed_before_actor_integration() {
|
||||
let filter = ksp_onchain_transport_lib::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::All),
|
||||
);
|
||||
let options = ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::Base64),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures),
|
||||
std::option::Option::Some(true),
|
||||
std::option::Option::None,
|
||||
);
|
||||
let request = ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
|
||||
assert!(request.validate().is_ok());
|
||||
let facade_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(!facade_source.contains("pub async fn transaction_subscribe"));
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_006_helius_transaction_lifecycle_is_actor_integrated_without_advancing_heartbeat() {
|
||||
let _transaction = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe;
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction");
|
||||
let source = include_str!("../src/ws_helius_transactions.rs");
|
||||
assert!(source.contains("transactionNotification"));
|
||||
assert!(source.contains("WsSubscriptionKind::HeliusTransaction"));
|
||||
assert!(!source.contains("tokio_tungstenite::connect_async"));
|
||||
let protocol_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(protocol_source.contains("unsupported_block"));
|
||||
assert!(protocol_source.contains("unsupported_vote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_007_helius_heartbeat_is_provider_owned_by_shared_actor_only() {
|
||||
let actor_source = include_str!("../src/ws_session.rs");
|
||||
assert!(actor_source.contains("HELIUS_WS_HEARTBEAT_INTERVAL"));
|
||||
assert!(actor_source.contains("std::time::Duration::from_secs(60)"));
|
||||
assert!(actor_source.contains("WsProtocolKind::HeliusLaserStream"));
|
||||
assert!(actor_source.contains("tungstenite::Message::Ping"));
|
||||
assert!(actor_source.contains("send_helius_heartbeat"));
|
||||
let settings_source = include_str!("../src/ws_settings.rs");
|
||||
assert!(!settings_source.contains("heartbeat_interval"));
|
||||
assert!(!settings_source.contains("heartbeat_enabled"));
|
||||
let protocol_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(!protocol_source.contains("heartbeat_interval"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_008_adversarial_guards_preserve_provider_isolation_and_safe_diagnostics() {
|
||||
let protocol_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(protocol_source.contains("unsupported_helius_transaction"));
|
||||
assert!(protocol_source.contains("unsupported_block"));
|
||||
assert!(protocol_source.contains("unsupported_vote"));
|
||||
let helius_source = include_str!("../src/ws_helius_transactions.rs");
|
||||
assert!(helius_source.contains("impl std::fmt::Debug for HeliusFullTransactionNotification"));
|
||||
assert!(helius_source.contains("impl std::fmt::Debug for HeliusTransactionSignatureNotification"));
|
||||
assert!(helius_source.contains("impl std::fmt::Debug for HeliusTransactionNotification"));
|
||||
assert!(helius_source.contains("Self::Unknown(_)"));
|
||||
assert!(helius_source.contains("<omitted>"));
|
||||
let actor_source = include_str!("../src/ws_session.rs");
|
||||
assert!(actor_source.contains("max_message_size_bytes"));
|
||||
assert!(actor_source.contains("max_frame_size_bytes"));
|
||||
assert!(actor_source.contains("WsNotificationDispatchOutcome::QueueFull"));
|
||||
assert!(actor_source.contains("ERROR_CODE_WS_BACKPRESSURE_OVERFLOW"));
|
||||
assert!(actor_source.contains("remote_to_local.remove"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_009_http_and_standard_websocket_inventories_remain_exact() {
|
||||
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 standard = [
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
|
||||
];
|
||||
assert_eq!(standard.len(), 9);
|
||||
let lifecycle_source = include_str!("../src/ws_lifecycle.rs");
|
||||
for method in [
|
||||
"accountSubscribe",
|
||||
"accountUnsubscribe",
|
||||
"blockSubscribe",
|
||||
"blockUnsubscribe",
|
||||
"logsSubscribe",
|
||||
"logsUnsubscribe",
|
||||
"programSubscribe",
|
||||
"programUnsubscribe",
|
||||
"rootSubscribe",
|
||||
"rootUnsubscribe",
|
||||
"signatureSubscribe",
|
||||
"signatureUnsubscribe",
|
||||
"slotSubscribe",
|
||||
"slotUnsubscribe",
|
||||
"slotsUpdatesSubscribe",
|
||||
"slotsUpdatesUnsubscribe",
|
||||
"voteSubscribe",
|
||||
"voteUnsubscribe",
|
||||
] {
|
||||
assert!(lifecycle_source.contains(method), "missing standard WebSocket method mapping: {method}");
|
||||
}
|
||||
let _account = ksp_onchain_transport_lib::SolanaStandardWsSession::account_subscribe;
|
||||
let _block = ksp_onchain_transport_lib::SolanaStandardWsSession::block_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::SolanaStandardWsSession::logs_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::SolanaStandardWsSession::program_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::SolanaStandardWsSession::root_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::SolanaStandardWsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::SolanaStandardWsSession::slot_subscribe;
|
||||
let _slots_updates = ksp_onchain_transport_lib::SolanaStandardWsSession::slots_updates_subscribe;
|
||||
let _vote = ksp_onchain_transport_lib::SolanaStandardWsSession::vote_subscribe;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_009_helius_surface_is_seven_standard_families_plus_transaction() {
|
||||
let _account = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::account_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::program_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::logs_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slot_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::root_subscribe;
|
||||
let _slots_updates = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slots_updates_subscribe;
|
||||
let _transaction = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe;
|
||||
let protocol_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(protocol_source.contains("unsupported_block"));
|
||||
assert!(protocol_source.contains("unsupported_vote"));
|
||||
assert!(!protocol_source.contains("unsupported_slots_updates"));
|
||||
let cluster_source = include_str!("../src/ws_cluster.rs");
|
||||
assert!(cluster_source.contains("impl crate::HeliusLaserStreamWsSession"));
|
||||
assert!(cluster_source.contains("pub async fn slots_updates_subscribe"));
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates.as_str(), "slots_updates");
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_009_config_secret_and_dependency_boundaries_remain_wired() {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport integration test must resolve the workspace root");
|
||||
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
|
||||
for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] {
|
||||
assert!(!transport_manifest.contains(forbidden), "forbidden direct Transport dependency detected: {forbidden}");
|
||||
}
|
||||
let config_manifest = std::fs::read_to_string(workspace.join("crates/ksp-config-lib/Cargo.toml")).expect("Config manifest must be readable");
|
||||
assert!(config_manifest.contains("ksp-onchain-transport-lib"));
|
||||
let config_transport =
|
||||
std::fs::read_to_string(workspace.join("crates/ksp-config-lib/src/transport.rs")).expect("Config Transport adapter source must be readable");
|
||||
assert!(config_transport.contains("WsProtocolKind::HeliusLaserStream"));
|
||||
let transport_example = std::fs::read_to_string(workspace.join("config/examples/std.transport.example.json")).expect("Transport example must be readable");
|
||||
assert!(transport_example.contains("\"kind\": \"helius_laserstream\""));
|
||||
assert!(transport_example.contains("${KSP_SECRET_HELIUS_API_KEY"));
|
||||
let env_example = std::fs::read_to_string(workspace.join(".env.example")).expect(".env.example must be readable");
|
||||
assert!(env_example.contains("KSP_SECRET_HELIUS_API_KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_010_live_smoke_policy_preserves_secret_and_dependency_ownership() {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport integration test must resolve the workspace root");
|
||||
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
|
||||
assert!(!transport_manifest.contains("ksp-config-lib"));
|
||||
let transport_ws_smoke =
|
||||
std::fs::read_to_string(manifest_directory.join("tests/websocket_devnet_smoke.rs")).expect("Transport WebSocket smoke must be readable");
|
||||
assert!(!transport_ws_smoke.contains("KSP_SECRET_HELIUS_API_KEY"));
|
||||
assert!(!transport_ws_smoke.contains("ConfigEnvironment"));
|
||||
assert!(!workspace.join("crates/ksp-config-lib/tests/helius_websocket_smoke.rs").exists());
|
||||
let readme = std::fs::read_to_string(manifest_directory.join("README.md")).expect("Transport README must be readable");
|
||||
assert!(readme.contains("Aucun smoke Helius live supplémentaire n’est committé en `0.2.8-pre.010`"));
|
||||
assert!(readme.contains("KSP_SECRET_HELIUS_API_KEY"));
|
||||
let usage = std::fs::read_to_string(manifest_directory.join("USAGE.md")).expect("Transport USAGE must be readable");
|
||||
assert!(usage.contains("### Smoke Helius live"));
|
||||
assert!(usage.contains("surface KSP d’intégration/orchestration dédiée"));
|
||||
assert!(usage.contains("cargo tree -p ksp-onchain-transport-lib"));
|
||||
let env_example = std::fs::read_to_string(workspace.join(".env.example")).expect(".env.example must be readable");
|
||||
assert!(env_example.contains("KSP_SECRET_HELIUS_API_KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_002_materializes_minimal_yellowstone_engine_without_provider_or_ws_coupling() {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport test must resolve workspace root");
|
||||
let root_manifest = std::fs::read_to_string(workspace.join("Cargo.toml")).expect("workspace manifest must be readable");
|
||||
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
|
||||
let settings_source = include_str!("../src/grpc_settings.rs");
|
||||
let channel_source = include_str!("../src/grpc_channel.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(root_manifest.contains("tonic = { version = \"^0.14\", default-features = false }"));
|
||||
assert!(root_manifest.contains("yellowstone-grpc-proto = { version = \"^12.6\", default-features = false }"));
|
||||
assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"channel\""));
|
||||
assert!(transport_manifest.contains("yellowstone-grpc-proto.workspace = true"));
|
||||
assert!(!transport_manifest.contains("yellowstone-grpc-client"));
|
||||
assert!(!transport_manifest.contains("ksp-config-lib"));
|
||||
assert!(settings_source.contains("YellowstoneGrpcEndpointUrl"));
|
||||
assert!(settings_source.contains("YellowstoneGrpcSessionSettings"));
|
||||
assert!(settings_source.contains("YellowstoneGrpcTransportSettings"));
|
||||
assert!(!settings_source.contains("WsProtocolKind"));
|
||||
assert!(!settings_source.contains("KSP_SECRET_"));
|
||||
assert!(channel_source.contains("tonic::transport::Endpoint::from_shared"));
|
||||
assert!(channel_source.contains("connect_lazy"));
|
||||
assert!(!channel_source.contains("WsSession"));
|
||||
assert!(!crate_root.contains("pub use tonic"));
|
||||
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
|
||||
let _published_wire = std::any::type_name::<yellowstone_grpc_proto::geyser::SubscribeRequest>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_003_adds_tls_metadata_and_exactly_seven_standard_unary_methods_without_subscribe() {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport test must resolve workspace root");
|
||||
let root_manifest = std::fs::read_to_string(workspace.join("Cargo.toml")).expect("workspace manifest must be readable");
|
||||
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
|
||||
let settings_source = include_str!("../src/grpc_settings.rs");
|
||||
let channel_source = include_str!("../src/grpc_channel.rs");
|
||||
let unary_source = include_str!("../src/grpc_unary.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(root_manifest.contains("http = { version = \"^1.5\", default-features = false }"));
|
||||
assert!(root_manifest.contains("tonic-prost = { version = \"^0.14\", default-features = false }"));
|
||||
assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"channel\", \"tls-aws-lc\", \"tls-webpki-roots\"] }"));
|
||||
assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"codegen\", \"server\"] }"));
|
||||
assert!(transport_manifest.contains("tonic-prost.workspace = true"));
|
||||
assert!(transport_manifest.contains("yellowstone-grpc-proto.workspace = true"));
|
||||
assert!(transport_manifest.contains("yellowstone-grpc-proto = { workspace = true, features = [\"tonic\"] }"));
|
||||
assert!(!transport_manifest.contains("yellowstone-grpc-client"));
|
||||
assert!(settings_source.contains("YellowstoneGrpcMetadataEntry"));
|
||||
assert!(settings_source.contains("set_sensitive"));
|
||||
assert!(channel_source.contains("ClientTlsConfig"));
|
||||
assert!(channel_source.contains("with_webpki_roots"));
|
||||
assert!(channel_source.contains("pub async fn connect"));
|
||||
for path in [
|
||||
"/geyser.Geyser/SubscribeReplayInfo",
|
||||
"/geyser.Geyser/Ping",
|
||||
"/geyser.Geyser/GetLatestBlockhash",
|
||||
"/geyser.Geyser/GetBlockHeight",
|
||||
"/geyser.Geyser/GetSlot",
|
||||
"/geyser.Geyser/IsBlockhashValid",
|
||||
"/geyser.Geyser/GetVersion",
|
||||
] {
|
||||
assert!(unary_source.contains(path), "missing standard Yellowstone unary path: {path}");
|
||||
}
|
||||
assert!(!unary_source.contains("const PATH_SUBSCRIBE: "));
|
||||
assert!(!unary_source.contains("SubscribeDeshred"));
|
||||
assert!(!unary_source.contains("PublicNode"));
|
||||
assert!(!crate_root.contains("pub use tonic"));
|
||||
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
|
||||
let _client = std::any::type_name::<ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_004_materializes_only_standard_subscribe_common_contract() {
|
||||
let source = include_str!("../src/grpc_subscribe.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
for wire_field in [
|
||||
"accounts:",
|
||||
"slots:",
|
||||
"transactions:",
|
||||
"transactions_status:",
|
||||
"blocks:",
|
||||
"blocks_meta:",
|
||||
"entry:",
|
||||
"commitment:",
|
||||
"accounts_data_slice:",
|
||||
"ping:",
|
||||
"from_slot:",
|
||||
] {
|
||||
assert!(source.contains(wire_field), "missing standard Yellowstone SubscribeRequest field: {wire_field}");
|
||||
}
|
||||
assert!(source.contains("MAX_GRPC_SUBSCRIBE_FILTER_GROUP_COUNT"));
|
||||
assert!(source.contains("MAX_GRPC_SUBSCRIBE_FILTER_NAME_LENGTH_BYTES"));
|
||||
assert!(source.contains("MAX_GRPC_SUBSCRIBE_DATA_SLICE_COUNT"));
|
||||
assert!(source.contains("offset.checked_add(length)"));
|
||||
assert!(source.contains("globally unique"));
|
||||
assert!(source.contains("SubscribeRequestFilterBlocksMeta"));
|
||||
assert!(source.contains("SubscribeRequestFilterEntry"));
|
||||
assert!(source.contains("YellowstoneSubscribeAccountFilter"));
|
||||
assert!(source.contains("YellowstoneSubscribeSlotFilter"));
|
||||
assert!(source.contains("YellowstoneSubscribeTransactionFilter"));
|
||||
assert!(source.contains("YellowstoneSubscribeBlockFilter"));
|
||||
assert!(!source.contains("SubscribeDeshred"));
|
||||
assert!(!source.contains("PublicNode"));
|
||||
assert!(!source.contains("OrbitFlare"));
|
||||
assert!(!source.contains("Helius"));
|
||||
assert!(!source.contains("pub fn to_wire"));
|
||||
assert!(!source.contains("pub(crate) fn to_wire"));
|
||||
assert!(!crate_root.contains("pub use tonic"));
|
||||
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
|
||||
let _request = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribeRequest>();
|
||||
let _account = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribeAccountFilter>();
|
||||
let _slot = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribeSlotFilter>();
|
||||
let _transaction = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribeTransactionFilter>();
|
||||
let _block = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribeBlockFilter>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_005_accounts_and_slots_contract_remains_complete() {
|
||||
let source = include_str!("../src/grpc_subscribe.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
for required in [
|
||||
"account: self.accounts",
|
||||
"owner: self.owners",
|
||||
"filters: self.filters",
|
||||
"nonempty_txn_signature",
|
||||
"cuckoo_accounts_filter",
|
||||
"SubscribeRequestFilterAccountsFilterMemcmp",
|
||||
"DataSize",
|
||||
"TokenAccountState",
|
||||
"YellowstoneAccountLamportsFilter",
|
||||
"filter_by_commitment",
|
||||
"interslot_updates",
|
||||
"YellowstoneAccountUpdate",
|
||||
"YellowstoneSlotUpdate",
|
||||
"YellowstoneTransactionSignature",
|
||||
"SlotFirstShredReceived",
|
||||
"SlotCompleted",
|
||||
"SlotCreatedBank",
|
||||
"SlotDead",
|
||||
"dead_error",
|
||||
] {
|
||||
assert!(source.contains(required), "missing pre.005 Accounts/Slots contract token: {required}");
|
||||
}
|
||||
assert!(source.contains("MAX_GRPC_SUBSCRIBE_ACCOUNT_SELECTOR_COUNT"));
|
||||
assert!(source.contains("MAX_GRPC_SUBSCRIBE_ACCOUNT_PREDICATE_COUNT"));
|
||||
assert!(source.contains("MAX_GRPC_SUBSCRIBE_CUCKOO_DATA_LENGTH_BYTES"));
|
||||
assert!(source.contains("MAX_GRPC_SUBSCRIBE_ACCOUNT_DATA_LENGTH_BYTES"));
|
||||
assert!(source.contains("MAX_GRPC_SUBSCRIBE_SLOT_DEAD_ERROR_LENGTH_BYTES"));
|
||||
assert!(source.contains("fn decode_account_update"));
|
||||
assert!(source.contains("fn decode_slot_update"));
|
||||
assert!(!source.contains("SubscribeDeshred"));
|
||||
assert!(!source.contains("PublicNode"));
|
||||
assert!(!crate_root.contains("pub use tonic"));
|
||||
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
|
||||
let _account = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneAccountUpdate>();
|
||||
let _slot = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSlotUpdate>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_006_namespaces_unambiguously_http_owned_private_modules() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
let http_client = include_str!("../src/http_client.rs");
|
||||
let http_executor = include_str!("../src/http_executor.rs");
|
||||
let http_pool = include_str!("../src/http_pool.rs");
|
||||
let http_resilience = include_str!("../src/http_resilience.rs");
|
||||
let http_settings = include_str!("../src/http_settings.rs");
|
||||
for declaration in ["mod http_client;", "mod http_executor;", "mod http_pool;", "mod http_resilience;", "mod http_settings;"] {
|
||||
assert!(crate_root.contains(declaration), "missing HTTP-owned private module declaration: {declaration}");
|
||||
}
|
||||
for historical in ["mod client;", "mod executor;", "mod pool;", "mod resilience;", "mod settings;"] {
|
||||
assert!(!crate_root.contains(historical), "historical ambiguous private module declaration remains: {historical}");
|
||||
}
|
||||
assert!(http_client.contains("HttpEndpointClient"));
|
||||
assert!(http_executor.contains("execute_standard_rpc"));
|
||||
assert!(http_pool.contains("HttpTransportPool"));
|
||||
assert!(http_resilience.contains("HttpRetryDecision"));
|
||||
assert!(http_settings.contains("HttpTransportSettings"));
|
||||
// Shared/protocol-oriented modules deliberately keep their existing names.
|
||||
for shared in ["mod constants;", "mod error;", "mod json_rpc;", "mod rpc_common;", "mod rpc_accounts;", "mod rpc_blocks;", "mod rpc_transactions;"] {
|
||||
assert!(crate_root.contains(shared), "shared/protocol module was incorrectly HTTP-prefixed: {shared}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_007_transactions_contract_remains_complete() {
|
||||
let source = include_str!("../src/grpc_subscribe.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
for required in [
|
||||
"YellowstoneSubscribeTransactionFilter",
|
||||
"YellowstoneTransactionSignatureSelector",
|
||||
"YellowstoneTokenAccountExpansion",
|
||||
"account_include",
|
||||
"account_exclude",
|
||||
"account_required",
|
||||
"cuckoo_account_include",
|
||||
"token_accounts",
|
||||
"YellowstoneTransactionUpdate",
|
||||
"YellowstoneTransactionStatusUpdate",
|
||||
"YellowstoneTransactionStatusMeta",
|
||||
"YellowstoneTransactionMessage",
|
||||
"YellowstoneMessageAddressTableLookup",
|
||||
"YellowstoneTransactionConfig",
|
||||
"compute_units_consumed",
|
||||
"cost_units",
|
||||
"inner_instructions_none",
|
||||
"log_messages_none",
|
||||
"return_data_none",
|
||||
"decode_transaction_update",
|
||||
"decode_transaction_status_update",
|
||||
] {
|
||||
assert!(source.contains(required), "missing pre.007 Transactions contract token: {required}");
|
||||
}
|
||||
assert!(source.contains("base58_decoded_length"));
|
||||
assert!(source.contains("YELLOWSTONE_TRANSACTION_SIGNATURE_WIRE_LENGTH_BYTES"));
|
||||
assert!(!source.contains("pub async fn subscribe("));
|
||||
assert!(!source.contains("SubscribeDeshred"));
|
||||
assert!(!source.contains("PublicNode"));
|
||||
assert!(!crate_root.contains("pub use tonic"));
|
||||
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
|
||||
let _filter = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribeTransactionFilter>();
|
||||
let _update = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTransactionUpdate>();
|
||||
let _status = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneTransactionStatusUpdate>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_008_standard_blocks_contract_remains_complete() {
|
||||
let source = include_str!("../src/grpc_subscribe.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
for required in [
|
||||
"YellowstoneSubscribeBlockFilter",
|
||||
"account_include",
|
||||
"include_transactions",
|
||||
"include_accounts",
|
||||
"include_entries",
|
||||
"cuckoo_account_include",
|
||||
"YellowstoneBlockRewards",
|
||||
"YellowstoneBlockUpdate",
|
||||
"YellowstoneBlockMetaUpdate",
|
||||
"YellowstoneEntryInfo",
|
||||
"YellowstoneEntryUpdate",
|
||||
"executed_transaction_count",
|
||||
"updated_account_count",
|
||||
"entries_count",
|
||||
"starting_transaction_index",
|
||||
"decode_block_update",
|
||||
"decode_block_meta_update",
|
||||
"decode_entry_update",
|
||||
"decode_block_rewards",
|
||||
] {
|
||||
assert!(source.contains(required), "missing pre.008 Blocks contract token: {required}");
|
||||
}
|
||||
assert!(source.contains("MAX_GRPC_BLOCK_VECTOR_COUNT"));
|
||||
assert!(source.contains("base58_decoded_length(value.as_str())"));
|
||||
assert!(source.contains("std::vec::Vec<crate::YellowstoneTransactionInfo>"));
|
||||
assert!(source.contains("std::vec::Vec<crate::YellowstoneAccountInfo>"));
|
||||
assert!(source.contains("std::vec::Vec<crate::YellowstoneEntryInfo>"));
|
||||
assert!(source.contains("fn decode_block_update"));
|
||||
assert!(!source.contains("pub async fn subscribe("));
|
||||
assert!(!source.contains("SubscribeDeshred"));
|
||||
assert!(!source.contains("PublicNode"));
|
||||
assert!(!crate_root.contains("pub use tonic"));
|
||||
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
|
||||
let _block = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneBlockUpdate>();
|
||||
let _meta = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneBlockMetaUpdate>();
|
||||
let _entry = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneEntryUpdate>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_009_bounded_standard_bidi_session_contract_remains_complete() {
|
||||
let stream_source = include_str!("../src/grpc_stream.rs");
|
||||
let subscribe_source = include_str!("../src/grpc_subscribe.rs");
|
||||
let channel_source = include_str!("../src/grpc_channel.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
for required in [
|
||||
"const PATH_SUBSCRIBE: &str = \"/geyser.Geyser/Subscribe\"",
|
||||
".streaming(",
|
||||
"tokio::sync::mpsc::channel",
|
||||
"tokio::sync::watch::channel",
|
||||
"try_send",
|
||||
"AUTO_SUBSCRIBE_PING_ID",
|
||||
"ping_request_wire",
|
||||
"finish_client_half_close",
|
||||
"timeout_at",
|
||||
"max_decoding_message_size",
|
||||
"max_encoding_message_size",
|
||||
"YellowstoneGrpcSubscribeState",
|
||||
"SolanaYellowstoneGrpcSubscribeSession",
|
||||
] {
|
||||
assert!(stream_source.contains(required), "missing pre.009 bidi runtime token: {required}");
|
||||
}
|
||||
for required in [
|
||||
"YellowstoneSubscribeUpdate",
|
||||
"YellowstoneSubscribePingUpdate",
|
||||
"YellowstoneSubscribePongUpdate",
|
||||
"yellowstone_subscribe_request_to_wire",
|
||||
"yellowstone_subscribe_update_from_wire",
|
||||
] {
|
||||
assert!(subscribe_source.contains(required), "missing pre.009 runtime wire bridge token: {required}");
|
||||
}
|
||||
assert!(channel_source.contains("pub async fn open_standard_subscribe"));
|
||||
assert!(!stream_source.contains("unbounded_channel"));
|
||||
assert!(!stream_source.contains("SubscribeDeshred"));
|
||||
assert!(!stream_source.contains("PublicNode"));
|
||||
assert!(!stream_source.contains("OrbitFlare"));
|
||||
assert!(!stream_source.contains("Helius"));
|
||||
assert!(!crate_root.contains("pub use tonic"));
|
||||
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
|
||||
let _session = std::any::type_name::<ksp_onchain_transport_lib::SolanaYellowstoneGrpcSubscribeSession>();
|
||||
let _state = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Active;
|
||||
let _update = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribeUpdate>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_9_pre_010_adds_bounded_reconnect_replay_and_conservative_continuity_observability() {
|
||||
let stream_source = include_str!("../src/grpc_stream.rs");
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
for required in [
|
||||
"YellowstoneGrpcSubscribeState::Reconnecting",
|
||||
"YellowstoneGrpcSubscribeSnapshot",
|
||||
"reconnect_subscribe_stream",
|
||||
"replay_first_available",
|
||||
"SubscribeReplayInfo",
|
||||
"last_requested_from_slot",
|
||||
"last_observed_slot",
|
||||
"reconnect_count",
|
||||
"replay_attempt_count",
|
||||
"continuity_gap_count",
|
||||
"duplicate_update_count",
|
||||
"MAX_RECENT_UPDATE_IDENTITIES",
|
||||
"first_available > requested",
|
||||
"max_optional_slot",
|
||||
"reconnect budget is exhausted",
|
||||
"request mutation",
|
||||
] {
|
||||
assert!(stream_source.contains(required), "missing pre.010 reconnect/replay contract token: {required}");
|
||||
}
|
||||
assert!(stream_source.contains("still delivered to the caller"));
|
||||
assert!(stream_source.contains("does not claim exactly-once delivery"));
|
||||
assert!(!stream_source.contains("unbounded_channel"));
|
||||
assert!(!stream_source.contains("SubscribeDeshred"));
|
||||
assert!(!stream_source.contains("PublicNode"));
|
||||
assert!(!stream_source.contains("OrbitFlare"));
|
||||
assert!(!stream_source.contains("Helius"));
|
||||
assert!(!crate_root.contains("pub use tonic"));
|
||||
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
|
||||
let _snapshot = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot>();
|
||||
let _state = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Reconnecting;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/websocket_devnet_smoke.rs
|
||||
// version: 1
|
||||
|
||||
//! Opt-in live Devnet smoke for one stable standard Solana WebSocket subscription.
|
||||
|
||||
fn devnet_websocket_endpoint() -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsEndpointSettings> {
|
||||
let url_result = ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com");
|
||||
let url = match url_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let session = ksp_onchain_transport_lib::WsSessionSettings::new(
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
ksp_onchain_transport_lib::WsReconnectSettings::new(0, std::time::Duration::from_millis(250), std::time::Duration::from_secs(2)),
|
||||
ksp_onchain_transport_lib::WsResubscribePolicy::ActiveSubscriptions,
|
||||
16,
|
||||
16,
|
||||
8,
|
||||
8,
|
||||
4 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
256 * 1024,
|
||||
);
|
||||
return std::result::Result::Ok(ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||
"solana_devnet_public_ws",
|
||||
true,
|
||||
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
|
||||
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
|
||||
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
|
||||
url,
|
||||
session,
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "opt-in live Solana Devnet WebSocket smoke; performs an external network connection"]
|
||||
async fn programmatic_devnet_websocket_reaches_slot_notification_then_unsubscribes_and_closes() {
|
||||
let endpoint = devnet_websocket_endpoint().expect("programmatic Devnet WebSocket settings must construct an endpoint");
|
||||
let session = ksp_onchain_transport_lib::WsSession::connect(endpoint).await.expect("Devnet WebSocket handshake must succeed");
|
||||
assert_eq!(session.snapshot().state(), ksp_onchain_transport_lib::WsSessionState::Active);
|
||||
let mut subscription = session.slot_subscribe().await.expect("Devnet slotSubscribe must succeed");
|
||||
assert_eq!(subscription.kind(), ksp_onchain_transport_lib::WsSubscriptionKind::Slot);
|
||||
assert_eq!(subscription.state(), ksp_onchain_transport_lib::WsSubscriptionState::Active);
|
||||
let notification_wait = tokio::time::timeout(std::time::Duration::from_secs(20), subscription.recv()).await;
|
||||
let notification = match notification_wait {
|
||||
std::result::Result::Ok(std::option::Option::Some(std::result::Result::Ok(value))) => value,
|
||||
std::result::Result::Ok(std::option::Option::Some(std::result::Result::Err(error))) => panic!("Devnet slot notification decoding failed: {error}"),
|
||||
std::result::Result::Ok(std::option::Option::None) => panic!("Devnet slot subscription closed before one notification arrived"),
|
||||
std::result::Result::Err(_) => panic!("Devnet slot notification did not arrive within the smoke timeout"),
|
||||
};
|
||||
assert!(notification.slot() > 0);
|
||||
let unsubscribed = subscription.unsubscribe().await.expect("Devnet slotUnsubscribe must succeed");
|
||||
assert!(unsubscribed);
|
||||
session.close().await.expect("Devnet WebSocket session must close cleanly");
|
||||
assert_eq!(session.snapshot().state(), ksp_onchain_transport_lib::WsSessionState::Closed);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
|
||||
// version: 2
|
||||
|
||||
//! Opt-in live OrbitFlare Devnet characterization smoke for authenticated provider-neutral Yellowstone gRPC Subscribe.
|
||||
|
||||
use std::io::IsTerminal; // rust-rules: trait-import
|
||||
|
||||
fn orbitflare_license_x_token() -> std::string::String {
|
||||
assert!(
|
||||
!std::io::stdin().is_terminal(),
|
||||
"pipe the OrbitFlare Solana Free License Key to this ignored smoke on stdin; never pass it as a command-line argument"
|
||||
);
|
||||
let mut token = std::string::String::new();
|
||||
match std::io::stdin().read_line(&mut token) {
|
||||
std::result::Result::Ok(_) => {},
|
||||
std::result::Result::Err(error) => panic!("OrbitFlare License Key x-token must be readable from smoke stdin: {error}"),
|
||||
}
|
||||
let token = token.trim().to_owned();
|
||||
assert!(!token.is_empty(), "OrbitFlare License Key x-token provided on smoke stdin must not be empty");
|
||||
return token;
|
||||
}
|
||||
|
||||
fn orbitflare_devnet_endpoint(x_token: &str) -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings> {
|
||||
let url = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse("http://devnet.rpc.orbitflare.com:10000") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let session = ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::new(
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcReconnectSettings::new(0, std::time::Duration::from_millis(250), std::time::Duration::from_secs(2)),
|
||||
8,
|
||||
8,
|
||||
16 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
);
|
||||
let endpoint = ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||
"orbitflare_devnet_yellowstone",
|
||||
true,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new("orbitflare"),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new("devnet"),
|
||||
url,
|
||||
session,
|
||||
);
|
||||
let metadata = match ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret("x-token", x_token) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return endpoint.with_metadata(vec![metadata]);
|
||||
}
|
||||
|
||||
fn slot_request() -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneSubscribeRequest> {
|
||||
let mut request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
|
||||
let name = match ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("slots") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
match request.insert_slot_filter(name, ksp_onchain_transport_lib::YellowstoneSubscribeSlotFilter::new()) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
request.set_commitment(std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||
return std::result::Result::Ok(request);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "opt-in live OrbitFlare Devnet Yellowstone gRPC characterization; reads one Solana Free License Key x-token from stdin, waits for Slot plus standard server Ping, then closes"]
|
||||
async fn orbitflare_devnet_yellowstone_streams_slots_and_emits_standard_server_ping_with_license_x_token() {
|
||||
let x_token = orbitflare_license_x_token();
|
||||
let endpoint = match orbitflare_devnet_endpoint(x_token.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("programmatic OrbitFlare Devnet Yellowstone settings must accept the License Key as secret x-token metadata: {error:?}")
|
||||
},
|
||||
};
|
||||
assert_eq!(endpoint.metadata().len(), 1);
|
||||
assert_eq!(endpoint.metadata()[0].key(), "x-token");
|
||||
assert!(endpoint.metadata()[0].is_secret());
|
||||
assert!(!endpoint.url().uses_tls(), "the documented OrbitFlare shared Devnet endpoint is intentionally HTTP/2 plaintext");
|
||||
let endpoint_debug = format!("{endpoint:?}");
|
||||
assert!(!endpoint_debug.contains("orbitflare.com"), "OrbitFlare endpoint URL must not appear in endpoint Debug");
|
||||
assert!(!endpoint_debug.contains(x_token.as_str()), "OrbitFlare License Key x-token must not appear in endpoint Debug");
|
||||
assert!(!endpoint_debug.contains("X-ORBIT-KEY"), "OrbitFlare Customer API credentials must not enter Yellowstone settings");
|
||||
let channel = match ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(&endpoint).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("OrbitFlare Devnet Yellowstone authenticated connection must succeed: {error:?}"),
|
||||
};
|
||||
assert_eq!(channel.endpoint_name(), "orbitflare_devnet_yellowstone");
|
||||
assert_eq!(channel.provider().as_str(), "orbitflare");
|
||||
assert_eq!(channel.cluster().as_str(), "devnet");
|
||||
let request = match slot_request() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("OrbitFlare Yellowstone slot request must be valid: {error:?}"),
|
||||
};
|
||||
let mut session = match channel.open_standard_subscribe(request).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("OrbitFlare Devnet standard Subscribe with License Key x-token must open: {error:?}"),
|
||||
};
|
||||
let deadline = tokio::time::sleep(std::time::Duration::from_secs(45));
|
||||
tokio::pin!(deadline);
|
||||
let mut slot_seen = false;
|
||||
let mut ping_seen = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
() = &mut deadline => break,
|
||||
next_update = session.next_update() => {
|
||||
let next_update = match next_update {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("OrbitFlare Devnet Yellowstone Subscribe update must decode: {error:?}"),
|
||||
};
|
||||
match next_update {
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneSubscribeUpdate::Slot(update)) => {
|
||||
if update.slot() > 0 {
|
||||
slot_seen = true;
|
||||
}
|
||||
},
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneSubscribeUpdate::Ping(_)) => ping_seen = true,
|
||||
std::option::Option::Some(_) => {},
|
||||
std::option::Option::None => break,
|
||||
}
|
||||
if slot_seen && ping_seen {
|
||||
break;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
let close_timeout_result = tokio::time::timeout(std::time::Duration::from_secs(7), session.close()).await;
|
||||
let close_result = match close_timeout_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("KSP Yellowstone Subscribe close must remain bounded beyond the configured five-second provider half-close deadline: {error}")
|
||||
},
|
||||
};
|
||||
match close_result {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => {
|
||||
assert_eq!(
|
||||
error.code(),
|
||||
ksp_onchain_transport_lib::ERROR_CODE_TIMEOUT,
|
||||
"after live characterization, OrbitFlare close may time out waiting for provider half-close but must not fail for another reason: {error:?}"
|
||||
);
|
||||
},
|
||||
}
|
||||
assert!(slot_seen, "OrbitFlare Devnet Yellowstone must publish at least one non-zero confirmed slot during the 45-second characterization window");
|
||||
assert!(
|
||||
ping_seen,
|
||||
"OrbitFlare Devnet must emit the standard Yellowstone server Ping during the 45-second characterization window before any provider-specific heartbeat is justified"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/yellowstone_publicnode_smoke.rs
|
||||
// version: 5
|
||||
|
||||
//! Opt-in live PublicNode Mainnet/Testnet smokes for authenticated provider-neutral Yellowstone gRPC Subscribe.
|
||||
|
||||
use std::io::IsTerminal; // rust-rules: trait-import
|
||||
|
||||
struct PublicNodeTokens {
|
||||
mainnet: std::string::String,
|
||||
testnet: std::string::String,
|
||||
}
|
||||
|
||||
static PUBLICNODE_X_TOKENS: std::sync::OnceLock<PublicNodeTokens> = std::sync::OnceLock::new();
|
||||
|
||||
fn read_token_line(label: &str) -> std::string::String {
|
||||
let mut token = std::string::String::new();
|
||||
match std::io::stdin().read_line(&mut token) {
|
||||
std::result::Result::Ok(_) => {},
|
||||
std::result::Result::Err(error) => panic!("PublicNode {label} x-token must be readable from smoke stdin: {error}"),
|
||||
}
|
||||
let token = token.trim().to_owned();
|
||||
assert!(!token.is_empty(), "PublicNode {label} x-token provided on smoke stdin must not be empty");
|
||||
return token;
|
||||
}
|
||||
|
||||
fn publicnode_x_tokens() -> &'static PublicNodeTokens {
|
||||
return PUBLICNODE_X_TOKENS.get_or_init(|| {
|
||||
assert!(
|
||||
!std::io::stdin().is_terminal(),
|
||||
"pipe two PublicNode personal x-tokens to this ignored smoke on stdin: Mainnet first, Testnet second; never pass them as command-line arguments"
|
||||
);
|
||||
return PublicNodeTokens { mainnet: read_token_line("Mainnet"), testnet: read_token_line("Testnet") };
|
||||
});
|
||||
}
|
||||
|
||||
fn publicnode_endpoint(
|
||||
name: &str,
|
||||
cluster: &str,
|
||||
url: &str,
|
||||
x_token: &str,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings> {
|
||||
let url = match ksp_onchain_transport_lib::YellowstoneGrpcEndpointUrl::parse(url) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let session = ksp_onchain_transport_lib::YellowstoneGrpcSessionSettings::new(
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcReconnectSettings::new(0, std::time::Duration::from_millis(250), std::time::Duration::from_secs(2)),
|
||||
8,
|
||||
8,
|
||||
16 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
);
|
||||
let endpoint = ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings::new(
|
||||
name,
|
||||
true,
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcProviderName::new("publicnode"),
|
||||
ksp_onchain_transport_lib::YellowstoneGrpcClusterName::new(cluster),
|
||||
url,
|
||||
session,
|
||||
);
|
||||
let metadata = match ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret("x-token", x_token) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return endpoint.with_metadata(vec![metadata]);
|
||||
}
|
||||
|
||||
fn slot_request() -> ksp_core_lib::Result<ksp_onchain_transport_lib::YellowstoneSubscribeRequest> {
|
||||
let mut request = ksp_onchain_transport_lib::YellowstoneSubscribeRequest::new();
|
||||
let name = match ksp_onchain_transport_lib::YellowstoneSubscribeFilterName::new("slots") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
match request.insert_slot_filter(name, ksp_onchain_transport_lib::YellowstoneSubscribeSlotFilter::new()) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
return std::result::Result::Ok(request);
|
||||
}
|
||||
|
||||
async fn assert_publicnode_slot_stream(name: &str, cluster: &str, url: &str, x_token: &str) {
|
||||
let endpoint = match publicnode_endpoint(name, cluster, url, x_token) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("programmatic PublicNode Yellowstone settings must accept secret x-token metadata: {error:?}"),
|
||||
};
|
||||
let endpoint_debug = format!("{endpoint:?}");
|
||||
assert!(!endpoint_debug.contains(x_token), "PublicNode x-token must not appear in endpoint Debug");
|
||||
let channel = match ksp_onchain_transport_lib::YellowstoneGrpcChannel::connect(&endpoint).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("PublicNode Yellowstone TLS connection must succeed: {error:?}"),
|
||||
};
|
||||
assert_eq!(channel.endpoint_name(), name);
|
||||
assert_eq!(channel.provider().as_str(), "publicnode");
|
||||
assert_eq!(channel.cluster().as_str(), cluster);
|
||||
let request = match slot_request() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("PublicNode Yellowstone slot request must be valid: {error:?}"),
|
||||
};
|
||||
let mut session = match channel.open_standard_subscribe(request).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("PublicNode Yellowstone authenticated Subscribe must open: {error:?}"),
|
||||
};
|
||||
let slot_result = tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
loop {
|
||||
let next_update = match session.next_update().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("PublicNode Yellowstone Subscribe update must decode: {error:?}"),
|
||||
};
|
||||
match next_update {
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::YellowstoneSubscribeUpdate::Slot(update)) => return update.slot(),
|
||||
std::option::Option::Some(_) => {},
|
||||
std::option::Option::None => panic!("PublicNode Yellowstone Subscribe ended before a slot update"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let slot = match slot_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("PublicNode Yellowstone Subscribe must publish a slot update before the smoke deadline: {error}"),
|
||||
};
|
||||
assert!(slot > 0);
|
||||
let close_timeout_result = tokio::time::timeout(std::time::Duration::from_secs(7), session.close()).await;
|
||||
let close_result = match close_timeout_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("KSP Yellowstone Subscribe close must remain bounded beyond the configured five-second provider half-close deadline: {error}")
|
||||
},
|
||||
};
|
||||
match close_result {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => {
|
||||
assert_eq!(
|
||||
error.code(),
|
||||
ksp_onchain_transport_lib::ERROR_CODE_TIMEOUT,
|
||||
"after a live slot was observed, PublicNode close may time out waiting for provider half-close but must not fail for another reason: {error:?}"
|
||||
);
|
||||
},
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "opt-in live PublicNode Mainnet Yellowstone gRPC smoke; reads the first personal x-token line from stdin and performs an external TLS/Subscribe request"]
|
||||
async fn publicnode_mainnet_yellowstone_streams_slots_with_network_scoped_secret_x_token() {
|
||||
let tokens = publicnode_x_tokens();
|
||||
assert_publicnode_slot_stream(
|
||||
"publicnode_mainnet_yellowstone",
|
||||
"mainnet-beta",
|
||||
"https://solana-yellowstone-grpc.publicnode.com:443",
|
||||
tokens.mainnet.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "opt-in live PublicNode Testnet Yellowstone gRPC smoke; reads the second personal x-token line from stdin and performs an external TLS/Subscribe request"]
|
||||
async fn publicnode_testnet_yellowstone_streams_slots_with_network_scoped_secret_x_token() {
|
||||
let tokens = publicnode_x_tokens();
|
||||
assert_publicnode_slot_stream(
|
||||
"publicnode_testnet_yellowstone",
|
||||
"testnet",
|
||||
"https://solana-testnet-yellowstone-grpc.publicnode.com:443",
|
||||
tokens.testnet.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
60
crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs
Normal file
60
crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs
|
||||
// version: 3
|
||||
|
||||
fn endpoint(enabled: bool, value: &str) -> crate::YellowstoneGrpcEndpointSettings {
|
||||
let parsed = crate::YellowstoneGrpcEndpointUrl::parse(value).expect("fixture Yellowstone gRPC URL must parse");
|
||||
return crate::YellowstoneGrpcEndpointSettings::new(
|
||||
"fixture",
|
||||
enabled,
|
||||
crate::YellowstoneGrpcProviderName::new("fixture-provider"),
|
||||
crate::YellowstoneGrpcClusterName::new("devnet"),
|
||||
parsed,
|
||||
crate::YellowstoneGrpcSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn grpc_channel_prepare_is_lazy_safe_and_keeps_tonic_private() {
|
||||
let endpoint = endpoint(true, "http://127.0.0.1:10000/GRPC-SECRET-CANARY");
|
||||
let channel = crate::YellowstoneGrpcChannel::prepare(&endpoint);
|
||||
assert!(channel.is_ok());
|
||||
let channel = match channel {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(channel.endpoint_name(), "fixture");
|
||||
assert_eq!(channel.provider().as_str(), "fixture-provider");
|
||||
assert_eq!(channel.cluster().as_str(), "devnet");
|
||||
let rendered = format!("{channel:?}");
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains("127.0.0.1"));
|
||||
let _client = channel.standard_unary_client();
|
||||
let _wire_type = std::any::type_name::<yellowstone_grpc_proto::geyser::SubscribeRequest>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_channel_prepare_requires_active_tokio_runtime_without_panicking() {
|
||||
let endpoint = endpoint(true, "http://127.0.0.1:10000/GRPC-SECRET-CANARY");
|
||||
let result = crate::YellowstoneGrpcChannel::prepare(&endpoint);
|
||||
assert!(result.is_err());
|
||||
let rendered = format!("{result:?}");
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains("127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_channel_prepare_rejects_disabled_endpoint_before_network_io() {
|
||||
let endpoint = endpoint(false, "http://127.0.0.1:10000");
|
||||
let result = crate::YellowstoneGrpcChannel::prepare(&endpoint);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn grpc_channel_prepare_configures_https_without_exposing_url() {
|
||||
let endpoint = endpoint(true, "https://example.invalid:443/GRPC-SECRET-CANARY");
|
||||
let result = crate::YellowstoneGrpcChannel::prepare(&endpoint);
|
||||
assert!(result.is_ok());
|
||||
let rendered = format!("{result:?}");
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains("example.invalid"));
|
||||
}
|
||||
198
crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs
Normal file
198
crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs
|
||||
// version: 3
|
||||
|
||||
fn endpoint(name: &str, enabled: bool, url: &str, session: crate::YellowstoneGrpcSessionSettings) -> crate::YellowstoneGrpcEndpointSettings {
|
||||
let parsed = crate::YellowstoneGrpcEndpointUrl::parse(url).expect("fixture Yellowstone gRPC URL must parse");
|
||||
return crate::YellowstoneGrpcEndpointSettings::new(
|
||||
name,
|
||||
enabled,
|
||||
crate::YellowstoneGrpcProviderName::new("fixture"),
|
||||
crate::YellowstoneGrpcClusterName::new("devnet"),
|
||||
parsed,
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_endpoint_url_accepts_http_and_https_and_redacts_debug() {
|
||||
for value in ["http://127.0.0.1:10000", "https://yellowstone.example.invalid:443/path?token=GRPC-SECRET-CANARY"] {
|
||||
let parsed = crate::YellowstoneGrpcEndpointUrl::parse(value);
|
||||
assert!(parsed.is_ok());
|
||||
let parsed = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
assert_eq!(parsed.as_str(), value);
|
||||
let rendered = format!("{parsed:?}");
|
||||
assert_eq!(rendered, "YellowstoneGrpcEndpointUrl(<redacted>)");
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_endpoint_url_rejects_non_http_schemes_without_echoing_secret() {
|
||||
let result = crate::YellowstoneGrpcEndpointUrl::parse("wss://GRPC-SECRET-CANARY@example.invalid/socket");
|
||||
assert!(result.is_err());
|
||||
let error = match result {
|
||||
std::result::Result::Ok(_) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
let rendered = error.to_string();
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
assert!(!format!("{error:?}").contains("GRPC-SECRET-CANARY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_endpoint_url_rejects_excessive_length_without_echoing_payload() {
|
||||
let value = format!("https://example.invalid/{}", "S".repeat(super::MAX_GRPC_ENDPOINT_URL_LENGTH_BYTES + 1));
|
||||
let result = crate::YellowstoneGrpcEndpointUrl::parse(value);
|
||||
assert!(result.is_err());
|
||||
let error = match result {
|
||||
std::result::Result::Ok(_) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
assert!(!format!("{error:?}").contains(&"S".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_session_defaults_are_bounded_and_validate() {
|
||||
let settings = crate::YellowstoneGrpcSessionSettings::default();
|
||||
assert!(settings.validate().is_ok());
|
||||
assert!(!settings.connect_timeout().is_zero());
|
||||
assert!(!settings.unary_timeout().is_zero());
|
||||
assert!(!settings.close_timeout().is_zero());
|
||||
assert!(settings.request_channel_capacity() > 0);
|
||||
assert!(settings.update_channel_capacity() > 0);
|
||||
assert!(settings.max_inbound_message_size_bytes() > 0);
|
||||
assert!(settings.max_outbound_message_size_bytes() > 0);
|
||||
assert!(settings.reconnect().max_retries() <= super::MAX_GRPC_RECONNECT_RETRIES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_session_rejects_zero_excessive_and_reversed_runtime_bounds() {
|
||||
let zero = crate::YellowstoneGrpcSessionSettings::new(
|
||||
std::time::Duration::ZERO,
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
crate::YellowstoneGrpcReconnectSettings::default(),
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
assert!(zero.validate().is_err());
|
||||
let excessive = crate::YellowstoneGrpcSessionSettings::new(
|
||||
super::MAX_GRPC_RUNTIME_DURATION + std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
crate::YellowstoneGrpcReconnectSettings::new(
|
||||
super::MAX_GRPC_RECONNECT_RETRIES + 1,
|
||||
std::time::Duration::from_millis(1),
|
||||
std::time::Duration::from_millis(2),
|
||||
),
|
||||
super::MAX_GRPC_CHANNEL_CAPACITY + 1,
|
||||
1,
|
||||
super::MAX_GRPC_MESSAGE_SIZE_BYTES + 1,
|
||||
1,
|
||||
);
|
||||
assert!(excessive.validate().is_err());
|
||||
let reversed = crate::YellowstoneGrpcSessionSettings::new(
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
crate::YellowstoneGrpcReconnectSettings::new(5, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
assert!(reversed.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_transport_settings_validate_unique_enabled_endpoints_and_redact_urls() {
|
||||
let first = endpoint("first", true, "https://GRPC-SECRET-CANARY@example.invalid:443", crate::YellowstoneGrpcSessionSettings::default());
|
||||
let second = endpoint("second", false, "http://127.0.0.1:10000", crate::YellowstoneGrpcSessionSettings::default());
|
||||
let settings = crate::YellowstoneGrpcTransportSettings::new(std::vec![first.clone(), second]);
|
||||
assert!(settings.validate().is_ok());
|
||||
let rendered = format!("{settings:?}");
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains("example.invalid"));
|
||||
let duplicate = crate::YellowstoneGrpcTransportSettings::new(std::vec![first.clone(), first]);
|
||||
assert!(duplicate.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_transport_settings_reject_excessive_descriptor_and_endpoint_count() {
|
||||
let oversized_name = "n".repeat(super::MAX_GRPC_DESCRIPTOR_LENGTH_BYTES + 1);
|
||||
let oversized = endpoint(oversized_name.as_str(), true, "http://127.0.0.1:10000", crate::YellowstoneGrpcSessionSettings::default());
|
||||
assert!(crate::YellowstoneGrpcTransportSettings::new(std::vec![oversized]).validate().is_err());
|
||||
let parsed = crate::YellowstoneGrpcEndpointUrl::parse("http://127.0.0.1:10000").expect("fixture Yellowstone gRPC URL must parse");
|
||||
let endpoints = (0..=super::MAX_GRPC_ENDPOINT_COUNT)
|
||||
.map(|index| {
|
||||
return crate::YellowstoneGrpcEndpointSettings::new(
|
||||
format!("endpoint-{index}"),
|
||||
true,
|
||||
crate::YellowstoneGrpcProviderName::new("fixture"),
|
||||
crate::YellowstoneGrpcClusterName::new("devnet"),
|
||||
parsed.clone(),
|
||||
crate::YellowstoneGrpcSessionSettings::default(),
|
||||
);
|
||||
})
|
||||
.collect();
|
||||
assert!(crate::YellowstoneGrpcTransportSettings::new(endpoints).validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_transport_settings_require_one_enabled_endpoint() {
|
||||
let disabled = endpoint("disabled", false, "http://127.0.0.1:10000", crate::YellowstoneGrpcSessionSettings::default());
|
||||
assert!(crate::YellowstoneGrpcTransportSettings::new(std::vec![disabled]).validate().is_err());
|
||||
assert!(crate::YellowstoneGrpcTransportSettings::new(std::vec![]).validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_metadata_validates_ascii_bounds_sensitivity_and_redacted_debug() {
|
||||
let public = crate::YellowstoneGrpcMetadataEntry::public("x-ksp-public", "VISIBLE-CANARY").expect("public metadata must validate");
|
||||
let secret = crate::YellowstoneGrpcMetadataEntry::secret("x-ksp-token", "GRPC-SECRET-CANARY").expect("secret metadata must validate");
|
||||
assert_eq!(public.key(), "x-ksp-public");
|
||||
assert!(!public.is_secret());
|
||||
assert!(secret.is_secret());
|
||||
let rendered = format!("{public:?} {secret:?}");
|
||||
assert!(!rendered.contains("VISIBLE-CANARY"));
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
let mut metadata = tonic::metadata::MetadataMap::new();
|
||||
assert!(secret.append_to(&mut metadata).is_ok());
|
||||
let appended = metadata.get("x-ksp-token").expect("secret metadata must be appended");
|
||||
assert!(appended.is_sensitive());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_metadata_rejects_reserved_binary_uppercase_malformed_and_oversized_values() {
|
||||
assert!(crate::YellowstoneGrpcMetadataEntry::public("grpc-timeout", "1").is_err());
|
||||
assert!(crate::YellowstoneGrpcMetadataEntry::public("x-ksp-bin", "1").is_err());
|
||||
assert!(crate::YellowstoneGrpcMetadataEntry::public("X-KSP-UPPER", "1").is_err());
|
||||
assert!(crate::YellowstoneGrpcMetadataEntry::public("x-ksp-bad", "line\nfeed").is_err());
|
||||
assert!(crate::YellowstoneGrpcMetadataEntry::secret("x-ksp-large", "S".repeat(super::MAX_GRPC_METADATA_VALUE_LENGTH_BYTES + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_endpoint_metadata_count_is_bounded_and_url_tracks_tls_scheme() {
|
||||
let http = crate::YellowstoneGrpcEndpointUrl::parse("http://127.0.0.1:10000").expect("HTTP URL must parse");
|
||||
let https = crate::YellowstoneGrpcEndpointUrl::parse("https://example.invalid:443").expect("HTTPS URL must parse");
|
||||
assert!(!http.uses_tls());
|
||||
assert!(https.uses_tls());
|
||||
let metadata = (0..=super::MAX_GRPC_METADATA_ENTRY_COUNT)
|
||||
.map(|index| {
|
||||
return crate::YellowstoneGrpcMetadataEntry::public(format!("x-ksp-{index}"), "value").expect("generated metadata must be valid");
|
||||
})
|
||||
.collect();
|
||||
let endpoint = crate::YellowstoneGrpcEndpointSettings::new(
|
||||
"metadata-bound",
|
||||
true,
|
||||
crate::YellowstoneGrpcProviderName::new("fixture"),
|
||||
crate::YellowstoneGrpcClusterName::new("devnet"),
|
||||
http,
|
||||
crate::YellowstoneGrpcSessionSettings::default(),
|
||||
);
|
||||
assert!(endpoint.with_metadata(metadata).is_err());
|
||||
}
|
||||
685
crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs
Normal file
685
crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs
Normal file
@@ -0,0 +1,685 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs
|
||||
// version: 2
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FixtureMode {
|
||||
RoundTrip,
|
||||
ClientHalfClose,
|
||||
HostileClose,
|
||||
Flood,
|
||||
RemoteStatus,
|
||||
Malformed,
|
||||
Oversized,
|
||||
Idle,
|
||||
ReconnectReplay,
|
||||
ReplayGap,
|
||||
ReconnectExhausted,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FixtureGeyser {
|
||||
mode: FixtureMode,
|
||||
half_close_seen: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
subscribe_calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
#[allow(clippy::implicit_return)] // tonic::async_trait generates async wrapper tails outside the authored fixture bodies.
|
||||
#[tonic::async_trait]
|
||||
impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
type SubscribeStream = super::MpscStream<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>>;
|
||||
type SubscribeDeshredStream = futures_util::stream::Empty<std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>>;
|
||||
|
||||
async fn subscribe(
|
||||
&self,
|
||||
request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeRequest>>,
|
||||
) -> std::result::Result<tonic::Response<Self::SubscribeStream>, tonic::Status> {
|
||||
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let subscribe_call = self.subscribe_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst).saturating_add(1);
|
||||
if matches!(self.mode, FixtureMode::ReconnectExhausted) && subscribe_call > 1 {
|
||||
return std::result::Result::Err(tonic::Status::unavailable("GRPC-RECONNECT-SECRET-CANARY"));
|
||||
}
|
||||
let mut inbound = request.into_inner();
|
||||
let (outbound_tx, outbound_rx) = tokio::sync::mpsc::channel(64);
|
||||
let mode = self.mode;
|
||||
let half_close_seen = self.half_close_seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let initial = inbound.message().await;
|
||||
let initial = match initial {
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
_ => return,
|
||||
};
|
||||
match mode {
|
||||
FixtureMode::RoundTrip => {
|
||||
assert!(initial.slots.contains_key("initial"));
|
||||
if outbound_tx.send(std::result::Result::Ok(ping_update())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let ping = inbound.message().await;
|
||||
let ping = match ping {
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
_ => return,
|
||||
};
|
||||
assert_eq!(ping.ping.map(|value| value.id), std::option::Option::Some(1));
|
||||
assert!(ping.accounts.is_empty());
|
||||
assert!(ping.slots.is_empty());
|
||||
assert!(ping.transactions.is_empty());
|
||||
assert!(ping.transactions_status.is_empty());
|
||||
assert!(ping.blocks.is_empty());
|
||||
assert!(ping.blocks_meta.is_empty());
|
||||
assert!(ping.entry.is_empty());
|
||||
if outbound_tx.send(std::result::Result::Ok(pong_update(1))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let mutation = inbound.message().await;
|
||||
let mutation = match mutation {
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
_ => return,
|
||||
};
|
||||
assert!(mutation.accounts.contains_key("mutated"));
|
||||
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(901))).await;
|
||||
},
|
||||
FixtureMode::ClientHalfClose => {
|
||||
let half_close = inbound.message().await;
|
||||
if matches!(half_close, std::result::Result::Ok(std::option::Option::None)) {
|
||||
half_close_seen.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
},
|
||||
FixtureMode::HostileClose => {
|
||||
let half_close = inbound.message().await;
|
||||
if matches!(half_close, std::result::Result::Ok(std::option::Option::None)) {
|
||||
half_close_seen.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
drop(outbound_tx);
|
||||
},
|
||||
FixtureMode::Flood => {
|
||||
for slot in 0_u64..32_u64 {
|
||||
if outbound_tx.send(std::result::Result::Ok(slot_update(1_000 + slot))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
FixtureMode::RemoteStatus => {
|
||||
let _ = outbound_tx.send(std::result::Result::Err(tonic::Status::permission_denied("GRPC-STREAM-REMOTE-SECRET-CANARY"))).await;
|
||||
},
|
||||
FixtureMode::Malformed => {
|
||||
let _ = outbound_tx
|
||||
.send(std::result::Result::Ok(yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: std::vec::Vec::new(),
|
||||
update_oneof: std::option::Option::None,
|
||||
created_at: std::option::Option::None,
|
||||
}))
|
||||
.await;
|
||||
},
|
||||
FixtureMode::Oversized => {
|
||||
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(77))).await;
|
||||
},
|
||||
FixtureMode::Idle => {
|
||||
let _ = inbound.message().await;
|
||||
},
|
||||
FixtureMode::ReconnectReplay => {
|
||||
if subscribe_call == 1 {
|
||||
assert_eq!(initial.from_slot, std::option::Option::None);
|
||||
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(500))).await;
|
||||
} else {
|
||||
assert_eq!(subscribe_call, 2);
|
||||
assert_eq!(initial.from_slot, std::option::Option::Some(500));
|
||||
if outbound_tx.send(std::result::Result::Ok(slot_update(500))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
if outbound_tx.send(std::result::Result::Ok(slot_update(501))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let half_close = inbound.message().await;
|
||||
if matches!(half_close, std::result::Result::Ok(std::option::Option::None)) {
|
||||
half_close_seen.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
},
|
||||
FixtureMode::ReplayGap => {
|
||||
if subscribe_call == 1 {
|
||||
assert_eq!(initial.from_slot, std::option::Option::None);
|
||||
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(500))).await;
|
||||
} else {
|
||||
assert_eq!(subscribe_call, 2);
|
||||
assert_eq!(initial.from_slot, std::option::Option::Some(505));
|
||||
if outbound_tx.send(std::result::Result::Ok(slot_update(505))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let half_close = inbound.message().await;
|
||||
if matches!(half_close, std::result::Result::Ok(std::option::Option::None)) {
|
||||
half_close_seen.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
},
|
||||
FixtureMode::ReconnectExhausted => {
|
||||
assert_eq!(subscribe_call, 1);
|
||||
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(700))).await;
|
||||
},
|
||||
}
|
||||
});
|
||||
return std::result::Result::Ok(tonic::Response::new(super::MpscStream::new(outbound_rx)));
|
||||
}
|
||||
|
||||
async fn subscribe_deshred(
|
||||
&self,
|
||||
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>,
|
||||
) -> std::result::Result<tonic::Response<Self::SubscribeDeshredStream>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("deshred is outside KSP 0.2.9"));
|
||||
}
|
||||
|
||||
async fn subscribe_replay_info(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeReplayInfoRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::SubscribeReplayInfoResponse>, tonic::Status> {
|
||||
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let first_available = match self.mode {
|
||||
FixtureMode::ReconnectReplay | FixtureMode::ReconnectExhausted => std::option::Option::Some(400),
|
||||
FixtureMode::ReplayGap => std::option::Option::Some(505),
|
||||
_ => return std::result::Result::Err(tonic::Status::unimplemented("replay info is outside this fixture mode")),
|
||||
};
|
||||
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::SubscribeReplayInfoResponse { first_available }));
|
||||
}
|
||||
|
||||
async fn ping(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::PingRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::PongResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside this fixture"));
|
||||
}
|
||||
|
||||
async fn get_latest_blockhash(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::GetLatestBlockhashRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetLatestBlockhashResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside this fixture"));
|
||||
}
|
||||
|
||||
async fn get_block_height(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::GetBlockHeightRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetBlockHeightResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside this fixture"));
|
||||
}
|
||||
|
||||
async fn get_slot(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::GetSlotRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetSlotResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside this fixture"));
|
||||
}
|
||||
|
||||
async fn is_blockhash_valid(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::IsBlockhashValidRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::IsBlockhashValidResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside this fixture"));
|
||||
}
|
||||
|
||||
async fn get_version(
|
||||
&self,
|
||||
_request: tonic::Request<yellowstone_grpc_proto::geyser::GetVersionRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetVersionResponse>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside this fixture"));
|
||||
}
|
||||
}
|
||||
|
||||
struct FixtureServer {
|
||||
endpoint_url: std::string::String,
|
||||
half_close_seen: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
subscribe_calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
shutdown: std::option::Option<tokio::sync::oneshot::Sender<()>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl FixtureServer {
|
||||
async fn start(mode: FixtureMode) -> Self {
|
||||
let bind_address: std::net::SocketAddr = "127.0.0.1:0".parse().expect("fixture bind address must parse");
|
||||
let incoming = tonic::transport::server::TcpIncoming::bind(bind_address).expect("fixture gRPC listener must bind");
|
||||
let local_address = incoming.local_addr().expect("fixture gRPC listener must expose local address");
|
||||
let (shutdown, shutdown_receiver) = tokio::sync::oneshot::channel();
|
||||
let half_close_seen = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let subscribe_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let service_half_close_seen = half_close_seen.clone();
|
||||
let service_subscribe_calls = subscribe_calls.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let service = yellowstone_grpc_proto::geyser::geyser_server::GeyserServer::new(FixtureGeyser {
|
||||
mode,
|
||||
half_close_seen: service_half_close_seen,
|
||||
subscribe_calls: service_subscribe_calls,
|
||||
});
|
||||
let result = tonic::transport::Server::builder()
|
||||
.serve_with_incoming_shutdown(service, incoming, async move {
|
||||
let _ = shutdown_receiver.await;
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
});
|
||||
return Self {
|
||||
endpoint_url: format!("http://{local_address}"),
|
||||
half_close_seen,
|
||||
subscribe_calls,
|
||||
shutdown: std::option::Option::Some(shutdown),
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
async fn stop(mut self) {
|
||||
if let std::option::Option::Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
let result = self.task.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_fixture_metadata(metadata: &tonic::metadata::MetadataMap) -> std::result::Result<(), tonic::Status> {
|
||||
let public = metadata.get("x-ksp-public").and_then(|value| return value.to_str().ok());
|
||||
let secret = metadata.get("x-ksp-token").and_then(|value| return value.to_str().ok());
|
||||
if public != std::option::Option::Some("fixture-public") || secret != std::option::Option::Some("GRPC-STREAM-SECRET-CANARY") {
|
||||
return std::result::Result::Err(tonic::Status::unauthenticated("fixture metadata mismatch"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn fixture_settings(
|
||||
url: &str,
|
||||
request_capacity: usize,
|
||||
update_capacity: usize,
|
||||
max_inbound_message_size_bytes: usize,
|
||||
max_outbound_message_size_bytes: usize,
|
||||
) -> crate::YellowstoneGrpcEndpointSettings {
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
return fixture_settings_with_reconnect(
|
||||
url,
|
||||
request_capacity,
|
||||
update_capacity,
|
||||
max_inbound_message_size_bytes,
|
||||
max_outbound_message_size_bytes,
|
||||
crate::YellowstoneGrpcReconnectSettings::new(0, defaults.reconnect().initial_backoff(), defaults.reconnect().max_backoff()),
|
||||
);
|
||||
}
|
||||
|
||||
fn fixture_settings_with_reconnect(
|
||||
url: &str,
|
||||
request_capacity: usize,
|
||||
update_capacity: usize,
|
||||
max_inbound_message_size_bytes: usize,
|
||||
max_outbound_message_size_bytes: usize,
|
||||
reconnect: crate::YellowstoneGrpcReconnectSettings,
|
||||
) -> crate::YellowstoneGrpcEndpointSettings {
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let session = crate::YellowstoneGrpcSessionSettings::new(
|
||||
defaults.connect_timeout(),
|
||||
defaults.unary_timeout(),
|
||||
std::time::Duration::from_millis(250),
|
||||
reconnect,
|
||||
request_capacity,
|
||||
update_capacity,
|
||||
max_inbound_message_size_bytes,
|
||||
max_outbound_message_size_bytes,
|
||||
);
|
||||
let metadata = std::vec![
|
||||
crate::YellowstoneGrpcMetadataEntry::public("x-ksp-public", "fixture-public").expect("fixture public metadata must be valid"),
|
||||
crate::YellowstoneGrpcMetadataEntry::secret("x-ksp-token", "GRPC-STREAM-SECRET-CANARY").expect("fixture secret metadata must be valid"),
|
||||
];
|
||||
return crate::YellowstoneGrpcEndpointSettings::new(
|
||||
"fixture-stream",
|
||||
true,
|
||||
crate::YellowstoneGrpcProviderName::new("fixture-provider"),
|
||||
crate::YellowstoneGrpcClusterName::new("devnet"),
|
||||
crate::YellowstoneGrpcEndpointUrl::parse(url).expect("fixture URL must parse"),
|
||||
session,
|
||||
)
|
||||
.with_metadata(metadata)
|
||||
.expect("fixture metadata settings must validate");
|
||||
}
|
||||
|
||||
fn initial_request() -> crate::YellowstoneSubscribeRequest {
|
||||
let mut request = crate::YellowstoneSubscribeRequest::new();
|
||||
request
|
||||
.insert_slot_filter(
|
||||
crate::YellowstoneSubscribeFilterName::new("initial").expect("fixture filter name must validate"),
|
||||
crate::YellowstoneSubscribeSlotFilter::new(),
|
||||
)
|
||||
.expect("fixture slot filter must insert");
|
||||
return request;
|
||||
}
|
||||
|
||||
fn mutated_request() -> crate::YellowstoneSubscribeRequest {
|
||||
let mut request = crate::YellowstoneSubscribeRequest::new();
|
||||
request
|
||||
.insert_account_filter(
|
||||
crate::YellowstoneSubscribeFilterName::new("mutated").expect("fixture filter name must validate"),
|
||||
crate::YellowstoneSubscribeAccountFilter::new(),
|
||||
)
|
||||
.expect("fixture account filter must insert");
|
||||
return request;
|
||||
}
|
||||
|
||||
fn ping_update() -> yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
return yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: std::vec::Vec::new(),
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Ping(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdatePing {},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn pong_update(id: i32) -> yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
return yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: std::vec::Vec::new(),
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Pong(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdatePong { id },
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn slot_update(slot: u64) -> yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
return yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: std::vec!["initial".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Slot(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateSlot {
|
||||
slot,
|
||||
parent: std::option::Option::Some(slot.saturating_sub(1)),
|
||||
status: yellowstone_grpc_proto::geyser::SlotStatus::SlotProcessed as i32,
|
||||
dead_error: std::option::Option::None,
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_bidi_round_trip_mutates_request_replies_to_ping_and_observes_server_half_close() {
|
||||
let server = FixtureServer::start(FixtureMode::RoundTrip).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), 8, 8, defaults.max_inbound_message_size_bytes(), defaults.max_outbound_message_size_bytes());
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
assert_eq!(session.state(), crate::YellowstoneGrpcSubscribeState::Active);
|
||||
let ping = session.next_update().await.expect("Ping update must decode").expect("Ping update must be present");
|
||||
assert!(matches!(ping, crate::YellowstoneSubscribeUpdate::Ping(_)));
|
||||
let pong = session.next_update().await.expect("Pong update must decode").expect("Pong update must be present");
|
||||
match pong {
|
||||
crate::YellowstoneSubscribeUpdate::Pong(value) => assert_eq!(value.id(), 1),
|
||||
_ => panic!("fixture must return Pong"),
|
||||
}
|
||||
session.try_update(&mutated_request()).expect("request mutation must enter bounded queue");
|
||||
let slot = session.next_update().await.expect("slot update must decode").expect("slot update must be present");
|
||||
match slot {
|
||||
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 901),
|
||||
_ => panic!("fixture must return Slot"),
|
||||
}
|
||||
assert!(session.next_update().await.expect("server half-close must be normal").is_none());
|
||||
assert_eq!(session.state(), crate::YellowstoneGrpcSubscribeState::Closed);
|
||||
session.close().await.expect("already half-closed fixture must close cleanly");
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_explicit_close_half_closes_request_stream_before_deadline() {
|
||||
let server = FixtureServer::start(FixtureMode::ClientHalfClose).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), 8, 8, defaults.max_inbound_message_size_bytes(), defaults.max_outbound_message_size_bytes());
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
session.close().await.expect("graceful half-close must complete");
|
||||
assert!(server.half_close_seen.load(std::sync::atomic::Ordering::SeqCst));
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_hostile_server_shutdown_is_bounded_by_close_timeout() {
|
||||
let server = FixtureServer::start(FixtureMode::HostileClose).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), 8, 8, defaults.max_inbound_message_size_bytes(), defaults.max_outbound_message_size_bytes());
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let error = session.close().await.expect_err("hostile server must hit the bounded graceful close deadline");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT);
|
||||
assert!(server.half_close_seen.load(std::sync::atomic::Ordering::SeqCst));
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_session_drop_best_effort_half_closes_request_stream() {
|
||||
let server = FixtureServer::start(FixtureMode::ClientHalfClose).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), 8, 8, defaults.max_inbound_message_size_bytes(), defaults.max_outbound_message_size_bytes());
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
drop(session);
|
||||
let observed = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
loop {
|
||||
if server.half_close_seen.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return true;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("fixture must observe dropped-session half-close before timeout");
|
||||
assert!(observed);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_slow_receiver_overflow_is_terminal_and_observable() {
|
||||
let server = FixtureServer::start(FixtureMode::Flood).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), 8, 2, defaults.max_inbound_message_size_bytes(), defaults.max_outbound_message_size_bytes());
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
assert!(session.next_update().await.expect("first queued update must remain readable").is_some());
|
||||
assert!(session.next_update().await.expect("second queued update must remain readable").is_some());
|
||||
let error = session.next_update().await.expect_err("overflow must become terminal after bounded queued updates drain");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_GRPC_BACKPRESSURE_OVERFLOW);
|
||||
let rendered = format!("{error:?} {session:?}");
|
||||
assert!(!rendered.contains("GRPC-STREAM-SECRET-CANARY"));
|
||||
assert!(!rendered.contains(server.endpoint_url.as_str()));
|
||||
let _ = session.close().await;
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_stream_remote_status_is_safe_and_terminal() {
|
||||
let server = FixtureServer::start(FixtureMode::RemoteStatus).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), 8, 8, defaults.max_inbound_message_size_bytes(), defaults.max_outbound_message_size_bytes());
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let error = session.next_update().await.expect_err("remote Status must fail stream");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_GRPC_STATUS);
|
||||
let rendered = format!("{error:?} {session:?}");
|
||||
assert!(!rendered.contains("GRPC-STREAM-REMOTE-SECRET-CANARY"));
|
||||
assert!(!rendered.contains("GRPC-STREAM-SECRET-CANARY"));
|
||||
assert!(!rendered.contains(server.endpoint_url.as_str()));
|
||||
let _ = session.close().await;
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_malformed_update_is_rejected_without_raw_wire_escape() {
|
||||
let server = FixtureServer::start(FixtureMode::Malformed).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), 8, 8, defaults.max_inbound_message_size_bytes(), defaults.max_outbound_message_size_bytes());
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let error = session.next_update().await.expect_err("missing update oneof must be rejected");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
|
||||
let _ = session.close().await;
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_inbound_message_limit_is_enforced_by_tonic_stream_decoder() {
|
||||
let server = FixtureServer::start(FixtureMode::Oversized).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), 8, 8, 1, defaults.max_outbound_message_size_bytes());
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let error = session.next_update().await.expect_err("message above configured inbound bound must fail decoding");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_GRPC_STATUS);
|
||||
let rendered = format!("{error:?} {session:?}");
|
||||
assert!(!rendered.contains(server.endpoint_url.as_str()));
|
||||
let _ = session.close().await;
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_outbound_mutation_size_is_rejected_before_queue_dispatch() {
|
||||
let server = FixtureServer::start(FixtureMode::Idle).await;
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), 8, 8, 64 * 1024 * 1024, 1);
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let session = channel
|
||||
.open_standard_subscribe(crate::YellowstoneSubscribeRequest::new())
|
||||
.await
|
||||
.expect("empty initial request must fit one-byte fixture bound");
|
||||
let error = session.try_update(&mutated_request()).expect_err("encoded mutation above configured bound must be rejected before dispatch");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_GRPC_BACKPRESSURE_OVERFLOW);
|
||||
session.close().await.expect("idle fixture must observe client half-close");
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_reconnect_replays_from_last_observed_slot_and_counts_duplicate_identity() {
|
||||
let server = FixtureServer::start(FixtureMode::ReconnectReplay).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings_with_reconnect(
|
||||
server.endpoint_url.as_str(),
|
||||
8,
|
||||
8,
|
||||
defaults.max_inbound_message_size_bytes(),
|
||||
defaults.max_outbound_message_size_bytes(),
|
||||
crate::YellowstoneGrpcReconnectSettings::new(3, std::time::Duration::from_millis(5), std::time::Duration::from_millis(20)),
|
||||
);
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let first = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
|
||||
match first {
|
||||
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 500),
|
||||
_ => panic!("fixture must return first Slot"),
|
||||
}
|
||||
let duplicate = session.next_update().await.expect("replayed duplicate must decode").expect("replayed duplicate must be present");
|
||||
match duplicate {
|
||||
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 500),
|
||||
_ => panic!("fixture must return replayed Slot"),
|
||||
}
|
||||
let resumed = session.next_update().await.expect("resumed slot must decode").expect("resumed slot must be present");
|
||||
match resumed {
|
||||
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 501),
|
||||
_ => panic!("fixture must return resumed Slot"),
|
||||
}
|
||||
let snapshot = session.snapshot();
|
||||
assert_eq!(snapshot.state(), crate::YellowstoneGrpcSubscribeState::Active);
|
||||
assert_eq!(snapshot.reconnect_count(), 1);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 1);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 0);
|
||||
assert_eq!(snapshot.duplicate_update_count(), 1);
|
||||
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(500));
|
||||
assert_eq!(snapshot.last_observed_slot(), std::option::Option::Some(501));
|
||||
session.close().await.expect("reconnected stream must close cleanly");
|
||||
assert_eq!(server.subscribe_calls.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_replay_info_proves_and_clamps_retention_gap_without_lossless_claim() {
|
||||
let server = FixtureServer::start(FixtureMode::ReplayGap).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings_with_reconnect(
|
||||
server.endpoint_url.as_str(),
|
||||
8,
|
||||
8,
|
||||
defaults.max_inbound_message_size_bytes(),
|
||||
defaults.max_outbound_message_size_bytes(),
|
||||
crate::YellowstoneGrpcReconnectSettings::new(3, std::time::Duration::from_millis(5), std::time::Duration::from_millis(20)),
|
||||
);
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let _ = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
|
||||
let resumed = session.next_update().await.expect("clamped replay must decode").expect("clamped replay must be present");
|
||||
match resumed {
|
||||
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 505),
|
||||
_ => panic!("fixture must return clamped Slot"),
|
||||
}
|
||||
let snapshot = session.snapshot();
|
||||
assert_eq!(snapshot.reconnect_count(), 1);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 1);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 1);
|
||||
assert_eq!(snapshot.duplicate_update_count(), 0);
|
||||
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(505));
|
||||
assert_eq!(snapshot.last_observed_slot(), std::option::Option::Some(505));
|
||||
session.close().await.expect("gap fixture must close cleanly");
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_reconnect_budget_exhaustion_is_terminal_and_safe() {
|
||||
let server = FixtureServer::start(FixtureMode::ReconnectExhausted).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings_with_reconnect(
|
||||
server.endpoint_url.as_str(),
|
||||
8,
|
||||
8,
|
||||
defaults.max_inbound_message_size_bytes(),
|
||||
defaults.max_outbound_message_size_bytes(),
|
||||
crate::YellowstoneGrpcReconnectSettings::new(2, std::time::Duration::from_millis(5), std::time::Duration::from_millis(10)),
|
||||
);
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let _ = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
|
||||
let error = session.next_update().await.expect_err("failed reconnect attempts must exhaust the bounded budget");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_GRPC_CHANNEL_FAILED);
|
||||
let snapshot = session.snapshot();
|
||||
assert_eq!(snapshot.state(), crate::YellowstoneGrpcSubscribeState::Failed);
|
||||
assert_eq!(snapshot.reconnect_count(), 0);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 2);
|
||||
assert_eq!(snapshot.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_GRPC_CHANNEL_FAILED));
|
||||
let rendered = format!("{error:?} {session:?}");
|
||||
assert!(!rendered.contains("GRPC-RECONNECT-SECRET-CANARY"));
|
||||
assert!(!rendered.contains(server.endpoint_url.as_str()));
|
||||
assert_eq!(server.subscribe_calls.load(std::sync::atomic::Ordering::SeqCst), 3);
|
||||
let _ = session.close().await;
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_shutdown_interrupts_reconnect_backoff_and_mutation_is_rejected_during_reconnect() {
|
||||
let server = FixtureServer::start(FixtureMode::ReconnectReplay).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings_with_reconnect(
|
||||
server.endpoint_url.as_str(),
|
||||
8,
|
||||
8,
|
||||
defaults.max_inbound_message_size_bytes(),
|
||||
defaults.max_outbound_message_size_bytes(),
|
||||
crate::YellowstoneGrpcReconnectSettings::new(3, std::time::Duration::from_millis(500), std::time::Duration::from_millis(500)),
|
||||
);
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let _ = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
loop {
|
||||
if session.state() == crate::YellowstoneGrpcSubscribeState::Reconnecting {
|
||||
return;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("fixture must enter reconnecting state");
|
||||
let mutation_error = session.try_update(&mutated_request()).expect_err("request mutation must be rejected during reconnect");
|
||||
assert_eq!(mutation_error.code(), crate::ERROR_CODE_GRPC_SESSION_CLOSED);
|
||||
session.close().await.expect("shutdown must interrupt reconnect backoff without opening another stream");
|
||||
assert_eq!(server.subscribe_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
server.stop().await;
|
||||
}
|
||||
777
crates/ksp-onchain-transport-lib/unit_tests/grpc_subscribe.rs
Normal file
777
crates/ksp-onchain-transport-lib/unit_tests/grpc_subscribe.rs
Normal file
@@ -0,0 +1,777 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_subscribe.rs
|
||||
// version: 5
|
||||
|
||||
fn filter_name(value: &str) -> crate::YellowstoneSubscribeFilterName {
|
||||
return crate::YellowstoneSubscribeFilterName::new(value).expect("fixture filter name must validate");
|
||||
}
|
||||
|
||||
fn minimal_transaction_info(signature_byte: u8, index: u64) -> yellowstone_grpc_proto::geyser::SubscribeUpdateTransactionInfo {
|
||||
let meta = yellowstone_grpc_proto::solana::storage::confirmed_block::TransactionStatusMeta { fee: 5_000, ..std::default::Default::default() };
|
||||
return yellowstone_grpc_proto::geyser::SubscribeUpdateTransactionInfo {
|
||||
signature: vec![signature_byte; 64],
|
||||
is_vote: false,
|
||||
transaction: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::Transaction {
|
||||
signatures: vec![vec![signature_byte; 64]],
|
||||
message: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::Message {
|
||||
header: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::MessageHeader {
|
||||
num_required_signatures: 1,
|
||||
num_readonly_signed_accounts: 0,
|
||||
num_readonly_unsigned_accounts: 0,
|
||||
}),
|
||||
account_keys: vec![vec![1_u8; 32]],
|
||||
recent_blockhash: vec![2_u8; 32],
|
||||
instructions: vec![],
|
||||
versioned: false,
|
||||
address_table_lookups: vec![],
|
||||
config: std::option::Option::None,
|
||||
}),
|
||||
}),
|
||||
meta: std::option::Option::Some(meta),
|
||||
index,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_subscribe_filter_name_is_bounded_globally_unique_and_debug_redacted() {
|
||||
assert!(crate::YellowstoneSubscribeFilterName::new("").is_err());
|
||||
assert!(crate::YellowstoneSubscribeFilterName::new(" leading").is_err());
|
||||
assert!(crate::YellowstoneSubscribeFilterName::new("trailing ").is_err());
|
||||
assert!(crate::YellowstoneSubscribeFilterName::new("line\nbreak").is_err());
|
||||
assert!(crate::YellowstoneSubscribeFilterName::new("x".repeat(129)).is_err());
|
||||
let name = filter_name("account-primary");
|
||||
assert_eq!(name.as_str(), "account-primary");
|
||||
assert!(!format!("{name:?}").contains("account-primary"));
|
||||
let mut request = crate::YellowstoneSubscribeRequest::new();
|
||||
assert!(request.insert_account_filter(name.clone(), crate::YellowstoneSubscribeAccountFilter::new()).is_ok());
|
||||
let duplicate = request.insert_slot_filter(name, crate::YellowstoneSubscribeSlotFilter::new());
|
||||
assert!(duplicate.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_subscribe_empty_and_named_empty_maps_encode_exactly() {
|
||||
let empty = crate::YellowstoneSubscribeRequest::new().to_wire().expect("empty request must encode");
|
||||
assert!(empty.accounts.is_empty());
|
||||
assert!(empty.slots.is_empty());
|
||||
assert!(empty.transactions.is_empty());
|
||||
assert!(empty.transactions_status.is_empty());
|
||||
assert!(empty.blocks.is_empty());
|
||||
assert!(empty.blocks_meta.is_empty());
|
||||
assert!(empty.entry.is_empty());
|
||||
assert_eq!(empty.commitment, std::option::Option::None);
|
||||
assert!(empty.accounts_data_slice.is_empty());
|
||||
assert_eq!(empty.ping, std::option::Option::None);
|
||||
assert_eq!(empty.from_slot, std::option::Option::None);
|
||||
let mut request = crate::YellowstoneSubscribeRequest::new();
|
||||
assert!(request.insert_account_filter(filter_name("accounts"), crate::YellowstoneSubscribeAccountFilter::new()).is_ok());
|
||||
assert!(request.insert_slot_filter(filter_name("slots"), crate::YellowstoneSubscribeSlotFilter::new()).is_ok());
|
||||
assert!(request.insert_transaction_filter(filter_name("transactions"), crate::YellowstoneSubscribeTransactionFilter::new()).is_ok());
|
||||
assert!(request.insert_transaction_status_filter(filter_name("transaction-status"), crate::YellowstoneSubscribeTransactionFilter::new()).is_ok());
|
||||
assert!(request.insert_block_filter(filter_name("blocks"), crate::YellowstoneSubscribeBlockFilter::new()).is_ok());
|
||||
assert!(request.insert_blocks_meta_filter(filter_name("blocks-meta"), crate::YellowstoneSubscribeBlocksMetaFilter::new()).is_ok());
|
||||
assert!(request.insert_entry_filter(filter_name("entry"), crate::YellowstoneSubscribeEntryFilter::new()).is_ok());
|
||||
let wire = request.to_wire().expect("named empty request must encode");
|
||||
assert_eq!(wire.accounts.len(), 1);
|
||||
assert_eq!(wire.slots.len(), 1);
|
||||
assert_eq!(wire.transactions.len(), 1);
|
||||
assert_eq!(wire.transactions_status.len(), 1);
|
||||
assert_eq!(wire.blocks.len(), 1);
|
||||
assert_eq!(wire.blocks_meta.len(), 1);
|
||||
assert_eq!(wire.entry.len(), 1);
|
||||
assert_eq!(wire.accounts.get("accounts"), std::option::Option::Some(&yellowstone_grpc_proto::geyser::SubscribeRequestFilterAccounts::default()));
|
||||
assert_eq!(wire.slots.get("slots"), std::option::Option::Some(&yellowstone_grpc_proto::geyser::SubscribeRequestFilterSlots::default()));
|
||||
assert_eq!(
|
||||
wire.transactions.get("transactions"),
|
||||
std::option::Option::Some(&yellowstone_grpc_proto::geyser::SubscribeRequestFilterTransactions::default()),
|
||||
);
|
||||
assert_eq!(
|
||||
wire.transactions_status.get("transaction-status"),
|
||||
std::option::Option::Some(&yellowstone_grpc_proto::geyser::SubscribeRequestFilterTransactions::default())
|
||||
);
|
||||
assert_eq!(wire.blocks.get("blocks"), std::option::Option::Some(&yellowstone_grpc_proto::geyser::SubscribeRequestFilterBlocks::default()));
|
||||
assert!(wire.blocks_meta.contains_key("blocks-meta"));
|
||||
assert!(wire.entry.contains_key("entry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_subscribe_common_fields_preserve_optional_and_ordered_wire_semantics() {
|
||||
let mut request = crate::YellowstoneSubscribeRequest::new();
|
||||
request.set_commitment(std::option::Option::Some(crate::SolanaCommitment::Finalized));
|
||||
let first = crate::YellowstoneAccountsDataSlice::new(8, 16).expect("first slice must validate");
|
||||
let second = crate::YellowstoneAccountsDataSlice::new(64, 0).expect("zero-length slice remains representable");
|
||||
assert!(request.push_accounts_data_slice(first).is_ok());
|
||||
assert!(request.push_accounts_data_slice(second).is_ok());
|
||||
request.set_ping(std::option::Option::Some(crate::YellowstoneSubscribePing::new(-7)));
|
||||
request.set_from_slot(std::option::Option::Some(42));
|
||||
assert_eq!(request.commitment(), std::option::Option::Some(crate::SolanaCommitment::Finalized));
|
||||
assert_eq!(request.accounts_data_slices(), &[first, second]);
|
||||
assert_eq!(request.ping(), std::option::Option::Some(crate::YellowstoneSubscribePing::new(-7)));
|
||||
assert_eq!(request.from_slot(), std::option::Option::Some(42));
|
||||
let wire = request.to_wire().expect("common fields must encode");
|
||||
assert_eq!(wire.commitment, std::option::Option::Some(yellowstone_grpc_proto::geyser::CommitmentLevel::Finalized as i32));
|
||||
assert_eq!(wire.accounts_data_slice.len(), 2);
|
||||
assert_eq!(wire.accounts_data_slice[0].offset, 8);
|
||||
assert_eq!(wire.accounts_data_slice[0].length, 16);
|
||||
assert_eq!(wire.accounts_data_slice[1].offset, 64);
|
||||
assert_eq!(wire.accounts_data_slice[1].length, 0);
|
||||
assert_eq!(wire.ping.map(|ping| return ping.id), std::option::Option::Some(-7));
|
||||
assert_eq!(wire.from_slot, std::option::Option::Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_subscribe_common_bounds_reject_before_wire_conversion() {
|
||||
assert!(crate::YellowstoneAccountsDataSlice::new(0, 64 * 1024 * 1024).is_ok());
|
||||
assert!(crate::YellowstoneAccountsDataSlice::new(0, 64 * 1024 * 1024 + 1).is_err());
|
||||
assert!(crate::YellowstoneAccountsDataSlice::new(u64::MAX, 1).is_err());
|
||||
let mut slices = crate::YellowstoneSubscribeRequest::new();
|
||||
for index in 0..128_u64 {
|
||||
let slice = crate::YellowstoneAccountsDataSlice::new(index, 1).expect("bounded fixture slice must validate");
|
||||
assert!(slices.push_accounts_data_slice(slice).is_ok());
|
||||
}
|
||||
let excess = crate::YellowstoneAccountsDataSlice::new(129, 1).expect("excess fixture slice itself must validate");
|
||||
assert!(slices.push_accounts_data_slice(excess).is_err());
|
||||
let mut filters = crate::YellowstoneSubscribeRequest::new();
|
||||
for index in 0..1_024_u32 {
|
||||
let name = crate::YellowstoneSubscribeFilterName::new(format!("f{index}")).expect("bounded filter name must validate");
|
||||
assert!(filters.insert_account_filter(name, crate::YellowstoneSubscribeAccountFilter::new()).is_ok());
|
||||
}
|
||||
let excess_name = crate::YellowstoneSubscribeFilterName::new("excess").expect("excess filter name must validate independently");
|
||||
assert!(filters.insert_account_filter(excess_name, crate::YellowstoneSubscribeAccountFilter::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_subscribe_debug_omits_filter_names_and_future_payloads() {
|
||||
let mut request = crate::YellowstoneSubscribeRequest::new();
|
||||
assert!(request.insert_account_filter(filter_name("sensitive-label"), crate::YellowstoneSubscribeAccountFilter::new()).is_ok());
|
||||
request.set_ping(std::option::Option::Some(crate::YellowstoneSubscribePing::new(9)));
|
||||
request.set_from_slot(std::option::Option::Some(77));
|
||||
let debug = format!("{request:?}");
|
||||
assert!(!debug.contains("sensitive-label"));
|
||||
assert!(debug.contains("account_filter_count"));
|
||||
assert!(debug.contains("from_slot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_account_and_slot_filters_encode_complete_current_wire() {
|
||||
let account = ksp_core_lib::Pubkey::new_from_array([1_u8; 32]);
|
||||
let owner = ksp_core_lib::Pubkey::new_from_array([2_u8; 32]);
|
||||
let mut filter = crate::YellowstoneSubscribeAccountFilter::new();
|
||||
assert!(filter.push_account(account).is_ok());
|
||||
assert!(filter.push_owner(owner).is_ok());
|
||||
let raw = crate::YellowstoneAccountMemcmp::bytes(4, vec![1_u8, 2, 3]).expect("raw memcmp must validate");
|
||||
let base58 = crate::YellowstoneAccountMemcmp::base58(8, "1234").expect("base58 memcmp must validate");
|
||||
let base64 = crate::YellowstoneAccountMemcmp::base64(12, "AQID==").expect("base64 memcmp must validate");
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::Memcmp(raw)).is_ok());
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::Memcmp(base58)).is_ok());
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::Memcmp(base64)).is_ok());
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::DataSize(165)).is_ok());
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::TokenAccountState(true)).is_ok());
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::Lamports(crate::YellowstoneAccountLamportsFilter::Eq(1))).is_ok());
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::Lamports(crate::YellowstoneAccountLamportsFilter::Ne(2))).is_ok());
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::Lamports(crate::YellowstoneAccountLamportsFilter::Lt(3))).is_ok());
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::Lamports(crate::YellowstoneAccountLamportsFilter::Gt(4))).is_ok());
|
||||
filter.set_nonempty_txn_signature(std::option::Option::Some(true));
|
||||
let cuckoo =
|
||||
crate::YellowstoneCuckooFilter::new(vec![9_u8; 16], 4, 4, 8, 77, crate::YellowstoneCuckooHashAlgorithm::SipHash).expect("cuckoo fixture must validate");
|
||||
filter.set_cuckoo_accounts_filter(std::option::Option::Some(cuckoo));
|
||||
let wire = filter.to_wire();
|
||||
assert_eq!(wire.account, vec![account.to_string()]);
|
||||
assert_eq!(wire.owner, vec![owner.to_string()]);
|
||||
assert_eq!(wire.filters.len(), 9);
|
||||
match wire.filters[0].filter.as_ref().expect("raw memcmp oneof must be present") {
|
||||
yellowstone_grpc_proto::geyser::subscribe_request_filter_accounts_filter::Filter::Memcmp(value) => assert_eq!(
|
||||
value.data,
|
||||
std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_request_filter_accounts_filter_memcmp::Data::Bytes(vec![1_u8, 2, 3]))
|
||||
),
|
||||
_ => panic!("first predicate must stay memcmp"),
|
||||
}
|
||||
match wire.filters[5].filter.as_ref().expect("lamports oneof must be present") {
|
||||
yellowstone_grpc_proto::geyser::subscribe_request_filter_accounts_filter::Filter::Lamports(value) => {
|
||||
assert_eq!(value.cmp, std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_request_filter_accounts_filter_lamports::Cmp::Eq(1)))
|
||||
},
|
||||
_ => panic!("sixth predicate must stay lamports"),
|
||||
}
|
||||
assert_eq!(wire.nonempty_txn_signature, std::option::Option::Some(true));
|
||||
let cuckoo = wire.cuckoo_accounts_filter.expect("cuckoo filter must be present");
|
||||
assert_eq!(cuckoo.data, vec![9_u8; 16]);
|
||||
assert_eq!(cuckoo.bucket_count, 4);
|
||||
assert_eq!(cuckoo.entries_per_bucket, 4);
|
||||
assert_eq!(cuckoo.fingerprint_bits, 8);
|
||||
assert_eq!(cuckoo.hash_seed, 77);
|
||||
assert_eq!(cuckoo.hash_algorithm, yellowstone_grpc_proto::geyser::CuckooHashAlgorithm::SipHash as i32);
|
||||
let mut slots = crate::YellowstoneSubscribeSlotFilter::new();
|
||||
slots.set_filter_by_commitment(std::option::Option::Some(false));
|
||||
slots.set_interslot_updates(std::option::Option::Some(true));
|
||||
assert_eq!(slots.filter_by_commitment(), std::option::Option::Some(false));
|
||||
assert_eq!(slots.interslot_updates(), std::option::Option::Some(true));
|
||||
let slots_wire = slots.to_wire();
|
||||
assert_eq!(slots_wire.filter_by_commitment, std::option::Option::Some(false));
|
||||
assert_eq!(slots_wire.interslot_updates, std::option::Option::Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_account_filter_bounds_and_debug_are_provider_neutral() {
|
||||
assert!(crate::YellowstoneCuckooFilter::new(vec![], 0, 4, 8, 0, crate::YellowstoneCuckooHashAlgorithm::SipHash).is_err());
|
||||
assert!(crate::YellowstoneCuckooFilter::new(vec![], 1, 0, 8, 0, crate::YellowstoneCuckooHashAlgorithm::SipHash).is_err());
|
||||
assert!(crate::YellowstoneCuckooFilter::new(vec![], 1, 4, 7, 0, crate::YellowstoneCuckooHashAlgorithm::SipHash).is_err());
|
||||
assert!(crate::YellowstoneCuckooFilter::new(vec![], 1, 4, 12, 0, crate::YellowstoneCuckooHashAlgorithm::SipHash).is_ok());
|
||||
assert!(crate::YellowstoneAccountMemcmp::base58(0, "contains whitespace").is_err());
|
||||
assert!(crate::YellowstoneAccountMemcmp::base64(0, "line\nbreak").is_err());
|
||||
let memcmp = crate::YellowstoneAccountMemcmp::bytes(5, vec![7_u8, 8, 9]).expect("memcmp must validate");
|
||||
let debug = format!("{memcmp:?}");
|
||||
assert!(debug.contains("payload_length"));
|
||||
assert!(!debug.contains("7, 8, 9"));
|
||||
let mut filter = crate::YellowstoneSubscribeAccountFilter::new();
|
||||
assert!(filter.push_account(ksp_core_lib::Pubkey::new_from_array([3_u8; 32])).is_ok());
|
||||
assert!(filter.push_filter(crate::YellowstoneAccountFilterPredicate::Memcmp(memcmp)).is_ok());
|
||||
let filter_debug = format!("{filter:?}");
|
||||
assert!(filter_debug.contains("account_count"));
|
||||
assert!(!filter_debug.contains(&ksp_core_lib::Pubkey::new_from_array([3_u8; 32]).to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_account_update_decodes_complete_wire_and_redacts_payload_debug() {
|
||||
let signature = vec![6_u8; 64];
|
||||
let wire = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["accounts-main".to_owned(), "accounts-owner".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Account(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateAccount {
|
||||
account: std::option::Option::Some(yellowstone_grpc_proto::geyser::SubscribeUpdateAccountInfo {
|
||||
pubkey: vec![1_u8; 32],
|
||||
lamports: 42,
|
||||
owner: vec![2_u8; 32],
|
||||
executable: true,
|
||||
rent_epoch: 9,
|
||||
data: vec![0xAA_u8, 0xBB, 0xCC],
|
||||
write_version: 7,
|
||||
txn_signature: std::option::Option::Some(signature),
|
||||
}),
|
||||
slot: 123,
|
||||
is_startup: true,
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::Some(yellowstone_grpc_proto::prost_types::Timestamp { seconds: 1_700_000_000, nanos: 123_456_789 }),
|
||||
};
|
||||
let update = super::decode_account_update(wire).expect("account update fixture must decode");
|
||||
assert_eq!(update.filters()[0].as_str(), "accounts-main");
|
||||
assert_eq!(update.filters()[1].as_str(), "accounts-owner");
|
||||
assert_eq!(update.created_at().expect("timestamp must be present").seconds(), 1_700_000_000);
|
||||
assert_eq!(update.created_at().expect("timestamp must be present").nanos(), 123_456_789);
|
||||
assert_eq!(update.slot(), 123);
|
||||
assert!(update.is_startup());
|
||||
assert_eq!(update.account().pubkey(), &ksp_core_lib::Pubkey::new_from_array([1_u8; 32]));
|
||||
assert_eq!(update.account().owner(), &ksp_core_lib::Pubkey::new_from_array([2_u8; 32]));
|
||||
assert_eq!(update.account().lamports(), 42);
|
||||
assert!(update.account().executable());
|
||||
assert_eq!(update.account().rent_epoch(), 9);
|
||||
assert_eq!(update.account().data(), &[0xAA_u8, 0xBB, 0xCC]);
|
||||
assert_eq!(update.account().write_version(), 7);
|
||||
assert_eq!(update.account().transaction_signature().expect("signature must be present").as_bytes(), &[6_u8; 64]);
|
||||
let debug = format!("{update:?}");
|
||||
assert!(!debug.contains("accounts-main"));
|
||||
assert!(!debug.contains("170, 187, 204"));
|
||||
assert!(!debug.contains(&ksp_core_lib::Pubkey::new_from_array([1_u8; 32]).to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_account_update_rejects_malformed_fixed_width_and_envelope_fields() {
|
||||
let malformed_pubkey = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["accounts".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Account(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateAccount {
|
||||
account: std::option::Option::Some(yellowstone_grpc_proto::geyser::SubscribeUpdateAccountInfo {
|
||||
pubkey: vec![1_u8; 31],
|
||||
lamports: 0,
|
||||
owner: vec![2_u8; 32],
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
data: vec![],
|
||||
write_version: 0,
|
||||
txn_signature: std::option::Option::None,
|
||||
}),
|
||||
slot: 0,
|
||||
is_startup: false,
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
assert!(super::decode_account_update(malformed_pubkey).is_err());
|
||||
let malformed_signature = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["accounts".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Account(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateAccount {
|
||||
account: std::option::Option::Some(yellowstone_grpc_proto::geyser::SubscribeUpdateAccountInfo {
|
||||
pubkey: vec![1_u8; 32],
|
||||
lamports: 0,
|
||||
owner: vec![2_u8; 32],
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
data: vec![],
|
||||
write_version: 0,
|
||||
txn_signature: std::option::Option::Some(vec![9_u8; 63]),
|
||||
}),
|
||||
slot: 0,
|
||||
is_startup: false,
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
assert!(super::decode_account_update(malformed_signature).is_err());
|
||||
let missing_info = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["accounts".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Account(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateAccount { account: std::option::Option::None, slot: 0, is_startup: false },
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
assert!(super::decode_account_update(missing_info).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_slot_update_preserves_all_current_statuses_and_bounds_dead_error() {
|
||||
let statuses = [
|
||||
(yellowstone_grpc_proto::geyser::SlotStatus::SlotProcessed, crate::YellowstoneSlotStatus::Processed),
|
||||
(yellowstone_grpc_proto::geyser::SlotStatus::SlotConfirmed, crate::YellowstoneSlotStatus::Confirmed),
|
||||
(yellowstone_grpc_proto::geyser::SlotStatus::SlotFinalized, crate::YellowstoneSlotStatus::Finalized),
|
||||
(yellowstone_grpc_proto::geyser::SlotStatus::SlotFirstShredReceived, crate::YellowstoneSlotStatus::FirstShredReceived),
|
||||
(yellowstone_grpc_proto::geyser::SlotStatus::SlotCompleted, crate::YellowstoneSlotStatus::Completed),
|
||||
(yellowstone_grpc_proto::geyser::SlotStatus::SlotCreatedBank, crate::YellowstoneSlotStatus::CreatedBank),
|
||||
(yellowstone_grpc_proto::geyser::SlotStatus::SlotDead, crate::YellowstoneSlotStatus::Dead),
|
||||
];
|
||||
for (wire_status, expected) in statuses {
|
||||
let wire = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["slots".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Slot(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateSlot {
|
||||
slot: 88,
|
||||
parent: std::option::Option::Some(87),
|
||||
status: wire_status as i32,
|
||||
dead_error: if expected == crate::YellowstoneSlotStatus::Dead {
|
||||
std::option::Option::Some("fork rejected".to_owned())
|
||||
} else {
|
||||
std::option::Option::None
|
||||
},
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::Some(yellowstone_grpc_proto::prost_types::Timestamp { seconds: 5, nanos: 6 }),
|
||||
};
|
||||
let update = super::decode_slot_update(wire).expect("slot update fixture must decode");
|
||||
assert_eq!(update.status(), expected);
|
||||
assert_eq!(update.slot(), 88);
|
||||
assert_eq!(update.parent(), std::option::Option::Some(87));
|
||||
assert_eq!(update.filters()[0].as_str(), "slots");
|
||||
if expected == crate::YellowstoneSlotStatus::Dead {
|
||||
assert_eq!(update.dead_error(), std::option::Option::Some("fork rejected"));
|
||||
assert!(!format!("{update:?}").contains("fork rejected"));
|
||||
}
|
||||
}
|
||||
let unknown = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["slots".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Slot(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateSlot {
|
||||
slot: 1,
|
||||
parent: std::option::Option::None,
|
||||
status: 99,
|
||||
dead_error: std::option::Option::None,
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
assert!(super::decode_slot_update(unknown).is_err());
|
||||
let oversized = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["slots".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Slot(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateSlot {
|
||||
slot: 1,
|
||||
parent: std::option::Option::None,
|
||||
status: yellowstone_grpc_proto::geyser::SlotStatus::SlotDead as i32,
|
||||
dead_error: std::option::Option::Some("x".repeat(16 * 1024 + 1)),
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
assert!(super::decode_slot_update(oversized).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_transaction_filters_encode_complete_current_wire_and_redact_selectors() {
|
||||
let mut filter = crate::YellowstoneSubscribeTransactionFilter::new();
|
||||
filter.set_vote(std::option::Option::Some(false));
|
||||
filter.set_failed(std::option::Option::Some(true));
|
||||
let signature = crate::YellowstoneTransactionSignatureSelector::new("1".repeat(64)).expect("signature selector must validate");
|
||||
filter.set_signature(std::option::Option::Some(signature));
|
||||
assert!(filter.push_account_include(ksp_core_lib::Pubkey::new_from_array([1_u8; 32])).is_ok());
|
||||
assert!(filter.push_account_exclude(ksp_core_lib::Pubkey::new_from_array([2_u8; 32])).is_ok());
|
||||
assert!(filter.push_account_required(ksp_core_lib::Pubkey::new_from_array([3_u8; 32])).is_ok());
|
||||
let cuckoo =
|
||||
crate::YellowstoneCuckooFilter::new(vec![0_u8; 16], 4, 4, 8, 7, crate::YellowstoneCuckooHashAlgorithm::SipHash).expect("cuckoo filter must validate");
|
||||
filter.set_cuckoo_account_include(std::option::Option::Some(cuckoo));
|
||||
filter.set_token_accounts(std::option::Option::Some(crate::YellowstoneTokenAccountExpansion::BalanceChanged));
|
||||
let wire = filter.to_wire();
|
||||
assert_eq!(wire.vote, std::option::Option::Some(false));
|
||||
assert_eq!(wire.failed, std::option::Option::Some(true));
|
||||
assert!(wire.signature.is_some());
|
||||
assert_eq!(wire.account_include.len(), 1);
|
||||
assert_eq!(wire.account_exclude.len(), 1);
|
||||
assert_eq!(wire.account_required.len(), 1);
|
||||
assert!(wire.cuckoo_account_include.is_some());
|
||||
assert_eq!(wire.token_accounts, std::option::Option::Some(yellowstone_grpc_proto::geyser::TokenAccountExpansionControlFlag::BalanceChanged as i32));
|
||||
let debug = format!("{filter:?}");
|
||||
assert!(debug.contains("account_include_count"));
|
||||
assert!(!debug.contains(&"1".repeat(32)));
|
||||
assert!(!debug.contains(&ksp_core_lib::Pubkey::new_from_array([1_u8; 32]).to_string()));
|
||||
assert!(crate::YellowstoneTransactionSignatureSelector::new("contains-0-O-I-l").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_transaction_update_decodes_current_storage_wire_including_v1_config_and_meta() {
|
||||
let confirmed = yellowstone_grpc_proto::geyser::SubscribeUpdateTransactionInfo {
|
||||
signature: vec![9_u8; 64],
|
||||
is_vote: false,
|
||||
transaction: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::Transaction {
|
||||
signatures: vec![vec![9_u8; 64], vec![8_u8; 64]],
|
||||
message: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::Message {
|
||||
header: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::MessageHeader {
|
||||
num_required_signatures: 2,
|
||||
num_readonly_signed_accounts: 1,
|
||||
num_readonly_unsigned_accounts: 1,
|
||||
}),
|
||||
account_keys: vec![vec![1_u8; 32], vec![2_u8; 32]],
|
||||
recent_blockhash: vec![3_u8; 32],
|
||||
instructions: vec![yellowstone_grpc_proto::solana::storage::confirmed_block::CompiledInstruction {
|
||||
program_id_index: 1,
|
||||
accounts: vec![0_u8, 1],
|
||||
data: vec![4_u8, 5, 6],
|
||||
}],
|
||||
versioned: true,
|
||||
address_table_lookups: vec![yellowstone_grpc_proto::solana::storage::confirmed_block::MessageAddressTableLookup {
|
||||
account_key: vec![4_u8; 32],
|
||||
writable_indexes: vec![1_u8, 2],
|
||||
readonly_indexes: vec![3_u8],
|
||||
}],
|
||||
config: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::TransactionConfig {
|
||||
priority_fee: std::option::Option::Some(7),
|
||||
compute_unit_limit: std::option::Option::Some(8),
|
||||
loaded_accounts_data_size_limit: std::option::Option::Some(9),
|
||||
heap_size: std::option::Option::Some(10),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
meta: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::TransactionStatusMeta {
|
||||
err: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::TransactionError { err: vec![11_u8, 12] }),
|
||||
fee: 5_000,
|
||||
pre_balances: vec![100, 200],
|
||||
post_balances: vec![90, 210],
|
||||
inner_instructions: vec![yellowstone_grpc_proto::solana::storage::confirmed_block::InnerInstructions {
|
||||
index: 0,
|
||||
instructions: vec![yellowstone_grpc_proto::solana::storage::confirmed_block::InnerInstruction {
|
||||
program_id_index: 1,
|
||||
accounts: vec![0_u8],
|
||||
data: vec![13_u8, 14],
|
||||
stack_height: std::option::Option::Some(2),
|
||||
}],
|
||||
}],
|
||||
inner_instructions_none: false,
|
||||
log_messages: vec!["Program log: fixture".to_owned()],
|
||||
log_messages_none: false,
|
||||
pre_token_balances: vec![yellowstone_grpc_proto::solana::storage::confirmed_block::TokenBalance {
|
||||
account_index: 0,
|
||||
mint: "mint-fixture".to_owned(),
|
||||
ui_token_amount: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::UiTokenAmount {
|
||||
ui_amount: 1.5,
|
||||
decimals: 6,
|
||||
amount: "1500000".to_owned(),
|
||||
ui_amount_string: "1.5".to_owned(),
|
||||
}),
|
||||
owner: "owner-fixture".to_owned(),
|
||||
program_id: "program-fixture".to_owned(),
|
||||
}],
|
||||
post_token_balances: vec![],
|
||||
rewards: vec![yellowstone_grpc_proto::solana::storage::confirmed_block::Reward {
|
||||
pubkey: ksp_core_lib::Pubkey::new_from_array([5_u8; 32]).to_string(),
|
||||
lamports: 17,
|
||||
post_balance: 18,
|
||||
reward_type: yellowstone_grpc_proto::solana::storage::confirmed_block::RewardType::Staking as i32,
|
||||
commission: "5".to_owned(),
|
||||
commission_bps: "500".to_owned(),
|
||||
}],
|
||||
loaded_writable_addresses: vec![vec![6_u8; 32]],
|
||||
loaded_readonly_addresses: vec![vec![7_u8; 32]],
|
||||
return_data: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::ReturnData {
|
||||
program_id: vec![8_u8; 32],
|
||||
data: vec![15_u8, 16],
|
||||
}),
|
||||
return_data_none: false,
|
||||
compute_units_consumed: std::option::Option::Some(123),
|
||||
cost_units: std::option::Option::Some(456),
|
||||
}),
|
||||
index: 3,
|
||||
};
|
||||
let wire = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["transactions-main".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Transaction(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateTransaction { transaction: std::option::Option::Some(confirmed), slot: 42 },
|
||||
)),
|
||||
created_at: std::option::Option::Some(yellowstone_grpc_proto::prost_types::Timestamp { seconds: 100, nanos: 200 }),
|
||||
};
|
||||
let update = super::decode_transaction_update(wire).expect("transaction update fixture must decode");
|
||||
assert_eq!(update.slot(), 42);
|
||||
assert_eq!(update.filters()[0].as_str(), "transactions-main");
|
||||
assert_eq!(update.transaction().signature().as_bytes(), &[9_u8; 64]);
|
||||
assert_eq!(format!("{:?}", update.transaction().signature()), "YellowstoneTransactionSignature(<redacted>)");
|
||||
assert_eq!(update.transaction().index(), 3);
|
||||
assert_eq!(update.transaction().transaction().signatures().len(), 2);
|
||||
assert!(update.transaction().transaction().message().versioned());
|
||||
let config = update.transaction().transaction().message().config().expect("v1 config must be preserved");
|
||||
assert_eq!(config.priority_fee(), std::option::Option::Some(7));
|
||||
assert_eq!(config.heap_size(), std::option::Option::Some(10));
|
||||
assert_eq!(update.transaction().meta().fee(), 5_000);
|
||||
assert_eq!(update.transaction().meta().error().expect("error must be present").as_bytes(), &[11_u8, 12]);
|
||||
assert_eq!(update.transaction().meta().inner_instructions()[0].instructions()[0].stack_height(), std::option::Option::Some(2));
|
||||
assert_eq!(update.transaction().meta().pre_token_balances()[0].ui_token_amount().expect("token amount must be present").amount(), "1500000");
|
||||
let token_debug = format!("{:?}", update.transaction().meta().pre_token_balances()[0]);
|
||||
assert!(!token_debug.contains("mint-fixture"));
|
||||
assert!(!token_debug.contains("owner-fixture"));
|
||||
assert!(!token_debug.contains("program-fixture"));
|
||||
assert_eq!(update.transaction().meta().rewards()[0].reward_type(), crate::YellowstoneRewardType::Staking);
|
||||
assert_eq!(update.transaction().meta().loaded_writable_addresses()[0], ksp_core_lib::Pubkey::new_from_array([6_u8; 32]));
|
||||
assert_eq!(update.transaction().meta().return_data().expect("return data must be present").data(), &[15_u8, 16]);
|
||||
assert_eq!(update.transaction().meta().compute_units_consumed(), std::option::Option::Some(123));
|
||||
assert_eq!(update.transaction().meta().cost_units(), std::option::Option::Some(456));
|
||||
let debug = format!("{update:?}");
|
||||
assert!(!debug.contains("Program log: fixture"));
|
||||
assert!(!debug.contains("11, 12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_transaction_status_update_preserves_error_and_rejects_malformed_signature() {
|
||||
let wire = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["status".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::TransactionStatus(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateTransactionStatus {
|
||||
slot: 55,
|
||||
signature: vec![2_u8; 64],
|
||||
is_vote: true,
|
||||
index: 4,
|
||||
err: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::TransactionError { err: vec![99_u8] }),
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
let update = super::decode_transaction_status_update(wire).expect("transaction-status update must decode");
|
||||
assert_eq!(update.slot(), 55);
|
||||
assert!(update.is_vote());
|
||||
assert_eq!(update.index(), 4);
|
||||
assert_eq!(update.signature().as_bytes(), &[2_u8; 64]);
|
||||
assert_eq!(update.error().expect("status error must be present").as_bytes(), &[99_u8]);
|
||||
assert!(!format!("{update:?}").contains("99"));
|
||||
let malformed = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["status".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::TransactionStatus(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateTransactionStatus {
|
||||
slot: 1,
|
||||
signature: vec![0_u8; 63],
|
||||
is_vote: false,
|
||||
index: 0,
|
||||
err: std::option::Option::None,
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
assert!(super::decode_transaction_status_update(malformed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_block_filter_encodes_complete_current_wire_and_redacts_selectors() {
|
||||
let mut filter = crate::YellowstoneSubscribeBlockFilter::new();
|
||||
let account = ksp_core_lib::Pubkey::new_from_array([31_u8; 32]);
|
||||
assert!(filter.push_account_include(account).is_ok());
|
||||
filter.set_include_transactions(std::option::Option::Some(true));
|
||||
filter.set_include_accounts(std::option::Option::Some(false));
|
||||
filter.set_include_entries(std::option::Option::Some(true));
|
||||
let cuckoo = crate::YellowstoneCuckooFilter::new(vec![0_u8; 16], 4, 4, 8, 9, crate::YellowstoneCuckooHashAlgorithm::SipHash)
|
||||
.expect("block Cuckoo filter must validate");
|
||||
filter.set_cuckoo_account_include(std::option::Option::Some(cuckoo));
|
||||
let wire = filter.to_wire();
|
||||
assert_eq!(wire.account_include, vec![account.to_string()]);
|
||||
assert_eq!(wire.include_transactions, std::option::Option::Some(true));
|
||||
assert_eq!(wire.include_accounts, std::option::Option::Some(false));
|
||||
assert_eq!(wire.include_entries, std::option::Option::Some(true));
|
||||
assert!(wire.cuckoo_account_include.is_some());
|
||||
let debug = format!("{filter:?}");
|
||||
assert!(debug.contains("account_include_count"));
|
||||
assert!(!debug.contains(&account.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_block_update_reuses_transaction_account_entry_dtos_and_preserves_server_counts() {
|
||||
let blockhash = ksp_core_lib::Pubkey::new_from_array([21_u8; 32]).to_string();
|
||||
let parent_blockhash = ksp_core_lib::Pubkey::new_from_array([22_u8; 32]).to_string();
|
||||
let reward_pubkey = ksp_core_lib::Pubkey::new_from_array([23_u8; 32]);
|
||||
let account_pubkey = ksp_core_lib::Pubkey::new_from_array([24_u8; 32]);
|
||||
let wire = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["blocks-main".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Block(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateBlock {
|
||||
slot: 500,
|
||||
blockhash: blockhash.clone(),
|
||||
rewards: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::Rewards {
|
||||
rewards: vec![yellowstone_grpc_proto::solana::storage::confirmed_block::Reward {
|
||||
pubkey: reward_pubkey.to_string(),
|
||||
lamports: 10,
|
||||
post_balance: 11,
|
||||
reward_type: yellowstone_grpc_proto::solana::storage::confirmed_block::RewardType::Fee as i32,
|
||||
commission: "".to_owned(),
|
||||
commission_bps: "".to_owned(),
|
||||
}],
|
||||
num_partitions: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::NumPartitions { num_partitions: 3 }),
|
||||
}),
|
||||
block_time: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::UnixTimestamp { timestamp: 1_700_000_000 }),
|
||||
block_height: std::option::Option::Some(yellowstone_grpc_proto::solana::storage::confirmed_block::BlockHeight { block_height: 499 }),
|
||||
transactions: vec![minimal_transaction_info(7, 2)],
|
||||
parent_slot: 499,
|
||||
parent_blockhash: parent_blockhash.clone(),
|
||||
executed_transaction_count: 12,
|
||||
updated_account_count: 34,
|
||||
accounts: vec![yellowstone_grpc_proto::geyser::SubscribeUpdateAccountInfo {
|
||||
pubkey: vec![24_u8; 32],
|
||||
lamports: 77,
|
||||
owner: vec![25_u8; 32],
|
||||
executable: false,
|
||||
rent_epoch: 4,
|
||||
data: vec![1_u8, 2, 3],
|
||||
write_version: 8,
|
||||
txn_signature: std::option::Option::Some(vec![7_u8; 64]),
|
||||
}],
|
||||
entries_count: 56,
|
||||
entries: vec![yellowstone_grpc_proto::geyser::SubscribeUpdateEntry {
|
||||
slot: 500,
|
||||
index: 4,
|
||||
num_hashes: 5,
|
||||
hash: vec![26_u8; 32],
|
||||
executed_transaction_count: 6,
|
||||
starting_transaction_index: 7,
|
||||
}],
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::Some(yellowstone_grpc_proto::prost_types::Timestamp { seconds: 12, nanos: 34 }),
|
||||
};
|
||||
let update = super::decode_block_update(wire).expect("block update fixture must decode");
|
||||
assert_eq!(update.filters()[0].as_str(), "blocks-main");
|
||||
assert_eq!(update.slot(), 500);
|
||||
assert_eq!(update.blockhash(), blockhash);
|
||||
assert_eq!(update.parent_slot(), 499);
|
||||
assert_eq!(update.parent_blockhash(), parent_blockhash);
|
||||
assert_eq!(update.block_time(), std::option::Option::Some(1_700_000_000));
|
||||
assert_eq!(update.block_height(), std::option::Option::Some(499));
|
||||
assert_eq!(update.executed_transaction_count(), 12);
|
||||
assert_eq!(update.transactions().len(), 1);
|
||||
assert_eq!(update.transactions()[0].index(), 2);
|
||||
assert_eq!(update.updated_account_count(), 34);
|
||||
assert_eq!(update.accounts().len(), 1);
|
||||
assert_eq!(update.accounts()[0].pubkey(), &account_pubkey);
|
||||
assert_eq!(update.entries_count(), 56);
|
||||
assert_eq!(update.entries().len(), 1);
|
||||
assert_eq!(update.entries()[0].starting_transaction_index(), 7);
|
||||
let rewards = update.rewards().expect("block rewards must be present");
|
||||
assert_eq!(rewards.rewards().len(), 1);
|
||||
assert_eq!(rewards.num_partitions(), std::option::Option::Some(3));
|
||||
assert_eq!(rewards.rewards()[0].pubkey(), &reward_pubkey);
|
||||
let debug = format!("{update:?}");
|
||||
assert!(!debug.contains(&blockhash));
|
||||
assert!(!debug.contains(&parent_blockhash));
|
||||
assert!(!debug.contains(&reward_pubkey.to_string()));
|
||||
assert!(!debug.contains(&account_pubkey.to_string()));
|
||||
let malformed = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["blocks-main".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Block(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateBlock {
|
||||
slot: 1,
|
||||
blockhash: "not-a-solana-hash".to_owned(),
|
||||
rewards: std::option::Option::None,
|
||||
block_time: std::option::Option::None,
|
||||
block_height: std::option::Option::None,
|
||||
transactions: vec![],
|
||||
parent_slot: 0,
|
||||
parent_blockhash: ksp_core_lib::Pubkey::new_from_array([1_u8; 32]).to_string(),
|
||||
executed_transaction_count: 0,
|
||||
updated_account_count: 0,
|
||||
accounts: vec![],
|
||||
entries_count: 0,
|
||||
entries: vec![],
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
assert!(super::decode_block_update(malformed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellowstone_block_meta_and_entry_updates_preserve_optional_and_legacy_entry_fields() {
|
||||
let blockhash = ksp_core_lib::Pubkey::new_from_array([41_u8; 32]).to_string();
|
||||
let parent_blockhash = ksp_core_lib::Pubkey::new_from_array([42_u8; 32]).to_string();
|
||||
let meta_wire = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["meta".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::BlockMeta(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateBlockMeta {
|
||||
slot: 900,
|
||||
blockhash: blockhash.clone(),
|
||||
rewards: std::option::Option::None,
|
||||
block_time: std::option::Option::None,
|
||||
block_height: std::option::Option::None,
|
||||
parent_slot: 899,
|
||||
parent_blockhash: parent_blockhash.clone(),
|
||||
executed_transaction_count: 17,
|
||||
entries_count: 18,
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
let meta = super::decode_block_meta_update(meta_wire).expect("block-meta update must decode");
|
||||
assert_eq!(meta.slot(), 900);
|
||||
assert_eq!(meta.blockhash(), blockhash);
|
||||
assert_eq!(meta.parent_blockhash(), parent_blockhash);
|
||||
assert_eq!(meta.rewards(), std::option::Option::None);
|
||||
assert_eq!(meta.block_time(), std::option::Option::None);
|
||||
assert_eq!(meta.block_height(), std::option::Option::None);
|
||||
assert_eq!(meta.executed_transaction_count(), 17);
|
||||
assert_eq!(meta.entries_count(), 18);
|
||||
assert!(!format!("{meta:?}").contains(&blockhash));
|
||||
let entry_wire = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["entries".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Entry(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateEntry {
|
||||
slot: 900,
|
||||
index: 3,
|
||||
num_hashes: 4,
|
||||
hash: vec![43_u8; 32],
|
||||
executed_transaction_count: 5,
|
||||
starting_transaction_index: 0,
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::Some(yellowstone_grpc_proto::prost_types::Timestamp { seconds: 6, nanos: 7 }),
|
||||
};
|
||||
let entry = super::decode_entry_update(entry_wire).expect("entry update must decode");
|
||||
assert_eq!(entry.filters()[0].as_str(), "entries");
|
||||
assert_eq!(entry.entry().slot(), 900);
|
||||
assert_eq!(entry.entry().index(), 3);
|
||||
assert_eq!(entry.entry().num_hashes(), 4);
|
||||
assert_eq!(entry.entry().hash().as_bytes(), &[43_u8; 32]);
|
||||
assert_eq!(entry.entry().executed_transaction_count(), 5);
|
||||
assert_eq!(entry.entry().starting_transaction_index(), 0);
|
||||
assert!(!format!("{entry:?}").contains("43, 43"));
|
||||
let malformed_entry = yellowstone_grpc_proto::geyser::SubscribeUpdate {
|
||||
filters: vec!["entries".to_owned()],
|
||||
update_oneof: std::option::Option::Some(yellowstone_grpc_proto::geyser::subscribe_update::UpdateOneof::Entry(
|
||||
yellowstone_grpc_proto::geyser::SubscribeUpdateEntry {
|
||||
slot: 1,
|
||||
index: 0,
|
||||
num_hashes: 0,
|
||||
hash: vec![0_u8; 31],
|
||||
executed_transaction_count: 0,
|
||||
starting_transaction_index: 0,
|
||||
},
|
||||
)),
|
||||
created_at: std::option::Option::None,
|
||||
};
|
||||
assert!(super::decode_entry_update(malformed_entry).is_err());
|
||||
}
|
||||
288
crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs
Normal file
288
crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs
|
||||
// version: 2
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct FixtureGeyser;
|
||||
|
||||
#[allow(clippy::implicit_return)] // tonic::async_trait generates async wrapper tails outside the authored fixture bodies.
|
||||
#[tonic::async_trait]
|
||||
impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
type SubscribeStream = std::pin::Pin<
|
||||
std::boxed::Box<dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdate, tonic::Status>> + Send + 'static>,
|
||||
>;
|
||||
type SubscribeDeshredStream = std::pin::Pin<
|
||||
std::boxed::Box<
|
||||
dyn futures_util::Stream<Item = std::result::Result<yellowstone_grpc_proto::geyser::SubscribeUpdateDeshred, tonic::Status>> + Send + 'static,
|
||||
>,
|
||||
>;
|
||||
|
||||
async fn subscribe(
|
||||
&self,
|
||||
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeRequest>>,
|
||||
) -> std::result::Result<tonic::Response<Self::SubscribeStream>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("streaming is outside the pre.003 fixture"));
|
||||
}
|
||||
|
||||
async fn subscribe_deshred(
|
||||
&self,
|
||||
_request: tonic::Request<tonic::Streaming<yellowstone_grpc_proto::geyser::SubscribeDeshredRequest>>,
|
||||
) -> std::result::Result<tonic::Response<Self::SubscribeDeshredStream>, tonic::Status> {
|
||||
return std::result::Result::Err(tonic::Status::unimplemented("deshred is outside KSP 0.2.9"));
|
||||
}
|
||||
|
||||
async fn subscribe_replay_info(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeReplayInfoRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::SubscribeReplayInfoResponse>, tonic::Status> {
|
||||
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::SubscribeReplayInfoResponse {
|
||||
first_available: std::option::Option::Some(100),
|
||||
}));
|
||||
}
|
||||
|
||||
async fn ping(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::PingRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::PongResponse>, tonic::Status> {
|
||||
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let count = request.into_inner().count;
|
||||
if count == -999 {
|
||||
return std::result::Result::Err(tonic::Status::permission_denied("GRPC-REMOTE-SECRET-CANARY"));
|
||||
}
|
||||
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::PongResponse { count }));
|
||||
}
|
||||
|
||||
async fn get_latest_blockhash(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::GetLatestBlockhashRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetLatestBlockhashResponse>, tonic::Status> {
|
||||
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = require_commitment(request.get_ref().commitment, yellowstone_grpc_proto::geyser::CommitmentLevel::Confirmed) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::GetLatestBlockhashResponse {
|
||||
slot: 101,
|
||||
blockhash: "fixture-blockhash".to_owned(),
|
||||
last_valid_block_height: 999,
|
||||
}));
|
||||
}
|
||||
|
||||
async fn get_block_height(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::GetBlockHeightRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetBlockHeightResponse>, tonic::Status> {
|
||||
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = require_commitment(request.get_ref().commitment, yellowstone_grpc_proto::geyser::CommitmentLevel::Confirmed) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::GetBlockHeightResponse { block_height: 202 }));
|
||||
}
|
||||
|
||||
async fn get_slot(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::GetSlotRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetSlotResponse>, tonic::Status> {
|
||||
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if request.get_ref().commitment == std::option::Option::Some(yellowstone_grpc_proto::geyser::CommitmentLevel::Processed as i32) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
} else if let std::result::Result::Err(error) =
|
||||
require_commitment(request.get_ref().commitment, yellowstone_grpc_proto::geyser::CommitmentLevel::Confirmed)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::GetSlotResponse { slot: 303 }));
|
||||
}
|
||||
|
||||
async fn is_blockhash_valid(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::IsBlockhashValidRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::IsBlockhashValidResponse>, tonic::Status> {
|
||||
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = require_commitment(request.get_ref().commitment, yellowstone_grpc_proto::geyser::CommitmentLevel::Confirmed) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if request.get_ref().blockhash != "fixture-blockhash" {
|
||||
return std::result::Result::Err(tonic::Status::invalid_argument("unexpected fixture blockhash"));
|
||||
}
|
||||
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::IsBlockhashValidResponse { slot: 404, valid: true }));
|
||||
}
|
||||
|
||||
async fn get_version(
|
||||
&self,
|
||||
request: tonic::Request<yellowstone_grpc_proto::geyser::GetVersionRequest>,
|
||||
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::GetVersionResponse>, tonic::Status> {
|
||||
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::GetVersionResponse {
|
||||
version: "fixture-yellowstone-12.6".to_owned(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
struct FixtureServer {
|
||||
endpoint_url: std::string::String,
|
||||
shutdown: std::option::Option<tokio::sync::oneshot::Sender<()>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl FixtureServer {
|
||||
async fn start() -> Self {
|
||||
let bind_address: std::net::SocketAddr = "127.0.0.1:0".parse().expect("fixture bind address must parse");
|
||||
let incoming = tonic::transport::server::TcpIncoming::bind(bind_address).expect("fixture gRPC listener must bind");
|
||||
let local_address = incoming.local_addr().expect("fixture gRPC listener must expose local address");
|
||||
let (shutdown, shutdown_receiver) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let service = yellowstone_grpc_proto::geyser::geyser_server::GeyserServer::new(FixtureGeyser);
|
||||
let result = tonic::transport::Server::builder()
|
||||
.serve_with_incoming_shutdown(service, incoming, async move {
|
||||
let _ = shutdown_receiver.await;
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
});
|
||||
return Self { endpoint_url: format!("http://{local_address}"), shutdown: std::option::Option::Some(shutdown), task };
|
||||
}
|
||||
|
||||
async fn stop(mut self) {
|
||||
if let std::option::Option::Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
let result = self.task.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_fixture_metadata(metadata: &tonic::metadata::MetadataMap) -> std::result::Result<(), tonic::Status> {
|
||||
let public = metadata.get("x-ksp-public").and_then(|value| return value.to_str().ok());
|
||||
let secret = metadata.get("x-ksp-token").and_then(|value| return value.to_str().ok());
|
||||
if public != std::option::Option::Some("fixture-public") || secret != std::option::Option::Some("GRPC-SECRET-CANARY") {
|
||||
return std::result::Result::Err(tonic::Status::unauthenticated("fixture metadata mismatch"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn require_commitment(actual: std::option::Option<i32>, expected: yellowstone_grpc_proto::geyser::CommitmentLevel) -> std::result::Result<(), tonic::Status> {
|
||||
if actual != std::option::Option::Some(expected as i32) {
|
||||
return std::result::Result::Err(tonic::Status::invalid_argument("fixture commitment mismatch"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn fixture_settings(url: &str, unary_timeout: std::time::Duration) -> crate::YellowstoneGrpcEndpointSettings {
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let session = crate::YellowstoneGrpcSessionSettings::new(
|
||||
defaults.connect_timeout(),
|
||||
unary_timeout,
|
||||
defaults.close_timeout(),
|
||||
defaults.reconnect().clone(),
|
||||
defaults.request_channel_capacity(),
|
||||
defaults.update_channel_capacity(),
|
||||
defaults.max_inbound_message_size_bytes(),
|
||||
defaults.max_outbound_message_size_bytes(),
|
||||
);
|
||||
let metadata = std::vec![
|
||||
crate::YellowstoneGrpcMetadataEntry::public("x-ksp-public", "fixture-public").expect("fixture public metadata must be valid"),
|
||||
crate::YellowstoneGrpcMetadataEntry::secret("x-ksp-token", "GRPC-SECRET-CANARY").expect("fixture secret metadata must be valid"),
|
||||
];
|
||||
return crate::YellowstoneGrpcEndpointSettings::new(
|
||||
"fixture",
|
||||
true,
|
||||
crate::YellowstoneGrpcProviderName::new("fixture-provider"),
|
||||
crate::YellowstoneGrpcClusterName::new("devnet"),
|
||||
crate::YellowstoneGrpcEndpointUrl::parse(url).expect("fixture URL must parse"),
|
||||
session,
|
||||
)
|
||||
.with_metadata(metadata)
|
||||
.expect("fixture metadata settings must validate");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_unary_fixture_covers_all_seven_standard_methods_and_metadata() {
|
||||
let server = FixtureServer::start().await;
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), std::time::Duration::from_secs(1));
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let client = channel.standard_unary_client();
|
||||
let replay = client.subscribe_replay_info().await.expect("ReplayInfo fixture must succeed");
|
||||
assert_eq!(replay.first_available(), std::option::Option::Some(100));
|
||||
let pong = client.ping(7).await.expect("Ping fixture must succeed");
|
||||
assert_eq!(pong.count(), 7);
|
||||
let latest = client
|
||||
.get_latest_blockhash(std::option::Option::Some(crate::SolanaCommitment::Confirmed))
|
||||
.await
|
||||
.expect("latest blockhash fixture must succeed");
|
||||
assert_eq!(latest.slot(), 101);
|
||||
assert_eq!(latest.blockhash(), "fixture-blockhash");
|
||||
assert_eq!(latest.last_valid_block_height(), 999);
|
||||
let height = client.get_block_height(std::option::Option::Some(crate::SolanaCommitment::Confirmed)).await.expect("block height fixture must succeed");
|
||||
assert_eq!(height.block_height(), 202);
|
||||
let slot = client.get_slot(std::option::Option::Some(crate::SolanaCommitment::Confirmed)).await.expect("slot fixture must succeed");
|
||||
assert_eq!(slot.slot(), 303);
|
||||
let validity = client
|
||||
.is_blockhash_valid("fixture-blockhash", std::option::Option::Some(crate::SolanaCommitment::Confirmed))
|
||||
.await
|
||||
.expect("blockhash validity fixture must succeed");
|
||||
assert_eq!(validity.slot(), 404);
|
||||
assert!(validity.valid());
|
||||
let version = client.get_version().await.expect("version fixture must succeed");
|
||||
assert_eq!(version.version(), "fixture-yellowstone-12.6");
|
||||
let rendered = format!("{client:?} {channel:?}");
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains(server.endpoint_url.as_str()));
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_remote_status_does_not_copy_message_details_or_metadata() {
|
||||
let server = FixtureServer::start().await;
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), std::time::Duration::from_secs(1));
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let error = channel.standard_unary_client().ping(-999).await.expect_err("fixture must return remote status");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_GRPC_STATUS);
|
||||
let rendered = format!("{error:?}");
|
||||
assert!(!rendered.contains("GRPC-REMOTE-SECRET-CANARY"));
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains(server.endpoint_url.as_str()));
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_unary_timeout_is_bounded_and_safe() {
|
||||
let server = FixtureServer::start().await;
|
||||
let settings = fixture_settings(server.endpoint_url.as_str(), std::time::Duration::from_millis(5));
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let error = channel
|
||||
.standard_unary_client()
|
||||
.get_slot(std::option::Option::Some(crate::SolanaCommitment::Processed))
|
||||
.await
|
||||
.expect_err("delayed fixture must exceed unary timeout");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT);
|
||||
let rendered = format!("{error:?}");
|
||||
assert!(!rendered.contains("GRPC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains(server.endpoint_url.as_str()));
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_invalid_blockhash_is_rejected_before_io() {
|
||||
let settings = fixture_settings("http://127.0.0.1:1", std::time::Duration::from_secs(1));
|
||||
let channel = crate::YellowstoneGrpcChannel::prepare(&settings).expect("lazy fixture channel must prepare");
|
||||
let error = channel
|
||||
.standard_unary_client()
|
||||
.is_blockhash_valid(" ", std::option::Option::None)
|
||||
.await
|
||||
.expect_err("invalid blockhash must be rejected locally");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/client.rs
|
||||
// version: 3
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/http_client.rs
|
||||
// version: 4
|
||||
|
||||
fn endpoint(enabled: bool, url_text: &str) -> crate::HttpEndpointSettings {
|
||||
let url = crate::HttpEndpointUrl::parse(url_text).expect("test endpoint URL must parse");
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/executor.rs
|
||||
// version: 2
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/http_executor.rs
|
||||
// version: 3
|
||||
|
||||
fn pool_for_url(url: &str, request_timeout: std::time::Duration, max_retries: u32) -> crate::HttpTransportPool {
|
||||
let role = crate::HttpEndpointRoleSettings::new(
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/pool.rs
|
||||
// version: 4
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/http_pool.rs
|
||||
// version: 5
|
||||
|
||||
fn role(name: &str, priority: u32, request_kinds: std::vec::Vec<crate::HttpRequestKind>) -> crate::HttpEndpointRoleSettings {
|
||||
return crate::HttpEndpointRoleSettings::new(
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/resilience.rs
|
||||
// version: 2
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/http_resilience.rs
|
||||
// version: 3
|
||||
|
||||
fn non_zero(value: u32) -> std::num::NonZeroU32 {
|
||||
return std::num::NonZeroU32::new(value).expect("test limit must be non-zero");
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/settings.rs
|
||||
// version: 2
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/http_settings.rs
|
||||
// version: 3
|
||||
|
||||
fn non_zero(value: u32) -> std::num::NonZeroU32 {
|
||||
return std::num::NonZeroU32::new(value).expect("test non-zero value must remain non-zero");
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
|
||||
// version: 9
|
||||
// version: 10
|
||||
|
||||
#[test]
|
||||
fn transaction_encoding_strings_match_current_and_legacy_wire_labels() {
|
||||
@@ -325,15 +325,36 @@ fn serve_transaction_once(body: &'static str) -> (std::string::String, std::thre
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
|
||||
let request = read_transaction_request(&mut stream);
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body,);
|
||||
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
||||
return request;
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
|
||||
let request = read_transaction_request(&mut stream);
|
||||
if !transaction_request_complete(request.as_bytes()) {
|
||||
continue;
|
||||
}
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body,);
|
||||
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
||||
return request;
|
||||
}
|
||||
});
|
||||
return (format!("http://{address}"), handle);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn transaction_fixture_ignores_abandoned_connection_before_complete_request() {
|
||||
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.null.json"));
|
||||
let address = url.strip_prefix("http://").expect("fixture URL must use HTTP");
|
||||
let abandoned = std::net::TcpStream::connect(address).expect("abandoned fixture connection must connect");
|
||||
drop(abandoned);
|
||||
let pool = transaction_pool_for_url(url.as_str());
|
||||
let result = pool
|
||||
.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::None)
|
||||
.await
|
||||
.expect("fixture must remain available after abandoned pre-request connection");
|
||||
assert!(result.is_none());
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("getTransaction"));
|
||||
}
|
||||
|
||||
fn serve_transaction_status_and_count(status_line: &'static str) -> (std::string::String, std::thread::JoinHandle<(usize, std::string::String)>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||
|
||||
202
crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
Normal file
202
crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
|
||||
// version: 1
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_accounts",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, method: &str, remote_id: u64, result: serde_json::Value) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":method,"params":{"result":result,"subscription":remote_id}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn account_wire(lamports: u64) -> serde_json::Value {
|
||||
return serde_json::json!({
|
||||
"lamports": lamports,
|
||||
"data": ["AQID", "base64"],
|
||||
"owner": "11111111111111111111111111111111",
|
||||
"executable": false,
|
||||
"rentEpoch": 7,
|
||||
"space": 3
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_subscribe_config_preserves_effective_websocket_options_without_min_context_slot() {
|
||||
let config = crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64Zstd),
|
||||
std::option::Option::Some(crate::SolanaDataSliceConfig::new(4, 16)),
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
);
|
||||
assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaAccountEncoding::Base64Zstd));
|
||||
assert_eq!(config.data_slice(), std::option::Option::Some(crate::SolanaDataSliceConfig::new(4, 16)));
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.to_json_value(), serde_json::json!({"encoding":"base64+zstd","dataSlice":{"offset":4,"length":16},"commitment":"confirmed"}));
|
||||
assert!(config.to_json_value().get("minContextSlot").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_subscribe_config_preserves_filters_with_context_and_deterministic_bounds() {
|
||||
let config = crate::SolanaProgramSubscribeConfig::new(
|
||||
crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(crate::SolanaCommitment::Finalized),
|
||||
),
|
||||
std::vec![
|
||||
crate::SolanaProgramAccountFilter::DataSize(80),
|
||||
crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(4, crate::SolanaMemcmpBytes::Bytes(std::vec![1, 2, 3]))),
|
||||
crate::SolanaProgramAccountFilter::TokenAccountState,
|
||||
],
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
assert_eq!(config.with_context(), std::option::Option::Some(true));
|
||||
assert_eq!(config.filters().len(), 3);
|
||||
assert_eq!(
|
||||
config.to_json_value(),
|
||||
serde_json::json!({
|
||||
"encoding":"base64",
|
||||
"commitment":"finalized",
|
||||
"filters":[{"dataSize":80},{"memcmp":{"offset":4,"bytes":[1,2,3],"encoding":"bytes"}},"tokenAccountState"],
|
||||
"withContext":true
|
||||
})
|
||||
);
|
||||
let too_many = std::vec![
|
||||
crate::SolanaProgramAccountFilter::DataSize(1),
|
||||
crate::SolanaProgramAccountFilter::DataSize(2),
|
||||
crate::SolanaProgramAccountFilter::DataSize(3),
|
||||
crate::SolanaProgramAccountFilter::DataSize(4),
|
||||
crate::SolanaProgramAccountFilter::DataSize(5),
|
||||
];
|
||||
let error = super::validate_program_subscribe_filters(too_many.as_slice()).expect_err("five programSubscribe filters must reject locally");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
let oversized =
|
||||
std::vec![crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Bytes(std::vec![0; 129]),))];
|
||||
let error = super::validate_program_subscribe_filters(oversized.as_slice()).expect_err("oversized raw memcmp bytes must reject locally");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_notification_decoder_accepts_contextual_and_non_contextual_wire_forms() {
|
||||
let keyed = serde_json::json!({"pubkey":"11111111111111111111111111111111","account":account_wire(42)});
|
||||
let bare = super::decode_program_notification("programSubscribe", keyed.clone()).expect("bare program notification must decode");
|
||||
assert!(bare.context().is_none());
|
||||
assert_eq!(bare.account().account().lamports(), 42);
|
||||
let contextual = super::decode_program_notification("programSubscribe", serde_json::json!({"context":{"slot":99,"apiVersion":"4.2.1"},"value":keyed}))
|
||||
.expect("contextual program notification must decode");
|
||||
assert_eq!(contextual.context().expect("context must be retained").slot(), 99);
|
||||
assert_eq!(contextual.account().account().lamports(), 42);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stable_account_and_program_wrappers_use_exact_methods_decode_notifications_and_unsubscribe_by_handle() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let account_request = read_request(&mut websocket).await;
|
||||
assert_eq!(account_request["method"], serde_json::json!("accountSubscribe"));
|
||||
assert_eq!(
|
||||
account_request["params"],
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"base64","dataSlice":{"offset":1,"length":2},"commitment":"confirmed"}])
|
||||
);
|
||||
send_result(&mut websocket, &account_request, serde_json::json!(51)).await;
|
||||
send_notification(&mut websocket, "accountNotification", 51, serde_json::json!({"context":{"slot":700},"value":account_wire(123)})).await;
|
||||
let account_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(account_unsubscribe["method"], serde_json::json!("accountUnsubscribe"));
|
||||
assert_eq!(account_unsubscribe["params"], serde_json::json!([51]));
|
||||
send_result(&mut websocket, &account_unsubscribe, serde_json::json!(true)).await;
|
||||
let program_request = read_request(&mut websocket).await;
|
||||
assert_eq!(program_request["method"], serde_json::json!("programSubscribe"));
|
||||
assert_eq!(
|
||||
program_request["params"],
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"jsonParsed","filters":[{"dataSize":80}],"withContext":true}])
|
||||
);
|
||||
send_result(&mut websocket, &program_request, serde_json::json!(73)).await;
|
||||
send_notification(
|
||||
&mut websocket,
|
||||
"programNotification",
|
||||
73,
|
||||
serde_json::json!({
|
||||
"context":{"slot":701},
|
||||
"value":{"pubkey":"11111111111111111111111111111111","account":account_wire(456)}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let program_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(program_unsubscribe["method"], serde_json::json!("programUnsubscribe"));
|
||||
assert_eq!(program_unsubscribe["params"], serde_json::json!([73]));
|
||||
send_result(&mut websocket, &program_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let account_pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("account pubkey fixture must parse");
|
||||
let account_config = crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::Some(crate::SolanaDataSliceConfig::new(1, 2)),
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
);
|
||||
let mut account = session.account_subscribe(&account_pubkey, std::option::Option::Some(&account_config)).await.expect("accountSubscribe must register");
|
||||
assert_eq!(account.kind(), crate::WsSubscriptionKind::Account);
|
||||
let notification = account.recv().await.expect("account notification must arrive").expect("account notification must decode");
|
||||
assert_eq!(notification.context().slot(), 700);
|
||||
assert_eq!(notification.value().lamports(), 123);
|
||||
assert!(account.unsubscribe().await.expect("account unsubscribe must complete"));
|
||||
let program_config = crate::SolanaProgramSubscribeConfig::new(
|
||||
crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
std::vec![crate::SolanaProgramAccountFilter::DataSize(80)],
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
let mut program = session.program_subscribe(&account_pubkey, std::option::Option::Some(&program_config)).await.expect("programSubscribe must register");
|
||||
assert_eq!(program.kind(), crate::WsSubscriptionKind::Program);
|
||||
let notification = program.recv().await.expect("program notification must arrive").expect("program notification must decode");
|
||||
assert_eq!(notification.context().expect("program context must be retained").slot(), 701);
|
||||
assert_eq!(notification.account().account().lamports(), 456);
|
||||
assert!(program.unsubscribe().await.expect("program unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
222
crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs
Normal file
222
crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs
|
||||
// version: 1
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn fixture_pubkey() -> ksp_core_lib::Pubkey {
|
||||
return "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
||||
}
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_blocks",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn send_error(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, code: i64, message: &str) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local error response must send");
|
||||
}
|
||||
|
||||
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, remote_id: u64, result: serde_json::Value) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":"blockNotification","params":{"result":result,"subscription":remote_id}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_subscribe_config_preserves_all_unstable_options_and_rejects_processed_commitment() {
|
||||
let config = crate::SolanaBlockSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Accounts),
|
||||
std::option::Option::Some(7),
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed));
|
||||
assert_eq!(config.transaction_details(), std::option::Option::Some(crate::SolanaTransactionDetails::Accounts));
|
||||
assert_eq!(config.max_supported_transaction_version(), std::option::Option::Some(7));
|
||||
assert_eq!(config.show_rewards(), std::option::Option::Some(true));
|
||||
assert_eq!(
|
||||
config.to_json_value(),
|
||||
serde_json::json!({
|
||||
"commitment": "confirmed",
|
||||
"encoding": "jsonParsed",
|
||||
"transactionDetails": "accounts",
|
||||
"maxSupportedTransactionVersion": 7,
|
||||
"showRewards": true
|
||||
})
|
||||
);
|
||||
let invalid = crate::SolanaBlockSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Processed),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert_eq!(invalid.validate().expect_err("processed commitment must be rejected").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_notification_decoder_preserves_nulls_and_shared_confirmed_block_shape() {
|
||||
let nulls = super::decode_block_notification(
|
||||
"blockSubscribe",
|
||||
serde_json::json!({"context":{"slot":51},"value":{"slot":51,"block":null,"err":{"reason":"missing"}}}),
|
||||
)
|
||||
.expect("nullable block notification must decode");
|
||||
assert_eq!(nulls.context().slot(), 51);
|
||||
assert_eq!(nulls.value().slot(), 51);
|
||||
assert!(nulls.value().block().is_none());
|
||||
assert_eq!(nulls.value().err(), std::option::Option::Some(&serde_json::json!({"reason":"missing"})));
|
||||
let block = super::decode_block_notification(
|
||||
"blockSubscribe",
|
||||
serde_json::json!({
|
||||
"context":{"slot":52},
|
||||
"value":{
|
||||
"slot":52,
|
||||
"block":{
|
||||
"previousBlockhash":"prev",
|
||||
"blockhash":"current",
|
||||
"parentSlot":51,
|
||||
"signatures":["sig-a"],
|
||||
"rewards":null,
|
||||
"numRewardPartitions":4,
|
||||
"blockTime":123,
|
||||
"blockHeight":9
|
||||
},
|
||||
"err":null
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("shared confirmed block shape must decode");
|
||||
assert_eq!(block.value().block().expect("block must exist").blockhash(), "current");
|
||||
assert!(block.value().err().is_none());
|
||||
let large_transaction = "A".repeat(2_048);
|
||||
let large_wire = serde_json::json!({
|
||||
"context":{"slot":53},
|
||||
"value":{
|
||||
"slot":53,
|
||||
"block":{
|
||||
"previousBlockhash":"prev",
|
||||
"blockhash":"large-current",
|
||||
"parentSlot":52,
|
||||
"transactions":[{"transaction":[large_transaction,"base64"],"meta":{"err":null,"fee":5000},"version":"legacy"}],
|
||||
"rewards":[],
|
||||
"blockTime":123,
|
||||
"blockHeight":10
|
||||
},
|
||||
"err":null
|
||||
}
|
||||
});
|
||||
assert!(large_wire.to_string().len() > 1_232);
|
||||
assert!(super::decode_block_notification("blockSubscribe", large_wire).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn unstable_block_wrapper_uses_exact_request_notification_and_handle_unsubscribe() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let pubkey = fixture_pubkey();
|
||||
let server_pubkey = pubkey;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("blockSubscribe"));
|
||||
assert_eq!(
|
||||
subscribe["params"],
|
||||
serde_json::json!([
|
||||
{"mentionsAccountOrProgram":server_pubkey.to_string()},
|
||||
{"commitment":"confirmed","encoding":"base64","transactionDetails":"signatures","maxSupportedTransactionVersion":3,"showRewards":false}
|
||||
])
|
||||
);
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(301)).await;
|
||||
send_notification(&mut websocket, 301, serde_json::json!({"context":{"slot":77},"value":{"slot":77,"block":null,"err":null}})).await;
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("blockUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([301]));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let config = crate::SolanaBlockSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(crate::SolanaTransactionEncoding::Base64),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Signatures),
|
||||
std::option::Option::Some(3),
|
||||
std::option::Option::Some(false),
|
||||
);
|
||||
let mut subscription = session
|
||||
.block_subscribe(&crate::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(pubkey), std::option::Option::Some(&config))
|
||||
.await
|
||||
.expect("blockSubscribe must register");
|
||||
let notification = subscription.recv().await.expect("block notification must arrive").expect("block notification must decode");
|
||||
assert_eq!(notification.value().slot(), 77);
|
||||
assert!(notification.value().block().is_none());
|
||||
assert!(subscription.unsubscribe().await.expect("block unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn unstable_block_validator_capability_rpc_error_does_not_fail_physical_session() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let block_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(block_subscribe["method"], serde_json::json!("blockSubscribe"));
|
||||
send_error(&mut websocket, &block_subscribe, -32601, "block subscription disabled").await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(302)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let error = session
|
||||
.block_subscribe(&crate::SolanaBlockSubscribeFilter::All, std::option::Option::None)
|
||||
.await
|
||||
.expect_err("validator capability application error must surface to the caller");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
let root = session.root_subscribe().await.expect("session must remain usable after block application error");
|
||||
assert_eq!(root.state(), crate::WsSubscriptionState::Active);
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
233
crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
Normal file
233
crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
|
||||
// version: 2
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_cluster",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, method: &str, remote_id: u64, result: serde_json::Value) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":method,"params":{"result":result,"subscription":remote_id}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_notification_decoder_preserves_slot_parent_and_root() {
|
||||
let notification =
|
||||
super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75,"root":44})).expect("slot notification must decode");
|
||||
assert_eq!(notification.slot(), 76);
|
||||
assert_eq!(notification.parent(), 75);
|
||||
assert_eq!(notification.root(), 44);
|
||||
assert!(super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75})).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stable_slot_and_root_wrappers_use_no_params_decode_exact_notifications_and_unsubscribe_by_handle() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let slot_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(slot_subscribe["method"], serde_json::json!("slotSubscribe"));
|
||||
assert_eq!(slot_subscribe["params"], serde_json::json!([]));
|
||||
send_result(&mut websocket, &slot_subscribe, serde_json::json!(201)).await;
|
||||
send_notification(&mut websocket, "slotNotification", 201, serde_json::json!({"slot":76,"parent":75,"root":44})).await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
assert_eq!(root_subscribe["params"], serde_json::json!([]));
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(202)).await;
|
||||
send_notification(&mut websocket, "rootNotification", 202, serde_json::json!(42)).await;
|
||||
let slot_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(slot_unsubscribe["method"], serde_json::json!("slotUnsubscribe"));
|
||||
assert_eq!(slot_unsubscribe["params"], serde_json::json!([201]));
|
||||
send_result(&mut websocket, &slot_unsubscribe, serde_json::json!(true)).await;
|
||||
let root_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
|
||||
assert_eq!(root_unsubscribe["params"], serde_json::json!([202]));
|
||||
send_result(&mut websocket, &root_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mut slot_subscription = session.slot_subscribe().await.expect("slotSubscribe must register");
|
||||
let slot = slot_subscription.recv().await.expect("slot notification must arrive").expect("slot notification must decode");
|
||||
assert_eq!((slot.slot(), slot.parent(), slot.root()), (76, 75, 44));
|
||||
let mut root_subscription = session.root_subscribe().await.expect("rootSubscribe must register");
|
||||
let root = root_subscription.recv().await.expect("root notification must arrive").expect("root notification must decode");
|
||||
assert_eq!(root, 42);
|
||||
assert!(slot_subscription.unsubscribe().await.expect("slot unsubscribe must complete"));
|
||||
assert!(root_subscription.unsubscribe().await.expect("root unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_update_decoder_preserves_all_known_variants_and_unknown_bounded_fallback() {
|
||||
let cases = [
|
||||
(serde_json::json!({"slot":1,"timestamp":10,"type":"firstShredReceived"}), "firstShredReceived"),
|
||||
(serde_json::json!({"slot":2,"timestamp":20,"type":"completed"}), "completed"),
|
||||
(serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank","parent":2}), "createdBank"),
|
||||
(
|
||||
serde_json::json!({
|
||||
"slot":4,
|
||||
"timestamp":40,
|
||||
"type":"frozen",
|
||||
"stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3}
|
||||
}),
|
||||
"frozen",
|
||||
),
|
||||
(serde_json::json!({"slot":5,"timestamp":50,"type":"dead","err":"fixture dead"}), "dead"),
|
||||
(serde_json::json!({"slot":6,"timestamp":60,"type":"optimisticConfirmation"}), "optimisticConfirmation"),
|
||||
(serde_json::json!({"slot":7,"timestamp":70,"type":"root"}), "root"),
|
||||
];
|
||||
for (wire, expected_type) in cases {
|
||||
let update = super::decode_slots_update_notification("slotsUpdatesSubscribe", wire).expect("known slot update must decode");
|
||||
assert_eq!(update.update_type(), expected_type);
|
||||
assert!(update.slot().is_some());
|
||||
assert!(update.timestamp().is_some());
|
||||
assert!(update.unknown_raw().is_none());
|
||||
}
|
||||
let frozen = super::decode_slots_update_notification(
|
||||
"slotsUpdatesSubscribe",
|
||||
serde_json::json!({
|
||||
"slot":4,
|
||||
"timestamp":40,
|
||||
"type":"frozen",
|
||||
"stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3}
|
||||
}),
|
||||
)
|
||||
.expect("frozen update must decode");
|
||||
match frozen {
|
||||
crate::SolanaSlotUpdate::Frozen { stats, .. } => {
|
||||
assert_eq!(stats.max_transactions_per_entry(), 64);
|
||||
assert_eq!(stats.num_failed_transactions(), 1);
|
||||
assert_eq!(stats.num_successful_transactions(), 9);
|
||||
assert_eq!(stats.num_transaction_entries(), 3);
|
||||
},
|
||||
_ => panic!("fixture must decode as frozen"),
|
||||
}
|
||||
let unknown_wire = serde_json::json!({"slot":8,"timestamp":80,"type":"futureBankState","futureField":{"x":1}});
|
||||
let unknown = super::decode_slots_update_notification("slotsUpdatesSubscribe", unknown_wire.clone()).expect("unknown update must remain consumable");
|
||||
assert_eq!(unknown.update_type(), "futureBankState");
|
||||
assert_eq!(unknown.slot(), std::option::Option::Some(8));
|
||||
assert_eq!(unknown.timestamp(), std::option::Option::Some(80));
|
||||
assert_eq!(unknown.unknown_raw(), std::option::Option::Some(&unknown_wire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_update_known_variants_require_their_variant_specific_fields() {
|
||||
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank"})).is_err());
|
||||
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":4,"timestamp":40,"type":"frozen"})).is_err());
|
||||
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":5,"timestamp":50,"type":"dead"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vote_notification_decoder_preserves_timestamp_omitted_null_and_value() {
|
||||
let pubkey = "11111111111111111111111111111111";
|
||||
for (wire, expected_timestamp) in [
|
||||
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-a","signature":"sig-a"}), std::option::Option::None),
|
||||
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-b","timestamp":null,"signature":"sig-b"}), std::option::Option::None),
|
||||
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-c","timestamp":123,"signature":"sig-c"}), std::option::Option::Some(123)),
|
||||
] {
|
||||
let vote = super::decode_vote_notification("voteSubscribe", wire).expect("vote notification must decode");
|
||||
assert_eq!(vote.vote_pubkey().to_string(), pubkey);
|
||||
assert_eq!(vote.slots(), &[1, 2]);
|
||||
assert_eq!(vote.timestamp(), expected_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn unstable_slots_updates_and_vote_wrappers_use_no_params_and_handle_unsubscribe() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let slots_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(slots_subscribe["method"], serde_json::json!("slotsUpdatesSubscribe"));
|
||||
assert_eq!(slots_subscribe["params"], serde_json::json!([]));
|
||||
send_result(&mut websocket, &slots_subscribe, serde_json::json!(401)).await;
|
||||
send_notification(
|
||||
&mut websocket,
|
||||
"slotsUpdatesNotification",
|
||||
401,
|
||||
serde_json::json!({"slot":76,"timestamp":1625081266243_i64,"type":"optimisticConfirmation"}),
|
||||
)
|
||||
.await;
|
||||
let vote_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(vote_subscribe["method"], serde_json::json!("voteSubscribe"));
|
||||
assert_eq!(vote_subscribe["params"], serde_json::json!([]));
|
||||
send_result(&mut websocket, &vote_subscribe, serde_json::json!(402)).await;
|
||||
send_notification(
|
||||
&mut websocket,
|
||||
"voteNotification",
|
||||
402,
|
||||
serde_json::json!({
|
||||
"votePubkey":"11111111111111111111111111111111",
|
||||
"slots":[75,76],
|
||||
"hash":"fixture-hash",
|
||||
"timestamp":null,
|
||||
"signature":"fixture-signature"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let slots_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(slots_unsubscribe["method"], serde_json::json!("slotsUpdatesUnsubscribe"));
|
||||
assert_eq!(slots_unsubscribe["params"], serde_json::json!([401]));
|
||||
send_result(&mut websocket, &slots_unsubscribe, serde_json::json!(true)).await;
|
||||
let vote_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(vote_unsubscribe["method"], serde_json::json!("voteUnsubscribe"));
|
||||
assert_eq!(vote_unsubscribe["params"], serde_json::json!([402]));
|
||||
send_result(&mut websocket, &vote_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mut slots_subscription = session.slots_updates_subscribe().await.expect("slotsUpdatesSubscribe must register");
|
||||
let update = slots_subscription.recv().await.expect("slots update must arrive").expect("slots update must decode");
|
||||
assert_eq!(update.update_type(), "optimisticConfirmation");
|
||||
let mut vote_subscription = session.vote_subscribe().await.expect("voteSubscribe must register");
|
||||
let vote = vote_subscription.recv().await.expect("vote notification must arrive").expect("vote notification must decode");
|
||||
assert_eq!(vote.slots(), &[75, 76]);
|
||||
assert!(vote.timestamp().is_none());
|
||||
assert!(slots_subscription.unsubscribe().await.expect("slots update unsubscribe must complete"));
|
||||
assert!(vote_subscription.unsubscribe().await.expect("vote unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_helius_standard.rs
|
||||
// version: 2
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn helius_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_helius_standard_fixture",
|
||||
true,
|
||||
crate::WsProviderName::new("helius"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::HeliusLaserStream,
|
||||
crate::WsEndpointUrl::parse(url).expect("local Helius WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn expect_pair(
|
||||
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
|
||||
subscribe_method: &str,
|
||||
expected_params: serde_json::Value,
|
||||
unsubscribe_method: &str,
|
||||
remote_id: u64,
|
||||
) {
|
||||
let subscribe = read_request(websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::Value::String(subscribe_method.to_owned()));
|
||||
assert_eq!(subscribe["params"], expected_params);
|
||||
send_result(websocket, &subscribe, serde_json::json!(remote_id)).await;
|
||||
let unsubscribe = read_request(websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::Value::String(unsubscribe_method.to_owned()));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([remote_id]));
|
||||
send_result(websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_facade_reuses_exact_standard_wire_for_all_seven_currently_supported_families() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
expect_pair(
|
||||
&mut websocket,
|
||||
"accountSubscribe",
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"base64","commitment":"confirmed"}]),
|
||||
"accountUnsubscribe",
|
||||
101,
|
||||
)
|
||||
.await;
|
||||
expect_pair(
|
||||
&mut websocket,
|
||||
"programSubscribe",
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"jsonParsed","filters":[{"dataSize":80}],"withContext":true}]),
|
||||
"programUnsubscribe",
|
||||
102,
|
||||
)
|
||||
.await;
|
||||
expect_pair(&mut websocket, "logsSubscribe", serde_json::json!(["all", {"commitment":"finalized"}]), "logsUnsubscribe", 103).await;
|
||||
expect_pair(
|
||||
&mut websocket,
|
||||
"signatureSubscribe",
|
||||
serde_json::json!(["fixture-signature", {"commitment":"confirmed","enableReceivedNotification":true}]),
|
||||
"signatureUnsubscribe",
|
||||
104,
|
||||
)
|
||||
.await;
|
||||
expect_pair(&mut websocket, "slotSubscribe", serde_json::json!([]), "slotUnsubscribe", 105).await;
|
||||
expect_pair(&mut websocket, "rootSubscribe", serde_json::json!([]), "rootUnsubscribe", 106).await;
|
||||
expect_pair(&mut websocket, "slotsUpdatesSubscribe", serde_json::json!([]), "slotsUpdatesUnsubscribe", 107).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect");
|
||||
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
||||
let account_config = crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
);
|
||||
let mut account = session.account_subscribe(&pubkey, std::option::Option::Some(&account_config)).await.expect("Helius accountSubscribe must register");
|
||||
assert!(account.unsubscribe().await.expect("Helius accountUnsubscribe must complete"));
|
||||
let program_config = crate::SolanaProgramSubscribeConfig::new(
|
||||
crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
std::vec![crate::SolanaProgramAccountFilter::DataSize(80)],
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
let mut program = session.program_subscribe(&pubkey, std::option::Option::Some(&program_config)).await.expect("Helius programSubscribe must register");
|
||||
assert!(program.unsubscribe().await.expect("Helius programUnsubscribe must complete"));
|
||||
let logs_config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
|
||||
let mut logs = session
|
||||
.logs_subscribe(&crate::SolanaLogsSubscribeFilter::All, std::option::Option::Some(&logs_config))
|
||||
.await
|
||||
.expect("Helius logsSubscribe must register");
|
||||
assert!(logs.unsubscribe().await.expect("Helius logsUnsubscribe must complete"));
|
||||
let signature_config =
|
||||
crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(true));
|
||||
let mut signature = session
|
||||
.signature_subscribe("fixture-signature", std::option::Option::Some(&signature_config))
|
||||
.await
|
||||
.expect("Helius signatureSubscribe must register");
|
||||
assert!(signature.unsubscribe().await.expect("Helius signatureUnsubscribe must complete"));
|
||||
let mut slot = session.slot_subscribe().await.expect("Helius slotSubscribe must register");
|
||||
assert!(slot.unsubscribe().await.expect("Helius slotUnsubscribe must complete"));
|
||||
let mut root = session.root_subscribe().await.expect("Helius rootSubscribe must register");
|
||||
assert!(root.unsubscribe().await.expect("Helius rootUnsubscribe must complete"));
|
||||
let mut slots_updates = session.slots_updates_subscribe().await.expect("Helius slotsUpdatesSubscribe must register");
|
||||
assert!(slots_updates.unsubscribe().await.expect("Helius slotsUpdatesUnsubscribe must complete"));
|
||||
session.close().await.expect("Helius facade close must complete");
|
||||
server.await.expect("local Helius peer task must complete");
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs
|
||||
// version: 5
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn pubkey(value: &str) -> ksp_core_lib::Pubkey {
|
||||
return value.parse::<ksp_core_lib::Pubkey>().expect("fixture public key must parse");
|
||||
}
|
||||
|
||||
fn base_filter() -> crate::HeliusTransactionSubscribeFilter {
|
||||
return crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some("fixture-signature-secret-canary".to_owned()),
|
||||
std::option::Option::Some(std::vec![pubkey("11111111111111111111111111111111")]),
|
||||
std::option::Option::Some(std::vec![pubkey("SysvarC1ock11111111111111111111111111111111")]),
|
||||
std::option::Option::Some(std::vec![pubkey("Vote111111111111111111111111111111111111111")]),
|
||||
std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged),
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_oversized_filter_rejected(filter: crate::HeliusTransactionSubscribeFilter) {
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::None);
|
||||
let error = request.validate().expect_err("50,001 Helius account filters must fail before I/O");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
assert!(error.to_string().contains("invalid_rpc_parameters"));
|
||||
assert!(!error.to_string().contains("11111111111111111111111111111111"));
|
||||
}
|
||||
|
||||
fn helius_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return helius_endpoint_with_session(url, crate::WsSessionSettings::default());
|
||||
}
|
||||
|
||||
fn helius_endpoint_with_session(url: &str, session: crate::WsSessionSettings) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_helius_transaction_fixture",
|
||||
true,
|
||||
crate::WsProviderName::new("helius"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::HeliusLaserStream,
|
||||
crate::WsEndpointUrl::parse(url).expect("local Helius WebSocket URL must parse"),
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
fn reconnect_session_settings(backoff: std::time::Duration) -> crate::WsSessionSettings {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
return crate::WsSessionSettings::new(
|
||||
std::time::Duration::from_millis(250),
|
||||
std::time::Duration::from_millis(200),
|
||||
crate::WsReconnectSettings::new(2, backoff, backoff),
|
||||
crate::WsResubscribePolicy::ActiveSubscriptions,
|
||||
defaults.command_queue_capacity(),
|
||||
defaults.notification_queue_capacity(),
|
||||
defaults.max_active_subscriptions(),
|
||||
defaults.max_pending_requests(),
|
||||
defaults.max_message_size_bytes(),
|
||||
defaults.max_frame_size_bytes(),
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
fn backpressure_session_settings() -> crate::WsSessionSettings {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
return crate::WsSessionSettings::new(
|
||||
std::time::Duration::from_millis(250),
|
||||
std::time::Duration::from_millis(200),
|
||||
crate::WsReconnectSettings::new(0, std::time::Duration::from_millis(10), std::time::Duration::from_millis(10)),
|
||||
crate::WsResubscribePolicy::ActiveSubscriptions,
|
||||
defaults.command_queue_capacity(),
|
||||
1,
|
||||
2,
|
||||
defaults.max_pending_requests(),
|
||||
defaults.max_message_size_bytes(),
|
||||
defaults.max_frame_size_bytes(),
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
fn adversarial_payload_session_settings() -> crate::WsSessionSettings {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
return crate::WsSessionSettings::new(
|
||||
std::time::Duration::from_millis(250),
|
||||
std::time::Duration::from_millis(200),
|
||||
crate::WsReconnectSettings::new(2, std::time::Duration::from_millis(20), std::time::Duration::from_millis(20)),
|
||||
crate::WsResubscribePolicy::ActiveSubscriptions,
|
||||
defaults.command_queue_capacity(),
|
||||
defaults.notification_queue_capacity(),
|
||||
defaults.max_active_subscriptions(),
|
||||
defaults.max_pending_requests(),
|
||||
256,
|
||||
128,
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
return;
|
||||
}
|
||||
|
||||
async fn send_error(
|
||||
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
|
||||
request: &serde_json::Value,
|
||||
code: i64,
|
||||
message: &str,
|
||||
data: serde_json::Value,
|
||||
) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message,"data":data}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local error response must send");
|
||||
return;
|
||||
}
|
||||
|
||||
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, subscription: u64, result: serde_json::Value) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":"transactionNotification","params":{"subscription":subscription,"result":result}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
return;
|
||||
}
|
||||
|
||||
async fn send_root_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, subscription: u64, root: u64) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":"rootNotification","params":{"subscription":subscription,"result":root}});
|
||||
websocket
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into()))
|
||||
.await
|
||||
.expect("local root notification must send");
|
||||
return;
|
||||
}
|
||||
|
||||
async fn wait_for_gap_count(session: &crate::HeliusLaserStreamWsSession, expected: u64) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
if session.snapshot().continuity_gap_count() >= expected && session.state() == crate::WsSessionState::Active {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "Helius session continuity gap count must advance before timeout");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_overflow_count(session: &crate::HeliusLaserStreamWsSession, expected: u64) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
if session.snapshot().overflow_count() >= expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "Helius session overflow count must advance before timeout");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_session_subscription_count(session: &crate::HeliusLaserStreamWsSession, expected: usize) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
if session.snapshot().subscription_count() == expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "Helius session subscription count must settle before timeout");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_subscription_state<T>(subscription: &crate::WsSubscription<T>, expected: crate::WsSubscriptionState) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
if subscription.state() == expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "Helius logical subscription state must advance before timeout");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_filter_and_option_enums_match_documented_wire_labels() {
|
||||
assert_eq!(crate::HeliusTokenAccountsFilter::None.as_str(), "none");
|
||||
assert_eq!(crate::HeliusTokenAccountsFilter::BalanceChanged.as_str(), "balanceChanged");
|
||||
assert_eq!(crate::HeliusTokenAccountsFilter::All.as_str(), "all");
|
||||
assert_eq!(crate::HeliusTransactionSubscribeEncoding::Base58.as_str(), "base58");
|
||||
assert_eq!(crate::HeliusTransactionSubscribeEncoding::Base64.as_str(), "base64");
|
||||
assert_eq!(crate::HeliusTransactionSubscribeEncoding::JsonParsed.as_str(), "jsonParsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_subscribe_request_serializes_complete_documented_filter_and_options() {
|
||||
let filter = base_filter();
|
||||
let options = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Accounts),
|
||||
std::option::Option::Some(true),
|
||||
std::option::Option::Some(0),
|
||||
);
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
|
||||
let params = super::helius_transaction_subscribe_params(&request).expect("complete documented Helius request must validate");
|
||||
assert_eq!(
|
||||
params,
|
||||
std::vec![
|
||||
serde_json::json!({
|
||||
"vote": false,
|
||||
"failed": false,
|
||||
"signature": "fixture-signature-secret-canary",
|
||||
"accountInclude": ["11111111111111111111111111111111"],
|
||||
"accountExclude": ["SysvarC1ock11111111111111111111111111111111"],
|
||||
"accountRequired": ["Vote111111111111111111111111111111111111111"],
|
||||
"tokenAccounts": "balanceChanged"
|
||||
}),
|
||||
serde_json::json!({
|
||||
"commitment": "confirmed",
|
||||
"encoding": "jsonParsed",
|
||||
"transactionDetails": "accounts",
|
||||
"showRewards": true,
|
||||
"maxSupportedTransactionVersion": 0
|
||||
})
|
||||
]
|
||||
);
|
||||
assert_eq!(request.filter().vote(), std::option::Option::Some(false));
|
||||
assert_eq!(request.filter().failed(), std::option::Option::Some(false));
|
||||
assert_eq!(request.filter().signature(), std::option::Option::Some("fixture-signature-secret-canary"));
|
||||
assert_eq!(request.filter().account_include().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1));
|
||||
assert_eq!(request.filter().account_exclude().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1));
|
||||
assert_eq!(request.filter().account_required().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1));
|
||||
assert_eq!(request.filter().token_accounts(), std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged));
|
||||
let options = request.options().expect("options must remain available");
|
||||
assert_eq!(options.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
assert_eq!(options.encoding(), std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed));
|
||||
assert_eq!(options.transaction_details(), std::option::Option::Some(crate::SolanaTransactionDetails::Accounts));
|
||||
assert_eq!(options.show_rewards(), std::option::Option::Some(true));
|
||||
assert_eq!(options.max_supported_transaction_version(), std::option::Option::Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_request_preserves_omitted_explicit_empty_and_explicit_none_states() {
|
||||
let omitted = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
|
||||
assert_eq!(
|
||||
super::helius_transaction_subscribe_params(&omitted).expect("fully omitted optional request must validate"),
|
||||
std::vec![serde_json::json!({})]
|
||||
);
|
||||
let explicit = crate::HeliusTransactionSubscribeRequest::new(
|
||||
crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec::Vec::new()),
|
||||
std::option::Option::Some(std::vec::Vec::new()),
|
||||
std::option::Option::Some(std::vec::Vec::new()),
|
||||
std::option::Option::Some(crate::HeliusTokenAccountsFilter::None),
|
||||
),
|
||||
std::option::Option::Some(crate::HeliusTransactionSubscribeOptions::default()),
|
||||
);
|
||||
assert_eq!(
|
||||
super::helius_transaction_subscribe_params(&explicit).expect("explicit empty Helius request states must validate"),
|
||||
std::vec![serde_json::json!({"accountInclude":[],"accountExclude":[],"accountRequired":[],"tokenAccounts":"none"}), serde_json::json!({})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_filter_enforces_each_documented_fifty_thousand_account_bound() {
|
||||
let key = pubkey("11111111111111111111111111111111");
|
||||
let maximum = std::vec![key; 50_000];
|
||||
let accepted = crate::HeliusTransactionSubscribeRequest::new(
|
||||
crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(maximum),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert!(accepted.validate().is_ok());
|
||||
assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec![key; 50_001]),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
));
|
||||
assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec![key; 50_001]),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
));
|
||||
assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec![key; 50_001]),
|
||||
std::option::Option::None,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_details_require_max_supported_version_only_for_accounts_and_full() {
|
||||
for details in [crate::SolanaTransactionDetails::Full, crate::SolanaTransactionDetails::Accounts] {
|
||||
let options = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(details),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
);
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(options));
|
||||
let error = request.validate().expect_err("full/accounts details must require maxSupportedTransactionVersion");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
}
|
||||
for details in [crate::SolanaTransactionDetails::Signatures, crate::SolanaTransactionDetails::None] {
|
||||
let options = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(details),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
);
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(options));
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
let full_with_version = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Full),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(0),
|
||||
);
|
||||
let request =
|
||||
crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(full_with_version));
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_notification_decoder_preserves_full_signature_and_unknown_shapes() {
|
||||
let full_value = serde_json::json!({
|
||||
"transaction":{"transaction":["AAAA","base64"],"meta":{"err":null}},
|
||||
"signature":"full-signature",
|
||||
"slot":224341380,
|
||||
"transactionIndex":42
|
||||
});
|
||||
let full = super::decode_helius_transaction_notification(full_value.clone()).expect("full Helius notification must decode");
|
||||
match full {
|
||||
crate::HeliusTransactionNotification::Full(notification) => {
|
||||
assert_eq!(notification.transaction(), &full_value["transaction"]);
|
||||
assert_eq!(notification.signature(), "full-signature");
|
||||
assert_eq!(notification.slot(), 224341380);
|
||||
assert_eq!(notification.transaction_index(), 42);
|
||||
},
|
||||
_ => panic!("transaction member must select the full Helius notification variant"),
|
||||
}
|
||||
let signature_value = serde_json::json!({
|
||||
"signature":"signature-only",
|
||||
"slot":224341381,
|
||||
"transactionIndex":43,
|
||||
"err":null,
|
||||
"memo":"memo-canary",
|
||||
"blockTime":1720000000,
|
||||
"confirmationStatus":"confirmed"
|
||||
});
|
||||
let signature = super::decode_helius_transaction_notification(signature_value).expect("signature Helius notification must decode");
|
||||
match signature {
|
||||
crate::HeliusTransactionNotification::Signature(notification) => {
|
||||
assert_eq!(notification.signature(), "signature-only");
|
||||
assert_eq!(notification.slot(), 224341381);
|
||||
assert_eq!(notification.transaction_index(), 43);
|
||||
assert!(matches!(notification.err(), crate::SolanaWireField::Null));
|
||||
assert!(matches!(notification.memo(), crate::SolanaWireField::Value(value) if value == "memo-canary"));
|
||||
assert!(matches!(notification.block_time(), crate::SolanaWireField::Value(1720000000)));
|
||||
assert!(matches!(notification.confirmation_status(), crate::SolanaWireField::Value(value) if value == "confirmed"));
|
||||
},
|
||||
_ => panic!("signature envelope must select the lightweight Helius notification variant"),
|
||||
}
|
||||
let unknown_value = serde_json::json!({"futureProviderShape":{"value":7}});
|
||||
let unknown = super::decode_helius_transaction_notification(unknown_value.clone()).expect("unknown Helius notification must remain forward-compatible");
|
||||
assert!(matches!(unknown, crate::HeliusTransactionNotification::Unknown(value) if value == unknown_value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_filter_debug_omits_signature_and_account_values() {
|
||||
let filter = base_filter();
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::None);
|
||||
let debug = format!("{request:?}");
|
||||
assert!(debug.contains("signature_present"));
|
||||
assert!(debug.contains("account_include_count"));
|
||||
assert!(!debug.contains("fixture-signature-secret-canary"));
|
||||
assert!(!debug.contains("11111111111111111111111111111111"));
|
||||
assert!(!debug.contains("SysvarC1ock11111111111111111111111111111111"));
|
||||
assert!(!debug.contains("Vote111111111111111111111111111111111111111"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_notification_debug_omits_raw_provider_payloads() {
|
||||
let full_value = serde_json::json!({
|
||||
"transaction":{"raw":"MASSIVE-RAW-PAYLOAD-CANARY"},
|
||||
"signature":"FULL-SIGNATURE-CANARY",
|
||||
"slot":77,
|
||||
"transactionIndex":3
|
||||
});
|
||||
let full = super::decode_helius_transaction_notification(full_value).expect("full Helius notification must decode");
|
||||
let signature_value = serde_json::json!({
|
||||
"signature":"SIGNATURE-MODE-CANARY",
|
||||
"slot":78,
|
||||
"transactionIndex":4,
|
||||
"err":{"secret":"ERROR-DATA-CANARY"},
|
||||
"memo":"MEMO-CANARY",
|
||||
"blockTime":123,
|
||||
"confirmationStatus":"CONFIRMATION-CANARY"
|
||||
});
|
||||
let signature = super::decode_helius_transaction_notification(signature_value).expect("signature Helius notification must decode");
|
||||
let unknown = super::decode_helius_transaction_notification(serde_json::json!({"provider":"UNKNOWN-PAYLOAD-CANARY"}))
|
||||
.expect("unknown Helius notification must remain forward-compatible");
|
||||
let rendered = format!("{full:?} {signature:?} {unknown:?}");
|
||||
assert!(rendered.contains("transaction: \"<omitted>\""));
|
||||
assert!(rendered.contains("signature: \"<omitted>\""));
|
||||
assert!(rendered.contains("err: \"value\""));
|
||||
assert!(rendered.contains("memo: \"value\""));
|
||||
assert!(rendered.contains("Unknown(\"<omitted>\")"));
|
||||
for forbidden in [
|
||||
"MASSIVE-RAW-PAYLOAD-CANARY",
|
||||
"FULL-SIGNATURE-CANARY",
|
||||
"SIGNATURE-MODE-CANARY",
|
||||
"ERROR-DATA-CANARY",
|
||||
"MEMO-CANARY",
|
||||
"CONFIRMATION-CANARY",
|
||||
"UNKNOWN-PAYLOAD-CANARY",
|
||||
] {
|
||||
assert!(!rendered.contains(forbidden));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_provider_rpc_application_error_is_safe_and_does_not_fail_session() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept Helius client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
|
||||
let transaction_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(transaction_subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
send_error(
|
||||
&mut websocket,
|
||||
&transaction_subscribe,
|
||||
-32602,
|
||||
"PROVIDER-MESSAGE-SECRET-CANARY",
|
||||
serde_json::json!({"apiKey":"PROVIDER-ERROR-SECRET-CANARY","payload":"X".repeat(4096)}),
|
||||
)
|
||||
.await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(72)).await;
|
||||
send_root_notification(&mut websocket, 72, 88).await;
|
||||
let root_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
|
||||
assert_eq!(root_unsubscribe["params"], serde_json::json!([72]));
|
||||
send_result(&mut websocket, &root_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let endpoint_url = format!("{url}/?api-key=HELIUS-ENDPOINT-SECRET-CANARY");
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(endpoint_url.as_str())).await.expect("Helius facade must connect");
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(base_filter(), std::option::Option::None);
|
||||
let error = session.transaction_subscribe(&request).await.expect_err("provider application error must reject only the logical subscribe request");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
||||
assert!(error.context().iter().any(|entry| return entry.key() == "rpc_code" && entry.value() == "-32602"));
|
||||
assert!(error.context().iter().any(|entry| return entry.key() == "method" && entry.value() == "transactionSubscribe"));
|
||||
let rendered = format!("{error:?} {error} {session:?} {:?}", session.snapshot());
|
||||
for forbidden in ["PROVIDER-MESSAGE-SECRET-CANARY", "PROVIDER-ERROR-SECRET-CANARY", "HELIUS-ENDPOINT-SECRET-CANARY", "fixture-signature-secret-canary"] {
|
||||
assert!(!rendered.contains(forbidden));
|
||||
}
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
wait_for_session_subscription_count(&session, 0).await;
|
||||
let mut root = session.root_subscribe().await.expect("session must accept a healthy subscription after provider application error");
|
||||
assert_eq!(root.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 88);
|
||||
assert!(root.unsubscribe().await.expect("healthy root unsubscribe must complete"));
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("provider error fixture server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_notification_method_mismatch_fails_only_transaction_subscription() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept Helius client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
|
||||
let transaction_subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &transaction_subscribe, serde_json::json!(41)).await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(42)).await;
|
||||
send_root_notification(&mut websocket, 41, 5).await;
|
||||
let cleanup = read_request(&mut websocket).await;
|
||||
assert_eq!(cleanup["method"], serde_json::json!("transactionUnsubscribe"));
|
||||
assert_eq!(cleanup["params"], serde_json::json!([41]));
|
||||
send_result(&mut websocket, &cleanup, serde_json::json!(true)).await;
|
||||
send_root_notification(&mut websocket, 42, 99).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect");
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
|
||||
let mut transaction = session.transaction_subscribe(&request).await.expect("transaction subscription must register");
|
||||
let mut root = session.root_subscribe().await.expect("root subscription must register");
|
||||
wait_for_subscription_state(&transaction, crate::WsSubscriptionState::Failed).await;
|
||||
assert_eq!(transaction.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_PROTOCOL_ERROR));
|
||||
assert!(transaction.recv().await.is_none());
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
wait_for_session_subscription_count(&session, 1).await;
|
||||
assert_eq!(root.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 99);
|
||||
assert_eq!(root.state(), crate::WsSubscriptionState::Active);
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("notification mismatch fixture server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_oversized_inbound_payload_reconnects_before_provider_json_decode() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (first_stream, _) = listener.accept().await.expect("initial Helius client must connect");
|
||||
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial Helius handshake must succeed");
|
||||
first
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text("PROVIDER-PAYLOAD-CANARY".repeat(32).into()))
|
||||
.await
|
||||
.expect("oversized provider fixture payload must send");
|
||||
let (replacement_stream, _) = listener.accept().await.expect("replacement Helius client must connect");
|
||||
let mut replacement = tokio_tungstenite::accept_async(replacement_stream).await.expect("replacement Helius handshake must succeed");
|
||||
let root_subscribe = read_request(&mut replacement).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
send_result(&mut replacement, &root_subscribe, serde_json::json!(91)).await;
|
||||
let root_unsubscribe = read_request(&mut replacement).await;
|
||||
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
|
||||
send_result(&mut replacement, &root_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut replacement).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), adversarial_payload_session_settings()))
|
||||
.await
|
||||
.expect("Helius facade must connect before adversarial payload");
|
||||
wait_for_gap_count(&session, 1).await;
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
assert_eq!(session.snapshot().continuity_gap_count(), 1);
|
||||
let mut root = session.root_subscribe().await.expect("recovered Helius session must remain usable");
|
||||
assert!(root.unsubscribe().await.expect("recovered root subscription must unsubscribe"));
|
||||
session.close().await.expect("recovered Helius session must close");
|
||||
server.await.expect("oversized provider payload fixture server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_transaction_live_handle_decodes_notification_and_unsubscribes_through_shared_actor() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
assert_eq!(
|
||||
subscribe["params"],
|
||||
serde_json::json!([
|
||||
{"failed":false,"accountInclude":["11111111111111111111111111111111"],"tokenAccounts":"balanceChanged"},
|
||||
{"commitment":"confirmed","encoding":"jsonParsed","transactionDetails":"full","showRewards":false,"maxSupportedTransactionVersion":0}
|
||||
])
|
||||
);
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(4242)).await;
|
||||
send_notification(
|
||||
&mut websocket,
|
||||
4242,
|
||||
serde_json::json!({
|
||||
"transaction":{"transaction":["AAAA","base64"],"meta":{"err":null}},
|
||||
"signature":"live-signature",
|
||||
"slot":99,
|
||||
"transactionIndex":7
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("transactionUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([4242]));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect");
|
||||
let filter = crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec![pubkey("11111111111111111111111111111111")]),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged),
|
||||
);
|
||||
let options = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some(0),
|
||||
);
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
|
||||
let mut subscription = session.transaction_subscribe(&request).await.expect("public Helius transaction subscription must register");
|
||||
assert_eq!(subscription.kind(), crate::WsSubscriptionKind::HeliusTransaction);
|
||||
let notification = subscription.recv().await.expect("Helius transaction notification must arrive").expect("Helius notification must decode");
|
||||
match notification {
|
||||
crate::HeliusTransactionNotification::Full(notification) => {
|
||||
assert_eq!(notification.signature(), "live-signature");
|
||||
assert_eq!(notification.slot(), 99);
|
||||
assert_eq!(notification.transaction_index(), 7);
|
||||
},
|
||||
_ => panic!("full live payload must decode as HeliusTransactionNotification::Full"),
|
||||
}
|
||||
assert!(subscription.unsubscribe().await.expect("transactionUnsubscribe must complete"));
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("local Helius transaction server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_transaction_reconnect_remaps_remote_id_and_ignores_late_notification_after_unsubscribe() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (first_stream, _) = listener.accept().await.expect("initial Helius client must connect");
|
||||
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial Helius handshake must succeed");
|
||||
let first_subscribe = read_request(&mut first).await;
|
||||
assert_eq!(first_subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
send_result(&mut first, &first_subscribe, serde_json::json!(41)).await;
|
||||
send_notification(&mut first, 41, serde_json::json!({"signature":"generation-one","slot":1,"transactionIndex":0})).await;
|
||||
drop(first);
|
||||
let (second_stream, _) = listener.accept().await.expect("replacement Helius client must connect");
|
||||
let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("replacement Helius handshake must succeed");
|
||||
let second_subscribe = read_request(&mut second).await;
|
||||
assert_eq!(second_subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
assert_eq!(second_subscribe["params"], first_subscribe["params"]);
|
||||
send_result(&mut second, &second_subscribe, serde_json::json!(99)).await;
|
||||
send_notification(&mut second, 99, serde_json::json!({"signature":"generation-two","slot":2,"transactionIndex":1})).await;
|
||||
let unsubscribe = read_request(&mut second).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("transactionUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([99]));
|
||||
send_notification(&mut second, 99, serde_json::json!({"signature":"late-after-cancel","slot":3,"transactionIndex":2})).await;
|
||||
send_result(&mut second, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut second).await;
|
||||
});
|
||||
let settings = reconnect_session_settings(std::time::Duration::from_millis(20));
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), settings)).await.expect("Helius facade must connect");
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
|
||||
let mut subscription = session.transaction_subscribe(&request).await.expect("initial Helius transaction subscription must register");
|
||||
let stable_id = subscription.id();
|
||||
let first = subscription.recv().await.expect("first generation notification must arrive").expect("first generation notification must decode");
|
||||
assert!(matches!(first, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "generation-one"));
|
||||
let second = tokio::time::timeout(std::time::Duration::from_secs(2), subscription.recv())
|
||||
.await
|
||||
.expect("resubscribed Helius notification must remain bounded")
|
||||
.expect("resubscribed Helius channel must remain open")
|
||||
.expect("resubscribed Helius notification must decode");
|
||||
assert!(matches!(second, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "generation-two"));
|
||||
assert_eq!(subscription.id(), stable_id);
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Active);
|
||||
wait_for_gap_count(&session, 1).await;
|
||||
assert_eq!(session.snapshot().continuity_gap_count(), 1);
|
||||
assert!(subscription.unsubscribe().await.expect("Helius transaction cancellation must complete"));
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
|
||||
assert!(tokio::time::timeout(std::time::Duration::from_millis(100), subscription.recv()).await.expect("closed Helius channel must settle").is_none());
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("local reconnect Helius server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_transaction_backpressure_fails_only_slow_subscription_and_uses_transaction_unsubscribe_cleanup() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept Helius client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
|
||||
let transaction_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(transaction_subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
send_result(&mut websocket, &transaction_subscribe, serde_json::json!(41)).await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(42)).await;
|
||||
send_notification(&mut websocket, 41, serde_json::json!({"signature":"queued","slot":1,"transactionIndex":0})).await;
|
||||
send_notification(&mut websocket, 41, serde_json::json!({"signature":"overflow","slot":2,"transactionIndex":1})).await;
|
||||
let cleanup = read_request(&mut websocket).await;
|
||||
assert_eq!(cleanup["method"], serde_json::json!("transactionUnsubscribe"));
|
||||
assert_eq!(cleanup["params"], serde_json::json!([41]));
|
||||
send_result(&mut websocket, &cleanup, serde_json::json!(true)).await;
|
||||
send_root_notification(&mut websocket, 42, 99).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), backpressure_session_settings()))
|
||||
.await
|
||||
.expect("Helius facade must connect");
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
|
||||
let mut slow = session.transaction_subscribe(&request).await.expect("slow Helius transaction subscription must register");
|
||||
let mut healthy = session.root_subscribe().await.expect("healthy Helius root subscription must register");
|
||||
wait_for_subscription_state(&slow, crate::WsSubscriptionState::Failed).await;
|
||||
wait_for_overflow_count(&session, 1).await;
|
||||
assert_eq!(slow.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW));
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
assert_eq!(session.snapshot().subscription_count(), 1);
|
||||
let queued = slow.recv().await.expect("first Helius notification must remain queued").expect("queued Helius notification must decode");
|
||||
assert!(matches!(queued, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "queued"));
|
||||
assert!(slow.recv().await.is_none());
|
||||
assert_eq!(healthy.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 99);
|
||||
assert_eq!(healthy.state(), crate::WsSubscriptionState::Active);
|
||||
assert_eq!(healthy.terminal_error_code(), std::option::Option::None);
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("local Helius backpressure server must finish");
|
||||
}
|
||||
135
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
Normal file
135
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
|
||||
// version: 5
|
||||
|
||||
fn non_zero(value: u64) -> std::num::NonZeroU64 {
|
||||
return std::num::NonZeroU64::new(value).expect("test ID must be non-zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_local_ids_preserve_ordering_and_numeric_identity() {
|
||||
let first_session = crate::WsSessionId::new(non_zero(1));
|
||||
let second_session = crate::WsSessionId::new(non_zero(2));
|
||||
let subscription = crate::WsSubscriptionId::new(non_zero(7));
|
||||
assert!(first_session < second_session);
|
||||
assert_eq!(subscription.get(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_state_models_expose_concurrent_lifecycle_states() {
|
||||
assert_eq!(crate::WsSessionState::Reconnecting { attempt: 3 }, crate::WsSessionState::Reconnecting { attempt: 3 });
|
||||
assert_eq!(crate::WsSubscriptionState::Resubscribing, crate::WsSubscriptionState::Resubscribing);
|
||||
assert_ne!(crate::WsSubscriptionState::Cancelling, crate::WsSubscriptionState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_subscription_kinds_cover_all_nine_standard_families() {
|
||||
let kinds = [
|
||||
crate::WsSubscriptionKind::Account,
|
||||
crate::WsSubscriptionKind::Block,
|
||||
crate::WsSubscriptionKind::Logs,
|
||||
crate::WsSubscriptionKind::Program,
|
||||
crate::WsSubscriptionKind::Root,
|
||||
crate::WsSubscriptionKind::Signature,
|
||||
crate::WsSubscriptionKind::Slot,
|
||||
crate::WsSubscriptionKind::SlotsUpdates,
|
||||
crate::WsSubscriptionKind::Vote,
|
||||
];
|
||||
assert_eq!(kinds.len(), 9);
|
||||
assert_eq!(kinds[7].as_str(), "slots_updates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_snapshots_expose_safe_metadata_without_remote_ids_or_urls() {
|
||||
let subscription = crate::WsSubscriptionSnapshot::new(
|
||||
crate::WsSubscriptionId::new(non_zero(9)),
|
||||
crate::WsSubscriptionKind::Slot,
|
||||
crate::WsSubscriptionState::Active,
|
||||
true,
|
||||
std::option::Option::None,
|
||||
);
|
||||
let snapshot = crate::WsSessionSnapshot::new(
|
||||
crate::WsSessionId::new(non_zero(3)),
|
||||
"devnet_public",
|
||||
crate::WsProviderName::new("solana-public"),
|
||||
crate::WsClusterName::new("devnet"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsSessionState::Active,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
std::vec![subscription],
|
||||
);
|
||||
assert_eq!(snapshot.id().get(), 3);
|
||||
assert_eq!(snapshot.endpoint_name(), "devnet_public");
|
||||
assert_eq!(snapshot.pending_request_count(), 2);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 1);
|
||||
assert_eq!(snapshot.subscription_count(), 1);
|
||||
assert!(snapshot.subscriptions()[0].remote_bound());
|
||||
assert_eq!(snapshot.subscriptions()[0].terminal_error_code(), std::option::Option::None);
|
||||
assert_eq!(snapshot.overflow_count(), 0);
|
||||
let rendered = format!("{snapshot:?}");
|
||||
assert!(!rendered.contains("wss://"));
|
||||
assert!(!rendered.contains("remote_subscription_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_subscription_snapshot_preserves_only_safe_terminal_error_code() {
|
||||
let snapshot = crate::WsSubscriptionSnapshot::new(
|
||||
crate::WsSubscriptionId::new(non_zero(10)),
|
||||
crate::WsSubscriptionKind::Logs,
|
||||
crate::WsSubscriptionState::Failed,
|
||||
false,
|
||||
std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW),
|
||||
);
|
||||
assert_eq!(snapshot.state(), crate::WsSubscriptionState::Failed);
|
||||
assert_eq!(snapshot.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW));
|
||||
assert!(!format!("{snapshot:?}").contains("remote_subscription_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_subscription_kinds_map_exact_standard_method_triplets() {
|
||||
let cases = [
|
||||
(crate::WsSubscriptionKind::Account, "accountSubscribe", "accountUnsubscribe", "accountNotification"),
|
||||
(crate::WsSubscriptionKind::Block, "blockSubscribe", "blockUnsubscribe", "blockNotification"),
|
||||
(crate::WsSubscriptionKind::Logs, "logsSubscribe", "logsUnsubscribe", "logsNotification"),
|
||||
(crate::WsSubscriptionKind::Program, "programSubscribe", "programUnsubscribe", "programNotification"),
|
||||
(crate::WsSubscriptionKind::Root, "rootSubscribe", "rootUnsubscribe", "rootNotification"),
|
||||
(crate::WsSubscriptionKind::Signature, "signatureSubscribe", "signatureUnsubscribe", "signatureNotification"),
|
||||
(crate::WsSubscriptionKind::Slot, "slotSubscribe", "slotUnsubscribe", "slotNotification"),
|
||||
(crate::WsSubscriptionKind::SlotsUpdates, "slotsUpdatesSubscribe", "slotsUpdatesUnsubscribe", "slotsUpdatesNotification"),
|
||||
(crate::WsSubscriptionKind::Vote, "voteSubscribe", "voteUnsubscribe", "voteNotification"),
|
||||
];
|
||||
for (kind, subscribe, unsubscribe, notification) in cases {
|
||||
assert_eq!(kind.subscribe_method(), subscribe);
|
||||
assert_eq!(kind.unsubscribe_method(), unsubscribe);
|
||||
assert_eq!(kind.notification_method(), notification);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_unstable_subscription_partition_is_exact() {
|
||||
let cases = [
|
||||
(crate::WsSubscriptionKind::Account, false),
|
||||
(crate::WsSubscriptionKind::Block, true),
|
||||
(crate::WsSubscriptionKind::Logs, false),
|
||||
(crate::WsSubscriptionKind::Program, false),
|
||||
(crate::WsSubscriptionKind::Root, false),
|
||||
(crate::WsSubscriptionKind::Signature, false),
|
||||
(crate::WsSubscriptionKind::Slot, false),
|
||||
(crate::WsSubscriptionKind::SlotsUpdates, true),
|
||||
(crate::WsSubscriptionKind::Vote, true),
|
||||
];
|
||||
for (kind, unstable) in cases {
|
||||
assert_eq!(kind.is_unstable(), unstable);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_subscription_kind_maps_exact_provider_method_triplet_without_expanding_standard_partition() {
|
||||
let kind = crate::WsSubscriptionKind::HeliusTransaction;
|
||||
assert_eq!(kind.as_str(), "helius_transaction");
|
||||
assert_eq!(kind.subscribe_method(), "transactionSubscribe");
|
||||
assert_eq!(kind.unsubscribe_method(), "transactionUnsubscribe");
|
||||
assert_eq!(kind.notification_method(), "transactionNotification");
|
||||
assert!(!kind.is_unstable());
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_protocol_session.rs
|
||||
// version: 3
|
||||
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn endpoint(url: &str, protocol: crate::WsProtocolKind) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_protocol_fixture",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
protocol,
|
||||
crate::WsEndpointUrl::parse(url).expect("local WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn accept_until_close(listener: tokio::net::TcpListener) {
|
||||
let (stream, _) = listener.accept().await.expect("local peer must accept connection");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
while let std::option::Option::Some(message) = websocket.next().await {
|
||||
let message = message.expect("local peer message must decode");
|
||||
if message.is_close() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_facades_share_the_existing_physical_session_path() {
|
||||
let (standard_listener, standard_url) = bind_local_listener().await;
|
||||
let standard_server = tokio::spawn(accept_until_close(standard_listener));
|
||||
let standard = crate::SolanaStandardWsSession::connect(endpoint(standard_url.as_str(), crate::WsProtocolKind::SolanaStandard))
|
||||
.await
|
||||
.expect("standard facade must connect");
|
||||
assert_eq!(standard.snapshot().protocol(), crate::WsProtocolKind::SolanaStandard);
|
||||
standard.close().await.expect("standard facade must close");
|
||||
standard_server.await.expect("standard peer task must finish");
|
||||
let (helius_listener, helius_url) = bind_local_listener().await;
|
||||
let helius_server = tokio::spawn(accept_until_close(helius_listener));
|
||||
let helius_url = format!("{helius_url}/?api-key=SECRET-CANARY");
|
||||
let helius = crate::HeliusLaserStreamWsSession::connect(endpoint(helius_url.as_str(), crate::WsProtocolKind::HeliusLaserStream))
|
||||
.await
|
||||
.expect("Helius facade must connect");
|
||||
assert_eq!(helius.snapshot().protocol(), crate::WsProtocolKind::HeliusLaserStream);
|
||||
let rendered = format!("{helius:?}");
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains(helius_url.as_str()));
|
||||
helius.close().await.expect("Helius facade must close");
|
||||
helius_server.await.expect("Helius peer task must finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn historical_generic_constructor_remains_standard_only_before_network_io() {
|
||||
let endpoint = endpoint("ws://127.0.0.1:9", crate::WsProtocolKind::HeliusLaserStream);
|
||||
let error = crate::WsSession::connect(endpoint).await.expect_err("generic historical constructor must reject Helius protocol");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
assert_eq!(
|
||||
error.context().iter().find(|entry| return entry.key() == "expected_protocol").map(|entry| return entry.value()),
|
||||
std::option::Option::Some("solana_standard")
|
||||
);
|
||||
assert_eq!(
|
||||
error.context().iter().find(|entry| return entry.key() == "actual_protocol").map(|entry| return entry.value()),
|
||||
std::option::Option::Some("helius_laserstream")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn typed_facades_reject_protocol_mismatch_before_network_io() {
|
||||
let helius_error = crate::HeliusLaserStreamWsSession::connect(endpoint("ws://127.0.0.1:9", crate::WsProtocolKind::SolanaStandard))
|
||||
.await
|
||||
.expect_err("Helius facade must reject standard endpoint");
|
||||
assert_eq!(helius_error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
let standard_error = crate::SolanaStandardWsSession::connect(endpoint("ws://127.0.0.1:9", crate::WsProtocolKind::HeliusLaserStream))
|
||||
.await
|
||||
.expect_err("standard facade must reject Helius endpoint");
|
||||
assert_eq!(standard_error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_facades_define_no_second_actor_socket_or_public_inner_escape_hatch() {
|
||||
let source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(!source.contains("tokio::spawn"));
|
||||
assert!(!source.contains("tokio_tungstenite"));
|
||||
assert!(!source.contains("WsSessionCommand"));
|
||||
assert!(!source.contains("pub fn inner("));
|
||||
assert!(!source.contains("pub fn into_inner("));
|
||||
assert!(!source.contains("pub async fn account_subscribe"));
|
||||
assert!(!source.contains("pub async fn block_subscribe"));
|
||||
assert!(!source.contains("pub async fn logs_subscribe"));
|
||||
assert!(!source.contains("pub async fn program_subscribe"));
|
||||
assert!(!source.contains("pub async fn root_subscribe"));
|
||||
assert!(!source.contains("pub async fn signature_subscribe"));
|
||||
assert!(!source.contains("pub async fn slot_subscribe"));
|
||||
assert!(!source.contains("pub async fn slots_updates_subscribe"));
|
||||
assert!(!source.contains("pub async fn vote_subscribe"));
|
||||
}
|
||||
1357
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
Normal file
1357
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
Normal file
File diff suppressed because it is too large
Load Diff
150
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
Normal file
150
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
|
||||
// version: 2
|
||||
|
||||
fn valid_endpoint(name: &str, url_text: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
name,
|
||||
true,
|
||||
crate::WsProviderName::new("solana-public"),
|
||||
crate::WsClusterName::new("devnet"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url_text).expect("test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_endpoint_url_accepts_ws_and_wss() {
|
||||
assert!(crate::WsEndpointUrl::parse("wss://api.devnet.solana.com").is_ok());
|
||||
assert!(crate::WsEndpointUrl::parse("ws://127.0.0.1:8900").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_endpoint_url_rejects_http_schemes() {
|
||||
let result = crate::WsEndpointUrl::parse("https://api.devnet.solana.com");
|
||||
let error = result.expect_err("HTTP URL must not be accepted by WebSocket settings");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_endpoint_url_debug_redacts_secret_material() {
|
||||
let url = crate::WsEndpointUrl::parse("wss://user:password@provider.invalid/path?api-key=SECRET-CANARY").expect("test URL must parse");
|
||||
let rendered = format!("{url:?}");
|
||||
assert_eq!(rendered, "WsEndpointUrl(<redacted>)");
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
assert!(!rendered.contains("password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_endpoint_url_errors_do_not_echo_sensitive_url() {
|
||||
let result = crate::WsEndpointUrl::parse("http://user:password@provider.invalid/path?api-key=SECRET-CANARY");
|
||||
let error = result.expect_err("unsupported scheme must fail");
|
||||
let rendered = format!("{error:?}");
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
assert!(!rendered.contains("password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_protocol_kind_distinguishes_standard_and_helius_laserstream_websocket() {
|
||||
assert_eq!(crate::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
|
||||
assert_eq!(crate::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
|
||||
assert_ne!(crate::WsProtocolKind::SolanaStandard, crate::WsProtocolKind::HeliusLaserStream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_session_defaults_are_bounded_and_validate() {
|
||||
let settings = crate::WsSessionSettings::default();
|
||||
assert!(settings.validate().is_ok());
|
||||
assert!(settings.command_queue_capacity() > 0);
|
||||
assert!(settings.notification_queue_capacity() > 0);
|
||||
assert!(settings.max_active_subscriptions() > 0);
|
||||
assert!(settings.max_pending_requests() > 0);
|
||||
assert!(settings.max_message_size_bytes() > 0);
|
||||
assert!(settings.max_frame_size_bytes() > 0);
|
||||
assert!(settings.max_write_buffer_size_bytes() > 0);
|
||||
assert_eq!(settings.resubscribe(), crate::WsResubscribePolicy::ActiveSubscriptions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_session_settings_reject_zero_runtime_bounds() {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
let settings = crate::WsSessionSettings::new(
|
||||
defaults.command_timeout(),
|
||||
defaults.close_timeout(),
|
||||
defaults.reconnect().clone(),
|
||||
defaults.resubscribe(),
|
||||
0,
|
||||
defaults.notification_queue_capacity(),
|
||||
defaults.max_active_subscriptions(),
|
||||
defaults.max_pending_requests(),
|
||||
defaults.max_message_size_bytes(),
|
||||
defaults.max_frame_size_bytes(),
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
let error = settings.validate().expect_err("zero command queue capacity must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_session_settings_reject_reversed_reconnect_backoff() {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
let settings = crate::WsSessionSettings::new(
|
||||
defaults.command_timeout(),
|
||||
defaults.close_timeout(),
|
||||
crate::WsReconnectSettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
|
||||
defaults.resubscribe(),
|
||||
defaults.command_queue_capacity(),
|
||||
defaults.notification_queue_capacity(),
|
||||
defaults.max_active_subscriptions(),
|
||||
defaults.max_pending_requests(),
|
||||
defaults.max_message_size_bytes(),
|
||||
defaults.max_frame_size_bytes(),
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_transport_settings_validate_unique_enabled_endpoints() {
|
||||
let settings = crate::WsTransportSettings::new(std::vec![
|
||||
valid_endpoint("devnet_primary", "wss://api.devnet.solana.com"),
|
||||
valid_endpoint("devnet_secondary", "wss://example.invalid/ws"),
|
||||
]);
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_transport_settings_reject_duplicate_endpoint_names() {
|
||||
let settings =
|
||||
crate::WsTransportSettings::new(std::vec![valid_endpoint("duplicate", "wss://one.invalid/ws"), valid_endpoint("duplicate", "wss://two.invalid/ws"),]);
|
||||
let error = settings.validate().expect_err("duplicate endpoint names must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_transport_settings_require_one_enabled_endpoint() {
|
||||
let endpoint = crate::WsEndpointSettings::new(
|
||||
"disabled",
|
||||
false,
|
||||
crate::WsProviderName::new("provider"),
|
||||
crate::WsClusterName::new("devnet"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse("wss://provider.invalid/ws").expect("test URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
let settings = crate::WsTransportSettings::new(std::vec![endpoint]);
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_transport_settings_debug_never_exposes_endpoint_url() {
|
||||
let settings =
|
||||
crate::WsTransportSettings::new(std::vec![valid_endpoint("secret_endpoint", "wss://user:password@provider.invalid/path?api-key=SECRET-CANARY",)]);
|
||||
let rendered = format!("{settings:?}");
|
||||
assert!(rendered.contains("WsEndpointUrl(<redacted>)"));
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
assert!(!rendered.contains("password"));
|
||||
}
|
||||
246
crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
Normal file
246
crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
|
||||
// version: 2
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_transactions",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_subscribe_filters_preserve_all_all_with_votes_and_exactly_one_mention() {
|
||||
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("mention fixture must parse");
|
||||
assert_eq!(crate::SolanaLogsSubscribeFilter::All.to_json_value(), serde_json::json!("all"));
|
||||
assert_eq!(crate::SolanaLogsSubscribeFilter::AllWithVotes.to_json_value(), serde_json::json!("allWithVotes"));
|
||||
assert_eq!(crate::SolanaLogsSubscribeFilter::Mentions(pubkey).to_json_value(), serde_json::json!({"mentions":["11111111111111111111111111111111"]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_notification_decoder_preserves_context_signature_nullable_error_and_ordered_logs() {
|
||||
let success = super::decode_logs_notification(
|
||||
"logsSubscribe",
|
||||
serde_json::json!({
|
||||
"context":{"slot":81,"apiVersion":"4.2.1"},
|
||||
"value":{"signature":"fixture-signature","err":null,"logs":["first","second"]}
|
||||
}),
|
||||
)
|
||||
.expect("successful logs notification must decode");
|
||||
assert_eq!(success.context().slot(), 81);
|
||||
assert_eq!(success.value().signature(), "fixture-signature");
|
||||
assert!(success.value().err().is_none());
|
||||
assert_eq!(success.value().logs(), &["first".to_owned(), "second".to_owned()]);
|
||||
let failed = super::decode_logs_notification(
|
||||
"logsSubscribe",
|
||||
serde_json::json!({"context":{"slot":82},"value":{"signature":"fixture-signature-2","err":{"InstructionError":[0,"Custom"]},"logs":[]}}),
|
||||
)
|
||||
.expect("failed logs notification must preserve transaction error wire value");
|
||||
assert_eq!(failed.context().slot(), 82);
|
||||
assert!(failed.value().err().is_some());
|
||||
let missing_err =
|
||||
super::decode_logs_notification("logsSubscribe", serde_json::json!({"context":{"slot":83},"value":{"signature":"fixture-signature-3","logs":[]}}));
|
||||
assert!(missing_err.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stable_logs_wrapper_uses_exact_filter_config_notification_and_handle_unsubscribe() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("logsSubscribe"));
|
||||
assert_eq!(subscribe["params"], serde_json::json!([{"mentions":["11111111111111111111111111111111"]},{"commitment":"finalized"}]));
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(88)).await;
|
||||
let id = subscribe.get("id").and_then(serde_json::Value::as_u64).expect("subscribe request id must exist");
|
||||
assert!(id > 0);
|
||||
let notification = serde_json::json!({
|
||||
"jsonrpc":"2.0",
|
||||
"method":"logsNotification",
|
||||
"params":{
|
||||
"result":{"context":{"slot":900},"value":{"signature":"fixture-signature","err":null,"logs":["Program fixture success"]}},
|
||||
"subscription":88
|
||||
}
|
||||
});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("logs notification must send");
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("logsUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([88]));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mention = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("mention fixture must parse");
|
||||
let filter = crate::SolanaLogsSubscribeFilter::Mentions(mention);
|
||||
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
|
||||
let mut subscription = session.logs_subscribe(&filter, std::option::Option::Some(&config)).await.expect("logsSubscribe must register");
|
||||
assert_eq!(subscription.kind(), crate::WsSubscriptionKind::Logs);
|
||||
let notification = subscription.recv().await.expect("logs notification must arrive").expect("logs notification must decode");
|
||||
assert_eq!(notification.context().slot(), 900);
|
||||
assert_eq!(notification.value().signature(), "fixture-signature");
|
||||
assert_eq!(notification.value().logs(), &["Program fixture success".to_owned()]);
|
||||
assert!(subscription.unsubscribe().await.expect("logs unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_subscribe_config_and_decoder_preserve_all_documented_wire_variants() {
|
||||
let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(false));
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.enable_received_notification(), std::option::Option::Some(false));
|
||||
assert_eq!(config.to_json_value(), serde_json::json!({"commitment":"confirmed","enableReceivedNotification":false}));
|
||||
let received = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":90},"value":"receivedSignature"}))
|
||||
.expect("receivedSignature notification must decode");
|
||||
assert_eq!(received.context().slot(), 90);
|
||||
assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature);
|
||||
assert!(!received.value().is_terminal());
|
||||
let success = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":91},"value":{"err":null}}))
|
||||
.expect("terminal successful signature notification must decode");
|
||||
assert!(success.value().is_terminal());
|
||||
assert!(success.value().err().is_none());
|
||||
let failure = super::decode_signature_notification(
|
||||
"signatureSubscribe",
|
||||
serde_json::json!({"context":{"slot":92},"value":{"err":{"InstructionError":[0,"Custom"]}}}),
|
||||
)
|
||||
.expect("terminal failed signature notification must decode");
|
||||
assert!(failure.value().is_terminal());
|
||||
assert!(failure.value().err().is_some());
|
||||
assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":93},"value":"futureVariant"})).is_err());
|
||||
assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":94},"value":{}})).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn signature_unsubscribe_before_terminal_notification_uses_current_remote_id() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("signatureSubscribe"));
|
||||
assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature"]));
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(301)).await;
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("signatureUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([301]));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let empty_config = crate::SolanaSignatureSubscribeConfig::default();
|
||||
let mut subscription =
|
||||
session.signature_subscribe("fixture-signature", std::option::Option::Some(&empty_config)).await.expect("signatureSubscribe must register");
|
||||
assert!(subscription.unsubscribe().await.expect("signature unsubscribe must complete"));
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn signature_terminal_notification_closes_handle_and_is_not_resubscribed_after_reconnect() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let (send_terminal_tx, send_terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let (replacement_ready_tx, replacement_ready_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept initial client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("initial WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("signatureSubscribe"));
|
||||
assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature",{"commitment":"finalized","enableReceivedNotification":true}]));
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(401)).await;
|
||||
let received = serde_json::json!({
|
||||
"jsonrpc":"2.0",
|
||||
"method":"signatureNotification",
|
||||
"params":{"result":{"context":{"slot":100},"value":"receivedSignature"},"subscription":401}
|
||||
});
|
||||
websocket
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(received.to_string().into()))
|
||||
.await
|
||||
.expect("receivedSignature notification must send");
|
||||
send_terminal_rx.await.expect("client must observe early signature notification before terminal send");
|
||||
let terminal = serde_json::json!({
|
||||
"jsonrpc":"2.0",
|
||||
"method":"signatureNotification",
|
||||
"params":{"result":{"context":{"slot":101},"value":{"err":null}},"subscription":401}
|
||||
});
|
||||
websocket
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(terminal.to_string().into()))
|
||||
.await
|
||||
.expect("terminal signature notification must send");
|
||||
let unexpected_cleanup = tokio::time::timeout(std::time::Duration::from_millis(100), websocket.next()).await;
|
||||
assert!(unexpected_cleanup.is_err(), "server-terminal signature notification must not trigger signatureUnsubscribe");
|
||||
drop(websocket);
|
||||
let (replacement_stream, _) = listener.accept().await.expect("local server must accept replacement client");
|
||||
let mut replacement = tokio_tungstenite::accept_async(replacement_stream).await.expect("replacement WebSocket handshake must succeed");
|
||||
let unexpected = tokio::time::timeout(std::time::Duration::from_millis(100), replacement.next()).await;
|
||||
assert!(unexpected.is_err(), "terminal signature subscription must not be replayed after reconnect");
|
||||
replacement_ready_tx.send(()).expect("replacement-ready signal must send");
|
||||
wait_for_close_frame(&mut replacement).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(true));
|
||||
let mut subscription =
|
||||
session.signature_subscribe("fixture-signature", std::option::Option::Some(&config)).await.expect("signatureSubscribe must register");
|
||||
let received = subscription.recv().await.expect("receivedSignature must arrive").expect("receivedSignature must decode");
|
||||
assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature);
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Active);
|
||||
send_terminal_tx.send(()).expect("terminal-send signal must reach fixture");
|
||||
let terminal = subscription.recv().await.expect("terminal signature notification must arrive").expect("terminal signature notification must decode");
|
||||
assert!(terminal.value().is_terminal());
|
||||
assert!(terminal.value().err().is_none());
|
||||
let closed = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
loop {
|
||||
if subscription.state() == crate::WsSubscriptionState::Closed {
|
||||
return;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(closed.is_ok());
|
||||
assert!(subscription.recv().await.is_none());
|
||||
assert!(subscription.terminal_error_code().is_none());
|
||||
assert!(!subscription.unsubscribe().await.expect("already terminal signature unsubscribe must be local-only"));
|
||||
replacement_ready_rx.await.expect("replacement connection must be observed without signature replay");
|
||||
assert_eq!(session.snapshot().subscription_count(), 0);
|
||||
assert!(session.snapshot().continuity_gap_count() >= 1);
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-wallet-lib/Cargo.toml
|
||||
# version: 6
|
||||
# version: 7
|
||||
|
||||
[package]
|
||||
name = "ksp-wallet-lib"
|
||||
@@ -9,12 +9,12 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
argon2 = { workspace = true, features = ["alloc", "zeroize"] }
|
||||
base64.workspace = true
|
||||
chacha20poly1305 = { workspace = true, features = ["alloc", "zeroize"] }
|
||||
ed25519-dalek = { workspace = true, features = ["signature", "zeroize"] }
|
||||
getrandom.workspace = true
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
base64.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
solana-keypair.workspace = true
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- file: crates/ksp-wallet-lib/README.md -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# `ksp-wallet-lib`
|
||||
|
||||
Statut : **stable depuis KSP `0.2.5` ; surface V2/multi-version stable depuis `0.2.6`**.
|
||||
Statut : **stable ; surface V1/V2 et façade multi-version validées**.
|
||||
|
||||
`ksp-wallet-lib` est la bibliothèque KSP propriétaire du Wallet Solana natif. Elle possède le format autonome `.kspwallet` V1 et le format binaire V2 canonique, les capacités indépendantes VIEW/OWNER, la protection du secret Solana, la signature, l'administration des metadata, les rotations de credentials, la persistence native et les adapters d'import/export explicitement supportés. Depuis KSP `0.2.6`, les APIs non versionnées créent/importent en V2 par default explicite et lisent V1/V2 par détection bornée. La migration V1 -> V2 est explicite et OWNER-authentifiée, sans migration à l'ouverture.
|
||||
`ksp-wallet-lib` est la bibliothèque KSP propriétaire du Wallet Solana natif. Elle possède le format autonome `.kspwallet` V1 et le format binaire V2 canonique, les capacités indépendantes VIEW/OWNER, la protection du secret Solana, la signature, l'administration des metadata, les rotations de credentials, la persistence native et les adapters d'import/export explicitement supportés. Les APIs non versionnées créent/importent en V2 par default explicite et lisent V1/V2 par détection bornée. La migration V1 -> V2 est explicite et OWNER-authentifiée, sans migration à l'ouverture.
|
||||
|
||||
La crate est volontairement indépendante de Config, du réseau et de Tauri. Un consumer fournit les chemins, passwords et metadata ; Wallet ouvre, protège, signe et persiste sans décider d'une policy de dépense ni contacter un RPC.
|
||||
|
||||
@@ -191,13 +191,13 @@ Elles couvrent le wire, Argon2id/XChaCha20-Poly1305, l'ouverture VIEW/OWNER, la
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — exemples des principales surfaces publiques ;
|
||||
- [`../../docs/formats/KSPWALLET_V1.md`](../../docs/formats/KSPWALLET_V1.md) — spécification normative indépendante de Rust ;
|
||||
- [`../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan historique et threat model de `0.2.5` ;
|
||||
- [`../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan historique et threat model de la fondation Wallet ;
|
||||
- [`../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) — matrice de sécurité/interoperabilité/compliance ;
|
||||
- [`../../prompts/011-V0_2_6_START_PROMPT.md`](../../prompts/011-V0_2_6_START_PROMPT.md) — reprise vers Wallet Desk après publication stable de `0.2.5`.
|
||||
- [`../../prompts/011-V0_2_6_START_PROMPT.md`](../../prompts/011-V0_2_6_START_PROMPT.md) — prompt historique de reprise vers Wallet Desk.
|
||||
|
||||
## V2 stable depuis `0.2.6`
|
||||
## Wire/runtime V2 et façade multi-version
|
||||
|
||||
`pre.015` a figé le wire structurel V2, son codec et ses transcripts/AAD. `pre.016` matérialise le runtime V2 complet et la façade multi-version :
|
||||
Le wire structurel V2, son codec et ses transcripts/AAD sont figés ; le runtime V2 complet et la façade multi-version exposent :
|
||||
|
||||
```text
|
||||
DEFAULT_WALLET_FORMAT = V2
|
||||
@@ -211,7 +211,7 @@ open/inspect génériques -> détection V1/V2
|
||||
open/inspect _v1/_v2 -> format forcé strict
|
||||
```
|
||||
|
||||
`WalletOwner` et `WalletView` conservent le format natif qu'ils ont ouvert : metadata, rotations OWNER/VIEW, disable/recreate VIEW, self-rotation VIEW, signature et export ne transcodent jamais implicitement le fichier. Le default est une décision explicite et ne suit pas automatiquement une future V3. `pre.017` matérialise la migration authentifiée V1 -> V2 comme opération séparée ; aucune lecture ou mutation ordinaire ne migre implicitement.
|
||||
`WalletOwner` et `WalletView` conservent le format natif qu'ils ont ouvert : metadata, rotations OWNER/VIEW, disable/recreate VIEW, self-rotation VIEW, signature et export ne transcodent jamais implicitement le fichier. Le default est une décision explicite et ne suit pas automatiquement une future V3. La migration authentifiée V1 -> V2 est une opération séparée ; aucune lecture ou mutation ordinaire ne migre implicitement.
|
||||
### Migration explicite V1 -> V2
|
||||
|
||||
```text
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-wallet-lib/USAGE.md -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# Utilisation de `ksp-wallet-lib`
|
||||
|
||||
@@ -278,9 +278,9 @@ ksp-onchain-transport-lib
|
||||
-> utilise cette Pubkey pour getBalance et autres lectures réseau
|
||||
```
|
||||
|
||||
Cette composition est le rôle de `0.2.6 — ksp-app-wallet-desk`, pas de `ksp-wallet-lib`.
|
||||
Cette composition appartient à `ksp-app-wallet-desk`, pas à `ksp-wallet-lib`.
|
||||
|
||||
## Wire/runtime V2 stable (`0.2.6`)
|
||||
## Wire/runtime V2
|
||||
|
||||
Le codec structurel V2 reste disponible directement pour les outils qui travaillent explicitement au niveau wire :
|
||||
|
||||
|
||||
327
deltas/0.2.10/pre.001.md
Normal file
327
deltas/0.2.10/pre.001.md
Normal file
@@ -0,0 +1,327 @@
|
||||
<!-- file: deltas/0.2.10/pre.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.10-pre.001` — audit/sizing OrbitFlare Yellowstone gRPC
|
||||
|
||||
## 1. Base requise et vérifiée
|
||||
|
||||
Archive autoritaire fournie :
|
||||
|
||||
```text
|
||||
khadhroony-solana-project-v0.2.9-full-from-gitea.zip
|
||||
```
|
||||
|
||||
État vérifié :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.9
|
||||
deltas/0.2.9/rel.001.md présent
|
||||
prompts/015-V0_2_10_START_PROMPT.md présent
|
||||
metadata .git absente de l'archive Gitea
|
||||
```
|
||||
|
||||
Cette livraison ouvre :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.10-pre.1
|
||||
commit attendu = v0.2.10-pre.001
|
||||
aucun tag prerelease
|
||||
```
|
||||
|
||||
## 2. Objet
|
||||
|
||||
`pre.001` exécute le gate documentaire obligatoire :
|
||||
|
||||
```text
|
||||
lecture base/règles/architecture
|
||||
baseline stable
|
||||
réaudit OrbitFlare actuel
|
||||
réaudit Yellowstone actuel
|
||||
auth control-plane/data-plane
|
||||
endpoints/network/security
|
||||
Free Devnet gRPC
|
||||
heartbeat
|
||||
capabilities/unary/replay
|
||||
limits/quotas
|
||||
architecture N1/N2 immuable
|
||||
threat model
|
||||
sizing et forecast
|
||||
```
|
||||
|
||||
Aucun code provider lourd, SDK OrbitFlare, metadata secrète ou heartbeat supplémentaire n'est ajouté.
|
||||
|
||||
## 3. Baseline opérateur acquise
|
||||
|
||||
Preuves fournies le 2026-08-25 sur `v0.2.9` :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
python3 scripts/audit_rust_workspace_rules.py OK / clean
|
||||
python3 scripts/audit_markdown_tables.py OK / 87 tables / 261 files
|
||||
cargo check --workspace OK
|
||||
cargo clippy --workspace --all-targets OK
|
||||
cargo test --workspace OK
|
||||
cargo tree -p ksp-onchain-transport-lib --duplicates fourni
|
||||
```
|
||||
|
||||
Transport pendant le workspace test :
|
||||
|
||||
```text
|
||||
383 unit
|
||||
49 public_api
|
||||
43 release_completeness
|
||||
4 doctests
|
||||
```
|
||||
|
||||
Les live smokes restent opt-in/ignored par défaut.
|
||||
|
||||
Le `cargo tree -p ksp-onchain-transport-lib` complet n'a pas encore été fourni et doit être rejoué avant commit.
|
||||
|
||||
## 4. Graphe de dépendances inspecté
|
||||
|
||||
Versions utiles visibles :
|
||||
|
||||
```text
|
||||
yellowstone-grpc-proto 12.6.0
|
||||
tonic 0.14.6
|
||||
tonic-prost 0.14.6
|
||||
prost 0.14.4
|
||||
tokio 1.53.1
|
||||
http 1.5.0
|
||||
reqwest 0.13.4
|
||||
```
|
||||
|
||||
Aucune divergence Tonic/Prost/Yellowstone ne justifie une intervention. Les doublons restants sont sans impact matériel pour OrbitFlare.
|
||||
|
||||
## 5. Réaudit OrbitFlare du 2026-08-25
|
||||
|
||||
### Free Devnet
|
||||
|
||||
Le pricing actuel confirme :
|
||||
|
||||
```text
|
||||
Free 0 USD/mois
|
||||
RPC 10 RPS
|
||||
transactions 1 TPS
|
||||
gRPC Devnet only
|
||||
credit limits unlimited
|
||||
```
|
||||
|
||||
Le CLI documente :
|
||||
|
||||
```text
|
||||
RPC Devnet = http://devnet.rpc.orbitflare.com
|
||||
gRPC Devnet = http://devnet.rpc.orbitflare.com:10000
|
||||
```
|
||||
|
||||
Objectif opérationnel fermé : utiliser OrbitFlare comme provider gRPC Devnet gratuit pour les tests KSP futurs, en complément de PublicNode Mainnet/Testnet.
|
||||
|
||||
### Auth
|
||||
|
||||
Classification :
|
||||
|
||||
```text
|
||||
X-ORBIT-KEY Customer API only
|
||||
Bearer Customer API v2
|
||||
RPC api_key HTTP RPC only
|
||||
account API key ne prouve pas une auth Yellowstone
|
||||
gRPC Dashboard token possible selon licence/service
|
||||
x-token mécanisme Yellowstone plausible si token gRPC distinct
|
||||
aucune metadata documentée par SDK Go pour endpoints régionaux
|
||||
```
|
||||
|
||||
La clé de compte opérateur ne doit pas être fournie au dépôt ni injectée par supposition dans gRPC.
|
||||
|
||||
### Heartbeat
|
||||
|
||||
OrbitFlare recommande un `SubscribeRequest::Ping` périodique. Yellowstone upstream envoie déjà un `SubscribeUpdate::Ping` périodique et KSP N1 répond automatiquement par un Ping standard sans modifier le dernier full request mémorisé.
|
||||
|
||||
Décision :
|
||||
|
||||
```text
|
||||
aucun timer OrbitFlare ajouté au moteur
|
||||
aucun changement YellowstoneGrpcSessionSettings
|
||||
aucune modification grpc_stream.rs
|
||||
pre.002 doit observer le Ping serveur live OrbitFlare Devnet
|
||||
```
|
||||
|
||||
Si ce Ping est observé, la policy standard N1 est suffisante. Sinon, une divergence provider doit être démontrée puis composée au-dessus de N1 sans le modifier.
|
||||
|
||||
### Limits
|
||||
|
||||
```text
|
||||
50 connexions gRPC simultanées par IP sur shared
|
||||
subscriptions par connexion annoncées unlimited
|
||||
idle timeout environ 10 minutes
|
||||
RESOURCE_EXHAUSTED au dépassement
|
||||
backoff exponentiel conseillé
|
||||
```
|
||||
|
||||
Ces valeurs restent provider-owned.
|
||||
|
||||
## 6. Réaudit Yellowstone actuel
|
||||
|
||||
État observé :
|
||||
|
||||
```text
|
||||
release GitHub courante v15.1.2+solana.4.2.0
|
||||
yellowstone-grpc-proto publié 12.6.0
|
||||
```
|
||||
|
||||
Aucune évolution matérielle ne remet en cause le standard N2 de `0.2.9`. Aucune dependency n'est modifiée.
|
||||
|
||||
## 7. Invariant architectural supersédant l'hypothèse initiale
|
||||
|
||||
Décision opérateur explicite :
|
||||
|
||||
```text
|
||||
le moteur gRPC ne change jamais pour un provider
|
||||
le moteur WebSocket ne change jamais pour un provider
|
||||
les fonctionnalités provider se composent au-dessus du moteur
|
||||
```
|
||||
|
||||
Pour `0.2.10` :
|
||||
|
||||
```text
|
||||
N1 moteur Yellowstone = IMMUTABLE
|
||||
N2 standard Solana = IMMUTABLE
|
||||
N3 OrbitFlare = profile/capability/facade seulement si besoin démontré
|
||||
```
|
||||
|
||||
Le prompt `015` reste autoritatif sauf sur toute formulation qui permettrait de mettre une policy OrbitFlare directement dans le moteur partagé.
|
||||
|
||||
## 8. Config V3
|
||||
|
||||
La shape existante suffit :
|
||||
|
||||
```text
|
||||
profile_id = orbitflare_devnet
|
||||
provider = orbitflare
|
||||
cluster = devnet
|
||||
protocol = solana_yellowstone
|
||||
url = http://devnet.rpc.orbitflare.com:10000
|
||||
metadata = []
|
||||
secret_metadata = []
|
||||
```
|
||||
|
||||
Pas de V4. Pas de secret ajouté avant preuve live. Pas de champ heartbeat moteur.
|
||||
|
||||
## 9. Smoke prévu
|
||||
|
||||
`pre.002` doit commencer par un canari standard N2 :
|
||||
|
||||
```text
|
||||
connect OrbitFlare Devnet Free sans metadata
|
||||
Subscribe slots confirmed
|
||||
recevoir Slot non nul
|
||||
observer SubscribeUpdate::Ping pendant une fenêtre bornée de caractérisation
|
||||
fermer proprement
|
||||
```
|
||||
|
||||
Puis sonder séparément les unary et replay sans rendre le smoke minimal dépendant d'entitlements provider non garantis.
|
||||
|
||||
## 10. Forecast recalibré
|
||||
|
||||
Chemin standard probable :
|
||||
|
||||
```text
|
||||
pre.001 audit/sizing
|
||||
pre.002 Config Devnet + characterization smoke standard N2
|
||||
pre.003 gate technique/live final
|
||||
pre.004 réconciliation documentaire finale
|
||||
pre.005 publication minimale
|
||||
rel.001 stable
|
||||
```
|
||||
|
||||
Chemin avec divergence provider démontrée :
|
||||
|
||||
```text
|
||||
pre.001 audit/sizing
|
||||
pre.002 Config Devnet + characterization smoke
|
||||
pre.003 provider overlay minimal au-dessus de N1/N2
|
||||
pre.004 gate technique/live final
|
||||
pre.005 réconciliation documentaire finale
|
||||
pre.006 publication minimale
|
||||
rel.001 stable
|
||||
```
|
||||
|
||||
## 11. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||
deltas/0.2.10/pre.001.md
|
||||
```
|
||||
|
||||
## 12. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
docs/000-README.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/validation/000-README.md
|
||||
```
|
||||
|
||||
## 13. Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
ROADMAP.md
|
||||
CHANGELOG.md
|
||||
.env.example
|
||||
config/**
|
||||
crates/**
|
||||
docs/architecture/**
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
```
|
||||
|
||||
Aucune implémentation provider ou Config runtime n'est introduite dans `pre.001`.
|
||||
|
||||
## 14. Sources externes réauditées
|
||||
|
||||
```text
|
||||
https://docs.orbitflare.com/llms.txt
|
||||
https://docs.orbitflare.com/cli
|
||||
https://docs.orbitflare.com/authentication
|
||||
https://docs.orbitflare.com/data-streaming/yellowstone
|
||||
https://docs.orbitflare.com/data-streaming/yellowstone-quickstart
|
||||
https://docs.orbitflare.com/sdk/go-grpc
|
||||
https://orbitflare.com/pricing
|
||||
https://orbitflare.com/products/rpc-nodes
|
||||
https://orbitflare.com/products/solana-grpc
|
||||
https://github.com/rpcpool/yellowstone-grpc
|
||||
https://github.com/rpcpool/yellowstone-grpc/releases
|
||||
https://github.com/rpcpool/yellowstone-grpc/blob/master/README.md
|
||||
https://github.com/rpcpool/yellowstone-grpc/blob/master/yellowstone-grpc-proto/proto/geyser.proto
|
||||
```
|
||||
|
||||
## 15. Validations de préparation
|
||||
|
||||
Le sandbox a exécuté :
|
||||
|
||||
```text
|
||||
inspection archive/version/rel/prompt OK
|
||||
lecture code N1/N2/Config et docs 0.2.9 OK
|
||||
réaudit OrbitFlare/Yellowstone actuel OK
|
||||
inspection cargo tree --duplicates fourni par opérateur OK
|
||||
```
|
||||
|
||||
Le sandbox ne dispose pas de Cargo ; aucun `cargo check/clippy/test/tree` post-overlay n'est déclaré réussi localement.
|
||||
|
||||
## 16. Validation opérateur attendue avant commit
|
||||
|
||||
Comme `pre.001` change uniquement la version Cargo et la documentation :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||
```
|
||||
|
||||
Attendu : aucun changement de dependency et aucun changement sous `crates/`.
|
||||
196
deltas/0.2.10/pre.002-fix.001.md
Normal file
196
deltas/0.2.10/pre.002-fix.001.md
Normal file
@@ -0,0 +1,196 @@
|
||||
<!-- file: deltas/0.2.10/pre.002-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.10-pre.002-fix.001` — OrbitFlare License Key `x-token`
|
||||
|
||||
## 1. Base et identité
|
||||
|
||||
Base exacte :
|
||||
|
||||
```text
|
||||
0.2.10-pre.002 commité
|
||||
```
|
||||
|
||||
Le fix porte une correction runtime/configuration de l’auth provider. La version Cargo devient donc :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.10-pre.2.fix.1
|
||||
commit attendu = v0.2.10-pre.002-fix.001
|
||||
aucun tag prerelease
|
||||
```
|
||||
|
||||
## 2. Défaut démontré par le live `pre.002`
|
||||
|
||||
Le gate statique/workspace `pre.002` est vert. Le smoke live sans metadata a atteint OrbitFlare puis l’ouverture du stream a échoué de manière sûre :
|
||||
|
||||
```text
|
||||
grpc_operation = SubscribeOpen
|
||||
grpc_status = Unauthenticated
|
||||
grpc_code = The request does not have valid authentication credentials
|
||||
```
|
||||
|
||||
Le Dashboard opérateur montre un produit `Solana Free` avec License Key `ORBIT-*` et `API Key Mode Active`.
|
||||
|
||||
La référence Yellowstone OrbitFlare précise ensuite le contrat data-plane :
|
||||
|
||||
```text
|
||||
ORBITFLARE_LICENSE_KEY
|
||||
-> metadata gRPC x-token
|
||||
-> Yellowstone
|
||||
```
|
||||
|
||||
Le `X-ORBIT-KEY` et le Bearer Device Flow restent des credentials Customer API et ne sont pas utilisés par Transport Yellowstone.
|
||||
|
||||
## 3. Correction Config V3
|
||||
|
||||
`config/std.transport.json` conserve le même endpoint et ajoute uniquement la metadata secrète requise :
|
||||
|
||||
```text
|
||||
provider = orbitflare
|
||||
cluster = devnet
|
||||
protocol = solana_yellowstone
|
||||
url = http://devnet.rpc.orbitflare.com:10000
|
||||
metadata = []
|
||||
secret_metadata = x-token <- ${KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN}
|
||||
```
|
||||
|
||||
Aucun format Config V4 n’est créé.
|
||||
|
||||
`.env.example` inventorie désormais :
|
||||
|
||||
```text
|
||||
KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN
|
||||
```
|
||||
|
||||
La valeur locale attendue est la License Key `ORBIT-*`. Une `X-ORBIT-KEY` Customer API ne doit jamais être placée dans cette variable.
|
||||
|
||||
## 4. Preuve Config déterministe
|
||||
|
||||
Le test `committed_v3_orbitflare_devnet_profile_maps_provider_neutral_yellowstone_grpc_with_license_x_token` injecte un canari secret via `ConfigEnvironment` et prouve :
|
||||
|
||||
```text
|
||||
profil explicite orbitflare_devnet
|
||||
companions HTTP/WS Devnet inchangés
|
||||
endpoint Yellowstone OrbitFlare inchangé
|
||||
metadata key = x-token
|
||||
metadata classée secret
|
||||
validation Transport PASS
|
||||
URL provider absente du Debug
|
||||
canari secret absent du Debug
|
||||
X-ORBIT-KEY absent du Transport
|
||||
```
|
||||
|
||||
## 5. Smoke live corrigé
|
||||
|
||||
`yellowstone_orbitflare_smoke.rs` ne dépend toujours pas de Config et ne lit aucun environnement. Il suit le pattern de sécurité déjà utilisé par les smokes PublicNode :
|
||||
|
||||
```text
|
||||
une License Key lue sur stdin
|
||||
aucun secret en argument de ligne de commande
|
||||
YellowstoneGrpcMetadataEntry::secret("x-token", ...)
|
||||
reconnect max_retries = 0
|
||||
Subscribe slots confirmed
|
||||
fenêtre Slot + Ping = 45 s
|
||||
close borné
|
||||
```
|
||||
|
||||
Le smoke vérifie également que la License Key n’apparaît pas dans `Debug`.
|
||||
|
||||
Commande opérateur :
|
||||
|
||||
```bash
|
||||
read -rsp 'OrbitFlare License Key: ' ORBITFLARE_LICENSE_KEY; echo
|
||||
printf '%s\n' "$ORBITFLARE_LICENSE_KEY" | cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
|
||||
unset ORBITFLARE_LICENSE_KEY
|
||||
```
|
||||
|
||||
## 6. Invariant architectural
|
||||
|
||||
Toujours inchangés :
|
||||
|
||||
```text
|
||||
src/grpc_settings.rs
|
||||
src/grpc_channel.rs
|
||||
src/grpc_unary.rs
|
||||
src/grpc_subscribe.rs
|
||||
src/grpc_stream.rs
|
||||
```
|
||||
|
||||
Le standard Yellowstone N2 reste inchangé. Aucun SDK OrbitFlare runtime n’est ajouté.
|
||||
|
||||
L’auth provider est entièrement composée par la surface metadata secrète déjà existante.
|
||||
|
||||
## 7. Heartbeat
|
||||
|
||||
Ce fix ne matérialise aucun heartbeat OrbitFlare.
|
||||
|
||||
Le rerun authentifié doit d’abord qualifier :
|
||||
|
||||
```text
|
||||
Slot non nul
|
||||
SubscribeUpdate::Ping serveur
|
||||
```
|
||||
|
||||
Si les deux sont observés, N1 répond déjà automatiquement au Ping standard et aucune couche provider heartbeat n’est requise.
|
||||
|
||||
Si le stream authentifié fonctionne mais n’émet aucun Ping serveur dans la fenêtre de caractérisation, `pre.003` devient une tranche provider-specific au-dessus de N1/N2. Il reste interdit d’ajouter un timer OrbitFlare au moteur gRPC.
|
||||
|
||||
## 8. Validation disponible dans l’environnement de génération
|
||||
|
||||
Exécuté :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||
Markdown table audit: clean (99 table(s), 99 file(s))
|
||||
|
||||
config/std.transport.json
|
||||
JSON parse PASS
|
||||
JSON Schema validation PASS
|
||||
|
||||
comparaison N1 avec pre.002
|
||||
grpc_settings.rs inchangé
|
||||
grpc_channel.rs inchangé
|
||||
grpc_unary.rs inchangé
|
||||
grpc_subscribe.rs inchangé
|
||||
grpc_stream.rs inchangé
|
||||
```
|
||||
|
||||
Aucune commande Cargo n’est déclarée exécutée dans l’environnement de génération.
|
||||
|
||||
## 9. Gates opérateur
|
||||
|
||||
Après application du fix :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-core-lib --test workspace_dependencies
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Puis exécuter le live authentifié décrit en section 5.
|
||||
|
||||
## 10. Fichiers ajoutés/modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
.env.example
|
||||
config/std.transport.json
|
||||
crates/ksp-config-lib/unit_tests/transport.rs
|
||||
crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
|
||||
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||
deltas/0.2.10/pre.002-fix.001.md
|
||||
```
|
||||
|
||||
Le delta commité `deltas/0.2.10/pre.002.md` reste immuable.
|
||||
287
deltas/0.2.10/pre.002.md
Normal file
287
deltas/0.2.10/pre.002.md
Normal file
@@ -0,0 +1,287 @@
|
||||
<!-- file: deltas/0.2.10/pre.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.10-pre.002` — OrbitFlare Devnet Config + characterization smoke
|
||||
|
||||
## 1. Base et version
|
||||
|
||||
Base de travail :
|
||||
|
||||
```text
|
||||
0.2.10-pre.001
|
||||
```
|
||||
|
||||
Validation opérateur fournie sur cette base le 2026-08-25 :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||
python3 scripts/audit_markdown_tables.py PASS / 97 tables / 233 files
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test --workspace PASS
|
||||
cargo tree -p ksp-onchain-transport-lib fourni
|
||||
cargo tree -p ksp-onchain-transport-lib --duplicates fourni
|
||||
```
|
||||
|
||||
Le graphe confirme notamment :
|
||||
|
||||
```text
|
||||
yellowstone-grpc-proto 12.6.0
|
||||
tonic 0.14.6
|
||||
tonic-prost 0.14.6
|
||||
prost 0.14.4
|
||||
tokio 1.53.1
|
||||
```
|
||||
|
||||
Cette livraison passe à :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.10-pre.2
|
||||
commit attendu = v0.2.10-pre.002
|
||||
aucun tag prerelease
|
||||
```
|
||||
|
||||
## 2. Invariant architectural
|
||||
|
||||
Aucun moteur physique n'est modifié :
|
||||
|
||||
```text
|
||||
src/grpc_settings.rs inchangé
|
||||
src/grpc_channel.rs inchangé
|
||||
src/grpc_unary.rs inchangé
|
||||
src/grpc_subscribe.rs inchangé
|
||||
src/grpc_stream.rs inchangé
|
||||
```
|
||||
|
||||
Le standard Yellowstone N2 reste également inchangé.
|
||||
|
||||
OrbitFlare est matérialisé uniquement par :
|
||||
|
||||
```text
|
||||
Config provider profile
|
||||
provider-neutral Yellowstone smoke
|
||||
provider validation/documentation
|
||||
```
|
||||
|
||||
Aucun SDK OrbitFlare, second client Tonic, proto provider, heartbeat moteur ou metadata secrète n'est ajouté.
|
||||
|
||||
## 3. Profil Config V3 `orbitflare_devnet`
|
||||
|
||||
`config/std.transport.json` ajoute un profil dédié :
|
||||
|
||||
```text
|
||||
profile_id = orbitflare_devnet
|
||||
```
|
||||
|
||||
Il conserve les companions standards déjà utilisés sur Devnet :
|
||||
|
||||
```text
|
||||
HTTP = https://api.devnet.solana.com
|
||||
WS = wss://api.devnet.solana.com
|
||||
```
|
||||
|
||||
et ajoute exactement un endpoint Yellowstone :
|
||||
|
||||
```text
|
||||
name = orbitflare_solana_devnet_yellowstone
|
||||
provider = orbitflare
|
||||
cluster = devnet
|
||||
protocol = solana_yellowstone
|
||||
url = http://devnet.rpc.orbitflare.com:10000
|
||||
metadata = []
|
||||
secret_metadata = []
|
||||
```
|
||||
|
||||
Le format reste V3. `.env.example` n'est pas modifié et aucune API key de compte OrbitFlare n'est reclassifiée comme credential gRPC.
|
||||
|
||||
`ksp-config-lib` ajoute un test déterministe qui prouve :
|
||||
|
||||
```text
|
||||
sélection explicite orbitflare_devnet
|
||||
companions HTTP/WS Devnet préservés
|
||||
provider/cluster/protocol gRPC corrects
|
||||
URL plaintext conservée telle quelle
|
||||
aucune metadata inventée
|
||||
Debug gRPC sans URL provider
|
||||
absence de X-ORBIT-KEY dans Transport settings
|
||||
```
|
||||
|
||||
## 4. Smoke OrbitFlare Devnet
|
||||
|
||||
Nouveau test opt-in :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
|
||||
```
|
||||
|
||||
Le test utilise directement les API publiques Yellowstone existantes de Transport, sans dépendre de Config :
|
||||
|
||||
```text
|
||||
endpoint Devnet OrbitFlare sans metadata
|
||||
reconnect max_retries = 0
|
||||
Subscribe slots
|
||||
commitment = confirmed
|
||||
fenêtre de caractérisation = 45 s
|
||||
preuve Slot non nul
|
||||
preuve SubscribeUpdate::Ping serveur
|
||||
close borné
|
||||
```
|
||||
|
||||
Le `Ping` reçu reste traité par le moteur N1 déjà livré en `0.2.9`, qui répond automatiquement sur le même stream. Le test n'ajoute aucune policy heartbeat.
|
||||
|
||||
Commande live :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Interprétation :
|
||||
|
||||
```text
|
||||
Slot + Ping PASS
|
||||
OrbitFlare reste standard ; aucune façade/policy provider heartbeat requise
|
||||
|
||||
connection/auth failure
|
||||
classifier le service/licence réel avant toute metadata
|
||||
|
||||
Slot PASS mais Ping absent
|
||||
qualifier une divergence heartbeat provider avant toute implémentation spécifique
|
||||
```
|
||||
|
||||
## 5. Audit Markdown étendu
|
||||
|
||||
`scripts/audit_markdown_tables.py` passe en version 2 et contrôle désormais aussi l'équivalent structurel de :
|
||||
|
||||
```text
|
||||
\n\s*\n\s*\n
|
||||
```
|
||||
|
||||
c'est-à-dire au moins deux lignes vides consécutives hors fenced code block.
|
||||
|
||||
Le contrôle :
|
||||
|
||||
```text
|
||||
considère une ligne whitespace-only comme vide
|
||||
signale fichier + première ligne + longueur de la séquence
|
||||
n'interprète pas les blancs internes aux ``` / ~~~ fenced blocks
|
||||
ignore les répertoires générés ou tiers en parcours récursif
|
||||
```
|
||||
|
||||
Répertoires récursifs exclus :
|
||||
|
||||
```text
|
||||
.git
|
||||
.idea
|
||||
.venv
|
||||
__pycache__
|
||||
dist
|
||||
node_modules
|
||||
target
|
||||
```
|
||||
|
||||
Cette exclusion rend le résultat déterministe entre une archive Gitea propre et un checkout développeur contenant des `node_modules`. Le nombre de fichiers audités peut donc diminuer par rapport à l'ancien script sans signifier une perte de couverture KSP-owned.
|
||||
|
||||
Une seule séquence de deux lignes vides existait dans le scope KSP actif de l'archive : elle est supprimée dans `crates/ksp-onchain-transport-lib/README.md`.
|
||||
|
||||
`docs/rules/RULES_DOCUMENTATION.md` formalise `DOC-BLANK-001` à `DOC-BLANK-003`, y compris l'immuabilité des anciens deltas publiés.
|
||||
|
||||
## 6. Documents de plan/validation
|
||||
|
||||
Le plan 017 et la validation 013 passent en version 2 pour enregistrer :
|
||||
|
||||
```text
|
||||
pre.001 operator gate fermé
|
||||
profil orbitflare_devnet matérialisé
|
||||
smoke de caractérisation matérialisé
|
||||
N1/N2 inchangés
|
||||
live Devnet encore PENDING
|
||||
```
|
||||
|
||||
Aucun README/USAGE OrbitFlare durable n'est finalisé ici ; cette responsabilité reste réservée au couloir documentaire final.
|
||||
|
||||
## 7. Validation disponible dans l'environnement de génération
|
||||
|
||||
Exécuté sans Cargo :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||
clean / 98 tables / 98 KSP-owned files
|
||||
|
||||
config/std.transport.json
|
||||
JSON parse PASS
|
||||
JSON Schema validation PASS
|
||||
```
|
||||
|
||||
Le nouvel audit Markdown a aussi été caractérisé avec des fixtures temporaires :
|
||||
|
||||
```text
|
||||
deux lignes vides hors fence FAIL attendu
|
||||
plusieurs lignes vides dans fence PASS attendu
|
||||
node_modules récursif ignoré attendu
|
||||
```
|
||||
|
||||
Aucune commande Cargo n'est déclarée exécutée dans l'environnement de génération.
|
||||
|
||||
## 8. Gates opérateur `pre.002`
|
||||
|
||||
Exécuter :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-core-lib --test workspace_dependencies
|
||||
cargo test --workspace
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||
```
|
||||
|
||||
Puis le live dédié :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
|
||||
```
|
||||
|
||||
`pre.002` ne ferme son gate provider que lorsque le résultat live est classifié. Un échec d'auth ou l'absence de `Ping` ne doit jamais être corrigé par modification du moteur N1.
|
||||
|
||||
## 9. Fichiers ajoutés/modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
config/std.transport.json
|
||||
crates/ksp-config-lib/unit_tests/transport.rs
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/tests/yellowstone_orbitflare_smoke.rs
|
||||
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||
docs/rules/RULES_DOCUMENTATION.md
|
||||
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||
scripts/audit_markdown_tables.py
|
||||
deltas/0.2.10/pre.002.md
|
||||
```
|
||||
|
||||
## 10. Suite conditionnelle
|
||||
|
||||
Si le live retourne `Slot + Ping PASS` :
|
||||
|
||||
```text
|
||||
pre.003 = gate technique/live final
|
||||
```
|
||||
|
||||
Si une divergence provider réelle est démontrée :
|
||||
|
||||
```text
|
||||
pre.003 = tranche provider-specific minimale au-dessus de N1/N2
|
||||
pre.004 = gate technique/live final
|
||||
```
|
||||
|
||||
Le couloir documentaire final puis la prerelease de publication restent distincts dans les deux cas.
|
||||
252
deltas/0.2.10/pre.003.md
Normal file
252
deltas/0.2.10/pre.003.md
Normal file
@@ -0,0 +1,252 @@
|
||||
<!-- file: deltas/0.2.10/pre.003.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.10-pre.003` — gate technique/live final OrbitFlare Yellowstone
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Base directe attendue :
|
||||
|
||||
```text
|
||||
livraison : 0.2.10-pre.002-fix.001
|
||||
Cargo : 0.2.10-pre.2.fix.1
|
||||
commit : v0.2.10-pre.002-fix.001
|
||||
```
|
||||
|
||||
Identité de cette tranche :
|
||||
|
||||
```text
|
||||
livraison : 0.2.10-pre.003
|
||||
Cargo : 0.2.10-pre.3
|
||||
commit : v0.2.10-pre.003
|
||||
aucun tag prerelease
|
||||
```
|
||||
|
||||
Cette prerelease est exclusivement le couloir **technique/live final** de `0.2.10`. Elle ne porte aucune réconciliation README/USAGE/plan/validation, aucune finalisation CHANGELOG/ROADMAP et aucun prompt `0.2.11`.
|
||||
|
||||
## 2. Gate acquis de `pre.002-fix.001`
|
||||
|
||||
La validation opérateur reçue le 2026-08-25 ferme le défaut d'auth classifié dans `pre.002`.
|
||||
|
||||
Le gate déterministe est intégralement vert :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py clean / 0 export candidate / clean
|
||||
python3 scripts/audit_markdown_tables.py PASS, 99 tables / 99 files
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test --workspace PASS
|
||||
Config unit 114/114 PASS
|
||||
Config ownership 5/5 PASS
|
||||
Config public API 15/15 PASS
|
||||
Transport unit 383/383 PASS
|
||||
Transport public API 49/49 PASS
|
||||
Transport release completeness 43/43 PASS
|
||||
Transport doctests 4/4 PASS
|
||||
workspace dependency canary 3/3 PASS
|
||||
```
|
||||
|
||||
Le smoke OrbitFlare Devnet authentifié est également vert :
|
||||
|
||||
```text
|
||||
endpoint http://devnet.rpc.orbitflare.com:10000
|
||||
auth License Key ORBIT-* -> secret metadata x-token
|
||||
Subscribe PASS
|
||||
Slot > 0 PASS
|
||||
server Ping PASS
|
||||
close borné PASS
|
||||
résultat 1 passed / 0 failed
|
||||
```
|
||||
|
||||
Cette preuve ferme la branche de divergence heartbeat : OrbitFlare émet le `SubscribeUpdate::Ping` Yellowstone standard et le moteur N1 `0.2.9` possède déjà la réponse automatique déterministe. Aucun heartbeat provider, aucune façade OrbitFlare et aucune modification N1/N2 ne sont nécessaires.
|
||||
|
||||
## 3. Responsabilité de `pre.003`
|
||||
|
||||
`pre.003` ne matérialise aucun nouveau comportement. Elle rejoue et ferme les preuves techniques finales après le fix d'auth :
|
||||
|
||||
```text
|
||||
version Cargo prerelease synchronisée
|
||||
workspace complet sans warning
|
||||
invariants Rust/Markdown
|
||||
Config V3 OrbitFlare x-token non régressée
|
||||
Yellowstone N1/N2 non régressés
|
||||
HTTP 52 current + 14 historical non régressés
|
||||
WebSocket standard et Helius non régressés
|
||||
smoke live OrbitFlare Devnet final
|
||||
smokes PublicNode Mainnet/Testnet finaux si credentials opérateur disponibles
|
||||
graphes Cargo Transport et doublons inspectés
|
||||
aucun SDK OrbitFlare runtime
|
||||
aucune dépendance Transport -> Config
|
||||
```
|
||||
|
||||
Le payload de `pre.003` ne change ni code runtime, ni test, ni Config, ni schema, ni dépendance. Le seul changement technique est le signal de version Cargo obligatoire pour une prerelease non-fix.
|
||||
|
||||
## 4. Invariants à fermer
|
||||
|
||||
Les fichiers moteur Yellowstone restent hors modification :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/src/grpc_settings.rs
|
||||
crates/ksp-onchain-transport-lib/src/grpc_channel.rs
|
||||
crates/ksp-onchain-transport-lib/src/grpc_unary.rs
|
||||
crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs
|
||||
crates/ksp-onchain-transport-lib/src/grpc_stream.rs
|
||||
```
|
||||
|
||||
Le gate doit confirmer :
|
||||
|
||||
```text
|
||||
N1 gRPC provider-neutral inchangé
|
||||
N2 Yellowstone standard inchangé
|
||||
OrbitFlare composé par endpoint + provider + cluster + x-token Config V3
|
||||
X-ORBIT-KEY absent du data-plane Yellowstone
|
||||
License Key jamais exposée par Debug/CLI
|
||||
pas de format Config V4
|
||||
pas de provider proto
|
||||
pas de second raw Tonic client
|
||||
pas de orbitflare-sdk runtime
|
||||
```
|
||||
|
||||
## 5. Gate déterministe final
|
||||
|
||||
Après application de `pre.003` :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-core-lib --test workspace_dependencies
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Seuils minimaux attendus :
|
||||
|
||||
```text
|
||||
Config unit 114
|
||||
Config ownership 5
|
||||
Config public API 15
|
||||
Transport unit 383
|
||||
Transport public API 49
|
||||
Transport release completeness 43
|
||||
Transport doctests 4
|
||||
workspace dependency canary 3
|
||||
```
|
||||
|
||||
Toute régression imputable à KSP ouvre `pre.003-fix.NNN`. Elle ne doit pas être repoussée dans le couloir documentaire `pre.004`.
|
||||
|
||||
## 6. Smoke OrbitFlare Devnet final
|
||||
|
||||
Saisir la License Key sans l'inscrire dans l'historique :
|
||||
|
||||
```bash
|
||||
read -rsp 'OrbitFlare License Key: ' ORBITFLARE_LICENSE_KEY; echo
|
||||
printf '%s\n' "$ORBITFLARE_LICENSE_KEY" \
|
||||
| cargo test -p ksp-onchain-transport-lib --test yellowstone_orbitflare_smoke -- --ignored --nocapture
|
||||
unset ORBITFLARE_LICENSE_KEY
|
||||
```
|
||||
|
||||
Attendu :
|
||||
|
||||
```text
|
||||
orbitflare_devnet_yellowstone_streams_slots_and_emits_standard_server_ping_with_license_x_token ... ok
|
||||
1 passed
|
||||
0 failed
|
||||
0 ignored
|
||||
```
|
||||
|
||||
Le smoke doit encore prouver dans une même session bornée :
|
||||
|
||||
```text
|
||||
auth x-token acceptée
|
||||
Subscribe standard ouvert
|
||||
Slot non nul reçu
|
||||
SubscribeUpdate::Ping standard observé
|
||||
fermeture bornée
|
||||
```
|
||||
|
||||
Un incident externe OrbitFlare/réseau démontré comme tel est documenté comme `EXTERNAL BLOCK`. Un échec de mapping, metadata, moteur, protocol ou fermeture imputable à KSP ouvre un fix technique.
|
||||
|
||||
## 7. PublicNode et graphes finaux
|
||||
|
||||
Les smokes PublicNode Mainnet/Testnet restent les canaris provider du standard livré par `0.2.9`. Si les deux credentials opérateur sont disponibles pendant ce gate, les rejouer :
|
||||
|
||||
```bash
|
||||
read -rsp 'PublicNode Mainnet Yellowstone x-token: ' PUBLICNODE_MAINNET_TOKEN; echo
|
||||
read -rsp 'PublicNode Testnet Yellowstone x-token: ' PUBLICNODE_TESTNET_TOKEN; echo
|
||||
printf '%s\n%s\n' "$PUBLICNODE_MAINNET_TOKEN" "$PUBLICNODE_TESTNET_TOKEN" \
|
||||
| cargo test -p ksp-onchain-transport-lib --test yellowstone_publicnode_smoke -- --ignored --nocapture
|
||||
unset PUBLICNODE_MAINNET_TOKEN PUBLICNODE_TESTNET_TOKEN
|
||||
```
|
||||
|
||||
Attendu lorsque rejoué :
|
||||
|
||||
```text
|
||||
2 passed
|
||||
0 failed
|
||||
0 ignored
|
||||
```
|
||||
|
||||
Inspecter ensuite :
|
||||
|
||||
```bash
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||
cargo tree --duplicates
|
||||
```
|
||||
|
||||
Points obligatoires :
|
||||
|
||||
```text
|
||||
yellowstone-grpc-proto 12.6.x sur la stack attendue
|
||||
Tonic/Prost KSP-owned sans seconde génération incompatible
|
||||
aucun yellowstone-grpc-client runtime
|
||||
aucun orbitflare-sdk runtime
|
||||
aucune dépendance ksp-onchain-transport-lib -> ksp-config-lib
|
||||
aucun tracing direct Transport
|
||||
aucun nouveau doublon introduit par 0.2.10
|
||||
```
|
||||
|
||||
Les smokes PublicNode ne deviennent pas un blocker si leurs credentials ne sont simplement pas disponibles au moment du gate : leur non-régression déterministe reste couverte par les suites Transport/Config et le code live n'a pas changé. En revanche, tout échec effectivement observé lors d'un rerun doit être classifié, jamais ignoré.
|
||||
|
||||
## 8. Critère de fermeture technique
|
||||
|
||||
`pre.003` est fermée lorsque :
|
||||
|
||||
```text
|
||||
gate déterministe final vert
|
||||
OrbitFlare Devnet Subscribe -> Slot + Ping live PASS
|
||||
graphes Cargo finaux inspectés
|
||||
N1 inchangé
|
||||
N2 inchangé
|
||||
Config V3 x-token cohérente et redacted
|
||||
aucun provider overlay nécessaire
|
||||
aucun heartbeat provider nécessaire
|
||||
aucun SDK OrbitFlare ajouté
|
||||
aucun défaut technique reporté vers pre.004
|
||||
```
|
||||
|
||||
Une fois ce gate acquis, la prochaine tranche est obligatoirement :
|
||||
|
||||
```text
|
||||
0.2.10-pre.004 — réconciliation documentaire finale
|
||||
```
|
||||
|
||||
Elle portera plan, validation, README/USAGE et références durables. Elle ne doit pas porter de nouveau correctif technique.
|
||||
|
||||
## 9. Modifications de cette livraison
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
# version: 264 -> 265
|
||||
workspace.package.version: 0.2.10-pre.2.fix.1 -> 0.2.10-pre.3
|
||||
|
||||
deltas/0.2.10/pre.003.md
|
||||
ajout du présent gate technique/live final
|
||||
```
|
||||
|
||||
Aucun autre fichier n'est modifié.
|
||||
128
deltas/0.2.10/pre.004.md
Normal file
128
deltas/0.2.10/pre.004.md
Normal file
@@ -0,0 +1,128 @@
|
||||
<!-- file: deltas/0.2.10/pre.004.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.10-pre.004` — réconciliation documentaire finale OrbitFlare Yellowstone
|
||||
|
||||
## 1. Base
|
||||
|
||||
```text
|
||||
0.2.10-pre.003
|
||||
```
|
||||
|
||||
Cette tranche est exclusivement le couloir de réconciliation documentaire finale. Le gate technique/live de `pre.003` est vert ; aucun correctif runtime, Config exécutable, test, schema, dependency ou feature n'est introduit ici.
|
||||
|
||||
## 2. Version technique
|
||||
|
||||
Comme toute prerelease non-fix, la version Cargo est synchronisée mécaniquement :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.10-pre.4
|
||||
```
|
||||
|
||||
Aucune sémantique runtime ne change.
|
||||
|
||||
## 3. Preuve technique réconciliée
|
||||
|
||||
Le gate opérateur `pre.003` du 2026-08-25 est fermé avec :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
Rust workspace audit PASS / 0 export candidate
|
||||
Markdown audit PASS / 99 tables / 100 files
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
Config unit 114/114 PASS
|
||||
Config ownership 5/5 PASS
|
||||
Config public API 15/15 PASS
|
||||
Transport unit 383/383 PASS
|
||||
Transport public API 49/49 PASS
|
||||
Transport release completeness 43/43 PASS
|
||||
Transport doctests 4/4 PASS
|
||||
workspace dependency canary 3/3 PASS
|
||||
cargo test --workspace PASS
|
||||
OrbitFlare Devnet Subscribe + Slot + Ping 1/1 PASS, 5.19 s
|
||||
```
|
||||
|
||||
Les graphes Cargo direct Transport, doublons Transport et doublons workspace ont été inspectés. La pile reste :
|
||||
|
||||
```text
|
||||
yellowstone-grpc-proto 12.6.0
|
||||
tonic / tonic-prost 0.14.6
|
||||
prost / prost-types 0.14.4
|
||||
```
|
||||
|
||||
Aucun `yellowstone-grpc-client` runtime, aucun `orbitflare-sdk` runtime et aucune dépendance `Transport -> Config` n'ont été ajoutés.
|
||||
|
||||
## 4. OrbitFlare final
|
||||
|
||||
Contrat durable réconcilié :
|
||||
|
||||
```text
|
||||
Devnet endpoint http://devnet.rpc.orbitflare.com:10000
|
||||
auth License Key ORBIT-* -> secret metadata x-token
|
||||
Config secret KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN
|
||||
Subscribe standard Yellowstone N2
|
||||
live Slot non nul + server Ping
|
||||
N1 inchangé
|
||||
N2 inchangé
|
||||
provider facade non nécessaire
|
||||
heartbeat N3 non nécessaire
|
||||
Config V4 non nécessaire
|
||||
```
|
||||
|
||||
Le premier échec `pre.002` sans metadata reste conservé dans son delta historique comme caractérisation ayant conduit au fix d'auth `pre.002-fix.001`.
|
||||
|
||||
## 5. Documents réconciliés
|
||||
|
||||
```text
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
docs/000-README.md
|
||||
```
|
||||
|
||||
Les anciennes affirmations `PENDING` sont remplacées par les résultats `pre.003` réellement observés. README/USAGE documentent désormais le profil OrbitFlare Devnet et son smoke live durable.
|
||||
|
||||
## 6. Frontière avec `pre.005`
|
||||
|
||||
Cette tranche ne touche pas :
|
||||
|
||||
```text
|
||||
CHANGELOG.md
|
||||
ROADMAP.md
|
||||
prompt suivant
|
||||
```
|
||||
|
||||
La décision sur la release suivante est prise pendant cette réconciliation, sans générer encore son prompt : Helius LaserStream gRPC est reporté dans les TODO Yellowstone sans numéro de release faute d'accès live raisonnable ; la séquence active avance et `0.2.11` devient `off-chain price transport`, suivie de `0.2.12` Price Desk + intégration Wallet Desk, `0.2.13` interface/wire foundation et `0.2.14` program-api foundation. Le futur `ROADMAP.md` de `pre.005` devra refléter exactement ce report et cette renumérotation.
|
||||
|
||||
Une fois cette décision prise, `pre.005` reste strictement publication-minimal : prompt suivant + `CHANGELOG.md` + `ROADMAP.md` + signal Cargo + delta.
|
||||
|
||||
Si un défaut technique de `0.2.10` est découvert après ce gate documentaire, il ne doit pas être glissé dans `pre.005`; ouvrir une nouvelle prerelease du couloir approprié puis rejouer les tranches finales nécessaires.
|
||||
|
||||
## 7. Fichiers modifiés/ajoutés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
docs/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/plans/017-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC_PLAN.md
|
||||
docs/validation/013-V0_2_10_ORBITFLARE_YELLOWSTONE_GRPC.md
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
deltas/0.2.10/pre.004.md
|
||||
```
|
||||
|
||||
## 8. Gate opérateur
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucun smoke live ni `cargo tree` n'est à rejouer si le delta est appliqué exactement : aucune surface technique ou dépendance n'est modifiée.
|
||||
132
deltas/0.2.10/pre.005.md
Normal file
132
deltas/0.2.10/pre.005.md
Normal file
@@ -0,0 +1,132 @@
|
||||
<!-- file: deltas/0.2.10/pre.005.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.10-pre.005` — préparation de publication minimale
|
||||
|
||||
## 1. Base
|
||||
|
||||
```text
|
||||
0.2.10-pre.004
|
||||
```
|
||||
|
||||
Cette tranche est exclusivement le couloir de préparation de publication. Elle ne modifie aucun code, test, schema, config, README, USAGE, plan, validation ou règle normative.
|
||||
|
||||
## 2. Gate `pre.003` et réconciliation `pre.004`
|
||||
|
||||
Le gate technique final `pre.003` a confirmé :
|
||||
|
||||
```text
|
||||
Rust workspace audit PASS
|
||||
Markdown audit PASS
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test --workspace PASS
|
||||
OrbitFlare live Subscribe -> Slot+Ping PASS en 5.19 s
|
||||
graphes Cargo inspectés
|
||||
```
|
||||
|
||||
`pre.004` a ensuite réconcilié les documents durables : plan/validation OrbitFlare, README/USAGE Transport, index documentaire et séquence fonctionnelle. Helius LaserStream gRPC a été reporté dans les TODO faute d'accès live raisonnable sans abonnement payant, sans remise en cause de sa compatibilité wire Yellowstone largement observée.
|
||||
|
||||
## 3. Version technique
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.10-pre.5
|
||||
```
|
||||
|
||||
La modification est mécanique ; aucune sémantique runtime ne change.
|
||||
|
||||
## 4. `CHANGELOG.md`
|
||||
|
||||
Le changelog stable est préparé pour `0.2.10 — OrbitFlare Yellowstone gRPC` avec les faits consolidés :
|
||||
|
||||
```text
|
||||
N1/N2 Yellowstone inchangés
|
||||
Config V3 orbitflare_devnet
|
||||
x-token = License Key via secret_metadata
|
||||
premier live sans metadata -> Unauthenticated
|
||||
fix auth documenté
|
||||
live Subscribe -> Slot + standard Ping PASS
|
||||
second live final PASS
|
||||
aucun heartbeat/provider SDK/Config V4 ajouté
|
||||
workspace + graphes finaux verts
|
||||
```
|
||||
|
||||
## 5. `ROADMAP.md`
|
||||
|
||||
`0.2.10` passe à l'état réalisé. Helius LaserStream gRPC quitte la séquence numérotée et rejoint les TODO Yellowstone. La séquence active devient :
|
||||
|
||||
```text
|
||||
0.2.11 off-chain price transport
|
||||
0.2.12 Price Desk + intégration prix Wallet Desk
|
||||
0.2.13 interface/wire foundation
|
||||
0.2.14 program-api foundation
|
||||
```
|
||||
|
||||
## 6. Audit préparatoire de la future `0.2.11`
|
||||
|
||||
Avant de rédiger le prompt suivant, un audit externe rapide au 2026-08-25 a comparé plusieurs familles de sources :
|
||||
|
||||
```text
|
||||
CoinGecko agrégateur, REST Demo, multi quote USD/EUR
|
||||
Jupiter V3 prix Solana heuristique issu des swaps, USD seulement
|
||||
Pyth Hermes oracle, REST/SSE, publish_time/confidence, auth en transition immédiate
|
||||
Birdeye market data Solana, spot/batch/historique, API key
|
||||
DexScreener prix DEX par pair/pool, accès public courant
|
||||
```
|
||||
|
||||
Ce snapshot ne choisit pas le provider. Il démontre surtout que les sémantiques diffèrent et que `0.2.11-pre.001` doit auditer/choisir explicitement le contrat prix, le modèle numérique, la fraîcheur et le provider initial avant code lourd.
|
||||
|
||||
Pyth annonce un changement d'authentification le 2026-08-26 à 16:00 UTC ; le prompt exige donc un réaudit immédiat de Pyth à l'ouverture et interdit de figer le snapshot de `pre.005`.
|
||||
|
||||
## 7. Prompt `0.2.11`
|
||||
|
||||
Ajout de :
|
||||
|
||||
```text
|
||||
prompts/016-V0_2_11_START_PROMPT.md
|
||||
```
|
||||
|
||||
Le prompt ouvre `0.2.11 — Off-chain price transport` uniquement depuis `v0.2.10` et impose `pre.001 = lecture + audit actuel providers + matrice sémantique + numeric model + threat model + sizing + planification`.
|
||||
|
||||
Le scope initial reste volontairement borné :
|
||||
|
||||
```text
|
||||
ksp-offchain-transport-lib
|
||||
prix SOL/USD + SOL/EUR
|
||||
premier provider live-testable sans abonnement payant dédié
|
||||
Config -> Off-chain Transport
|
||||
pas de Price Desk
|
||||
pas de Wallet Desk
|
||||
pas d'OHLCV/historique/quotes/metadata/multi-provider complexe
|
||||
```
|
||||
|
||||
## 8. Fichiers modifiés/ajoutés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
CHANGELOG.md
|
||||
ROADMAP.md
|
||||
prompts/016-V0_2_11_START_PROMPT.md
|
||||
deltas/0.2.10/pre.005.md
|
||||
```
|
||||
|
||||
Aucun autre fichier ne doit changer dans cette tranche.
|
||||
|
||||
## 9. Gate opérateur
|
||||
|
||||
Comme cette tranche ne modifie aucun code/runtime/config/schema :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucun smoke live ni `cargo tree` n'est à rejouer si le delta est appliqué exactement.
|
||||
|
||||
## 10. Suite
|
||||
|
||||
Si ce gate est vert, `0.2.10-rel.001` est la seule étape restante. Elle est limitée à la mécanique de publication stable : version Cargo `0.2.10`, delta `rel.001`, commit final et tag stable `v0.2.10`, sans rattrapage technique ou documentaire.
|
||||
197
deltas/0.2.10/rel.001.md
Normal file
197
deltas/0.2.10/rel.001.md
Normal file
@@ -0,0 +1,197 @@
|
||||
<!-- file: deltas/0.2.10/rel.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.10-rel.001` — publication stable OrbitFlare Yellowstone gRPC
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Base directe attendue :
|
||||
|
||||
```text
|
||||
0.2.10-pre.005
|
||||
workspace.package.version = 0.2.10-pre.5
|
||||
```
|
||||
|
||||
Commit attendu pour cette livraison :
|
||||
|
||||
```text
|
||||
v0.2.10-rel.001
|
||||
```
|
||||
|
||||
Tag stable attendu après validation :
|
||||
|
||||
```text
|
||||
v0.2.10
|
||||
```
|
||||
|
||||
## 2. Responsabilité de `rel.001`
|
||||
|
||||
Cette livraison effectue uniquement la mécanique de publication stable définie par le cycle KSP.
|
||||
|
||||
Elle :
|
||||
|
||||
- passe `workspace.package.version` de `0.2.10-pre.5` à `0.2.10` ;
|
||||
- ajoute le présent delta `rel.001` ;
|
||||
- ne corrige ni code, ni test, ni config/schema, ni README/USAGE, ni plan/validation, ni règle, ni prompt, ni `CHANGELOG.md`, ni `ROADMAP.md`.
|
||||
|
||||
Tout défaut appartenant à un couloir antérieur doit renvoyer vers une prerelease appropriée ; `rel.001` n'est jamais une tranche de rattrapage.
|
||||
|
||||
## 3. Preuves techniques acquises avant publication
|
||||
|
||||
Le gate technique/live final `0.2.10-pre.003` a été validé le **25 août 2026** avec :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||
General Rust rule audit clean
|
||||
Rust export completeness audit 0 candidate(s)
|
||||
KSP workspace Rust rule audit clean
|
||||
python3 scripts/audit_markdown_tables.py PASS
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test -p ksp-config-lib PASS
|
||||
cargo test -p ksp-onchain-transport-lib PASS
|
||||
cargo test -p ksp-core-lib --test workspace_dependencies PASS
|
||||
cargo test --workspace PASS
|
||||
```
|
||||
|
||||
Les suites directement concernées restent notamment :
|
||||
|
||||
```text
|
||||
Config unit 114 passed
|
||||
Transport unit 383 passed
|
||||
Transport public API 49 passed
|
||||
Transport release completeness 43 passed
|
||||
Transport doctests 4 passed
|
||||
```
|
||||
|
||||
Le smoke live final OrbitFlare a également été rejoué en `pre.003` :
|
||||
|
||||
```text
|
||||
OrbitFlare Devnet Yellowstone
|
||||
x-token = License Key
|
||||
Subscribe -> Slot + standard server Ping
|
||||
1/1 PASS en 5.19 s
|
||||
```
|
||||
|
||||
Les graphes Cargo finaux ont été inspectés dans le même couloir. `pre.004` et `pre.005` n'ont ensuite modifié aucune source runtime, dépendance, config ou schema.
|
||||
|
||||
## 4. Version stable publiée
|
||||
|
||||
La version Cargo devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.10
|
||||
```
|
||||
|
||||
La release stable publiée est :
|
||||
|
||||
```text
|
||||
0.2.10 — OrbitFlare Yellowstone gRPC
|
||||
```
|
||||
|
||||
Les acquis fonctionnels et documentaires finalisés avant `rel.001` comprennent :
|
||||
|
||||
```text
|
||||
réutilisation stricte du moteur N1 Yellowstone gRPC existant
|
||||
réutilisation stricte du contrat N2 Yellowstone standard existant
|
||||
aucune modification provider du moteur gRPC
|
||||
profil Config V3 orbitflare_devnet
|
||||
endpoint Devnet http://devnet.rpc.orbitflare.com:10000
|
||||
authentification gRPC x-token via License Key secrète
|
||||
premier smoke sans metadata -> Unauthenticated, caractérisé puis corrigé
|
||||
live authentifié Subscribe -> Slot + standard Ping PASS
|
||||
aucun heartbeat provider supplémentaire
|
||||
aucun SDK OrbitFlare ajouté
|
||||
aucun Config V4
|
||||
```
|
||||
|
||||
La release confirme donc qu'OrbitFlare compose au-dessus de N1/N2 par configuration provider et metadata secrète, sans divergence protocolaire nécessitant une façade gRPC dédiée.
|
||||
|
||||
## 5. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.2.10/rel.001.md
|
||||
```
|
||||
|
||||
## 6. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
```
|
||||
|
||||
## 7. Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## 8. Validations de préparation du delta
|
||||
|
||||
Contrôles statiques de l'overlay :
|
||||
|
||||
```text
|
||||
Cargo.toml version 0.2.10
|
||||
payload rel.001 2 fichiers exactement
|
||||
aucun fichier de rattrapage présent
|
||||
```
|
||||
|
||||
Aucune nouvelle compilation n'est requise pour construire l'archive elle-même. Le gate stable final doit toutefois être exécuté après application du delta et avant le commit/tag.
|
||||
|
||||
## 9. Validation stable après application
|
||||
|
||||
Exécuter avant publication :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.10
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Le changement étant limité au signal de version Cargo et au présent delta :
|
||||
|
||||
- aucun smoke live OrbitFlare n'est à rejouer si l'overlay appliqué est exactement `0.2.10-rel.001` ;
|
||||
- aucun `cargo tree` n'est à rejouer ;
|
||||
- aucun smoke PublicNode n'est à rejouer.
|
||||
|
||||
## 10. Commit et tag stable
|
||||
|
||||
Après succès du gate stable :
|
||||
|
||||
```text
|
||||
commit : v0.2.10-rel.001
|
||||
tag : v0.2.10
|
||||
```
|
||||
|
||||
Aucun tag intermédiaire `rel.001` n'est requis.
|
||||
|
||||
Commandes Git typiques :
|
||||
|
||||
```bash
|
||||
git add Cargo.toml deltas/0.2.10/rel.001.md
|
||||
git commit -m "v0.2.10-rel.001"
|
||||
git tag v0.2.10
|
||||
git push
|
||||
git push origin v0.2.10
|
||||
```
|
||||
|
||||
## 11. Suite
|
||||
|
||||
Après publication du tag stable `v0.2.10`, ouvrir :
|
||||
|
||||
```text
|
||||
0.2.11-pre.001 — Off-chain price transport
|
||||
```
|
||||
|
||||
avec :
|
||||
|
||||
```text
|
||||
prompts/016-V0_2_11_START_PROMPT.md
|
||||
```
|
||||
|
||||
`pre.001` reste une tranche d'audit actuel des providers, sémantiques de prix, modèle numérique, fraîcheur/provenance, threat model et sizing avant toute implémentation lourde.
|
||||
|
||||
Helius LaserStream gRPC reste reporté dans les TODO Yellowstone jusqu'à disponibilité d'un accès live pertinent ; il ne fait plus partie de la séquence numérotée active.
|
||||
147
deltas/0.2.7/pre.001-fix.001.md
Normal file
147
deltas/0.2.7/pre.001-fix.001.md
Normal file
@@ -0,0 +1,147 @@
|
||||
<!-- file: deltas/0.2.7/pre.001-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.001-fix.001` — correction documentaire du gate WebSocket
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison immédiatement précédente :
|
||||
|
||||
```text
|
||||
0.2.7-pre.001
|
||||
workspace.package.version = 0.2.7-pre.1
|
||||
```
|
||||
|
||||
Ce fix est **documentaire uniquement**. Conformément à `VERSION_WORKFLOW.md` et `FILE_CONTRACTS.md`, il ne modifie pas `Cargo.toml` et conserve :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.7-pre.1
|
||||
commit = v0.2.7-pre.001-fix.001
|
||||
aucun tag prerelease
|
||||
```
|
||||
|
||||
## Objet
|
||||
|
||||
Corriger et renforcer le gate `pre.001` avant toute implémentation WebSocket :
|
||||
|
||||
- partir des versions du plan `014` et de la compliance `010` réalignées par l'opérateur ;
|
||||
- interdire les séparateurs `|` à l'intérieur des cellules de tableaux Markdown, en utilisant `ou`, du texte ou une autre forme non ambiguë ;
|
||||
- remplacer le cross-check Agave `v3.1.8` par la baseline Git actuelle **Agave `v4.2.1`** ;
|
||||
- appliquer au WebSocket la même discipline que la compliance HTTP : documentation Solana, tag Git Agave courant et audit SIMD ;
|
||||
- ajouter un audit SIMD ciblé des changements pouvant affecter le wire, la sémantique ou les bornes de ressources WebSocket ;
|
||||
- préparer `std.transport` V2 à plusieurs familles WebSocket via un discriminateur explicite, sans ajouter de paramètres Helius dans `0.2.7`.
|
||||
|
||||
## Baseline Agave corrigée
|
||||
|
||||
Le tag Git audité est :
|
||||
|
||||
```text
|
||||
anza-xyz/agave v4.2.1
|
||||
```
|
||||
|
||||
Le cross-check `v4.2.1` confirme :
|
||||
|
||||
```text
|
||||
9 subscribe + 9 unsubscribe = 18 opérations standard actuelles
|
||||
aucune famille PubSub standard supplémentaire détectée
|
||||
accountSubscribe.minContextSlot toujours ignoré côté handler PubSub
|
||||
programSubscribe.withContext conservé
|
||||
a vote.timestamp optionnel côté RpcVote
|
||||
7 variantes SlotUpdate actuelles inchangées
|
||||
```
|
||||
|
||||
La documentation Solana reste l'autorité pour la **surface publique annoncée**. Le tag Agave courant sert de contrôle d'implémentation, même si une page documentaire contient encore un lien vers une révision source plus ancienne.
|
||||
|
||||
## Audit SIMD ajouté
|
||||
|
||||
Le plan et la compliance suivent désormais explicitement les SIMDs pertinents :
|
||||
|
||||
| SIMD | Statut | Décision KSP principale |
|
||||
|-----------------------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `0118` Partitioned Epoch Rewards Distribution | Activated | réutiliser les DTOs bloc/rewards lossless déjà acquis par HTTP |
|
||||
| `0291` Commission Rate in Basis Points | Review | ne pas dériver localement une représentation de commission depuis une autre |
|
||||
| `0296` Larger Transaction Size | Review | proposition jusqu'à 4096 octets ; aucune limite WS dérivée en dur de l'ancienne taille transaction de 1232 octets |
|
||||
| `0298` Bank Hash in Block Footer | Idea | aucun `bankHash` spéculatif |
|
||||
| `0301` parent bank hash | PR fermé | aucun `parentBankHash` spéculatif ; PR non mergée |
|
||||
| `0307` Add Block Footer | Review | aucun `footer` spéculatif ; réaudit lorsque l'upstream l'expose |
|
||||
| `0326` Alpenglow | Review | ne pas figer les sémantiques TowerBFT des flux unstable |
|
||||
| `0337` Alpenglow Fast Leader Handover Markers | Review | surveiller l'impact futur sur shape/taille des blocs |
|
||||
| `0384` Alpenglow migration | Review | ne pas supposer une séquence exhaustive de notifications commitment/optimistic confirmation |
|
||||
| `0385` Transaction V1 | Review | conserver versions transaction et `maxSupportedTransactionVersion` génériques |
|
||||
|
||||
Aucun de ces SIMDs n'ajoute, dans la baseline Agave `v4.2.1`, une dixième famille WebSocket standard.
|
||||
|
||||
## Préparation multi-familles WebSocket
|
||||
|
||||
Le shape Config V2 planifié devient explicitement extensible :
|
||||
|
||||
```text
|
||||
profiles[].ws_endpoints[].kind
|
||||
```
|
||||
|
||||
Pour `0.2.7` :
|
||||
|
||||
```text
|
||||
kind = solana_standard supporté
|
||||
autre kind rejet explicite
|
||||
```
|
||||
|
||||
Le type Transport correspondant est planifié `#[non_exhaustive]`. Une release ultérieure pourra ainsi ajouter notamment une famille **Helius Enhanced WebSocket** sans refondre `profiles[].ws_endpoints[]` et sans contaminer `WsSessionSettings` ou les wrappers Solana standard avec des options provider-specific.
|
||||
|
||||
Ce fix ne décide pas encore si Helius Enhanced WebSocket et LaserStream WebSocket partageront exactement la même famille de settings ou le même moteur ; ce point reste soumis à l'audit provider-specific prévu après `0.2.7`.
|
||||
|
||||
## Markdown
|
||||
|
||||
Correction appliquée dans la matrice lifecycle :
|
||||
|
||||
```text
|
||||
avant : Never PIPE ActiveSubscriptions
|
||||
après : Never ou ActiveSubscriptions
|
||||
```
|
||||
|
||||
Les caractères `|` restent uniquement les délimiteurs structurels nécessaires aux tableaux Markdown ; ils ne sont pas utilisés comme séparateurs sémantiques dans une cellule.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
```
|
||||
|
||||
## Fichier ajouté
|
||||
|
||||
```text
|
||||
deltas/0.2.7/pre.001-fix.001.md
|
||||
```
|
||||
|
||||
## Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
deltas/0.2.7/pre.001.md
|
||||
docs/000-README.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/validation/000-README.md
|
||||
CHANGELOG.md
|
||||
ROADMAP.md
|
||||
config/**
|
||||
crates/**
|
||||
```
|
||||
|
||||
Les index ne changent pas : ils référencent déjà les documents `014` et `010`.
|
||||
|
||||
## Validation documentaire du fix
|
||||
|
||||
À vérifier avant commit :
|
||||
|
||||
```text
|
||||
aucun pipe sémantique dans une cellule de tableau Markdown
|
||||
nombre de colonnes cohérent pour chaque table modifiée
|
||||
18 opérations WebSocket toujours présentes dans la compliance
|
||||
Agave v4.2.1 utilisé comme baseline Git
|
||||
SIMDs ciblés documentés avec leur statut courant
|
||||
Cargo.toml absent du delta fix
|
||||
```
|
||||
|
||||
Ce fix ne change aucun code, build, runtime, configuration effective ou migration ; les gates Cargo ne sont donc pas redéclarés comme exécutés par ce delta documentaire.
|
||||
245
deltas/0.2.7/pre.001.md
Normal file
245
deltas/0.2.7/pre.001.md
Normal file
@@ -0,0 +1,245 @@
|
||||
<!-- file: deltas/0.2.7/pre.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.001` — audit WebSocket Solana, threat model, dependencies et sizing
|
||||
|
||||
## Base requise
|
||||
|
||||
Release stable attendue et auditée :
|
||||
|
||||
```text
|
||||
v0.2.6
|
||||
workspace.package.version = 0.2.6 avant ouverture
|
||||
```
|
||||
|
||||
L'archive Gitea fournie contient `deltas/0.2.6/rel.001.md` et annonce `0.2.7 — WebSocket Solana standard` comme prochaine release. Elle est utilisée comme autorité primaire.
|
||||
|
||||
## Type de livraison
|
||||
|
||||
```text
|
||||
ksp-general-0.2.7-pre.001.zip
|
||||
```
|
||||
|
||||
L'archive d'échange est un delta applicable depuis la racine de `v0.2.6` et contient uniquement les fichiers ajoutés/modifiés par cette tranche.
|
||||
|
||||
## Objet
|
||||
|
||||
`pre.001` reste volontairement un gate de lecture/audit/conception. Aucun client WebSocket, session runtime, wrapper subscribe ou dependency réseau nouvelle n'est encore ajouté.
|
||||
|
||||
Le gate :
|
||||
|
||||
- relit les règles, architecture, plans, validations et contrats réels requis ;
|
||||
- vérifie la stabilité `v0.2.6` et l'héritage HTTP/Wallet Desk ;
|
||||
- réaudite `ksp-onchain-transport-lib` et l'adapter Config actuel ;
|
||||
- constate que `std.transport` V1 est strictement HTTP et décide un V2 explicite HTTP+WS avec backward V1 ;
|
||||
- audite l'archive bot3 comme référence historique seulement ;
|
||||
- réaudite la documentation Solana WebSocket officielle du 2026-08-22 et cross-checke Agave `v3.1.8` sur les ambiguïtés ;
|
||||
- documente notamment `accountSubscribe.minContextSlot` comme option partagée mais ignorée en PubSub, et `vote.timestamp` comme `Option<i64>` ;
|
||||
- compte exactement **18 méthodes = 9 subscribe + 9 unsubscribe** ;
|
||||
- classe `block`, `slotsUpdates` et `vote` comme paires unstable ;
|
||||
- crée la matrice compliance initiale `010` ;
|
||||
- audite les crates candidates et retient `tokio-tungstenite 0.30.0` + `futures-util 0.3.34` pour une tranche ultérieure ;
|
||||
- fixe le modèle actor/session, IDs locaux, state machines, reconnect/resubscribe, continuity gaps, backpressure et shutdown ;
|
||||
- fixe les exigences de redaction URL/credentials et de bornes de ressources ;
|
||||
- regranularise la release jusqu'à un forecast nominal `pre.014` sans imposer ce numéro comme deadline.
|
||||
|
||||
## Décisions principales
|
||||
|
||||
### Cardinalité
|
||||
|
||||
```text
|
||||
endpoint -> N sessions physiques explicites -> N subscriptions par session
|
||||
aucun pool/scheduler automatique en 0.2.7
|
||||
```
|
||||
|
||||
### Identités
|
||||
|
||||
```text
|
||||
WsSessionId stable local KSP
|
||||
WsSubscriptionId stable local KSP
|
||||
remote id éphémère et interne, remappé après reconnect
|
||||
```
|
||||
|
||||
### Reconnect / resubscribe
|
||||
|
||||
```text
|
||||
budget fini
|
||||
backoff exponentiel borné
|
||||
pas de jitter en 0.2.7
|
||||
policy Never | ActiveSubscriptions
|
||||
ordre de restore déterministe par ID local
|
||||
continuity gap explicite après toute reconnexion
|
||||
aucune garantie lossless / aucun backfill HTTP Transport
|
||||
```
|
||||
|
||||
### Backpressure
|
||||
|
||||
```text
|
||||
command queue bounded
|
||||
notification queue bounded par subscription
|
||||
overflow -> subscription Failed explicite + best-effort unsubscribe
|
||||
aucun drop silencieux
|
||||
les autres subscriptions restent actives
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
```text
|
||||
std.transport V1 reste strict et lisible
|
||||
std.transport V2 = HTTP existant + ws_defaults + profiles[].ws_endpoints
|
||||
Config -> Transport uniquement
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
```text
|
||||
tokio-tungstenite ^0.30, default-features=false, connect + rustls-tls-webpki-roots
|
||||
futures-util ^0.3, default-features=false, std + sink
|
||||
```
|
||||
|
||||
Ces dependencies sont **planifiées seulement** ; le graphe n'est pas modifié dans `pre.001`.
|
||||
|
||||
## Prévision souple recalibrée
|
||||
|
||||
```text
|
||||
pre.001 audit + matrice + threat model + dependencies + sizing
|
||||
pre.002 settings/IDs/states/snapshots/redaction
|
||||
pre.003 std.transport V2 + adapter Config
|
||||
pre.004 actor session physique + deps WS + local server
|
||||
pre.005 limits/control/cancellation/shutdown
|
||||
pre.006 registry + generic subscribe/unsubscribe + channels typed
|
||||
pre.007 reconnect/resubscribe/gap/races
|
||||
pre.008 backpressure/limits/leaks adversarial
|
||||
pre.009 account/program/logs
|
||||
pre.010 signature/slot/root
|
||||
pre.011 block/slotsUpdates/vote unstable
|
||||
pre.012 compliance 18/18 + Config composition + HTTP regression
|
||||
pre.013 smoke live + README/USAGE + cargo trees
|
||||
pre.014 workspace final + docs/compliance + prompt 0.2.8
|
||||
rel.001 publication stable
|
||||
```
|
||||
|
||||
Le sizing reste positif : la release n'est pas scindée fonctionnellement, mais le forecast initial `pre.008` est volontairement décompressé.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
deltas/0.2.7/pre.001.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
docs/000-README.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/validation/000-README.md
|
||||
```
|
||||
|
||||
## Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
CHANGELOG.md
|
||||
ROADMAP.md
|
||||
.env.example
|
||||
config/**
|
||||
crates/**
|
||||
docs/architecture/**
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
```
|
||||
|
||||
`ROADMAP.md` reste global et possède déjà l'entrée `0.2.7`. README/USAGE Transport ne sont pas modifiés avant qu'une surface runtime WebSocket existe réellement.
|
||||
|
||||
## Version technique
|
||||
|
||||
Conformément au workflow non-fix :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.7-pre.1
|
||||
commit = v0.2.7-pre.001
|
||||
aucun tag prerelease
|
||||
```
|
||||
|
||||
## Audit officiel WebSocket
|
||||
|
||||
Index :
|
||||
|
||||
```text
|
||||
https://solana.com/docs/rpc/websocket
|
||||
```
|
||||
|
||||
Inventaire exact au 2026-08-22 :
|
||||
|
||||
```text
|
||||
accountSubscribe/accountUnsubscribe
|
||||
blockSubscribe/blockUnsubscribe unstable pair
|
||||
logsSubscribe/logsUnsubscribe
|
||||
programSubscribe/programUnsubscribe
|
||||
rootSubscribe/rootUnsubscribe
|
||||
signatureSubscribe/signatureUnsubscribe
|
||||
slotSubscribe/slotUnsubscribe
|
||||
slotsUpdatesSubscribe/slotsUpdatesUnsubscribe unstable pair
|
||||
voteSubscribe/voteUnsubscribe unstable pair
|
||||
```
|
||||
|
||||
Aucune méthode de cet index n'est marquée Deprecated.
|
||||
|
||||
## Audit bot3
|
||||
|
||||
Référence inspectée :
|
||||
|
||||
```text
|
||||
ks-onchain-transport/src/standard_ws.rs
|
||||
ks-onchain-transport/src/ws_client.rs
|
||||
ks-onchain-transport/src/ws_pool.rs
|
||||
ks-onchain-transport/src/ws_session.rs
|
||||
```
|
||||
|
||||
Repris comme concepts : session multiplexée, ID local/remote séparé, reconnect/resubscribe borné. Rejetés : Transport -> Config, tracing direct, scheduler/pool, unsubscribe public par remote ID et broadcast data comme contrat principal.
|
||||
|
||||
## Validations exécutées avant modification
|
||||
|
||||
```text
|
||||
archive stable v0.2.6 extraite/auditée OK
|
||||
documents internes obligatoires relus OK
|
||||
inventory Transport + Config OK
|
||||
archive bot3 auditée OK
|
||||
documentation Solana WebSocket actuelle auditée OK
|
||||
dependencies Rust candidates auditée OK
|
||||
python3 scripts/audit_rust_workspace_rules.py OK
|
||||
```
|
||||
|
||||
Sortie audit Python :
|
||||
|
||||
```text
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
```
|
||||
|
||||
## Validations tentées mais impossibles dans le sandbox
|
||||
|
||||
Le binaire `cargo` n'est pas installé. Tentatives avant modification :
|
||||
|
||||
```text
|
||||
cargo fmt --all code 127
|
||||
cargo check --workspace code 127
|
||||
cargo clippy --workspace --all-targets code 127
|
||||
```
|
||||
|
||||
Aucune de ces commandes n'est déclarée réussie.
|
||||
|
||||
## Validation opérateur requise avant commit
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Aucun test Transport, Config, workspace ou smoke live n'est déclaré vert dans ce sandbox tant qu'il n'a pas été effectivement exécuté par l'opérateur. Aucun build Tauri n'est requis pour `0.2.7-pre.001`.
|
||||
156
deltas/0.2.7/pre.002-fix.001.md
Normal file
156
deltas/0.2.7/pre.002-fix.001.md
Normal file
@@ -0,0 +1,156 @@
|
||||
<!-- file: deltas/0.2.7/pre.002-fix.001.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Delta `0.2.7-pre.002-fix.001` — Clippy strict, canaris desktop et signal Cargo synchronisé
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
```text
|
||||
0.2.7-pre.002 appliquée
|
||||
workspace.package.version = 0.2.7-pre.2
|
||||
```
|
||||
|
||||
La validation opérateur de `0.2.7-pre.002` confirme :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
python3 scripts/audit_rust_workspace_rules.py clean
|
||||
cargo check --workspace OK avec 2 warnings dead_code WS
|
||||
cargo test -p ksp-onchain-transport-lib 256 unit + 28 public API + 22 release completeness OK
|
||||
```
|
||||
|
||||
`cargo clippy --workspace --all-targets` échoue sur les règles workspace strictes `clippy::implicit_return` et `clippy::question_mark_used`. `cargo test --workspace` atteint ensuite le canari Config Desk qui compare encore la version packagée stable `0.2.6` à la prerelease Cargo courante.
|
||||
|
||||
Cette version 2 du fichier delta accompagne l'archive d'échange **corrigée** de `pre.002-fix.001`. L'archive `pre.002-fix.001` précédemment transmise sans mise à jour du `Cargo.toml` racine est révoquée avant application/commit : elle ne doit pas être utilisée.
|
||||
|
||||
## 2. Signal technique
|
||||
|
||||
Le correctif touche des sources Rust et des tests Rust. Conformément à `VER-ID-007`, `VER-ID-010` et au contrat `Cargo.toml` de `FILE_CONTRACTS.md`, le signal Cargo est synchronisé :
|
||||
|
||||
```text
|
||||
livraison = 0.2.7-pre.002-fix.001
|
||||
workspace.package.version = 0.2.7-pre.2.fix.1
|
||||
commit = v0.2.7-pre.002-fix.001
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
Toutes les crates qui utilisent `version.workspace = true` héritent automatiquement de `0.2.7-pre.2.fix.1`.
|
||||
|
||||
Le manifeste racine passe aussi :
|
||||
|
||||
```text
|
||||
# version: 193 -> 194
|
||||
```
|
||||
|
||||
Aucune dépendance, feature, membre workspace ou lint n'est modifié.
|
||||
|
||||
## 3. Corrections Transport
|
||||
|
||||
`crates/ksp-onchain-transport-lib/src/ws_settings.rs` et `ws_lifecycle.rs` sont alignés avec les lints workspace stricts :
|
||||
|
||||
- les helpers `as_str()` utilisent un `return match` explicite ;
|
||||
- les propagations d'erreur n'utilisent plus l'opérateur `?`, interdit par `clippy::question_mark_used` ;
|
||||
- les constructeurs crate-internal de snapshots, encore réservés aux tests dans `pre.002`, sont compilés uniquement sous `cfg(test)` afin de supprimer les warnings `dead_code` avant leur consommation runtime future.
|
||||
|
||||
Aucun contrat public, default WebSocket, logging target ou règle de redaction n'est modifié. Le tracing reste exclusivement émis via `ksp-logging-lib` avec le `TRACING_TARGET` de `ksp-onchain-transport-lib`; aucun `tracing` direct n'est introduit.
|
||||
|
||||
## 4. Canaris desktop packagés
|
||||
|
||||
Le test Config Desk :
|
||||
|
||||
```text
|
||||
pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root
|
||||
```
|
||||
|
||||
ne compare plus `tauri.conf.json` et `package.json` à chaque valeur de `CARGO_PKG_VERSION`. Cette égalité rendait le test faux dès l'ouverture d'une prerelease Cargo alors que les ressources desktop packagées restaient volontairement sur la baseline stable `0.2.6`.
|
||||
|
||||
Le canari Wallet Desk analogue est corrigé dans la même livraison afin d'éviter le même faux négatif lors du `cargo test --workspace` suivant.
|
||||
|
||||
Les garanties conservées sont :
|
||||
|
||||
```text
|
||||
version tauri >= 0.2.6
|
||||
version package >= 0.2.6
|
||||
version tauri == version package
|
||||
```
|
||||
|
||||
Le cœur SemVer est comparé sur `major.minor.patch`; un suffixe prerelease éventuel ne change pas ce floor. Wallet Desk conserve en plus la vérification que le HTML embarque la version packagée réellement déclarée.
|
||||
|
||||
Les versions npm/Tauri ne sont pas forcées à suivre chaque prerelease Cargo par ce correctif.
|
||||
|
||||
## 5. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.2.7/pre.002-fix.001.md
|
||||
```
|
||||
|
||||
## 6. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||
crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
||||
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||
crates/ksp-onchain-transport-lib/src/ws_settings.rs
|
||||
```
|
||||
|
||||
## 7. Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## 8. Validations exécutées pour la préparation de l'archive corrigée
|
||||
|
||||
Sur la reconstruction `0.2.7-pre.002 + pre.002-fix.001 corrigé` :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py OK / clean
|
||||
inspection workspace.package.version 0.2.7-pre.2.fix.1
|
||||
inspection absence de tracing direct OK
|
||||
inspection absence de ? dans ws_settings.rs OK
|
||||
inspection contenu archive OK
|
||||
```
|
||||
|
||||
## 9. Validations non exécutées dans le sandbox
|
||||
|
||||
Cargo n'est pas disponible dans l'environnement de préparation. Aucun gate Cargo n'est déclaré réussi pour cette archive corrigée.
|
||||
|
||||
## 10. Validation opérateur requise
|
||||
|
||||
Après application de **cette archive corrigée uniquement** :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-app-config-desk --test desktop_contract
|
||||
cargo test -p ksp-app-wallet-desk --test desktop_contract
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Si ce checkpoint est vert, le commit attendu est :
|
||||
|
||||
```text
|
||||
v0.2.7-pre.002-fix.001
|
||||
```
|
||||
|
||||
La tranche suivante reste `0.2.7-pre.003`.
|
||||
|
||||
## 11. Décisions prises
|
||||
|
||||
```text
|
||||
le fix code porte bien un signal Cargo fix.1
|
||||
les canaris desktop conservent un floor >= 0.2.6 au lieu d'une égalité à CARGO_PKG_VERSION
|
||||
aucune modification fonctionnelle supplémentaire de la surface WebSocket
|
||||
aucune synchronisation forcée des versions npm/Tauri avec les prereleases Cargo
|
||||
```
|
||||
|
||||
## 12. Questions ouvertes
|
||||
|
||||
```text
|
||||
aucune pour ce correctif
|
||||
```
|
||||
263
deltas/0.2.7/pre.002.md
Normal file
263
deltas/0.2.7/pre.002.md
Normal file
@@ -0,0 +1,263 @@
|
||||
<!-- file: deltas/0.2.7/pre.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.002` — WebSocket settings + lifecycle contracts
|
||||
|
||||
## 1. Objet
|
||||
|
||||
Cette tranche matérialise la première surface Rust WebSocket de `ksp-onchain-transport-lib` sans ouvrir encore de socket physique et sans ajouter de dépendance WebSocket externe.
|
||||
|
||||
Version workspace :
|
||||
|
||||
```text
|
||||
0.2.7-pre.2
|
||||
```
|
||||
|
||||
Livraison :
|
||||
|
||||
```text
|
||||
0.2.7-pre.002
|
||||
```
|
||||
|
||||
Commit attendu :
|
||||
|
||||
```text
|
||||
v0.2.7-pre.002
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
## 2. Surface Transport ajoutée
|
||||
|
||||
Settings publics :
|
||||
|
||||
```text
|
||||
WsEndpointUrl
|
||||
WsProviderName
|
||||
WsClusterName
|
||||
WsProtocolKind
|
||||
WsReconnectSettings
|
||||
WsResubscribePolicy
|
||||
WsSessionSettings
|
||||
WsEndpointSettings
|
||||
WsTransportSettings
|
||||
```
|
||||
|
||||
Lifecycle public :
|
||||
|
||||
```text
|
||||
WsSessionId
|
||||
WsSubscriptionId
|
||||
WsSessionState
|
||||
WsSubscriptionState
|
||||
WsSubscriptionKind
|
||||
WsSessionSnapshot
|
||||
WsSubscriptionSnapshot
|
||||
```
|
||||
|
||||
`WsProtocolKind` est `#[non_exhaustive]` et ne fournit pour `0.2.7` que `SolanaStandard`. Cette forme prépare l'ajout futur d'une famille provider-specific telle que Helius Enhanced WebSocket sans ajouter de paramètres Helius dans les settings Solana standard.
|
||||
|
||||
Les IDs locaux reposent sur `NonZeroU64`. Aucun ID serveur WebSocket n'entre dans le contrat public de contrôle.
|
||||
|
||||
## 3. URL et secrets
|
||||
|
||||
`WsEndpointUrl` :
|
||||
|
||||
- accepte uniquement `ws://` et `wss://` ;
|
||||
- exige un host ;
|
||||
- conserve la valeur sensible uniquement pour le futur code de connexion ;
|
||||
- rend `WsEndpointUrl(<redacted>)` en `Debug` ;
|
||||
- ne copie pas la valeur URL dans les erreurs de validation.
|
||||
|
||||
`WsEndpointSettings` et `WsTransportSettings` peuvent conserver `Debug` dérivé car le sous-type URL est lui-même redacted.
|
||||
|
||||
Les snapshots ne contiennent jamais :
|
||||
|
||||
```text
|
||||
URL complète
|
||||
credential/query token
|
||||
request body
|
||||
raw notification
|
||||
remote subscription id
|
||||
```
|
||||
|
||||
## 4. Settings session bornés
|
||||
|
||||
Defaults initiaux Transport, explicitement policies KSP locales :
|
||||
|
||||
```text
|
||||
command timeout 10 s
|
||||
close timeout 5 s
|
||||
reconnect retries 5
|
||||
reconnect initial backoff 250 ms
|
||||
reconnect maximum backoff 5 s
|
||||
command queue 128
|
||||
notification queue per sub 256
|
||||
active subscriptions 1024
|
||||
pending JSON-RPC requests 128
|
||||
maximum message 64 MiB
|
||||
maximum frame 16 MiB
|
||||
maximum write buffer 1 MiB
|
||||
resubscribe default ActiveSubscriptions
|
||||
```
|
||||
|
||||
Ces valeurs ne sont pas présentées comme des limites Solana. `pre.004`/`pre.005` devront les appliquer réellement à l'actor/socket et pourront les recalibrer si les fixtures adversariales le justifient.
|
||||
|
||||
Validation structurelle :
|
||||
|
||||
- timeouts non nuls ;
|
||||
- reconnect backoff non nul et ordonné ;
|
||||
- capacités/limites strictement positives ;
|
||||
- au moins un endpoint WS configuré et enabled ;
|
||||
- noms endpoint uniques ;
|
||||
- name/provider/cluster non vides et sans whitespace de bord.
|
||||
|
||||
## 5. Lifecycle et snapshots
|
||||
|
||||
États session matérialisés :
|
||||
|
||||
```text
|
||||
Disconnected
|
||||
Connecting
|
||||
Active
|
||||
Reconnecting { attempt }
|
||||
Closing
|
||||
Closed
|
||||
Failed
|
||||
```
|
||||
|
||||
États subscription matérialisés :
|
||||
|
||||
```text
|
||||
Requested
|
||||
Active
|
||||
Resubscribing
|
||||
Cancelling
|
||||
Closed
|
||||
Failed
|
||||
```
|
||||
|
||||
`WsSubscriptionKind` couvre les neuf familles standard auditées : account, block, logs, program, root, signature, slot, slotsUpdates et vote.
|
||||
|
||||
`WsSessionSnapshot` expose uniquement des metadata sûres : local session ID, endpoint logical name, provider, cluster, protocol, state, pending request count, continuity gap count, overflow count et projections de subscriptions.
|
||||
|
||||
`WsSubscriptionSnapshot` expose local subscription ID, kind, state et `remote_bound`; l'ID distant reste interne et remappable.
|
||||
|
||||
Les constructeurs de snapshots sont crate-internal : les consumers ne peuvent pas fabriquer de faux états runtime.
|
||||
|
||||
## 6. Logging et tracing
|
||||
|
||||
La constante existante reste l'autorité crate-wide :
|
||||
|
||||
```rust
|
||||
TRACING_TARGET = "ksp-onchain-transport-lib"
|
||||
```
|
||||
|
||||
Elle est définie dans `crates/ksp-onchain-transport-lib/src/constants.rs`.
|
||||
|
||||
Toute nouvelle émission passe par `ksp-logging-lib` :
|
||||
|
||||
- `trace` pour entrée/succès de validations et metadata endpoint sûres ;
|
||||
- `debug` pour settings validés, compteurs et bornes ;
|
||||
- `warn` pour rejets de settings/URL ;
|
||||
- aucun `error` artificiel pour une erreur de validation caller.
|
||||
|
||||
Aucun appel direct à `tracing` n'est ajouté. Les logs n'incluent jamais la valeur de `WsEndpointUrl`.
|
||||
|
||||
## 7. Tests ajoutés
|
||||
|
||||
Tests unitaires settings :
|
||||
|
||||
```text
|
||||
ws/wss acceptés
|
||||
HTTP rejeté
|
||||
Debug URL redacted
|
||||
erreur de scheme sans secret
|
||||
protocol kind standard
|
||||
settings defaults bornés
|
||||
zero bound rejeté
|
||||
reconnect backoff inversé rejeté
|
||||
transport endpoints valides
|
||||
endpoint names dupliqués rejetés
|
||||
au moins un endpoint enabled
|
||||
Debug transport sans URL/credential
|
||||
```
|
||||
|
||||
Tests unitaires lifecycle :
|
||||
|
||||
```text
|
||||
IDs locaux non-zéro et ordonnables
|
||||
états reconnect/resubscribe/cancelling distincts
|
||||
9 familles standard couvertes
|
||||
snapshot sans URL ni remote subscription id
|
||||
```
|
||||
|
||||
Un canari `tests/public_api.rs` vérifie l'accès crate-root aux nouveaux contrats.
|
||||
|
||||
## 8. Fichiers principaux
|
||||
|
||||
Nouveaux :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/src/ws_settings.rs
|
||||
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
|
||||
deltas/0.2.7/pre.002.md
|
||||
```
|
||||
|
||||
Modifiés :
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-onchain-transport-lib/src/constants.rs
|
||||
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
```
|
||||
|
||||
Aucune dependency externe n'est ajoutée dans cette tranche.
|
||||
|
||||
## 9. Validation disponible dans le sandbox
|
||||
|
||||
Exécuté après modifications :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
```
|
||||
|
||||
Le sandbox ne fournit toujours pas `cargo`; les gates Rust compilés ne sont donc pas déclarés réussis ici.
|
||||
|
||||
Baseline opérateur reçue avant `pre.002` : `cargo fmt`, audit Python, `cargo check` et `cargo clippy` verts sur `0.2.7-pre.1`. `cargo test --workspace` n'échoue que sur le canari Config Desk qui compare encore la ressource packagée `0.2.6` à la version workspace `0.2.7-pre.1`; aucune régression WebSocket n'y est impliquée.
|
||||
|
||||
## 10. Gates opérateur avant commit
|
||||
|
||||
Après application du delta :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
Le test workspace complet peut encore reproduire le canari de version packagée Config Desk tant que cette ressource n'est volontairement resynchronisée.
|
||||
|
||||
## 11. Suite
|
||||
|
||||
`0.2.7-pre.003` doit matérialiser :
|
||||
|
||||
```text
|
||||
std.transport V2 HTTP + WS
|
||||
backward read V1 HTTP-only
|
||||
schema/fixtures V2
|
||||
Config -> WsTransportSettings
|
||||
ws_endpoints[].kind = solana_standard
|
||||
```
|
||||
|
||||
La direction reste strictement `Config -> Transport`; aucun reverse dependency n'est autorisé.
|
||||
225
deltas/0.2.7/pre.003.md
Normal file
225
deltas/0.2.7/pre.003.md
Normal file
@@ -0,0 +1,225 @@
|
||||
<!-- file: deltas/0.2.7/pre.003.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.003` — `std.transport` V2 HTTP + WebSocket
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
```text
|
||||
0.2.7-pre.002-fix.001 appliquée
|
||||
workspace.package.version = 0.2.7-pre.2.fix.1
|
||||
```
|
||||
|
||||
Le checkpoint opérateur reçu avant cette tranche est entièrement vert : `cargo fmt`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport ciblés et `cargo test --workspace`.
|
||||
|
||||
## 2. Signal technique
|
||||
|
||||
Cette prerelease non-fix modifie configuration exécutable, Rust et tests. Conformément à `VER-ID-009` :
|
||||
|
||||
```text
|
||||
livraison = 0.2.7-pre.003
|
||||
workspace.package.version = 0.2.7-pre.3
|
||||
commit = v0.2.7-pre.003
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
## 3. `std.transport` V2
|
||||
|
||||
Le document standard livré devient :
|
||||
|
||||
```text
|
||||
format_version = 2
|
||||
retry
|
||||
ws_defaults
|
||||
default_profile
|
||||
profiles[]
|
||||
profile_id
|
||||
endpoints[]
|
||||
ws_endpoints[]
|
||||
kind
|
||||
session?
|
||||
```
|
||||
|
||||
Le HTTP existant reste inchangé dans `endpoints[]`. `ws_defaults` contient les defaults génériques de `WsSessionSettings`; `ws_endpoints[].session` peut surcharger seulement les paramètres génériques nécessaires à un endpoint.
|
||||
|
||||
Le discriminateur est obligatoire :
|
||||
|
||||
```text
|
||||
kind = solana_standard
|
||||
```
|
||||
|
||||
Toute autre famille est rejetée en `0.2.7`. Aucun paramètre Helius, LaserStream ou autre provider-specific n'est pré-implémenté.
|
||||
|
||||
## 4. Backward V1 strict
|
||||
|
||||
Le schema enregistré passe à :
|
||||
|
||||
```text
|
||||
urn:ksp:schema:std.transport:v2
|
||||
```
|
||||
|
||||
Il conserve deux branches strictes discriminées par `format_version` :
|
||||
|
||||
```text
|
||||
V1 -> HTTP-only historique
|
||||
V2 -> HTTP + ws_defaults + ws_endpoints
|
||||
```
|
||||
|
||||
Le V1 n'est pas rendu compatible par un relâchement de `additionalProperties`. Une fixture V1 dédiée prouve le chemin historique.
|
||||
|
||||
Dans l'adapter :
|
||||
|
||||
```text
|
||||
V1 -> HttpTransportSettings + ws_settings = None
|
||||
V2 -> HttpTransportSettings + Some(WsTransportSettings)
|
||||
```
|
||||
|
||||
`WsTransportSettings` conserve donc son invariant `pre.002` : il n'existe jamais comme faux conteneur vide.
|
||||
|
||||
## 5. Adapter Config -> Transport
|
||||
|
||||
`ResolvedTransportConfig` conserve `settings()` pour compatibilité HTTP et ajoute :
|
||||
|
||||
```text
|
||||
http_settings()
|
||||
ws_settings() -> Option<&WsTransportSettings>
|
||||
into_transport_settings() -> (HttpTransportSettings, Option<WsTransportSettings>)
|
||||
```
|
||||
|
||||
Le mapper V2 construit :
|
||||
|
||||
```text
|
||||
WsProviderName
|
||||
WsClusterName
|
||||
WsProtocolKind::SolanaStandard
|
||||
WsEndpointUrl
|
||||
WsReconnectSettings
|
||||
WsResubscribePolicy
|
||||
WsSessionSettings
|
||||
WsEndpointSettings
|
||||
WsTransportSettings
|
||||
```
|
||||
|
||||
La direction reste strictement `Config -> Transport`. Aucun import de Config n'est ajouté à `ksp-onchain-transport-lib`.
|
||||
|
||||
## 6. Overrides session
|
||||
|
||||
Les valeurs de `ws_defaults` correspondent aux defaults KSP matérialisés en `pre.002`. Un endpoint peut surcharger indépendamment :
|
||||
|
||||
```text
|
||||
command_timeout_ms
|
||||
close_timeout_ms
|
||||
reconnect.{max_retries, initial_backoff_ms, max_backoff_ms}
|
||||
resubscribe
|
||||
command_queue_capacity
|
||||
notification_queue_capacity
|
||||
max_active_subscriptions
|
||||
max_pending_requests
|
||||
max_message_size_bytes
|
||||
max_frame_size_bytes
|
||||
max_write_buffer_size_bytes
|
||||
```
|
||||
|
||||
La session résultante est toujours validée par `WsSessionSettings::validate()`.
|
||||
|
||||
## 7. Secrets et logging
|
||||
|
||||
Les URLs HTTP et WebSocket peuvent provenir de `KSP_SECRET_*`; les valeurs réelles restent disponibles au runtime mais la projection Config safe les redacted. Les erreurs d'adaptation ne recopient pas l'URL.
|
||||
|
||||
Le mapping Config utilise exclusivement `ksp-logging-lib` avec le `TRACING_TARGET` existant de `ksp-config-lib` :
|
||||
|
||||
```text
|
||||
trace -> début mapping et chemin backward V1
|
||||
debug -> version, compteurs HTTP/WS après validation
|
||||
```
|
||||
|
||||
Aucun `tracing` direct n'est ajouté.
|
||||
|
||||
## 8. Environment inventory
|
||||
|
||||
Ajouts `.env.example` :
|
||||
|
||||
```text
|
||||
KSP_PUBLIC_SOLANA_DEVNET_WS_URL
|
||||
KSP_PUBLIC_SOLANA_MAINNET_WS_URL
|
||||
# KSP_SECRET_SOLANA_WS_URL
|
||||
```
|
||||
|
||||
Les URLs provider privées restent des valeurs complètes gérées via `KSP_SECRET_*`.
|
||||
|
||||
## 9. Tests ajoutés/étendus
|
||||
|
||||
```text
|
||||
V2 fixture HTTP + WS complète
|
||||
ws_defaults + endpoint overrides
|
||||
V1 strict toujours chargeable
|
||||
V1 n'invente pas de WsTransportSettings vide
|
||||
committed Devnet/Mainnet V2 mappe HTTP + WS
|
||||
provenance ws_defaults globale et ws_endpoints profil
|
||||
secret WebSocket URL disponible au runtime mais redacted en safe/Debug
|
||||
public API canary pour les nouveaux accessors
|
||||
```
|
||||
|
||||
## 10. Fichiers principaux modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
.env.example
|
||||
config/std.transport.json
|
||||
config/examples/std.transport.example.json
|
||||
config/schemas/std.transport.schema.json
|
||||
crates/ksp-config-lib/src/transport.rs
|
||||
crates/ksp-config-lib/src/lib.rs
|
||||
crates/ksp-config-lib/src/registry.rs
|
||||
crates/ksp-config-lib/unit_tests/transport.rs
|
||||
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
|
||||
crates/ksp-config-lib/tests/public_api.rs
|
||||
crates/ksp-config-lib/README.md
|
||||
crates/ksp-config-lib/USAGE.md
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
```
|
||||
|
||||
Nouveau :
|
||||
|
||||
```text
|
||||
crates/ksp-config-lib/unit_tests/fixtures_v1/std.transport.json
|
||||
deltas/0.2.7/pre.003.md
|
||||
```
|
||||
|
||||
Aucune dépendance externe n'est ajoutée.
|
||||
|
||||
## 11. Validation de préparation
|
||||
|
||||
Le sandbox de génération ne fournit pas Cargo. Sont exécutés ici :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
validation JSON des documents/schema
|
||||
inspection absence de tracing direct
|
||||
inspection version workspace
|
||||
inspection archive delta
|
||||
```
|
||||
|
||||
Les gates compilés restent opérateur-only.
|
||||
|
||||
## 12. Gates opérateur avant commit
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Si le checkpoint est vert :
|
||||
|
||||
```text
|
||||
commit = v0.2.7-pre.003
|
||||
```
|
||||
|
||||
La tranche suivante est `0.2.7-pre.004` : dépendances WebSocket, actor physique, handshake/read/write, pending JSON-RPC et serveur local déterministe.
|
||||
79
deltas/0.2.7/pre.004-fix.001.md
Normal file
79
deltas/0.2.7/pre.004-fix.001.md
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- file: deltas/0.2.7/pre.004-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.004-fix.001` — conformité Clippy du runtime WebSocket
|
||||
|
||||
## 1. Objet
|
||||
|
||||
Ce correctif ferme les écarts de compilation stricte détectés par la validation opérateur de `0.2.7-pre.004` sans modifier la surface fonctionnelle WebSocket, les dépendances ni l'architecture de session.
|
||||
|
||||
Comme le correctif modifie du code Rust, le signal technique Cargo est synchronisé avec l'identité de livraison conformément aux règles KSP :
|
||||
|
||||
```text
|
||||
livraison = 0.2.7-pre.004-fix.001
|
||||
workspace.package.version = 0.2.7-pre.4.fix.1
|
||||
commit = v0.2.7-pre.004-fix.001
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
## 2. Écarts détectés sur `pre.004`
|
||||
|
||||
La validation opérateur a confirmé :
|
||||
|
||||
- `cargo fmt --all` : OK ;
|
||||
- `scripts/audit_rust_workspace_rules.py` : clean ;
|
||||
- `cargo check --workspace` : compilation réussie mais un warning `unreachable_code` dans `ws_session.rs` ;
|
||||
- `cargo clippy --workspace --all-targets` : échec sur trois violations `clippy::implicit_return` plus le warning `unreachable_code` ;
|
||||
- `cargo test --workspace` : tests fonctionnels verts, dont les cinq nouveaux canaris WebSocket de `pre.004`.
|
||||
|
||||
Le problème est donc limité à la conformité aux règles Rust strictes du workspace et non au comportement couvert par les tests.
|
||||
|
||||
## 3. Corrections `ws_session.rs`
|
||||
|
||||
Le runtime WebSocket est conservé fonctionnellement à l'identique.
|
||||
|
||||
Les corrections sont :
|
||||
|
||||
- `handle_session_command` n'utilise plus `return match ...` lorsque toutes les branches divergent déjà par des `return` explicites ; cela supprime l'expression inatteignable signalée par Rust ;
|
||||
- la collecte des requêtes JSON-RPC expirées n'utilise plus une closure `filter_map` à retours implicites ; une boucle explicite construit désormais la liste des identifiants expirés ;
|
||||
- la closure passée à `AtomicU64::fetch_update` retourne explicitement `current.checked_add(1)` afin de respecter `clippy::implicit_return` ;
|
||||
- le header de version de `ws_session.rs` passe de `1` à `2`.
|
||||
|
||||
Aucun changement n'est apporté :
|
||||
|
||||
- à `WsSession` ou à sa surface publique ;
|
||||
- aux états de lifecycle ;
|
||||
- aux limites de message/frame/write buffer ;
|
||||
- au pending map et aux timeouts ;
|
||||
- aux dépendances `tokio-tungstenite` / `futures-util` ;
|
||||
- au firewall de dépendances ;
|
||||
- au tracing : toutes les émissions restent exclusivement via `ksp-logging-lib` et `TRACING_TARGET = "ksp-onchain-transport-lib"`.
|
||||
|
||||
## 4. Version Cargo
|
||||
|
||||
Le `Cargo.toml` racine passe à :
|
||||
|
||||
```toml
|
||||
[workspace.package]
|
||||
version = "0.2.7-pre.4.fix.1"
|
||||
```
|
||||
|
||||
Aucune autre entrée Cargo n'est modifiée.
|
||||
|
||||
## 5. Validation attendue
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Le smoke Devnet reste opt-in et n'est pas requis pour ce correctif.
|
||||
|
||||
## 6. Suite
|
||||
|
||||
Si ce checkpoint est vert, `0.2.7-pre.004` est considéré clos via `pre.004-fix.001` et la série peut poursuivre avec `0.2.7-pre.005` : limites adversariales, control frames, cancellation, close et shutdown borné.
|
||||
240
deltas/0.2.7/pre.004.md
Normal file
240
deltas/0.2.7/pre.004.md
Normal file
@@ -0,0 +1,240 @@
|
||||
<!-- file: deltas/0.2.7/pre.004.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.004` — runtime WebSocket physique + actor JSON-RPC
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
```text
|
||||
0.2.7-pre.003 appliquée
|
||||
workspace.package.version = 0.2.7-pre.3
|
||||
```
|
||||
|
||||
Le checkpoint opérateur reçu avant cette tranche est vert : `cargo fmt --all`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport, tests Config et `cargo test --workspace`.
|
||||
|
||||
## 2. Signal technique
|
||||
|
||||
Cette prerelease non-fix modifie dépendances, runtime Rust et tests. Conformément au workflow KSP :
|
||||
|
||||
```text
|
||||
livraison = 0.2.7-pre.004
|
||||
workspace.package.version = 0.2.7-pre.4
|
||||
commit = v0.2.7-pre.004
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
## 3. Dépendances WebSocket matérialisées
|
||||
|
||||
Le réaudit du 22 août 2026 confirme les versions retenues depuis `pre.001` :
|
||||
|
||||
```text
|
||||
tokio-tungstenite 0.30.0
|
||||
futures-util 0.3.34
|
||||
```
|
||||
|
||||
Le root déclare sans features consumer :
|
||||
|
||||
```toml
|
||||
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||
futures-util = { version = "^0.3", default-features = false }
|
||||
```
|
||||
|
||||
Transport active seulement :
|
||||
|
||||
```text
|
||||
tokio-tungstenite : connect + rustls-tls-webpki-roots
|
||||
futures-util : sink + std
|
||||
tokio : macros + rt + sync + time
|
||||
```
|
||||
|
||||
Le fixture serveur local ajoute `tokio/net` côté dev.
|
||||
|
||||
Aucune dépendance Config, Store, Program, Wallet ou `tracing` direct n'est introduite.
|
||||
|
||||
## 4. `WsSession` physique
|
||||
|
||||
Nouvelle surface publique :
|
||||
|
||||
```text
|
||||
WsSession::connect(WsEndpointSettings)
|
||||
WsSession::id()
|
||||
WsSession::state()
|
||||
WsSession::snapshot()
|
||||
```
|
||||
|
||||
Un appel de `connect` crée exactement une connexion physique. Deux appels avec le même endpoint créent deux sockets indépendants ; aucun singleton, pool ou scheduler automatique n'est ajouté.
|
||||
|
||||
Le caller ne reçoit jamais le socket brut.
|
||||
|
||||
## 5. Actor propriétaire du socket
|
||||
|
||||
Une tâche actor unique possède :
|
||||
|
||||
```text
|
||||
WebSocketStream
|
||||
compteur JSON-RPC request id
|
||||
map pending requests
|
||||
bounded command receiver
|
||||
publication WsSessionSnapshot
|
||||
```
|
||||
|
||||
Le handle communique avec l'actor par `tokio::sync::mpsc` borné selon `command_queue_capacity`.
|
||||
|
||||
Les snapshots sont publiés via `tokio::sync::watch` et conservent seulement les metadata sûres prévues en `pre.002`.
|
||||
|
||||
## 6. Handshake et `WebSocketConfig`
|
||||
|
||||
`WsSession::connect` attend le handshake sous `command_timeout` et configure explicitement :
|
||||
|
||||
```text
|
||||
write_buffer_size = 0
|
||||
max_write_buffer_size = WsSessionSettings.max_write_buffer_size_bytes
|
||||
max_message_size = WsSessionSettings.max_message_size_bytes
|
||||
max_frame_size = WsSessionSettings.max_frame_size_bytes
|
||||
```
|
||||
|
||||
Le `write_buffer_size = 0` évite de rendre la validité de la configuration KSP dépendante du buffer par défaut interne de Tungstenite et garantit que le plafond configuré reste strictement supérieur au target buffer.
|
||||
|
||||
Les tests oversized et les recalibrages éventuels restent le gate `pre.005`.
|
||||
|
||||
## 7. Pending JSON-RPC
|
||||
|
||||
La primitive interne actor :
|
||||
|
||||
```text
|
||||
execute_json_rpc(method, params)
|
||||
```
|
||||
|
||||
reste **`pub(crate)`**. Elle n'est volontairement pas exposée comme API raw provider-extension publique.
|
||||
|
||||
Comportement :
|
||||
|
||||
- ID numérique KSP monotone par session ;
|
||||
- sérialisation via `JsonRpcRequest` existant ;
|
||||
- map `BTreeMap` bornée par `max_pending_requests` ;
|
||||
- deadline par request issue de `command_timeout` ;
|
||||
- dispatch des réponses par `id`, y compris si elles arrivent hors ordre ;
|
||||
- erreurs JSON-RPC applicatives renvoyées au caller concerné sans teardown de la connexion ;
|
||||
- ID réponse inconnu/stale ignoré avec diagnostic sûr ;
|
||||
- JSON structurellement invalide classé erreur protocole session.
|
||||
|
||||
Cette primitive sera consommée par le moteur de subscriptions à partir de `pre.006`.
|
||||
|
||||
## 8. Lifecycle limité à la tranche
|
||||
|
||||
`pre.004` matérialise :
|
||||
|
||||
```text
|
||||
Connecting -> Active
|
||||
connection/read/write failure -> Failed
|
||||
last handle dropped -> cleanup best-effort -> Closed
|
||||
```
|
||||
|
||||
Le reconnect/resubscribe reste `pre.007`.
|
||||
|
||||
Le shutdown async public, les budgets de Close et les fixtures peer hostile restent `pre.005`.
|
||||
|
||||
Ping reçu est répondu par Pong afin de conserver l'interopérabilité du socket. Aucun heartbeat applicatif périodique n'est ajouté.
|
||||
|
||||
## 9. Erreurs
|
||||
|
||||
Nouveaux codes publics :
|
||||
|
||||
```text
|
||||
ws_backpressure_overflow
|
||||
ws_connection_failed
|
||||
ws_protocol_error
|
||||
ws_session_closed
|
||||
```
|
||||
|
||||
Les erreurs de connexion WebSocket ne conservent volontairement pas la source Tungstenite brute : celle-ci pourrait contenir une request/URI ou d'autres détails provider. Les erreurs KSP exposent uniquement `session_id`, endpoint logique, provider et cluster.
|
||||
|
||||
## 10. Logging / tracing
|
||||
|
||||
Toutes les émissions passent exclusivement par `ksp-logging-lib` et réutilisent :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/src/constants.rs
|
||||
TRACING_TARGET = "ksp-onchain-transport-lib"
|
||||
```
|
||||
|
||||
Répartition principale :
|
||||
|
||||
```text
|
||||
trace -> ouverture socket, send/dispatch JSON-RPC, Ping/Pong, notification prématurée ignorée
|
||||
debug -> actor start, handshake actif, réponse stale, timeout pending, remote close
|
||||
warn -> handshake/read/write failure, malformed wire, capacité pending épuisée
|
||||
```
|
||||
|
||||
Ne sont jamais loggés : URL complète, credentials, query token, payload JSON-RPC complet ou notification brute.
|
||||
|
||||
## 11. Serveur local déterministe
|
||||
|
||||
Nouveaux tests runtime sans Internet :
|
||||
|
||||
```text
|
||||
handshake local + round-trip JSON-RPC
|
||||
deux sessions physiques distinctes sur la même URL
|
||||
deux requests concurrentes + réponses inversées
|
||||
application error sans teardown de session
|
||||
connection error sans fuite URL/credential
|
||||
```
|
||||
|
||||
Le serveur utilise `tokio::net::TcpListener` + `tokio_tungstenite::accept_async`.
|
||||
|
||||
## 12. Canaris workspace/public API
|
||||
|
||||
Le canari workspace dependencies est synchronisé avec les nouvelles dépendances/features et continue de vérifier le firewall Transport.
|
||||
|
||||
Le canari public API vérifie la disponibilité de `WsSession` et les quatre nouveaux codes d'erreur.
|
||||
|
||||
## 13. Documentation synchronisée
|
||||
|
||||
Mis à jour :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
```
|
||||
|
||||
`ROADMAP.md` et `CHANGELOG.md` restent inchangés conformément à la politique de série prerelease.
|
||||
|
||||
## 14. Validation de préparation
|
||||
|
||||
Exécuté dans le sandbox :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
|
||||
inspection absence tracing direct OK
|
||||
inspection TRACING_TARGET OK
|
||||
inspection dépendances workspace/member OK
|
||||
inspection Markdown tables OK
|
||||
```
|
||||
|
||||
Cargo n'est pas disponible dans le sandbox de génération ; aucun résultat Cargo local n'est revendiqué.
|
||||
|
||||
## 15. Gates opérateur avant commit
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Si le checkpoint est vert :
|
||||
|
||||
```text
|
||||
commit = v0.2.7-pre.004
|
||||
```
|
||||
|
||||
La tranche suivante est `0.2.7-pre.005` : adversarial limits frame/message/request, control frames, cancellation, close/shutdown explicite et peer hostile.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user