92 Commits

Author SHA1 Message Date
afaa9c0239 v0.2.10-rel.001 2026-08-25 16:40:46 +02:00
7d456b41dc v0.2.10-pre.005 2026-08-25 15:43:15 +02:00
0edeed1c48 v0.2.10-pre.004 2026-08-25 15:26:04 +02:00
be5e3464ee v0.2.10-pre.003 2026-08-25 15:18:28 +02:00
1967e845b0 v0.2.10-pre.002-fix.001 2026-08-25 14:17:40 +02:00
af807afff5 v0.2.10-pre.002 2026-08-25 10:29:16 +02:00
7f567bc1bb v0.2.10-pre.001 2026-08-25 10:14:37 +02:00
09aacc36ab v0.2.9-rel.001 2026-08-25 08:36:34 +02:00
15b129ad04 v0.2.9-pre.015 2026-08-25 08:35:16 +02:00
6ddf2b4995 v0.2.9-pre.014 2026-08-25 05:44:41 +02:00
dcabceb52f v0.2.9-pre.013-fix.004 2026-08-24 23:39:11 +02:00
51bcf870d3 v0.2.9-pre.013-fix.003 2026-08-24 23:13:49 +02:00
c6c5793c61 v0.2.9-pre.013-fix.002 2026-08-24 23:02:18 +02:00
d98ee736be v0.2.9-pre.013-fix.001 2026-08-24 22:48:33 +02:00
39dbd86f1e v0.2.9-pre.013 2026-08-24 21:13:38 +02:00
c938b2409e v0.2.9-pre.012-fix.001 2026-08-24 20:56:14 +02:00
5b30bb9948 v0.2.9-pre.012-fix.001 2026-08-24 20:56:04 +02:00
813a45385a v0.2.9-pre.012 2026-08-24 20:48:30 +02:00
21ed5f88da v0.2.9-pre.011 2026-08-24 19:53:45 +02:00
3198cc5666 v0.2.9-pre.010-fix.001 2026-08-24 18:53:10 +02:00
4568d94a62 v0.2.9-pre.010 2026-08-24 18:39:19 +02:00
d32d8de3a5 v0.2.9-pre.009-fix.001 2026-08-24 17:36:41 +02:00
a9fb6a7ac8 v0.2.9-pre.009 2026-08-24 17:26:27 +02:00
433e69272a v0.2.9-pre.008-fix.001 2026-08-24 17:10:34 +02:00
affa18a6e9 v0.2.9-pre.008 2026-08-24 16:16:48 +02:00
05aec88178 v0.2.9-pre.007 2026-08-24 14:10:57 +02:00
b32de48c0b v0.2.9-pre.006 2026-08-24 13:17:24 +02:00
15e3402e1c v0.2.9-pre.005-fix.001 2026-08-24 12:53:15 +02:00
dca0a4b809 v0.2.9-pre.005 2026-08-24 12:31:19 +02:00
20320dba7b v0.2.9-pre.004-fix.001 2026-08-24 12:09:48 +02:00
e4bbc78a41 v0.2.9-pre.004 2026-08-24 11:55:17 +02:00
feb9befb35 v0.2.9-pre.003-fix.001 2026-08-24 11:16:04 +02:00
a038194679 v0.2.9-pre.003 2026-08-24 11:10:59 +02:00
3172cda241 v0.2.9-pre.002-fix.002 2026-08-24 11:00:38 +02:00
61fba107ef v0.2.9-pre.002-fix.001 2026-08-24 10:05:05 +02:00
835de48cb7 v0.2.9-pre.002 2026-08-24 09:54:14 +02:00
21de590aea v0.2.9-pre.001-fix.002 2026-08-24 09:23:03 +02:00
3081bd1085 v0.2.9-pre.001-fix.001 2026-08-24 09:17:30 +02:00
bdcab1e1a7 v0.2.9-pre.001 2026-08-24 08:37:36 +02:00
d70c3a1672 v0.2.8-rel.001 2026-08-23 23:14:58 +02:00
7cdf5e80c9 v0.2.8-pre.011 2026-08-23 20:01:28 +02:00
d8bfd7cd2e v0.2.8-pre.010 2026-08-23 18:35:44 +02:00
9c0d4fc197 v0.2.8-pre.009 2026-08-23 18:16:03 +02:00
7c12ec886b v0.2.8-pre.008 2026-08-23 17:19:44 +02:00
7eb6dec809 v0.2.8-pre.007-fix.004 2026-08-23 16:54:00 +02:00
68f4384c5b v0.2.8-pre.007-fix.003 2026-08-23 16:44:31 +02:00
9cc140fb84 v0.2.8-pre.007-fix.002 2026-08-23 16:16:39 +02:00
3b64d1e0ec v0.2.8-pre.007-fix.001 2026-08-23 16:11:44 +02:00
56b9ce6abc v0.2.8-pre.007 2026-08-23 16:05:41 +02:00
1c8d69778b v0.2.8-pre.006 2026-08-23 15:41:54 +02:00
8e739b9e55 v0.2.8-pre.005-fix.002 2026-08-23 15:13:03 +02:00
53dbb5bccd v0.2.8-pre.005-fix.001 2026-08-23 15:02:41 +02:00
92224e5ac6 v0.2.8-pre.005 2026-08-23 14:49:56 +02:00
cbb4e7b0de v0.2.8-pre.004-fix.002 2026-08-23 14:27:25 +02:00
38fd62c256 v0.2.8-pre.004-fix.001 2026-08-23 14:20:24 +02:00
f2a3ec62aa v0.2.8-pre.004 2026-08-23 14:08:12 +02:00
b3363073c4 v0.2.8-pre.003 2026-08-23 13:52:57 +02:00
c94a54f3e3 v0.2.8-pre.002-fix.001 2026-08-23 13:22:19 +02:00
0871b85df9 v0.2.8-pre.002 2026-08-23 13:15:42 +02:00
315e7e67e5 v0.2.8-pre.001-fix.002 2026-08-23 12:53:13 +02:00
df95f2f558 v0.2.8-pre.001-fix.001 2026-08-23 12:46:17 +02:00
f0865d5137 v0.2.8-pre.001 2026-08-23 12:36:54 +02:00
4d77b607ea v0.2.7-rel.001 2026-08-23 11:38:52 +02:00
307711f873 v0.2.7-pre.014-fix.001 2026-08-23 11:28:01 +02:00
5aa7b45840 v0.2.7-pre.014 2026-08-23 11:15:57 +02:00
3c5786f273 v0.2.7-pre.013 2026-08-23 10:24:28 +02:00
3b4d355537 v0.2.7-pre.012-fix.001 2026-08-23 09:55:34 +02:00
628b4f12f2 v0.2.7-pre.012 2026-08-23 09:40:44 +02:00
66deaf8245 v0.2.7-pre.011-fix.001 2026-08-23 09:17:34 +02:00
0e256a8ecf v0.2.7-pre.011-fix.001 2026-08-23 09:17:13 +02:00
9eb0e19d81 v0.2.7-pre.011 2026-08-23 08:58:03 +02:00
74686892e9 v0.2.7-pre.010-fix.001 2026-08-23 00:26:17 +02:00
1391858972 v0.2.7-pre.010 2026-08-23 00:12:39 +02:00
98bf88e431 v0.2.7-pre.009-fix.002 2026-08-22 23:26:09 +02:00
f0f444bc86 v0.2.7-pre.009-fix.001 2026-08-22 23:14:32 +02:00
d17161234a v0.2.7-pre.009 2026-08-22 23:08:47 +02:00
93199d1856 v0.2.7-pre.008-fix.001 2026-08-22 21:54:27 +02:00
6fefc64e75 v0.2.7-pre.008 2026-08-22 21:44:18 +02:00
4c540d67a7 v0.2.7-pre.007-fix.001 2026-08-22 21:00:29 +02:00
b67fa89f44 v0.2.7-pre.007 2026-08-22 20:42:06 +02:00
435126f67a v0.2.7-pre.006-fix.001 2026-08-22 20:08:55 +02:00
8721e54b18 v0.2.7-pre.006 2026-08-22 19:56:20 +02:00
6e3a0fa034 v0.2.7-pre.005 2026-08-22 19:16:41 +02:00
34637848eb v0.2.7-pre.004-fix.001 2026-08-22 18:34:52 +02:00
778ea58ee1 v0.2.7-pre.004 2026-08-22 18:21:20 +02:00
b0461f15ec v0.2.7-pre.003 2026-08-22 17:56:17 +02:00
d5df0fe9af v0.2.7-pre.003 2026-08-22 17:56:05 +02:00
ab29dc51bb v0.2.7-pre.002-fix.001 2026-08-22 16:55:21 +02:00
b6908cb573 v0.2.7-pre.002-fix.001 2026-08-22 16:49:43 +02:00
b64a799c85 v0.2.7-pre.002 2026-08-22 16:39:13 +02:00
cf4b28df2b v0.2.7-pre.001-fix.001 2026-08-22 16:00:56 +02:00
9e8fd53291 v0.2.7-pre.001 2026-08-22 15:34:03 +02:00
206 changed files with 49926 additions and 498 deletions

View File

@@ -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

View File

@@ -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 lextension 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 lactor 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 lenvironnement 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 dintégration/orchestration afin de préserver lownership Config du secret. Les graphes Cargo finaux nintroduisent 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 linventaire 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 laudit 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.

View File

@@ -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]

View File

@@ -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 laudit 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 lactor 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 dupdate, 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 dimplé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 dimplémentation.
### Règles Transport pour toute la série

View File

@@ -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}"
}
]
}
]

View File

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

View File

@@ -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}"
}
]
}
]
}
]

View File

@@ -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

View File

@@ -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 dun 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 dun développement ou diagnostic, puis redescendus avant la release suivante.
## Traçabilité frontend

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 :

View File

@@ -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"));

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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");

View File

@@ -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}"
}
]
}
]

View File

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

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

View File

@@ -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"));

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

View 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 lorsquun 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.

View File

@@ -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;
}

View File

@@ -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"] }

View File

@@ -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` ;

View File

@@ -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

View File

@@ -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 lorsquune 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 lutilisent 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 lactor pour le resubscribe déterministe, et toutes les règles de backpressure/terminal error sappliquent 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 laudit 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 sils existent sur la façade Solana standard. `transactionSubscribe` reste provider-specific et nest jamais ajouté à `SolanaStandardWsSession`.
Les endpoints Helius mainnet/devnet utilisent un `api-key` dans lURL. 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 nexposent pas le credential. Transport ne lit jamais lenvironnement et ne dépend jamais de Config.
Pour Helius, lactor envoie automatiquement un control frame WebSocket `Ping` toutes les 60 secondes sur une session active. Cette policy est provider-owned, non configurable et ne sapplique pas aux sessions `SolanaStandard`. Une perte physique suit le reconnect/resubscribe borné déjà décrit; aucun replay/lossless nest 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 nest 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 lobservabilité ; 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 nest committé ni placé dans lURL. Le même token a été validé par lopé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. Lendpoint 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 quaucune 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 nest committé en `0.2.8-pre.010`. Un tel test devrait à la fois obtenir `KSP_SECRET_HELIUS_API_KEY` via Config et exercer Transport ; lajouter dans Transport violerait lownership environnement/secret, tandis que lajouter dans Config étendrait lexception cross-crates que le projet veut au contraire résorber. La première surface KSP dinté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.

View File

@@ -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 lendpoint WebSocket résolu puis ouvrir la façade Helius, sans reconstruire ni journaliser lURL contenant lAPI 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 lID 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 lenvironnement dans Transport, ne pas recopier lURL 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;
```
LURL reste sensible : `Debug`, erreurs KSP et snapshots nen exposent pas la valeur. Les metadata publiques/secrètes se construisent avec `YellowstoneGrpcMetadataEntry`; Transport ne lit jamais lenvironnement. 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 laccesseur 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 nautorise 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.
Lendpoint 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 dune 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 nest 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 nest 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 lune de ces deux crates contournerait donc une frontière déjà documentée.
Lorsque la surface KSP dinté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 lendpoint Helius et teste donc auth + façade provider + actor + unsubscribe sans dépendre dune entitlement particulière de `transactionSubscribe`. Un smoke `transactionSubscribe` pourra être ajouté séparément comme opt-in provider-specific si lenvironnement 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 dauth, entitlement absente ou incident réseau nest 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.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/constants.rs
// version: 1
// version: 2
//! Transport-owned tracing constants.

View File

@@ -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");

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.007008`.
//! `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;

View File

@@ -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.

View File

@@ -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),
),

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

View File

@@ -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;
}

View File

@@ -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 nest 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 dinté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;
}

View File

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

View File

@@ -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"
);
}

View File

@@ -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;
}

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

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

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

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

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

View File

@@ -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");

View File

@@ -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(

View File

@@ -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(

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");

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

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

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

View File

@@ -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");
}

View File

@@ -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");
}

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

View File

@@ -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"));
}

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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/`.

View 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 lauth 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 louverture 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 nest 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 napparaî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 nest ajouté.
Lauth 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 dabord 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 nest 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 dajouter un timer OrbitFlare au moteur gRPC.
## 8. Validation disponible dans lenvironnement 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 nest déclarée exécutée dans lenvironnement 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
View 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
View 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
View 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
View 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
View 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.

View 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
View 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`.

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

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