55 Commits

Author SHA1 Message Date
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
147 changed files with 29598 additions and 447 deletions

View File

@@ -1,5 +1,5 @@
# file: .env.example
# version: 5
# version: 8
# 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.
@@ -34,9 +34,17 @@ KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
# 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
# Optional complete private-provider WebSocket 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_WS_URL=wss://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
# 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,24 @@
<!-- file: CHANGELOG.md -->
<!-- version: 11 -->
<!-- version: 13 -->
# Changelog KSP
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
## 0.2.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é.

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 216
# version: 261
[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.7"
version = "0.2.9"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
@@ -15,31 +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 }
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 }
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" }
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: 82 -->
<!-- version: 86 -->
# Roadmap KSP
@@ -52,12 +52,23 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
- [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`.
- [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.
- [ ] `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.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.
- [ ] `0.2.10`Ajouter lintégration Yellowstone gRPC OrbitFlare sur le moteur/standard acquis, avec audit explicite des auth/capabilities/restrictions/extensions et Devnet comme cible gratuite prioritaire.
- [ ] `0.2.11`Ajouter lintégration Helius LaserStream gRPC sur le moteur Yellowstone partagé, avec audit de compatibilité/replay/auth/extensions, sans confondre cette surface avec LaserStream WebSocket.
- [ ] `0.2.12` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
- [ ] `0.2.13` — 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.14` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
- [ ] `0.2.15` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
### TODO/IDEAS — providers Yellowstone non planifiés
- [ ] **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

@@ -90,18 +90,59 @@
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
},
{
"name": "mainnet_private_ws",
"name": "mainnet_helius_ws",
"enabled": true,
"provider": "private-provider",
"provider": "helius",
"cluster": "mainnet-beta",
"kind": "solana_standard",
"url": "${KSP_SECRET_SOLANA_WS_URL:-wss://example.invalid}",
"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,13 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:ksp:schema:std.transport:v2",
"title": "KSP standard HTTP + WebSocket Transport configuration",
"$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": {
@@ -335,7 +338,8 @@
},
"kind": {
"enum": [
"solana_standard"
"solana_standard",
"helius_laserstream"
]
},
"url": {
@@ -454,6 +458,275 @@
}
}
}
},
"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,5 +1,5 @@
{
"format_version": 2,
"format_version": 3,
"retry": {
"max_retries": 2,
"initial_backoff_ms": 100,
@@ -22,6 +22,20 @@
"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": [
{
@@ -105,6 +119,120 @@
"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-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: 8 -->
<!-- version: 9 -->
# ksp-config-lib
@@ -81,7 +81,7 @@ 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 HTTP et WebSocket : 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
@@ -89,7 +89,7 @@ Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configur
- [`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 Transport V2 HTTP + WebSocket, avec lecture backward du V1 HTTP-only ;
- [`../../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: 8 -->
<!-- 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 + WebSocket 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) {
@@ -137,16 +137,30 @@ let transport = match engine.load_resolved_transport_config(std::option::Option:
let http_settings = transport.http_settings();
let ws_settings = transport.ws_settings();
let _ = (http_settings, ws_settings);
let grpc_settings = transport.grpc_settings();
let _ = (http_settings, ws_settings, grpc_settings);
```
`std.transport` V2 conserve `retry` et `profiles[].endpoints[]` pour HTTP, ajoute `ws_defaults` et `profiles[].ws_endpoints[]`, puis exige actuellement `kind = "solana_standard"`. Un `ws_endpoints[].session` optionnel surcharge seulement les paramètres génériques de `WsSessionSettings`.
`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.
Le même schema enregistré conserve la lecture stricte du V1 historique : dans ce cas `http_settings()` reste disponible et `ws_settings()` retourne `None`. Aucun `WsTransportSettings` vide n'est inventé pour simuler l'absence de WebSocket.
Un endpoint gRPC V3 sépare explicitement :
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. Les URLs HTTP et WebSocket 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.
```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_*
```
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.
`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: 17
// 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 + WebSocket 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 + WebSocket 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 + WebSocket 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 + WebSocket 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 Transport configuration mapped to HTTP and optional WebSocket runtime settings.
/// 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: 9
// 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 + WebSocket 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 + WebSocket 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 + WebSocket 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 + WebSocket 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: 3
// version: 5
/// Effective standard on-chain Transport configuration resolved from Config and mapped to HTTP and optional WebSocket runtime contracts.
/// 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,
@@ -11,6 +11,7 @@ pub struct ResolvedTransportConfig {
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 {
@@ -61,7 +62,7 @@ impl ResolvedTransportConfig {
return &self.settings;
}
/// Returns validated WebSocket Transport settings when the selected document uses format V2.
/// 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]
@@ -69,19 +70,42 @@ impl ResolvedTransportConfig {
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 both HTTP and optional WebSocket runtime 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 {
@@ -94,15 +118,17 @@ impl std::fmt::Debug for ResolvedTransportConfig {
.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 Transport document, selects a profile, resolves environment placeholders and maps HTTP plus optional WebSocket 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 Transport URL wrappers own runtime redaction. V1 documents remain HTTP-only; V2 documents require WebSocket defaults and endpoints.
/// 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>,
@@ -121,7 +147,8 @@ impl crate::ConfigDocumentEngine {
return resolve_transport_profile(&profile, environment);
}
/// Maps an already resolved standard Transport profile to HTTP plus optional WebSocket runtime adapters while preserving 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(
@@ -148,9 +175,13 @@ struct EffectiveTransportSource {
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)]
@@ -255,6 +286,72 @@ struct EffectiveWsReconnectOverrideSource {
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);
@@ -294,16 +391,26 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
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(),
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 {
@@ -314,6 +421,7 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
effective,
settings,
ws_settings,
grpc_settings,
});
}
@@ -328,18 +436,22 @@ fn map_optional_ws_settings(
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");
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 => {
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 requires ws_defaults")),
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 profile requires ws_endpoints")),
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 {
@@ -360,6 +472,283 @@ fn map_optional_ws_settings(
};
}
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,
@@ -443,6 +832,7 @@ fn map_ws_protocol_kind(
) -> 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)

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/public_api.rs
// version: 22
// version: 23
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
//! Logging/Transport adapters and management contracts.
@@ -254,7 +254,9 @@ fn transport_adapter_contract_is_available_from_crate_root() {
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

@@ -71,6 +71,55 @@
"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,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: 4
// version: 10
#[test]
fn fixture_transport_profile_maps_complete_runtime_contract() {
@@ -41,10 +41,11 @@ 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(), 1);
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");
@@ -64,6 +65,13 @@ fn fixture_transport_profile_maps_complete_runtime_contract() {
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());
}
}
@@ -83,6 +91,7 @@ fn v1_transport_fixture_remains_backward_readable_and_http_only() {
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");
}
}
@@ -102,8 +111,9 @@ 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 V2 Devnet profile should expose WebSocket 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);
@@ -114,8 +124,9 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
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 V2 Mainnet profile should expose WebSocket 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");
}
@@ -123,7 +134,147 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
}
#[test]
fn committed_v2_websocket_endpoint_composes_with_public_session_constructor_without_polling() {
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,
@@ -131,11 +282,11 @@ fn committed_v2_websocket_endpoint_composes_with_public_session_constructor_with
};
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 V2 Transport profile should map: {resolved:?}");
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 V2 Transport profile should expose WebSocket settings");
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);
@@ -163,6 +314,7 @@ fn transport_profile_preserves_global_and_profile_origin() {
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));
@@ -259,6 +411,78 @@ fn secret_websocket_url_is_runtime_available_but_safe_projection_is_redacted() {
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();
@@ -342,6 +566,22 @@ fn v1_fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
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

@@ -1,5 +1,5 @@
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
// version: 4
// version: 7
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
@@ -62,11 +62,17 @@ 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\", \"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 = [\"net\", \"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]
@@ -86,7 +92,20 @@ fn transport_manifest_runtime_and_dev_dependency_names_are_exact() {
let dependency_names = manifest_dependency_names(dependencies);
assert_eq!(
dependency_names,
std::vec!["futures-util", "ksp-core-lib", "ksp-logging-lib", "reqwest", "serde", "serde_json", "tokio", "tokio-tungstenite"]
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");
@@ -98,7 +117,7 @@ fn transport_manifest_runtime_and_dev_dependency_names_are_exact() {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert_eq!(manifest_dependency_names(dev_dependencies), std::vec!["tokio"]);
assert_eq!(manifest_dependency_names(dev_dependencies), std::vec!["tokio", "tonic", "yellowstone-grpc-proto"]);
}
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {

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-onchain-transport-lib/Cargo.toml
# version: 5
# version: 9
[package]
name = "ksp-onchain-transport-lib"
@@ -8,17 +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" }
futures-util = { workspace = true, features = ["sink", "std"] }
reqwest = { workspace = true, features = ["rustls"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
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 = ["net", "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: 19 -->
<!-- version: 22 -->
# `ksp-onchain-transport-lib`
`ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Elle fournit le transport HTTP JSON-RPC complet et le moteur WebSocket Solana standard ; les extensions provider-specific et gRPC sont ajoutées séparément lorsquune 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
@@ -21,6 +21,8 @@ La crate possède :
- l'exécution générique des méthodes standard supportées ;
- 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`.
@@ -37,6 +39,7 @@ ksp-config-lib
-> ksp-logging-lib
-> reqwest / tokio / serde
-> tokio-tungstenite / futures-util
-> tonic / tonic-prost / yellowstone-grpc-proto
```
La direction inverse est interdite :
@@ -199,6 +202,50 @@ Ces familles restent explicitement **unstable**. Le moteur commun `subscribe_typ
`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.
## Résilience
L'admission est calculée par couple endpoint/rôle. Le pool applique :
@@ -235,7 +282,7 @@ 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.
Trois smokes Devnet opt-in sont séparés par responsabilité :
Quatre smokes réseau opt-in sont séparés par responsabilité :
```text
Transport HTTP pur : settings programmatiques -> HttpTransportPool
@@ -252,13 +299,19 @@ Transport WebSocket pur : settings programmatiques -> WsSession
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
-> getHealth/getGenesisHash/getVersion/getBalance
Transport Yellowstone gRPC : settings programmatiques + x-token secret
-> PublicNode Mainnet + Testnet
-> TLS -> Subscribe slots -> Slot non nul -> close borné
```
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.
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 trois tests sont `ignored` par défaut. Les deux 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.
Les quatre tests sont `ignored` par défaut ; le test Yellowstone contient deux cas live Mainnet/Testnet. 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
@@ -270,4 +323,6 @@ Les trois tests sont `ignored` par défaut. Les deux smokes Transport appartienn
- [`../../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 + WebSocket V2, avec lecture backward V1 HTTP-only.
- [`../../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 ;
- [`../../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: 19 -->
<!-- version: 22 -->
# Utilisation de `ksp-onchain-transport-lib`
@@ -183,6 +183,46 @@ let mut blocks = match session
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`.
@@ -217,7 +257,111 @@ Les limites de taille et de capacité sont des policies KSP configurables par `W
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. Appels typés
## 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.
### 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`.
@@ -277,7 +421,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.
## 5. 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 :
@@ -291,7 +435,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.
## 6. 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 :
@@ -306,13 +450,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.
## 7. 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.
## 8. 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()`.
@@ -320,7 +464,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.
## 9. Logging
## 10. Logging
Les événements Transport utilisent le target :
@@ -332,7 +476,7 @@ 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.
## 10. Smokes Devnet opt-in
## 11. Smokes réseau opt-in
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 :
@@ -350,6 +494,31 @@ cargo test -p ksp-onchain-transport-lib --test websocket_devnet_smoke -- --ignor
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 de **composition Config -> Transport** reste également disponible :
```bash
@@ -358,7 +527,25 @@ 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 et WebSocket 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 :

View File

@@ -1,8 +1,16 @@
// file: crates/ksp-onchain-transport-lib/src/error.rs
// version: 4
// 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.

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: 29
// version: 44
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -24,15 +24,41 @@
//! 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.
//! 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;
@@ -42,26 +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.
@@ -94,6 +121,192 @@ pub use self::error::ERROR_CODE_WS_CONNECTION_FAILED;
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.
@@ -108,22 +321,6 @@ 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`.
@@ -296,26 +493,6 @@ 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.
@@ -336,6 +513,22 @@ pub use self::ws_cluster::SolanaSlotUpdate;
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.
@@ -344,13 +537,17 @@ pub use self::ws_lifecycle::WsSessionSnapshot;
pub use self::ws_lifecycle::WsSessionState;
/// Stable local identity assigned to one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionId;
/// Standard Solana subscription family represented by one logical WebSocket subscription.
/// 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;
/// Shareable handle for one explicitly created physical WebSocket session.
/// 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;
@@ -370,7 +567,7 @@ pub use self::ws_settings::WsResubscribePolicy;
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 Solana WebSocket subscription.
/// 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;
@@ -383,18 +580,24 @@ 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.

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
// version: 6
// version: 7
/// Commitment level accepted by typed Solana HTTP and WebSocket 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.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_accounts.rs
// version: 2
// version: 3
const MAX_PROGRAM_SUBSCRIBE_FILTERS: usize = 4;
const MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES: usize = 128;
@@ -277,6 +277,46 @@ fn validate_program_subscribe_filters(filters: &[crate::SolanaProgramAccountFilt
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

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_blocks.rs
// version: 1
// version: 2
/// Filter accepted by unstable Solana `blockSubscribe`.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -210,6 +210,17 @@ struct WireRpcResponse {
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

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs
// version: 3
// version: 5
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -401,6 +401,46 @@ fn decode_vote_notification(method: &str, value: serde_json::Value) -> ksp_core_
});
}
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

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
// version: 6
// version: 7
/// Stable local identity assigned to one physical WebSocket session.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
@@ -76,7 +76,7 @@ pub enum WsSubscriptionState {
Failed,
}
/// Standard Solana subscription family represented by one logical WebSocket subscription.
/// WebSocket subscription family represented by one logical subscription.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum WsSubscriptionKind {
@@ -98,10 +98,12 @@ pub enum WsSubscriptionKind {
SlotsUpdates,
/// `voteSubscribe` family.
Vote,
/// Helius LaserStream WebSocket `transactionSubscribe` extension family.
HeliusTransaction,
}
impl WsSubscriptionKind {
/// Returns the stable KSP descriptor for this standard subscription family.
/// Returns the stable KSP descriptor for this WebSocket subscription family.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
@@ -114,10 +116,11 @@ impl WsSubscriptionKind {
Self::Slot => "slot",
Self::SlotsUpdates => "slots_updates",
Self::Vote => "vote",
Self::HeliusTransaction => "helius_transaction",
};
}
/// Returns the exact standard Solana subscribe JSON-RPC method for this family.
/// 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",
@@ -129,10 +132,11 @@ impl WsSubscriptionKind {
Self::Slot => "slotSubscribe",
Self::SlotsUpdates => "slotsUpdatesSubscribe",
Self::Vote => "voteSubscribe",
Self::HeliusTransaction => "transactionSubscribe",
};
}
/// Returns the exact standard Solana unsubscribe JSON-RPC method for this family.
/// 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",
@@ -144,10 +148,11 @@ impl WsSubscriptionKind {
Self::Slot => "slotUnsubscribe",
Self::SlotsUpdates => "slotsUpdatesUnsubscribe",
Self::Vote => "voteUnsubscribe",
Self::HeliusTransaction => "transactionUnsubscribe",
};
}
/// Returns the exact standard Solana notification method emitted for this family.
/// Returns the exact notification method emitted for this family.
pub(crate) const fn notification_method(self) -> &'static str {
return match self {
Self::Account => "accountNotification",
@@ -159,10 +164,13 @@ impl WsSubscriptionKind {
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);
}
@@ -211,7 +219,7 @@ impl WsSubscriptionSnapshot {
return self.id;
}
/// Returns the standard subscription family.
/// Returns the logical WebSocket subscription family.
#[must_use]
pub const fn kind(&self) -> crate::WsSubscriptionKind {
return self.kind;

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;

View File

@@ -1,11 +1,13 @@
// file: crates/ksp-onchain-transport-lib/src/ws_session.rs
// version: 12
// version: 14
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
static NEXT_WS_SESSION_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
const HELIUS_WS_HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
type WsPhysicalStream = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
/// Shareable handle for one explicitly created physical WebSocket session.
@@ -24,15 +26,32 @@ pub struct WsSession {
}
impl WsSession {
/// Opens one physical WebSocket connection for the supplied endpoint settings.
/// Opens one physical standard Solana WebSocket connection for the supplied endpoint settings.
///
/// Calling this function twice with the same endpoint creates two independent physical sessions. The function returns only after the WebSocket
/// handshake succeeds or the configured command timeout expires.
/// This historical constructor remains standard-only after provider-specific protocol kinds are added. Calling this function twice with the same endpoint
/// creates two independent physical sessions. Provider-specific callers must use their typed protocol facade instead of obtaining a generic handle.
pub async fn connect(endpoint: crate::WsEndpointSettings) -> ksp_core_lib::Result<Self> {
return Self::connect_for_protocol(endpoint, crate::WsProtocolKind::SolanaStandard).await;
}
/// Opens one physical WebSocket connection after validating the typed facade protocol.
pub(crate) async fn connect_for_protocol(endpoint: crate::WsEndpointSettings, expected_protocol: crate::WsProtocolKind) -> ksp_core_lib::Result<Self> {
let validation = crate::WsTransportSettings::new(std::vec![endpoint.clone()]).validate();
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
if endpoint.protocol() != expected_protocol {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket session constructor does not accept the endpoint protocol")
.with_context("field", "ws_endpoints.protocol")
.with_context("expected_protocol", expected_protocol.as_str())
.with_context("actual_protocol", endpoint.protocol().as_str()),
);
}
return Self::connect_physical(endpoint).await;
}
async fn connect_physical(endpoint: crate::WsEndpointSettings) -> ksp_core_lib::Result<Self> {
let id_result = next_session_id();
let id = match id_result {
std::result::Result::Ok(id) => id,
@@ -395,6 +414,8 @@ async fn run_ws_session_actor(
let mut pending = std::collections::BTreeMap::<u64, PendingWsRequest>::new();
let mut subscriptions = std::collections::BTreeMap::<u64, crate::WsSubscriptionRuntime>::new();
let mut remote_to_local = std::collections::BTreeMap::<u64, crate::WsSubscriptionId>::new();
let heartbeat_enabled = helius_heartbeat_enabled(endpoint.protocol());
let mut heartbeat_deadline = next_helius_heartbeat_deadline();
loop {
prune_cancelled_pending(id, &mut pending, &mut subscriptions, &mut remote_to_local);
let timeout_deadline = next_pending_deadline(&pending);
@@ -454,6 +475,13 @@ async fn run_ws_session_actor(
)
.await
},
() = tokio::time::sleep_until(heartbeat_deadline), if heartbeat_enabled => {
let heartbeat = send_helius_heartbeat(id, &endpoint, &mut websocket, &mut shutdown_rx).await;
if matches!(&heartbeat, WsActorIoOutcome::Continue) {
heartbeat_deadline = next_helius_heartbeat_deadline();
}
heartbeat
},
() = tokio::time::sleep_until(timeout_deadline) => {
expire_pending_requests(id, &mut pending, &mut subscriptions, &mut remote_to_local);
WsActorIoOutcome::Continue
@@ -533,7 +561,12 @@ async fn run_ws_session_actor(
)
.await;
match recovery {
WsReconnectOutcome::Connected { websocket: replacement } => websocket = *replacement,
WsReconnectOutcome::Connected { websocket: replacement } => {
websocket = *replacement;
if heartbeat_enabled {
heartbeat_deadline = next_helius_heartbeat_deadline();
}
},
WsReconnectOutcome::ShutdownRequested { deadline } => {
finish_disconnected_shutdown(
id,
@@ -594,7 +627,12 @@ async fn run_ws_session_actor(
)
.await;
match recovery {
WsReconnectOutcome::Connected { websocket: replacement } => websocket = *replacement,
WsReconnectOutcome::Connected { websocket: replacement } => {
websocket = *replacement;
if heartbeat_enabled {
heartbeat_deadline = next_helius_heartbeat_deadline();
}
},
WsReconnectOutcome::ShutdownRequested { deadline } => {
finish_disconnected_shutdown(
id,
@@ -641,6 +679,69 @@ async fn run_ws_session_actor(
}
}
const fn helius_heartbeat_enabled(protocol: crate::WsProtocolKind) -> bool {
return matches!(protocol, crate::WsProtocolKind::HeliusLaserStream);
}
fn next_helius_heartbeat_deadline() -> tokio::time::Instant {
return tokio::time::Instant::now() + HELIUS_WS_HEARTBEAT_INTERVAL;
}
async fn send_helius_heartbeat<S>(
id: crate::WsSessionId,
endpoint: &crate::WsEndpointSettings,
websocket: &mut tokio_tungstenite::WebSocketStream<S>,
shutdown_rx: &mut tokio::sync::watch::Receiver<std::option::Option<tokio::time::Instant>>,
) -> WsActorIoOutcome
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + std::marker::Unpin,
{
let message = tokio_tungstenite::tungstenite::Message::Ping(std::vec::Vec::new().into());
let send_result = tokio::select! {
biased;
shutdown_changed = shutdown_rx.changed() => {
let deadline = resolve_shutdown_deadline(shutdown_rx, shutdown_changed, endpoint.session().close_timeout());
return WsActorIoOutcome::ShutdownRequested { deadline };
},
send_result = websocket.send(message) => send_result,
() = tokio::time::sleep(endpoint.session().command_timeout()) => {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
session_id = id.get(),
endpoint_name = endpoint.name(),
"Helius WebSocket heartbeat Ping write timed out"
);
return WsActorIoOutcome::Failed {
code: crate::ERROR_CODE_WS_CONNECTION_FAILED,
pending_message: "WebSocket connection failed while writing Helius heartbeat Ping",
};
},
};
return match send_result {
std::result::Result::Ok(()) => {
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
session_id = id.get(),
endpoint_name = endpoint.name(),
"sent Helius WebSocket heartbeat Ping control frame"
);
WsActorIoOutcome::Continue
},
std::result::Result::Err(_) => {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
session_id = id.get(),
endpoint_name = endpoint.name(),
"Helius WebSocket heartbeat Ping write failed"
);
WsActorIoOutcome::Failed {
code: crate::ERROR_CODE_WS_CONNECTION_FAILED,
pending_message: "WebSocket connection failed while writing Helius heartbeat Ping",
}
},
};
}
fn websocket_config(endpoint: &crate::WsEndpointSettings) -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig {
return tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
.write_buffer_size(0)

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_settings.rs
// version: 3
// version: 4
const DEFAULT_WS_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const DEFAULT_WS_COMMAND_QUEUE_CAPACITY: usize = 128;
@@ -119,13 +119,15 @@ impl WsClusterName {
/// WebSocket protocol family understood by KSP Transport.
///
/// `0.2.7` exposes only standard Solana WebSocket. The non-exhaustive contract allows later provider-specific families without changing the common endpoint
/// container or injecting provider-only options into [`WsSessionSettings`].
/// 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 {
@@ -134,6 +136,7 @@ impl WsProtocolKind {
pub const fn as_str(self) -> &'static str {
return match self {
Self::SolanaStandard => "solana_standard",
Self::HeliusLaserStream => "helius_laserstream",
};
}
}

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs
// version: 5
// version: 6
/// Typed handle for one logical Solana WebSocket subscription.
/// 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.
@@ -43,7 +43,7 @@ impl<T> WsSubscription<T> {
return self.id;
}
/// Returns the standard Solana subscription family.
/// Returns the logical WebSocket subscription family.
#[must_use]
pub const fn kind(&self) -> crate::WsSubscriptionKind {
return self.kind;
@@ -71,7 +71,7 @@ impl<T> WsSubscription<T> {
return self.notification_rx.recv().await;
}
/// Cancels this logical subscription and sends the matching Solana unsubscribe request when a remote binding still exists.
/// 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
@@ -198,11 +198,11 @@ impl WsSubscriptionRegistration {
pub(crate) struct WsSubscriptionRuntime {
/// Stable local identity.
pub(crate) id: crate::WsSubscriptionId,
/// Standard Solana subscription family.
/// WebSocket subscription family.
pub(crate) kind: crate::WsSubscriptionKind,
/// Current logical lifecycle state.
pub(crate) state: crate::WsSubscriptionState,
/// Original standard subscribe parameters retained internally for deterministic resubscribe.
/// 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>,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_transactions.rs
// version: 3
// version: 4
/// Optional configuration accepted by standard Solana `signatureSubscribe`.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -246,6 +246,46 @@ fn decode_logs_notification(method: &str, value: serde_json::Value) -> ksp_core_
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: 33
// version: 48
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -676,3 +676,339 @@ fn public_v0_2_7_pre_012_complete_standard_websocket_surface_is_available_from_c
];
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,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 24
// version: 41
//! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage.
@@ -769,3 +769,578 @@ fn release_v0_2_7_pre_012_http_inventory_remains_52_current_plus_14_historical()
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,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

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

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
// version: 4
// version: 5
fn non_zero(value: u64) -> std::num::NonZeroU64 {
return std::num::NonZeroU64::new(value).expect("test ID must be non-zero");
@@ -123,3 +123,13 @@ fn websocket_unstable_subscription_partition_is_exact() {
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"));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
// version: 7
// version: 11
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
@@ -20,6 +20,22 @@ fn local_endpoint_with_session(url: &str, session: crate::WsSessionSettings) ->
);
}
fn helius_local_endpoint(url: &str) -> crate::WsEndpointSettings {
return helius_local_endpoint_with_session(url, crate::WsSessionSettings::default());
}
fn helius_local_endpoint_with_session(url: &str, session: crate::WsSessionSettings) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_helius_ws",
true,
crate::WsProviderName::new("helius-fixture"),
crate::WsClusterName::new("local"),
crate::WsProtocolKind::HeliusLaserStream,
crate::WsEndpointUrl::parse(url).expect("local Helius test WebSocket URL must parse"),
session,
);
}
fn session_settings(
command_timeout: std::time::Duration,
close_timeout: std::time::Duration,
@@ -166,6 +182,26 @@ async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream
}
}
async fn yield_runtime_steps() {
for _ in 0..16 {
tokio::task::yield_now().await;
}
return;
}
async fn wait_for_observed_ping_with_io_progress(ping_rx: &mut tokio::sync::mpsc::UnboundedReceiver<()>) -> std::time::Duration {
let started = tokio::time::Instant::now();
tokio::time::resume();
let observed = tokio::time::timeout(std::time::Duration::from_secs(1), ping_rx.recv()).await;
tokio::time::pause();
let elapsed = tokio::time::Instant::now().duration_since(started);
assert!(
matches!(observed, std::result::Result::Ok(std::option::Option::Some(()))),
"heartbeat Ping was not observed during bounded real I/O progress"
);
return elapsed;
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_session_connects_and_round_trips_internal_json_rpc() {
let (listener, url) = bind_local_listener().await;
@@ -400,6 +436,191 @@ async fn websocket_ping_flushes_automatic_pong_and_keeps_session_active() {
server.await.expect("local server task must complete");
}
#[test]
fn helius_heartbeat_policy_is_provider_owned_and_fixed_to_sixty_seconds() {
assert!(super::helius_heartbeat_enabled(crate::WsProtocolKind::HeliusLaserStream));
assert!(!super::helius_heartbeat_enabled(crate::WsProtocolKind::SolanaStandard));
assert_eq!(super::HELIUS_WS_HEARTBEAT_INTERVAL, std::time::Duration::from_secs(60));
}
#[tokio::test(flavor = "current_thread")]
async fn helius_heartbeat_sends_ping_at_sixty_seconds_and_rearms() {
let (listener, url) = bind_local_listener().await;
let (ping_tx, mut ping_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local Helius server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Ping(_))) => {
ping_tx.send(()).expect("heartbeat observation channel must remain open");
websocket.flush().await.expect("automatic heartbeat Pong must flush");
},
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,
}
}
});
let session = crate::HeliusLaserStreamWsSession::connect(helius_local_endpoint(url.as_str())).await.expect("Helius fixture session must connect");
tokio::time::pause();
yield_runtime_steps().await;
tokio::time::advance(std::time::Duration::from_secs(59)).await;
yield_runtime_steps().await;
assert!(matches!(ping_rx.try_recv(), std::result::Result::Err(tokio::sync::mpsc::error::TryRecvError::Empty)));
tokio::time::advance(std::time::Duration::from_secs(1)).await;
let first_io_elapsed = wait_for_observed_ping_with_io_progress(&mut ping_rx).await;
let pre_second_interval =
std::time::Duration::from_secs(59).checked_sub(first_io_elapsed).expect("bounded first Ping I/O progress must remain below one second");
tokio::time::advance(pre_second_interval).await;
yield_runtime_steps().await;
assert!(matches!(ping_rx.try_recv(), std::result::Result::Err(tokio::sync::mpsc::error::TryRecvError::Empty)));
tokio::time::advance(std::time::Duration::from_secs(1) + first_io_elapsed).await;
let _second_io_elapsed = wait_for_observed_ping_with_io_progress(&mut ping_rx).await;
session.close().await.expect("Helius heartbeat fixture session must close");
tokio::time::resume();
server.await.expect("Helius heartbeat fixture server must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn standard_session_never_emits_helius_provider_heartbeat() {
let (listener, url) = bind_local_listener().await;
let (ping_tx, mut ping_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local standard server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local standard handshake must succeed");
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Ping(_))) => {
ping_tx.send(()).expect("standard heartbeat observation channel must remain open");
websocket.flush().await.expect("automatic Pong must flush");
},
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,
}
}
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("standard fixture session must connect");
tokio::time::pause();
tokio::time::advance(std::time::Duration::from_secs(180)).await;
yield_runtime_steps().await;
assert!(matches!(ping_rx.try_recv(), std::result::Result::Err(tokio::sync::mpsc::error::TryRecvError::Empty)));
session.close().await.expect("standard fixture session must close");
tokio::time::resume();
server.await.expect("standard heartbeat absence fixture server must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn helius_explicit_close_cancels_heartbeat_before_deadline() {
let (listener, url) = bind_local_listener().await;
let (ping_tx, mut ping_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local Helius close server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius close handshake must succeed");
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Ping(_))) => {
ping_tx.send(()).expect("close heartbeat observation channel must remain open");
websocket.flush().await.expect("automatic heartbeat Pong must flush");
},
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,
}
}
});
let session = crate::HeliusLaserStreamWsSession::connect(helius_local_endpoint(url.as_str())).await.expect("Helius close fixture session must connect");
tokio::time::pause();
yield_runtime_steps().await;
tokio::time::advance(std::time::Duration::from_secs(30)).await;
yield_runtime_steps().await;
assert!(matches!(ping_rx.try_recv(), std::result::Result::Err(tokio::sync::mpsc::error::TryRecvError::Empty)));
session.close().await.expect("Helius close fixture session must close before heartbeat deadline");
yield_runtime_steps().await;
assert!(!matches!(ping_rx.try_recv(), std::result::Result::Ok(())));
tokio::time::resume();
server.await.expect("Helius close fixture server must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn helius_heartbeat_write_failure_maps_to_existing_reconnect_failure_outcome() {
let (client_io, peer_io) = tokio::io::duplex(64);
let mut websocket =
tokio_tungstenite::WebSocketStream::from_raw_socket(client_io, tokio_tungstenite::tungstenite::protocol::Role::Client, std::option::Option::None).await;
drop(peer_io);
let endpoint = helius_local_endpoint("ws://127.0.0.1:65535");
let (_shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(std::option::Option::None::<tokio::time::Instant>);
let session_id = crate::WsSessionId::new(std::num::NonZeroU64::new(1).expect("fixture session id must be non-zero"));
let outcome = super::send_helius_heartbeat(session_id, &endpoint, &mut websocket, &mut shutdown_rx).await;
match outcome {
super::WsActorIoOutcome::Failed { code, pending_message } => {
assert_eq!(code, crate::ERROR_CODE_WS_CONNECTION_FAILED);
assert_eq!(pending_message, "WebSocket connection failed while writing Helius heartbeat Ping");
},
_ => panic!("heartbeat write failure must use the existing reconnect failure outcome"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn helius_heartbeat_is_rearmed_from_successful_reconnect() {
let (listener, url) = bind_local_listener().await;
let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::<()>();
let (replacement_tx, replacement_rx) = tokio::sync::oneshot::channel::<()>();
let (ping_tx, mut ping_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.expect("first Helius connection must be accepted");
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("first Helius handshake must succeed");
disconnect_rx.await.expect("disconnect trigger must arrive");
first.send(tokio_tungstenite::tungstenite::Message::Close(std::option::Option::None)).await.expect("first Helius close must send");
let (replacement_stream, _) = listener.accept().await.expect("replacement Helius connection must be accepted");
let mut replacement = tokio_tungstenite::accept_async(replacement_stream).await.expect("replacement Helius handshake must succeed");
replacement_tx.send(()).expect("replacement observation must be delivered");
loop {
let message = replacement.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Ping(_))) => {
ping_tx.send(()).expect("replacement heartbeat observation channel must remain open");
replacement.flush().await.expect("replacement automatic Pong must flush");
},
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,
}
}
});
let settings = reconnect_session_settings(1, std::time::Duration::from_millis(20), crate::WsResubscribePolicy::ActiveSubscriptions);
let session = crate::HeliusLaserStreamWsSession::connect(helius_local_endpoint_with_session(url.as_str(), settings))
.await
.expect("Helius reconnect fixture session must connect");
disconnect_tx.send(()).expect("disconnect trigger must send");
tokio::time::timeout(std::time::Duration::from_secs(1), replacement_rx)
.await
.expect("replacement connection must remain bounded")
.expect("replacement connection must be observed");
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
loop {
if session.state() == crate::WsSessionState::Active && session.snapshot().continuity_gap_count() == 1 {
break;
}
assert!(tokio::time::Instant::now() < deadline, "Helius fixture must recover before heartbeat rearm check");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
tokio::time::pause();
tokio::time::advance(std::time::Duration::from_secs(59)).await;
yield_runtime_steps().await;
assert!(matches!(ping_rx.try_recv(), std::result::Result::Err(tokio::sync::mpsc::error::TryRecvError::Empty)));
tokio::time::advance(std::time::Duration::from_secs(1)).await;
yield_runtime_steps().await;
assert!(matches!(ping_rx.try_recv(), std::result::Result::Ok(())));
session.close().await.expect("reconnected Helius fixture session must close");
tokio::time::resume();
server.await.expect("Helius reconnect heartbeat fixture server must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_remote_close_consumes_bounded_reconnect_budget_before_terminal_failure() {
let (listener, url) = bind_local_listener().await;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
// version: 1
// version: 2
fn valid_endpoint(name: &str, url_text: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
@@ -47,8 +47,10 @@ fn websocket_endpoint_url_errors_do_not_echo_sensitive_url() {
}
#[test]
fn websocket_protocol_kind_is_extensible_but_only_standard_is_available_now() {
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]

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

@@ -0,0 +1,269 @@
<!-- file: deltas/0.2.8/pre.001-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.001-fix.001` — séparation des façades WebSocket et forecast visible
## 1. Base requise
Ce correctif s'applique exclusivement après :
```text
0.2.8-pre.001
workspace.package.version = 0.2.8-pre.1
```
Il corrige le **plan et la matrice de validation** de `pre.001` avant toute implémentation de `pre.002`.
Le correctif est documentaire uniquement. Conformément à `VER-ID-008` :
```text
livraison = 0.2.8-pre.001-fix.001
workspace.package.version = 0.2.8-pre.1 # inchangé
commit attendu = v0.2.8-pre.001-fix.001
```
## 2. Motif du fix
Le plan initial faisait porter à un `WsSession` public commun toute la surface WebSocket, puis utilisait une capability matrix pour rejeter avant I/O les méthodes standard non supportées par Helius.
Après revue du code réel `v0.2.7`, cette forme est jugée trop permissive au niveau API : les neuf wrappers standard sont directement implémentés sur `WsSession`. Un endpoint Helius aurait donc pu être représenté par un type exposant publiquement `block_subscribe`, `slots_updates_subscribe` et `vote_subscribe`, même si ces appels étaient ensuite rejetés.
Décision corrigée :
```text
séparer les façades publiques par protocole
partager intégralement le moteur physique/lifecycle
rendre les méthodes provider non supportées absentes de la façade Helius
conserver une validation interne defense-in-depth
```
## 3. Architecture corrigée
Cible :
```text
WsSession
moteur physique partagé
actor/socket/reconnect/queues
┌────────────┴────────────┐
▼ ▼
SolanaStandardWsSession HeliusLaserStreamWsSession
9 familles 6 standard communes
+ transaction
+ heartbeat policy
```
### Surface standard
```text
Account
Block
Logs
Program
Root
Signature
Slot
SlotsUpdates
Vote
```
### Surface Helius
```text
Account
Logs
Program
Root
Signature
Slot
HeliusTransaction
```
Absents de la façade Helius :
```text
Block
SlotsUpdates
Vote
```
Les DTOs standard réellement identiques restent partagés. Les DTOs Helius sont créés uniquement pour les contrats provider-specific (`transactionSubscribe`, `tokenAccounts`, notification transaction, etc.).
`WsSession` reste compatible avec la surface standard publiée en `0.2.7`. La façade Helius ne doit fournir aucun escape hatch public (`inner`/`into_inner`) qui permettrait de récupérer un handle générique et de contourner sa surface.
## 4. Capability matrix — rôle corrigé
La capability matrix n'est plus la première barrière publique. Elle devient une protection interne :
```text
API/façade correcte
-> méthodes impossibles absentes
-> validation descriptor/constructor avant I/O
-> actor commun
```
Matrice interne conservée :
```text
SolanaStandard:
Account Block Logs Program Root Signature Slot SlotsUpdates Vote -> allowed
HeliusTransaction -> rejected
HeliusLaserStream:
Account Logs Program Root Signature Slot HeliusTransaction -> allowed
Block SlotsUpdates Vote -> rejected
```
## 5. Gate Cargo désormais fermé
Les commandes manquantes de `pre.001` ont été fournies par l'opérateur :
```bash
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
```
Résolution observée :
```text
futures-util 0.3.34
tokio 1.53.1
tokio-tungstenite 0.30.0
reqwest 0.13.4
new Helius SDK aucun
```
Doublons ciblés :
```text
syn 2.0.119 / 3.0.3
webpki-roots 0.26.11 / 1.0.9
```
Ils sont transitifs dans le graphe actuel et ne bloquent pas le gate.
**Verdict : gate `pre.001` positif après ce fix documentaire.**
## 6. Forecast souple recalibré
Le forecast revient à `pre.001 -> pre.011` parce que la séparation de façade mérite une tranche propre et que les six wrappers standard supportés par Helius doivent être validés séparément de la mécanique de connexion commune.
```text
pre.001 audit/sizing/matrice/dependencies
pre.001-fix.001 architecture de façades + gate Cargo + forecast visible
pre.002 socle protocolaire : protocol kind + façades + connexion physique partagée
pre.003 6 familles standard Helius + absence typée Block/SlotsUpdates/Vote
pre.004 Config V2 helius_laserstream + secret strategy
pre.005 transactionSubscribe/unsubscribe request/filter/options/tokenAccounts
pre.006 transactionNotification + reconnect/resubscribe/unsubscribe races
pre.007 heartbeat/idle lifecycle
pre.008 adversarial provider/security/backpressure
pre.009 compliance + standard WS 18/18 + HTTP 52/14 + Config/API canaries
pre.010 live smoke opt-in si sûr + README/USAGE + dependency audit
pre.011 workspace final + fermeture docs/matrix/indexes + prompt 0.2.9
rel.001 stable
```
Chaque tranche vise nominalement 1520 minutes. Le plan contient désormais un tableau immédiatement visible avec objectif, preuves, budget et critères de split pour chaque tranche.
`pre.011` n'est pas une deadline ; `pre.012+` ou des fixes sont ajoutés si nécessaire.
## 7. Fichiers ajoutés
```text
deltas/0.2.8/pre.001-fix.001.md
```
## 8. Fichiers modifiés
```text
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
```
## 9. Fichiers volontairement inchangés
```text
Cargo.toml
ROADMAP.md
CHANGELOG.md
.env.example
config/**
crates/**
docs/000-README.md
docs/plans/000-README.md
docs/validation/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
deltas/0.2.8/pre.001.md
```
Le delta `pre.001` publié reste immutable ; ce fix ne le réécrit pas.
## 10. Validations et preuves
### Réellement fournies par l'opérateur avant ce fix
```text
baseline v0.2.7 : fmt/audit/check/clippy/test workspace = OK
cargo tree transport = exécuté
cargo tree transport --duplicates = exécuté
```
### Réellement exécutées dans l'environnement de préparation du fix
```text
inspection du code public WsSession/WsSubscriptionKind/WsSessionSnapshot = OK
lecture VERSION_WORKFLOW.md pour VER-ID-008 = OK
contrôle overlay documentaire = OK
```
L'audit Rust workspace est réexécuté sur le workspace reconstitué après application de l'overlay lorsque le script est disponible.
### Non exécutées pour ce fix documentaire
```text
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
```
Motif : aucun code/build/runtime/config n'est modifié par ce correctif et l'environnement de préparation ne fournit pas Cargo.
## 11. Décisions prises
```text
façades publiques séparées par protocole
moteur WsSession actor unique partagé
SolanaStandardWsSession = 9 familles standard
HeliusLaserStreamWsSession = 6 familles standard + transaction
Block/SlotsUpdates/Vote absents de la façade Helius
capability matrix conservée defense-in-depth
DTOs communs réutilisés si wire identique
DTOs Helius dédiés seulement aux divergences
pas d'escape hatch Helius vers raw WsSession
forecast visible et recalibré jusqu'à pre.011
Cargo pre.1 inchangé car fix documentaire
```
## 12. Questions ouvertes laissées à `pre.002+`
```text
forme interne minimale pour partager la connexion physique sans dupliquer l'actor
forme exacte des constructeurs des deux façades
extension minimale de WsSubscriptionKind/snapshot pour HeliusTransaction
forme typed des différents transactionDetails insuffisamment documentés
wire éventuel futur de enhanced/filtered accountSubscribe
```
Ces questions ne remettent pas en cause la frontière décidée : **surface publique séparée, moteur physique partagé**.
## 13. Prochaine tranche
```text
0.2.8-pre.002
```
Mission : matérialiser uniquement le socle protocolaire et les façades/constructeurs autour du moteur `WsSession` existant. Ne pas commencer `transactionSubscribe` ni Config Helius dans cette tranche.

View File

@@ -0,0 +1,174 @@
<!-- file: deltas/0.2.8/pre.001-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.001-fix.002` — forecast KSP compact et namespace LaserStream WebSocket
## 1. Base requise
Ce correctif s'applique exclusivement après :
```text
0.2.8-pre.001-fix.001
workspace.package.version = 0.2.8-pre.1
```
Il reste **documentaire uniquement** : aucun code, Config runtime, schema, manifest ou dépendance n'est modifié.
```text
livraison = 0.2.8-pre.001-fix.002
workspace.package.version = 0.2.8-pre.1 # inchangé
commit attendu = v0.2.8-pre.001-fix.002
aucun tag prerelease
```
## 2. Motifs du fix
Deux corrections de lisibilité/contrat sont nécessaires avant `pre.002`.
### 2.1 Forecast
Le tableau introduit par `pre.001-fix.001` ne correspond pas au format employé par les plans KSP récents :
```text
0.2.5 -> bloc "Prévision souple révisée", fixes regroupés avec la candidate concernée
0.2.7 -> bloc "Forecast recalibré", une ligne compacte par prerelease avec état DONE
```
Le forecast `0.2.8` revient donc à cette forme compacte. Les `fix.*` sont rattachés visuellement à leur `pre.NNN` et peuvent porter leur propre changement d'état sans être présentés comme de nouvelles tranches planifiées.
### 2.2 Nom LaserStream
`Helius LaserStream` désigne chez Helius plusieurs surfaces produit. `0.2.8` ne couvre que **LaserStream WebSocket** ; le futur LaserStream gRPC reste distinct et hors scope.
Le code court suivant est conservé :
```text
WsProtocolKind::HeliusLaserStream
as_str() = "helius_laserstream"
```
mais uniquement parce qu'il est possédé par un namespace explicitement WebSocket :
```text
WsProtocolKind
profiles[].ws_endpoints[].kind
HeliusLaserStreamWsSession
```
Dans tout contexte où cet ownership n'est pas visible, la désignation durable est **Helius LaserStream WebSocket**.
Le futur gRPC devra utiliser son propre backend/type/Config et ne pourra jamais être un alias du contrat WS. Son nom exact n'est pas anticipé dans `0.2.8` et sera choisi pendant son audit normatif.
## 3. Forecast souple corrigé
```text
pre.001 DONE — audit Helius actuel + matrice + architecture + threat model + dependencies + sizing
fix.001 DONE — séparation des façades standard/Helius + moteur unique + gate Cargo fermé
fix.002 DONE — forecast normalisé + namespace LaserStream WebSocket/gRPC clarifié
pre.002 socle protocolaire : WsProtocolKind::HeliusLaserStream + façades standard/Helius
+ connexion physique partagée + guards, sans duplication de l'actor
pre.003 surface Helius standard supportée : account/logs/program/root/signature/slot
+ absence typée de block/slotsUpdates/vote sur Helius + non-régression standard 9/9
pre.004 Config V2 helius_laserstream + schema/fixtures + mapping Config -> Transport
+ stratégie de secret Helius et redaction URL
pre.005 transactionSubscribe request typed + filters/options/tokenAccounts + transactionUnsubscribe
+ bounds 50k + maxSupportedTransactionVersion conditionnel
pre.006 transactionNotification + actor integration + reconnect/resubscribe/unsubscribe races
+ late notifications + backpressure ciblée
pre.007 heartbeat Helius WebSocket/idle + timers + interaction reconnect/control frames/shutdown
pre.008 provider adversarial lifecycle + capability guards + payload/backpressure + security/redaction
pre.009 compliance Helius WebSocket + non-régressions Solana standard 18/18 + HTTP 52/14
+ Config/API/dependency-firewall canaries
pre.010 smoke Helius WebSocket live opt-in si stratégie sûre + README/USAGE
+ cargo tree direct/duplicates final
pre.011 validation workspace finale + fermeture plan/matrice/indexes + prompt 0.2.9
rel.001 publication stable stricte
```
Règles : budget nominal d'environ 1520 minutes par nouvelle prerelease ; fixes insérables sans changer artificiellement le forecast ; split dès qu'une tranche masque plusieurs problèmes indépendants ; `pre.011` n'est pas une deadline.
## 4. Nomenclature durable WebSocket / gRPC
Règle adoptée :
```text
contexte typé/config WS visible : HeliusLaserStream / helius_laserstream autorisé
prose ou metadata ambiguë : "Helius LaserStream WebSocket" obligatoire
future surface gRPC : backend/type/Config distincts ; jamais WsProtocolKind/WsEndpointSettings
```
Conséquences :
- `HeliusLaserStreamWsSession` reste le nom de façade visé en `0.2.8` ;
- `helius_laserstream` reste acceptable comme `kind` sous `ws_endpoints[]` ;
- aucune dépendance, protobuf, SDK ou Config gRPC n'est introduite ;
- le futur audit gRPC choisira son propre discriminateur sans être contraint par le code court WS ;
- la documentation doit toujours qualifier explicitement WebSocket ou gRPC lorsqu'un lecteur pourrait confondre les deux.
## 5. Fichiers ajoutés
```text
deltas/0.2.8/pre.001-fix.002.md
```
## 6. Fichiers modifiés
```text
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
```
## 7. Fichiers volontairement inchangés
```text
Cargo.toml
ROADMAP.md
CHANGELOG.md
.env.example
config/**
crates/**
docs/000-README.md
docs/plans/000-README.md
docs/validation/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
deltas/0.2.8/pre.001.md
deltas/0.2.8/pre.001-fix.001.md
prompts/013-V0_2_8_START_PROMPT.md
```
Les deltas déjà publiés restent immutables.
## 8. Validations
Preuves opérateur déjà acquises et inchangées :
```text
baseline v0.2.7 fmt/audit/check/clippy/test workspace = OK
cargo tree transport = exécuté/inspecté
cargo tree transport --duplicates = exécuté/inspecté
```
Validations du fix documentaire :
```text
comparaison forecast avec plans 0.2.5 / 0.2.7 = effectuée
cohérence plan / validation / delta = contrôlée
audit Rust workspace après overlay = à exécuter si script disponible
```
Aucun gate Cargo supplémentaire n'est créé par ce fix puisque le runtime, le build, la Config et les dépendances restent inchangés.
## 9. Verdict et prochaine tranche
```text
gate pre.001 = positif
Cargo = 0.2.8-pre.1 inchangé
architecture = façades séparées, moteur unique
forecast = compact, fixes groupés, pre.001 -> pre.011
nom WS = HeliusLaserStream / helius_laserstream sous ownership WebSocket
futur gRPC = explicitement distinct, hors 0.2.8
prochaine tranche = 0.2.8-pre.002
```
`pre.002` peut matérialiser le socle protocolaire/façades. Il ne doit pas commencer Config Helius ni `transactionSubscribe`.

268
deltas/0.2.8/pre.001.md Normal file
View File

@@ -0,0 +1,268 @@
<!-- file: deltas/0.2.8/pre.001.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.001` — audit/sizing Helius LaserStream WebSocket
## 1. Base requise et vérifiée
Archive autoritaire fournie :
```text
khadhroony-solana-project-v0.2.7-full-from-gitea.zip
```
État vérifié :
```text
workspace.package.version = 0.2.7
deltas/0.2.7/rel.001.md présent
prompts/013-V0_2_8_START_PROMPT.md présent
metadata .git absente de l'archive
```
Cette livraison ouvre :
```text
workspace.package.version = 0.2.8-pre.1
commit attendu = v0.2.8-pre.001
aucun tag prerelease
```
## 2. Objet
`pre.001` est strictement le gate **audit + brainstorming + sizing** de `0.2.8 — Helius LaserStream WebSocket`.
Il ne modifie :
```text
aucun fichier Rust
aucun schema/config runtime
aucune dependency
aucun README/USAGE Transport
aucun secret/environment runtime
```
Il crée le plan durable, ouvre la matrice de validation, synchronise les index et recalcule la prévision souple.
## 3. Baseline acquise
Preuve opérateur jointe avant ouverture :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py OK / clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
```
Le sandbox de préparation a aussi exécuté :
```text
python3 scripts/audit_rust_workspace_rules.py OK
```
mais ne contient pas `cargo`; il ne déclare donc aucun gate Cargo local réussi.
Les graphes requis n'étaient pas présents dans le log opérateur et restent à exécuter avant commit :
```bash
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
```
## 4. Résultat de l'audit Helius du 2026-08-23
Terminologie/endpoints :
```text
produit courant = LaserStream WebSocket
Enhanced WebSockets = ancien nom intégré au produit courant
mainnet = wss://mainnet.helius-rpc.com/?api-key=...
devnet = wss://devnet.helius-rpc.com/?api-key=...
api-key = Secret query credential
LaserStream gRPC / Gatekeeper beta = hors scope
```
Surface retenue :
```text
Helius supporte les paires standard :
account, logs, program, root, signature, slot
Helius ne supporte pas d'après l'index exhaustif :
block, slotsUpdates, vote
Helius extension :
transactionSubscribe
transactionUnsubscribe
notification = transactionNotification
```
La documentation Helius diverge sur `slotsUpdates`; l'index exhaustif `LaserStream WebSocket Methods` le classe explicitement parmi les méthodes unstable non supportées, tandis que `websocket/llms.txt` le place aussi dans une section « stable ». Le gate retient **non supporté** et exige un rejet KSP avant I/O pour `HeliusLaserStream`.
`transactionSubscribe` expose actuellement :
```text
vote
failed
signature
accountInclude <= 50_000
accountExclude <= 50_000
accountRequired <= 50_000
tokenAccounts = none | balanceChanged | all
commitment
encoding = base58 | base64 | jsonParsed
transactionDetails = full | signatures | accounts | none
showRewards
maxSupportedTransactionVersion
```
`maxSupportedTransactionVersion` est requis par la référence lorsque `transactionDetails` vaut `accounts` ou `full`.
`notifyOn` est toujours visible dans les références account/program mais est désormais **deprecated et no-op depuis Agave 4.2**. Il ne sera pas ajouté à KSP.
La documentation continue de parler d'« enhanced/filtered accountSubscribe » sans publier, dans les références courantes auditées, un wire provider supplémentaire assez exact pour une API typed. Cette capacité est reportée explicitement au lieu d'être inventée.
## 5. Décisions du gate
```text
WsProtocolKind cible = HeliusLaserStream
wire/config string = helius_laserstream
provider metadata = helius
session actor = WsSession existant, aucun second client
new subscription kind = HeliusTransaction
provider capability = validation déterministe avant I/O
Helius standard support = Account Logs Program Root Signature Slot
Helius standard reject = Block SlotsUpdates Vote
notifyOn = non exposé
tokenAccounts = enum provider typed
heartbeat = actor Helius-only, cible 60 s
Config = ajout kind V2, même ws_endpoints[]
credential = URL résolue par Config derrière WsEndpointUrl
new dependency = aucune
WebSocket historical replay= aucune promesse
```
## 6. Threat model retenu
Points couverts par le plan :
```text
api-key dans query URL et erreurs handshake
heartbeat concurrent avec reconnect/close
late messages après transactionUnsubscribe
remote ids transitoires
provider capability mismatch
provider RPC errors sans session death automatique
transaction payload volumineux
3 listes de filtres jusqu'à 50k chacune
queue/frame/message bounds
unknown provider fields/modes de notification
continuity gaps après reconnect
```
## 7. Forecast recalibré
Le forecast initial `pre.001 -> pre.011` est resserré car `notifyOn` n'est pas une capacité utile et aucun wire account/program provider additionnel précis n'est actuellement publiable.
Forecast courant :
```text
pre.001 audit/sizing/matrice
pre.002 protocol descriptor + subscription kind + capability/redaction
pre.003 Config V2 helius_laserstream + secret strategy
pre.004 transactionSubscribe/unsubscribe request/filter/options
pre.005 transactionNotification + reconnect/resubscribe/unsubscribe races
pre.006 heartbeat/idle lifecycle
pre.007 adversarial provider/security/backpressure
pre.008 compliance + WS 18/18 + HTTP 52/14 + Config canaries
pre.009 live smoke opt-in si sûr + README/USAGE + cargo graphs
pre.010 workspace final + docs/matrix + prompt 0.2.9
rel.001 stable
```
Chaque tranche vise ~1520 minutes. Le forecast peut être scindé/étendu si une ambiguïté normative ou une difficulté de lifecycle le justifie. `pre.010` n'est pas une deadline.
## 8. Fichiers ajoutés
```text
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.001.md
```
## 9. 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
```
## 10. 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
```
`ROADMAP.md` possède déjà l'entrée globale `0.2.8`. Les documents/runtime Config/Transport ne changent pas avant le gate positif.
## 11. Validations réellement exécutées dans le sandbox de préparation
Avant modification :
```text
inspection archive/version/rel/prompt OK
lecture règles/architecture/plans/validation/code OK
réaudit officiel Helius actuel OK
inspection versions publiques dépendances OK
python3 scripts/audit_rust_workspace_rules.py OK
```
Après génération de l'overlay, l'audit Python a été réexécuté :
```text
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
```
## 12. Validations impossibles dans le sandbox
```text
cargo fmt --all
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
```
Cause : binaire `cargo` absent.
## 13. Validation opérateur requise avant commit
Appliquer l'overlay puis exécuter :
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
```
`cargo test --workspace` a déjà été fourni vert sur la base `v0.2.7`; `pre.001` ne change aucun code/runtime, mais il peut être rejoué si l'opérateur souhaite un checkpoint complet de la nouvelle version Cargo.
Le gate `pre.001` ne devient entièrement positif qu'après revue des deux graphes Cargo. Aucun `pre.002` runtime ne doit commencer avant cela.

View File

@@ -0,0 +1,250 @@
<!-- file: deltas/0.2.8/pre.002-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.002-fix.001` — Clippy et normalisation documentaire
## 1. Objet
Ce correctif ferme le défaut Clippy observé après application de `0.2.8-pre.002` et audite l'organisation des documents actifs `0.2.8` afin d'éviter les sections désordonnées ou les fichiers fourre-tout.
La livraison `pre.002.md` reste immutable ; ce fix porte uniquement les corrections nouvelles.
Version workspace :
```text
0.2.8-pre.2.fix.1
```
Livraison / commit attendu :
```text
0.2.8-pre.002-fix.001
v0.2.8-pre.002-fix.001
```
Aucun tag prerelease.
## 2. Clippy : défaut reproduit par l'opérateur
Le checkpoint opérateur de `pre.002` a donné :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace OK
cargo clippy --workspace --all-targets FAIL
cargo test -p ksp-onchain-transport-lib OK
cargo test --workspace OK
```
Le seul blocage est `clippy::implicit_return` dans le nouveau fichier :
```text
crates/ksp-onchain-transport-lib/unit_tests/ws_protocol_session.rs
```
Cinq diagnostics sont concernés :
```text
1 retour explicite manquant après la boucle `while let` de `accept_until_close`
2 predicates `find` sans `return` explicite
2 closures `map` sans `return` explicite
```
Le runtime WebSocket, les façades protocolaires et les tests fonctionnels ne sont pas en échec : le ciblé Transport et le workspace complet passent avant ce fix.
## 3. Correction Rust
Le fix ajoute uniquement les retours explicites exigés par la politique Clippy KSP dans la fixture de test.
Aucun changement n'est apporté à :
```text
WsSession
SolanaStandardWsSession
HeliusLaserStreamWsSession
WsProtocolKind
wire WebSocket
reconnect/backpressure/shutdown
Config
transactionSubscribe
heartbeat
```
Comme le correctif modifie du code de test consommé par le build, Cargo passe conformément à `VERSION_WORKFLOW.md` de :
```text
0.2.8-pre.2
```
à :
```text
0.2.8-pre.2.fix.1
```
## 4. Audit structurel des documents
Références appliquées :
```text
docs/rules/RULES_DOCUMENTATION.md
docs/rules/FILE_CONTRACTS.md
docs/plans/000-README.md
docs/validation/000-README.md
```
Constats :
```text
docs/plans : 000-README puis 001..015, ordre cohérent, aucune collision
docs/validation : 000-README puis 001..011, ordre cohérent, aucune collision
plan 015 : une seule responsabilité, planifier/auditer la release 0.2.8
validation 011 : une seule responsabilité, conserver critères/matrices/preuves 0.2.8
deltas/0.2.8 : journal de livraison séparé, aucun second changelog dans docs/
```
Aucun nouveau répertoire ou fichier documentaire durable n'est nécessaire. Les plans historiques `0.2.5` à `0.2.7` sont de taille comparable ou supérieure ; la taille du plan `015` ne justifie donc pas à elle seule un split.
Deux défauts d'organisation internes sont toutefois corrigés :
1. le plan présentait les décisions de façade dans une première section puis des décisions d'architecture détaillées beaucoup plus loin ; elles sont regroupées dans une seule section `Architecture et frontières de protocole` ;
2. la validation répétait le forecast détaillé alors que `FILE_CONTRACTS.md` attribue cette responsabilité au plan ; cette duplication est supprimée.
L'ordre du plan actif devient :
```text
état courant
forecast souple
sources / baseline
héritage v0.2.7
audit Helius
matrice normative
architecture et frontières
threat model
dépendances
stratégie de validation / smoke
questions reportées
critères de split / clôture
checkpoint courant
```
La validation reste organisée autour de :
```text
références
gate pre.001
matrices provider/façades
contrat transaction à valider
checklists lifecycle/non-régression/sécurité
smoke
preuves pre.002/fix
contrôle structurel docs
```
## 5. État de validation `pre.002` reporté correctement
Les preuves opérateur reçues sont intégrées à `docs/validation/011-*` :
```text
Transport unit tests 313 passed
Transport public API tests 37 passed
release completeness 25 passed
doctor tests compile_fail 2 passed
cargo test -p Transport OK
cargo test --workspace OK
```
Le seul gate restant à revalider après application du fix est la chaîne complète, en particulier Clippy.
## 6. Fichiers de la livraison
Nouveau :
```text
deltas/0.2.8/pre.002-fix.001.md
```
Modifiés :
```text
Cargo.toml
crates/ksp-onchain-transport-lib/unit_tests/ws_protocol_session.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
```
Aucun autre fichier n'est nécessaire.
## 7. Validations exécutées dans le sandbox de préparation
Exécuté après le fix :
```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
```
Audit structurel local des documents :
```text
DOC STRUCTURE AUDIT: clean
docs/plans = 000..015 ordonné
docs/validation = 000..011 ordonné
H2/H3 plan actif = cohérent
H2/H3 validation = cohérent
```
Le sandbox ne fournit pas Cargo/rustfmt ; aucune commande Cargo n'est donc déclarée réussie pour le fix lui-même.
## 8. Gate opérateur
Après application :
```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
```
Attendu :
```text
aucun `clippy::implicit_return`
aucun warning Rust
313+ tests Transport hérités/pré.002 verts
37+ public API verts
25+ release completeness verts
2 doctests compile_fail verts
workspace complet vert
```
Si ce checkpoint est vert, `pre.002` + `pre.002-fix.001` sont `DONE` et `pre.003` peut commencer.
## 9. Suite
`pre.003` reste inchangé : ajouter uniquement sur `HeliusLaserStreamWsSession` les six familles standard Helius supportées :
```text
account
logs
program
root
signature
slot
```
avec réutilisation du wire standard et absence durable de :
```text
block
slotsUpdates
vote
```
Config Helius, `transactionSubscribe` et heartbeat restent hors `pre.003` conformément au forecast.

278
deltas/0.2.8/pre.002.md Normal file
View File

@@ -0,0 +1,278 @@
<!-- file: deltas/0.2.8/pre.002.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.002` — Socle protocolaire et façades WebSocket
## 1. Objet
Cette tranche matérialise le socle protocolaire décidé par `pre.001-fix.001`/`fix.002` sans commencer Config Helius, `transactionSubscribe` ni le heartbeat provider.
Version workspace :
```text
0.2.8-pre.2
```
Livraison :
```text
0.2.8-pre.002
```
Commit attendu :
```text
v0.2.8-pre.002
```
Aucun tag prerelease.
## 2. Protocol kind WebSocket
`WsProtocolKind` contient désormais :
```text
SolanaStandard -> solana_standard
HeliusLaserStream -> helius_laserstream
```
Le nom `HeliusLaserStream` appartient explicitement au namespace WebSocket. Cette tranche n'ajoute aucun type, discriminateur ou backend LaserStream gRPC.
## 3. Deux façades, un seul moteur physique
Nouvelles façades publiques :
```text
SolanaStandardWsSession
HeliusLaserStreamWsSession
```
Elles contiennent un `WsSession` privé et délèguent toutes les opérations physiques au moteur acquis en `0.2.7`.
Le chemin partagé est :
```text
facade::connect
-> WsSession::connect_for_protocol # crate-internal guard
-> WsSession::connect_physical # unique physical constructor
-> tokio::spawn(run_ws_session_actor)
```
Aucun second :
```text
socket type
actor
WsSessionCommand
pending registry
subscription registry
reconnect loop
backpressure path
shutdown path
snapshot model
```
n'est créé.
## 4. Compatibilité `WsSession` historique
`WsSession::connect` reste public pour les consommateurs `0.2.7`, mais devient explicitement :
```text
standard-only
```
Un endpoint `WsProtocolKind::HeliusLaserStream` présenté à ce constructeur est rejeté avant toute I/O avec :
```text
ERROR_CODE_INVALID_SETTINGS
field = ws_endpoints.protocol
expected_protocol = solana_standard
actual_protocol = helius_laserstream
```
Ces contextes sont des descriptors sûrs ; aucune URL ou credential n'est copiée.
La façade Helius n'expose ni `inner()` ni `into_inner()` et ne permet donc pas de récupérer un `WsSession` générique afin de contourner sa surface provider-specific.
## 5. Surface standard dans la nouvelle façade
`SolanaStandardWsSession` délègue immédiatement les neuf wrappers standard acquis, sans dupliquer leur wire ou leurs decoders :
```text
account_subscribe
block_subscribe
logs_subscribe
program_subscribe
root_subscribe
signature_subscribe
slot_subscribe
slots_updates_subscribe
vote_subscribe
```
Les méthodes historiques correspondantes restent aussi disponibles sur `WsSession` pour compatibilité.
## 6. Surface Helius volontairement minimale dans `pre.002`
`HeliusLaserStreamWsSession` expose uniquement :
```text
connect
id
snapshot
state
close
```
Elle n'expose encore aucune subscription. Cela garde la tranche sur le socle et réserve à `pre.003` l'ajout contrôlé des six familles Helius documentées comme compatibles avec le wire standard :
```text
account
logs
program
root
signature
slot
```
`block`, `slotsUpdates` et `vote` restent absents. Des rustdocs `compile_fail` verrouillent dès cette tranche l'absence de `block_subscribe` et de `into_inner`.
## 7. Tests et canaries ajoutés
Unitaires Transport :
```text
WsProtocolKind expose deux descriptors distincts
les deux façades ouvrent et ferment un WebSocket contre un peer local
snapshot de chaque façade conserve le protocol kind attendu
WsSession::connect rejette Helius avant I/O
chaque façade rejette le mauvais protocol kind avant I/O
Debug Helius ne projette pas une api-key canary présente dans l'URL
module de façade sans second tokio::spawn / tokio_tungstenite / WsSessionCommand
module de façade sans getter inner/into_inner public
```
Public API :
```text
WsProtocolKind::HeliusLaserStream visible au crate-root
SolanaStandardWsSession visible au crate-root
HeliusLaserStreamWsSession visible au crate-root
9 wrappers standard accessibles via SolanaStandardWsSession
release-completeness conserve les 9 kinds standard et les deux descripteurs protocolaires
WsSession historique toujours visible
```
Les tests `compile_fail` de la rustdoc couvrent :
```text
HeliusLaserStreamWsSession::block_subscribe absent
HeliusLaserStreamWsSession::into_inner absent
```
## 8. Hors périmètre préservé
Cette tranche ne modifie pas :
```text
ksp-config-lib
config/std.transport.json
config/schemas/std.transport.schema.json
.env.example
WsSubscriptionKind
transactionSubscribe / transactionUnsubscribe
notification transaction Helius
heartbeat/idle timer
HTTP 52/14
Store / Program / Wallet
```
Aucune dépendance Rust n'est ajoutée.
## 9. Fichiers de la livraison
Nouveaux :
```text
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_protocol_session.rs
deltas/0.2.8/pre.002.md
```
Modifiés :
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/src/ws_settings.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
```
## 10. Validation exécutée dans le sandbox de préparation
Exécuté après modification :
```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 de préparation ne fournit pas `cargo`/`rustfmt`. Les commandes compilées ne sont donc pas déclarées réussies ici.
## 11. Gates opérateur avant commit
Après application de l'overlay :
```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
```
Puis, si le ciblé est vert :
```bash
cargo test --workspace
```
Points à surveiller spécifiquement dans la sortie :
```text
les deux doctests compile_fail doivent réussir
aucun warning missing_docs/unreachable_pub
aucune régression des 309+ tests Transport hérités
aucune régression Config causée par l'ajout de la variante non_exhaustive
```
## 12. Suite
`0.2.8-pre.003` doit ajouter uniquement sur `HeliusLaserStreamWsSession` :
```text
account_subscribe
logs_subscribe
program_subscribe
root_subscribe
signature_subscribe
slot_subscribe
```
avec réutilisation exacte des DTOs/wire standard, puis prouver durablement que :
```text
block_subscribe absent
slots_updates_subscribe absent
vote_subscribe absent
```
La tranche `pre.003` ne doit toujours pas commencer Config Helius ni `transactionSubscribe`.

259
deltas/0.2.8/pre.003.md Normal file
View File

@@ -0,0 +1,259 @@
<!-- file: deltas/0.2.8/pre.003.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.003` — Surface standard Helius WebSocket
## 1. Objet
Cette tranche expose sur `HeliusLaserStreamWsSession` uniquement les six familles WebSocket standard que l'audit Helius courant classe comme supportées, sans ajouter de wire provider parallèle et sans ouvrir encore Config Helius, `transactionSubscribe` ou le heartbeat.
Le checkpoint opérateur de `pre.002-fix.001` est intégralement vert ; aucune dette de gate n'est reportée dans cette tranche.
Version workspace :
```text
0.2.8-pre.3
```
Livraison / commit attendu :
```text
0.2.8-pre.003
v0.2.8-pre.003
```
Aucun tag prerelease.
## 2. Surface Helius ajoutée
`HeliusLaserStreamWsSession` expose désormais :
```text
account_subscribe
program_subscribe
logs_subscribe
signature_subscribe
slot_subscribe
root_subscribe
```
Chaque wrapper délègue au `WsSession` physique partagé et réutilise exactement les DTOs, encoders, decoders, `WsSubscriptionKind` et méthodes d'unsubscribe standard acquis en `0.2.7`.
Aucun type suivant n'est créé :
```text
HeliusAccountSubscribeConfig
HeliusAccountNotification
HeliusProgramSubscribeConfig
HeliusProgramNotification
HeliusLogsSubscribeFilter
HeliusLogsNotification
HeliusSignatureSubscribeConfig
HeliusSignatureNotification
HeliusSlotNotification
HeliusRootNotification
```
L'absence de ces copies est volontaire : aucun écart de wire Helius courant ne les justifie.
## 3. Surface Helius explicitement absente
La façade Helius continue de ne pas exposer :
```text
block_subscribe
slots_updates_subscribe
vote_subscribe
transaction_subscribe
```
Les trois familles standard non supportées sont verrouillées par des rustdocs `compile_fail` :
```text
blockSubscribe
slotsUpdatesSubscribe
voteSubscribe
```
`transactionSubscribe` reste réservé à la tranche provider-specific dédiée du forecast.
## 4. Organisation du code
`ws_protocol_session.rs` reste limité au lifecycle des façades et à l'accès crate-private au moteur partagé. Les wrappers sont rangés auprès du propriétaire de leur wire :
```text
ws_accounts.rs
SolanaStandardWsSession : account / program
HeliusLaserStreamWsSession : account / program
ws_transactions.rs
SolanaStandardWsSession : logs / signature
HeliusLaserStreamWsSession : logs / signature
ws_cluster.rs
SolanaStandardWsSession : root / slot / slotsUpdates / vote
HeliusLaserStreamWsSession : root / slot
ws_blocks.rs
SolanaStandardWsSession : block uniquement
ws_protocol_session.rs
connect / id / snapshot / state / close
physical_session() crate-private
```
Cette répartition évite de transformer le module de façade en fichier fourre-tout tout en conservant un seul actor/socket/registry.
Aucun accès public `inner()` / `into_inner()` / `physical_session()` n'est ajouté.
## 5. Fixture Helius standard
Une fixture locale dédiée couvre les six familles contre un peer WebSocket local Helius-typed.
Elle vérifie pour chaque paire :
```text
nom subscribe exact
params exacts
remote subscription id
nom unsubscribe exact
params [remote_id]
result true
```
Couverture :
```text
accountSubscribe / accountUnsubscribe
programSubscribe / programUnsubscribe
logsSubscribe / logsUnsubscribe
signatureSubscribe / signatureUnsubscribe
slotSubscribe / slotUnsubscribe
rootSubscribe / rootUnsubscribe
```
Les tests standard historiques restent propriétaires du décodage exact des notifications ; la nouvelle fixture prouve que la façade Helius traverse le même wire plutôt que de créer une seconde pile de décodage.
## 6. Canaries publiques et release completeness
Ajouts :
```text
public API : les six wrappers Helius sont accessibles depuis le crate-root
public API : les types de paramètres/résultats restent les types Solana partagés
release completeness : surface Helius courante = six familles standard, avant extension transaction
source canary : ws_protocol_session.rs ne redevient pas propriétaire des wrappers métier
```
Les neuf familles de `SolanaStandardWsSession` et les 18 opérations standard acquises restent inchangées.
## 7. Hors périmètre préservé
Cette tranche ne modifie pas :
```text
ksp-config-lib
config/std.transport.json
config/schemas/std.transport.schema.json
.env.example
WsSubscriptionKind
transactionSubscribe / transactionUnsubscribe
notification transaction Helius
heartbeat/idle timer
HTTP 52/14
Store / Program / Wallet
```
Aucune dépendance Rust n'est ajoutée.
## 8. Preuve opérateur héritée avant ouverture
Le checkpoint fourni après `pre.002-fix.001` est :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-onchain-transport-lib OK
cargo test --workspace OK
```
`pre.002` + `pre.002-fix.001` sont donc considérés `DONE` avant ce delta.
## 9. Fichiers de la livraison
Nouveau :
```text
crates/ksp-onchain-transport-lib/unit_tests/ws_helius_standard.rs
deltas/0.2.8/pre.003.md
```
Modifiés :
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
crates/ksp-onchain-transport-lib/src/ws_accounts.rs
crates/ksp-onchain-transport-lib/src/ws_blocks.rs
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_protocol_session.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
```
## 10. Validation exécutée dans le sandbox de préparation
Exécuté après modification :
```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 pas Cargo/rustfmt ; les nouvelles fixtures et doctests ne sont donc pas déclarés compilés avant le checkpoint opérateur.
## 11. Gate opérateur
Après application :
```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
```
Attendu :
```text
nouvelle fixture Helius six familles verte
4 doctests compile_fail de HeliusLaserStreamWsSession verts
public API pre.003 verte
release completeness pre.003 verte
aucun warning/clippy
standard WebSocket 9/9 non régressé
workspace complet vert
```
## 12. Suite
Si le checkpoint est vert, `pre.004` ouvre uniquement :
```text
Config V2 kind = helius_laserstream
schema / fixtures
mapping Config -> Transport
secret Helius dans URL résolue par Config
redaction api-key
```
`transactionSubscribe` reste hors `pre.004` conformément au forecast courant.

View File

@@ -0,0 +1,182 @@
<!-- file: deltas/0.2.8/pre.004-fix.001.md -->
<!-- version: 2 -->
# Delta `0.2.8-pre.004-fix.001` — redaction Helius Config + couverture Devnet
## 1. Objet
Ce fix corrige deux défauts circonscrits de `0.2.8-pre.004` :
```text
1. faux négatif du nouveau canari de redaction `safe_value` Helius ;
2. représentation déterministe incomplète : mainnet était matérialisé, Devnet ne l'était pas encore.
```
Le résolveur Config, le mapping Config -> Transport et le moteur WebSocket restent inchangés.
Version workspace :
```text
0.2.8-pre.4.fix.1
```
Livraison / commit attendu :
```text
0.2.8-pre.004-fix.001
v0.2.8-pre.004-fix.001
```
Aucun tag prerelease.
## 2. Preuve opérateur ayant déclenché le fix
Le checkpoint `pre.004` reçu le 2026-08-23 donne :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-config-lib FAIL 109/110
cargo test -p ksp-onchain-transport-lib OK
cargo test --workspace FAIL sur le même test Config
```
L'échec exact est :
```text
observé = wss://mainnet.helius-rpc.com/?api-key=********
attendu = ********
```
Le test avait déjà prouvé avant cette assertion que le schema accepte `helius_laserstream`, que l'adapter produit `WsProtocolKind::HeliusLaserStream` et que l'URL runtime contient la clé Helius résolue.
## 3. Redaction Config confirmée
Le contrat historique Config est segmentaire pour une chaîne composée :
```text
source composée safe_value
https://rpc.example/?token=${SECRET} -> https://rpc.example/?token=********
token=${SECRET} -> token=********
```
La projection sûre conserve donc les littéraux non sensibles et masque uniquement le segment secret.
Pour Helius :
```text
mainnet runtime = wss://mainnet.helius-rpc.com/?api-key=<clé réelle>
mainnet safe_value = wss://mainnet.helius-rpc.com/?api-key=********
devnet runtime = wss://devnet.helius-rpc.com/?api-key=<clé réelle>
devnet safe_value = wss://devnet.helius-rpc.com/?api-key=********
```
La clé réelle doit rester absente de `safe_value` et de toutes les représentations `Debug`.
## 4. Couverture Helius Devnet ajoutée
La documentation Helius WebSocket actuelle expose un endpoint unifié par réseau :
```text
mainnet wss://mainnet.helius-rpc.com/?api-key=<api-key>
devnet wss://devnet.helius-rpc.com/?api-key=<api-key>
```
`pre.004` avait documenté les deux réseaux dans le plan mais n'avait matérialisé que mainnet dans l'exemple et la fixture Config. Le fix complète ce manque.
Décision de structure :
```text
mainnet et devnet vivent dans des profils Config distincts ;
un même profil logique ne mélange pas les deux clusters ;
la même variable KSP_SECRET_HELIUS_API_KEY peut alimenter les deux URLs ;
config/std.transport.json reste standard-only.
```
L'exemple versionné ajoute un profil `devnet_helius`. La fixture Config ajoute un profil `helius_devnet` et le test charge explicitement ce profil en plus du profil mainnet par défaut.
## 5. Correction du test
Le canari devient :
```text
helius_laserstream_mainnet_and_devnet_api_key_map_to_protocol_and_safe_redaction
```
Il vérifie pour **mainnet et devnet** :
```text
provider = helius
cluster exact
protocol = WsProtocolKind::HeliusLaserStream
URL runtime exacte avec la clé résolue
safe_value exact avec segment ********
provenance = KSP_SECRET_HELIUS_API_KEY / Process
Debug sans la clé réelle
composition avec HeliusLaserStreamWsSession au moins sur le profil Devnet dédié
```
## 6. Invariants non modifiés
Aucune modification n'est apportée à :
```text
ConfigEnvironment / moteur de résolution
sensitivity / provenance semantics
JSON Schema Transport V2
mapping helius_laserstream -> HeliusLaserStream
WsEndpointUrl
WsSession / façades / actor
config/std.transport.json canonique
transactionSubscribe
heartbeat
new dependency
```
`.env.example` conserve une seule variable `KSP_SECRET_HELIUS_API_KEY`, suffisante pour les deux endpoints Helius.
## 7. Fichiers de la livraison
Nouveau :
```text
deltas/0.2.8/pre.004-fix.001.md
```
Modifiés :
```text
Cargo.toml
config/examples/std.transport.example.json
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
crates/ksp-config-lib/unit_tests/transport.rs
crates/ksp-config-lib/USAGE.md
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
```
Inchangés volontairement :
```text
config/std.transport.json
config/schemas/std.transport.schema.json
.env.example
Transport WebSocket runtime
```
## 8. Validation à rejouer
```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
```
`pre.004` + `fix.001` restent non fermés jusqu'à réception de ce checkpoint intégralement vert.

View File

@@ -0,0 +1,139 @@
<!-- file: deltas/0.2.8/pre.004-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.004-fix.002` — provenance Helius Config composée
## 1. Objet
Ce second fix de `pre.004` corrige uniquement une hypothèse erronée du canari Config Helius introduit par `pre.004-fix.001`.
Le checkpoint opérateur prouve que :
```text
fmt / audit / check / clippy verts
Transport vert
Config 109/110
échec provenance.len() observé = 2, attendu = 1
```
Le runtime Config, le schema, les profils Helius mainnet/devnet et le mapping Config -> Transport sont corrects et ne sont pas modifiés.
Version workspace :
```text
0.2.8-pre.4.fix.2
```
Livraison / commit attendu :
```text
0.2.8-pre.004-fix.002
v0.2.8-pre.004-fix.002
```
Aucun tag prerelease.
## 2. Cause exacte
L'URL Helius est une chaîne composée :
```text
wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-...}
wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-...}
```
Le modèle de provenance Config distingue les segments littéraux et les substitutions d'environnement.
Avec une valeur fournie par le process, la provenance correcte est donc :
```text
0 = ConfigValueProvenance::DocumentLiteral
1 = ConfigValueProvenance::EnvironmentProcess {
variable_name = KSP_SECRET_HELIUS_API_KEY
}
```
Le test de `fix.001` attendait à tort un seul segment, comme pour une valeur constituée uniquement d'un placeholder.
## 3. Correction
Le canari :
```text
helius_laserstream_mainnet_and_devnet_api_key_map_to_protocol_and_safe_redaction
```
attend désormais, pour mainnet et devnet :
```text
provenance.len() = 2
provenance[0] = DocumentLiteral
provenance[1].environment_source() = Process
provenance[1].variable_name() = KSP_SECRET_HELIUS_API_KEY
```
Les assertions déjà présentes restent inchangées :
```text
provider = helius
cluster = mainnet-beta / devnet
protocol = WsProtocolKind::HeliusLaserStream
runtime URL = vraie clé résolue
safe_value = URL avec api-key=********
Debug = aucune clé réelle
```
## 4. Invariants non modifiés
Aucune modification n'est apportée à :
```text
ConfigEnvironment
ResolvedConfigJson / provenance implementation
sensitivity / redaction
config/schemas/std.transport.schema.json
config/std.transport.json
config/examples/std.transport.example.json
fixture std.transport.json
mapping helius_laserstream -> HeliusLaserStream
profils Helius mainnet/devnet
KSP_SECRET_HELIUS_API_KEY
WsEndpointUrl
WsSession / façades / actor
transactionSubscribe
heartbeat
dependencies
```
`pre.004-fix.001.md` reste immutable.
## 5. Fichiers de la livraison
Nouveau :
```text
deltas/0.2.8/pre.004-fix.002.md
```
Modifiés :
```text
Cargo.toml
crates/ksp-config-lib/unit_tests/transport.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
```
## 6. Validation à rejouer
```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
```
`pre.005` reste fermé jusqu'à réception de ce checkpoint intégralement vert.

174
deltas/0.2.8/pre.004.md Normal file
View File

@@ -0,0 +1,174 @@
<!-- file: deltas/0.2.8/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.004` — Config V2 Helius LaserStream WebSocket
## 1. Objet
Cette tranche ouvre Config V2 au protocole WebSocket `helius_laserstream`, matérialise son mapping vers `WsProtocolKind::HeliusLaserStream` et prouve la résolution/redaction de la clé Helius sans modifier le moteur WebSocket ni commencer `transactionSubscribe`.
Le checkpoint opérateur de `pre.003` est intégralement vert.
Version workspace :
```text
0.2.8-pre.4
```
Livraison / commit attendu :
```text
0.2.8-pre.004
v0.2.8-pre.004
```
Aucun tag prerelease.
## 2. Discriminateur Config WebSocket
Le JSON Schema V2 accepte désormais exactement :
```text
solana_standard
helius_laserstream
```
L'adapter `ksp-config-lib` mappe :
```text
solana_standard -> WsProtocolKind::SolanaStandard
helius_laserstream -> WsProtocolKind::HeliusLaserStream
```
Le discriminateur reste possédé par `profiles[].ws_endpoints[].kind`; aucun alias ou contrat gRPC n'est introduit.
## 3. Fixture et exemple Helius
La fixture `ksp-config-lib` ajoute un second endpoint WebSocket :
```text
name = fixture_helius_ws
provider = helius
cluster = mainnet-beta
kind = helius_laserstream
url = wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}
```
`config/examples/std.transport.example.json` remplace l'ancien second endpoint WebSocket provider générique par un exemple Helius explicite :
```text
name = mainnet_helius_ws
provider = helius
kind = helius_laserstream
url = wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}
```
Le document canonique `config/std.transport.json` reste volontairement inchangé et standard-only : la configuration par défaut ne doit pas embarquer un endpoint provider nécessitant une credential factice.
## 4. Secret Helius et redaction
`.env.example` inventorie :
```text
KSP_SECRET_HELIUS_API_KEY
```
sous forme commentée avec placeholder non secret.
Un nouveau test Config injecte une clé canari et prouve :
```text
l'URL runtime contient la clé résolue
le protocol runtime vaut HeliusLaserStream
la projection safe_value redige /ws_endpoints/1/url
ResolvedTransportConfig Debug ne contient pas la clé
```
Config reste l'unique propriétaire de l'environnement. Transport ne lit pas `std::env`.
## 5. Composition Config -> Transport
La fixture principale vérifie maintenant deux endpoints WebSocket :
```text
fixture_private_ws -> SolanaStandard
fixture_helius_ws -> HeliusLaserStream
```
Le second endpoint est également passé au constructeur public `HeliusLaserStreamWsSession::connect` sans polling afin de verrouiller la compatibilité de types Config -> Transport. Les guards runtime du protocole restent propriétaires de Transport.
## 6. Documentation ciblée
`ksp-config-lib/USAGE.md` documente les deux valeurs `kind`, le namespace WebSocket du discriminateur Helius et l'ownership/redaction de `KSP_SECRET_HELIUS_API_KEY`.
Le plan reste propriétaire du forecast ; la validation reste propriétaire des critères et preuves. Aucun nouveau document/fichier fourre-tout n'est créé.
## 7. Hors périmètre préservé
Cette tranche ne modifie pas :
```text
config/std.transport.json
WsSession / actor / reconnect / queues
les six wrappers Helius standard acquis en pre.003
WsSubscriptionKind
transactionSubscribe / transactionUnsubscribe
transactionNotification
heartbeat provider
HTTP 52/14
Wallet / Store / Program
```
Aucune dépendance Rust n'est ajoutée.
## 8. Preuve opérateur héritée
Le checkpoint `pre.003` fourni le 2026-08-23 est :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-onchain-transport-lib OK
cargo test --workspace OK
```
Les preuves spécifiques Helius standard sont également vertes : 314 tests unitaires Transport, 38 public API, 26 release-completeness et 4 doctests compile-fail.
## 9. Fichiers de la livraison
Nouveau :
```text
deltas/0.2.8/pre.004.md
```
Modifiés :
```text
Cargo.toml
.env.example
config/examples/std.transport.example.json
config/schemas/std.transport.schema.json
crates/ksp-config-lib/src/transport.rs
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
crates/ksp-config-lib/unit_tests/transport.rs
crates/ksp-config-lib/USAGE.md
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
```
## 10. Validation à exécuter
```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
```
`pre.004` reste `PREPARED` jusqu'à réception de ce checkpoint.

View File

@@ -0,0 +1,95 @@
<!-- file: deltas/0.2.8/pre.005-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.005-fix.001` — canaris JSON, visibilité test/private et audit des chemins
## 1. Cause
Le checkpoint opérateur de `pre.005` compile le workspace normal mais échoue dès la compilation des tests Transport. Quatre assertions comparent un `Vec<serde_json::Value>` produit par les encodeurs de params à un `serde_json::Value` construit par `serde_json::json!([...])`, ce qui produit `E0277`.
Le même checkpoint révèle cinq warnings `unused import` au crate-root : cinq helpers Helius avaient été rendus `pub(crate)` et réexportés uniquement pour être appelés par les tests. Cette visibilité est contraire aux règles KSP : une visibilité n'est pas élargie pour les tests.
## 2. Correction des canaris
Les attentes de params utilisent maintenant des `Vec<Value>` explicites :
```text
transactionSubscribe complet
omission complète
états []/none explicites
transactionUnsubscribe [remote_id]
```
Le wire attendu ne change pas.
## 3. Correction de visibilité
Les helpers suivants redeviennent strictement privés au module `ws_helius_transactions` :
```text
helius_transaction_subscribe_params
helius_transaction_subscribe_method
helius_transaction_unsubscribe_method
decode_helius_transaction_subscribe_result
helius_transaction_unsubscribe_params
decode_helius_transaction_unsubscribe_result
```
Les cinq réexports `pub(crate)` de `lib.rs` sont supprimés. Les tests séparés y accèdent via `super::...`. Aucun `#[allow(dead_code)]` n'est conservé pour masquer une visibilité prématurée.
Les types réellement publics de `pre.005` restent réexportés au crate-root et les tests continuent à les consommer via `crate::Item`.
En `pre.006`, si un helper devient réellement partagé entre modules de production, il pourra être promu en `pub(crate)`, réexporté au crate-root et consommé via `crate::Item` conformément aux règles.
## 4. Durcissement des règles et de l'audit
`RULES_RUST.md` explicite désormais :
```text
private parent dans unit_tests -> super::Item obligatoire
pub/pub(crate) -> crate::Item obligatoire, jamais super:: ni nom nu
visibilité -> jamais élargie uniquement pour tester
```
`audit_rust_export_completeness.py` ajoute des canaris mécaniques bidirectionnels pour les fichiers `unit_tests/` rattachés :
```text
RUST-IMPORT-204 private parent appelé sans super::
RUST-IMPORT-205 visible parent appelé sans crate-root
RUST-IMPORT-202 visible parent appelé via super:: (déjà présent)
```
## 5. Version
```text
workspace.package.version = 0.2.8-pre.5.fix.1
commit attendu = v0.2.8-pre.005-fix.001
tag prerelease = aucun
```
## 6. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs
scripts/audit_rust_export_completeness.py
docs/rules/RULES_RUST.md
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.005-fix.001.md
```
## 7. Gate opérateur
```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 fix reste `PREPARED` jusqu'à ce gate.

View File

@@ -0,0 +1,58 @@
<!-- file: deltas/0.2.8/pre.005-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.005-fix.002` — suppression des helpers wire morts hors tests
## 1. Cause
Le checkpoint de `pre.005-fix.001` corrige les quatre erreurs de type et valide les tests Transport, mais `cargo check`, `cargo clippy --workspace --all-targets` et la compilation des tests émettent encore neuf warnings `dead_code` dans `ws_helius_transactions.rs`.
Ces warnings concernent uniquement des helpers strictement privés préparant le wire `transactionSubscribe`/`transactionUnsubscribe` avant son intégration actor de `pre.006`. Ils n'ont volontairement aucun consommateur de production en `pre.005`.
## 2. Correction
`pre.005-fix.002` ne réélargit aucune visibilité et n'ajoute aucun `#[allow(dead_code)]`. Les helpers concernés restent privés et sont compilés uniquement sous `#[cfg(test)]` :
```text
HeliusTransactionSubscribeFilter::to_json_value
HeliusTransactionSubscribeOptions::to_json_value
helius_transaction_subscribe_params
helius_transaction_subscribe_method
helius_transaction_unsubscribe_method
decode_helius_transaction_subscribe_result
helius_transaction_unsubscribe_params
decode_helius_transaction_unsubscribe_result
insert_account_list
```
Le contrat public typed (`HeliusTransactionSubscribe*`) et les validations déterministes restent compilés dans le build de production. Les tests unitaires continuent d'accéder aux helpers privés via `super::Item`; les éléments visibles continuent d'être consommés via `crate::Item`.
## 3. Règle durable
`RUST-API-008` formalise qu'un helper strictement privé uniquement utile à un `unit_tests/` de préparation reste sous `#[cfg(test)]` tant qu'aucun chemin de production ne le consomme réellement. La visibilité ne doit pas être élargie et `#[allow(dead_code)]` ne doit pas servir de compensation.
En `pre.006`, seuls les helpers réellement nécessaires à l'actor seront promus dans le build normal ; si un partage cross-module impose `pub(crate)`, il devra alors passer par le crate-root et être consommé via `crate::Item`.
## 4. Fichiers
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
docs/rules/RULES_RUST.md
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.005-fix.002.md
```
## 5. Gate opérateur
```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
```
Critère supplémentaire de fermeture : `cargo check` et Clippy ne doivent plus émettre les neuf warnings `dead_code` observés après `fix.001`.

277
deltas/0.2.8/pre.005.md Normal file
View File

@@ -0,0 +1,277 @@
<!-- file: deltas/0.2.8/pre.005.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.005` — contrat typed Helius `transactionSubscribe`
## 1. Objet
Cette tranche matérialise le contrat de requête Helius LaserStream WebSocket `transactionSubscribe` : filtres, options, `tokenAccounts`, validations déterministes, acknowledgement numérique et wire `transactionUnsubscribe`.
Elle ne publie volontairement **pas encore** de handle live transaction : `transactionNotification`, le registry actor, les remaps de remote IDs et les races reconnect/unsubscribe doivent arriver atomiquement en `pre.006` afin de ne jamais exposer un abonnement public incapable de livrer correctement ses notifications.
Le checkpoint opérateur de `pre.004-fix.002` est intégralement vert.
Version workspace :
```text
0.2.8-pre.5
```
Livraison / commit attendu :
```text
0.2.8-pre.005
v0.2.8-pre.005
```
Aucun tag prerelease.
## 2. Audit Helius courant verrouillé
La documentation Helius relue le 2026-08-23 confirme pour `transactionSubscribe` :
```text
filter.vote bool optionnel
filter.failed bool optionnel
filter.signature signature exacte optionnelle
filter.accountInclude liste OR, <= 50_000 adresses
filter.accountExclude liste d'exclusion, <= 50_000 adresses
filter.accountRequired liste AND, <= 50_000 adresses
filter.tokenAccounts none | balanceChanged | all
options.commitment processed | confirmed | finalized
options.encoding base58 | base64 | jsonParsed
options.transactionDetails full | signatures | accounts | none
options.showRewards bool optionnel
options.maxSupportedTransactionVersion
requis pour transactionDetails = accounts | full
subscribe result integer subscription id
unsubscribe params [subscriptionId]
unsubscribe result bool
late notifications possibles brièvement après unsubscribe
```
`tokenAccounts = none` est équivalent à l'omission du champ. `balanceChanged` et `all` étendent le matching d'un `accountInclude` wallet aux token accounts qu'il possède selon les règles Helius documentées.
## 3. Contrat public typed
Nouveau module ciblé :
```text
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
```
Il publie depuis le crate-root :
```text
HeliusTokenAccountsFilter
HeliusTransactionSubscribeEncoding
HeliusTransactionSubscribeFilter
HeliusTransactionSubscribeOptions
HeliusTransactionSubscribeRequest
```
Le contrat réutilise les types KSP existants lorsqu'ils sont wire-identiques :
```text
commitment -> SolanaCommitment
transactionDetails -> SolanaTransactionDetails
account filters -> ksp_core_lib::Pubkey
```
Aucun DTO Solana commun n'est recopié sous un nom Helius sans nécessité wire.
## 4. Sémantique des filtres et options
`HeliusTransactionSubscribeFilter` conserve explicitement la différence entre :
```text
champ omis
liste présente mais vide []
liste présente avec valeurs
```
pour `accountInclude`, `accountExclude` et `accountRequired`.
Chaque liste est validée indépendamment avec la limite Helius :
```text
0 ..= 50_000 accepté
50_001 rejeté avant I/O
```
Les erreurs déterministes n'incluent aucune signature ni adresse du filtre ; elles transportent uniquement le nom du champ et les cardinalités sûres.
`HeliusTransactionSubscribeOptions` impose avant I/O :
```text
transactionDetails = full -> maxSupportedTransactionVersion requis
transactionDetails = accounts -> maxSupportedTransactionVersion requis
transactionDetails = signatures -> version optionnelle
transactionDetails = none -> version optionnelle
```
La distinction suivante est préservée sur le wire :
```text
options = None -> params = [filter]
options = Some(default) -> params = [filter, {}]
```
## 5. Wire subscribe/unsubscribe préparé
Les helpers crate-private préparés pour l'intégration actor de `pre.006` verrouillent :
```text
transactionSubscribe
transactionUnsubscribe
subscribe result integer -> u64
unsubscribe params -> [remote_subscription_id]
unsubscribe result -> bool
```
Les décodeurs refusent les formes de réponse d'un type différent au lieu de les coercer.
Ces helpers restent crate-private : aucun raw provider-extension API public n'est introduit.
## 6. Réutilisation du moteur physique
Une fixture locale passe réellement :
```text
HeliusLaserStreamWsSession
-> physical_session() crate-private
-> WsSession::execute_json_rpc
-> actor/socket unique existant
-> transactionSubscribe
-> transactionUnsubscribe
```
Elle vérifie les méthodes, params, acknowledgements et résultat d'unsubscribe exacts contre un peer WebSocket local.
Aucun second :
```text
actor
socket
pending map
reconnect loop
subscription engine
```
n'est ajouté.
## 7. Sécurité et surface différée
`Debug` pour le filtre/requête expose seulement des indicateurs, modes et cardinalités ; il ne rend ni la signature exacte ni les valeurs des comptes filtrés.
`HeliusLaserStreamWsSession` n'expose toujours pas :
```text
pub async fn transaction_subscribe(...)
```
Cette absence est verrouillée par release-completeness. Le handle live arrive en `pre.006` avec :
```text
transactionNotification
registry local/remote
reconnect + resubscribe
unsubscribe races
late notifications
backpressure ciblée
```
Le heartbeat Helius reste réservé à `pre.007`.
## 8. Canaries et non-régressions
Les nouveaux tests couvrent :
```text
strings wire exactes tokenAccounts/encoding
serialization complète filtre/options
omission vs [] explicite
borne 50_000 / rejet 50_001 pour les trois listes
règle conditionnelle maxSupportedTransactionVersion
ack subscribe numérique strict
wire/result unsubscribe strict
Debug sans signature/adresses
round-trip local via actor physique partagé
public API des nouveaux types
absence du live handle avant pre.006
```
Les surfaces acquises restent inchangées :
```text
SolanaStandardWsSession 9 familles standard
HeliusLaserStreamWsSession 6 familles standard supportées
HTTP 52 current + 14 historiques
Config Helius mainnet + devnet, secret/redaction/provenance validés
```
Aucune nouvelle dépendance Rust n'est ajoutée.
## 9. Preuve opérateur héritée
Le checkpoint `pre.004-fix.002` fourni le 2026-08-23 est intégralement vert :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-config-lib OK 110/110 + ownership/public API
cargo test -p ksp-onchain-transport-lib OK 314 unit + 38 public + 26 completeness + 4 doctests
cargo test --workspace OK
```
`pre.004`, `pre.004-fix.001` et `pre.004-fix.002` sont donc `DONE` avant cette tranche.
## 10. Fichiers de la livraison
Nouveaux :
```text
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs
deltas/0.2.8/pre.005.md
```
Modifiés :
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
```
Aucun fichier Config, schema, `.env`, README/USAGE, ROADMAP ou CHANGELOG n'est modifié.
## 11. Validation de préparation et gate opérateur
Validation statique disponible dans le sandbox de préparation :
```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 pas Cargo/rustfmt ; la tranche reste donc `PREPARED` jusqu'au checkpoint opérateur suivant :
```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
```

235
deltas/0.2.8/pre.006.md Normal file
View File

@@ -0,0 +1,235 @@
<!-- file: deltas/0.2.8/pre.006.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.006` — Helius transactionNotification + lifecycle actor
## 1. Base et objet
Base appliquée :
```text
0.2.8-pre.5.fix.2
```
Le checkpoint opérateur de cette base est intégralement vert et sans warning : `cargo fmt`, audit Rust, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport et `cargo test --workspace` passent. Transport compte alors `322` tests unitaires, `39` tests public API, `27` tests release-completeness et `4` doctests compile-fail.
Cette tranche transforme le contrat de requête Helius préparé en `pre.005` en une souscription live complète, sans créer de second moteur WebSocket :
```text
transactionSubscribe
-> registry actor existant
-> WsSubscription<HeliusTransactionNotification>
-> transactionNotification
-> reconnect/resubscribe/remap remote ID
-> transactionUnsubscribe
```
## 2. Version technique
```text
workspace.package.version = 0.2.8-pre.6
commit attendu = v0.2.8-pre.006
Git tag = aucun tag prerelease
```
Le header root `Cargo.toml` passe en version `226`.
## 3. Subscription kind provider
`WsSubscriptionKind` gagne :
```text
HeliusTransaction
```
avec le triplet exact :
```text
as_str helius_transaction
subscribe_method transactionSubscribe
unsubscribe_method transactionUnsubscribe
notification_method transactionNotification
```
Cette extension ne modifie pas la partition standard Solana de neuf familles et reste hors des trois familles standard classées unstable (`Block`, `SlotsUpdates`, `Vote`).
Le generic actor existant reste l'unique propriétaire :
- du socket physique ;
- du pending map JSON-RPC ;
- des local IDs ;
- des remote IDs ;
- du registry de subscriptions ;
- du reconnect/resubscribe ;
- des queues de notifications ;
- du cleanup unsubscribe ;
- du shutdown.
## 4. Handle live Helius
`HeliusLaserStreamWsSession` expose maintenant :
```rust
transaction_subscribe(
&self,
request: &HeliusTransactionSubscribeRequest,
) -> Result<WsSubscription<HeliusTransactionNotification>>
```
La validation déterministe et la sérialisation de `pre.005` restent exécutées avant l'enregistrement actor. Le helper de sérialisation et ses sous-helpers redeviennent du code de production uniquement parce qu'ils ont désormais un consommateur réel ; ils restent strictement privés au module.
Aucune visibilité n'est élargie pour les tests. Les canaris du sous-module accèdent aux helpers privés avec `super::Item`; les contrats publics sont consommés via `crate::Item`.
## 5. Notification typed
Trois formes publiques sont exposées au crate-root.
### 5.1 Full/accounts
`HeliusFullTransactionNotification` conserve :
```text
transaction serde_json::Value
signature String
slot u64
transactionIndex u64
```
Le nested `transaction` reste lossless en JSON, car sa forme dépend de `encoding` et `transactionDetails`; Transport ne décode pas les Programs.
### 5.2 Signatures
`HeliusTransactionSignatureNotification` conserve :
```text
signature String
slot u64
transactionIndex u64
err Omitted | Null | Value(JSON)
memo Omitted | Null | Value(String)
blockTime Omitted | Null | Value(i64)
confirmationStatus Omitted | Null | Value(String)
```
Les champs optionnels réutilisent `SolanaWireField` afin de ne pas confondre omission et `null`.
### 5.3 Union publique
```text
HeliusTransactionNotification::Full(...)
HeliusTransactionNotification::Signature(...)
HeliusTransactionNotification::Unknown(JSON)
```
`Unknown` conserve uniquement le `params.result` provider. L'enveloppe JSON-RPC complète et `params.subscription` ne franchissent pas le boundary public. Cette forme couvre notamment un `transactionDetails=none` ou une évolution provider non encore typée sans tuer arbitrairement la logical subscription.
## 6. Reconnect, unsubscribe tardif et backpressure
Le support Helius s'appuie directement sur les garanties du moteur `0.2.7` :
- les params `transactionSubscribe` originaux sont conservés par le registry ;
- après reconnect, un nouvel ID remote remplace l'ancien ;
- le `WsSubscriptionId` local reste stable ;
- le remote ID n'est jamais public ;
- au début d'un unsubscribe, le mapping remote -> local est retiré avant l'émission de `transactionUnsubscribe` ;
- une notification provider déjà en vol après cancellation est donc ignorée ;
- un overflow de queue échoue seulement la logical subscription lente ;
- le cleanup best-effort utilise automatiquement `transactionUnsubscribe` grâce au nouveau `WsSubscriptionKind`.
Cette sémantique correspond au contrat Helius actuel qui précise que quelques messages en vol peuvent encore arriver brièvement après `transactionUnsubscribe`.
## 7. Canaris ajoutés/actualisés
Les tests Helius transaction couvrent maintenant :
```text
notification Full / Signature / Unknown
live transactionSubscribe exact via façade publique
transactionNotification routée vers WsSubscription
transactionUnsubscribe exact via handle public
reconnect : remote ID 41 -> 99
resubscribe : params identiques
stable local WsSubscriptionId
late transactionNotification après demande unsubscribe ignorée
overflow transaction : handle lent Failed + ERROR_CODE_WS_BACKPRESSURE_OVERFLOW
cleanup overflow : transactionUnsubscribe [remote_id]
Helius root sain reste Active et reçoit encore sa notification
```
Un canari lifecycle verrouille aussi le triplet exact du nouveau `WsSubscriptionKind::HeliusTransaction`.
Les public/release canaries gagnent :
- le symbole public `HeliusLaserStreamWsSession::transaction_subscribe` ;
- les trois types publics de notification ;
- la présence du kind provider ;
- l'absence de second `connect_async`/actor dans le module Helius ;
- la conservation des compile-fail Helius block/slotsUpdates/vote/escape-hatch.
Comptages attendus :
```text
Transport unit 325
Transport public API 40
release completeness 28
doctests compile-fail 4
```
## 8. Documentation
Le plan `015` :
- ferme `pre.005`, `fix.001` et `fix.002` après preuve opérateur sans warning ;
- marque `pre.006` PREPARED ;
- documente l'union notification, le remap remote/local et les nouveaux canaris lifecycle.
La validation `011` :
- enregistre le checkpoint final `pre.005` ;
- ouvre la gate `pre.006` ;
- conserve heartbeat, adversarial élargi et smoke live dans leurs tranches prévues.
## 9. Hors scope
Restent explicitement hors de `pre.006` :
```text
heartbeat / idle timer pre.007
provider adversarial/security élargi pre.008
compliance finale pre.009
smoke Helius live opt-in pre.010
LaserStream gRPC future transport séparé
```
Aucune nouvelle dépendance n'est ajoutée.
## 10. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.006.md
```
## 11. Gate opérateur
```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
```
Critère de fermeture : aucune erreur, aucun warning nouveau, audit Rust clean et tous les nouveaux canaris lifecycle Helius verts.

View File

@@ -0,0 +1,111 @@
<!-- file: deltas/0.2.8/pre.007-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.007-fix.001` — déterminisme canaris heartbeat Tokio
## 1. Base et défaut corrigé
Base appliquée :
```text
0.2.8-pre.7
```
Le checkpoint opérateur confirme que `fmt`, audit Rust et `cargo check --workspace` sont propres. Le gate reste toutefois bloqué par :
```text
cargo clippy --workspace --all-targets
1 erreur clippy::implicit_return dans le helper test yield_runtime_steps
cargo test -p ksp-onchain-transport-lib
329/331 unit passent
2 échecs heartbeat test-only
```
Les autres canaris heartbeat, notamment policy Helius-only, absence sur standard, write failure/reconnect et réarmement après reconnexion, passent déjà. Aucun défaut runtime heartbeat n'est démontré.
## 2. Version technique
```text
workspace.package.version = 0.2.8-pre.7.fix.1
commit attendu = v0.2.8-pre.007-fix.001
Git tag = aucun tag prerelease
```
Le header root `Cargo.toml` passe en version `228`.
## 3. Correction `implicit_return`
Le helper privé test-only :
```rust
async fn yield_runtime_steps()
```
termine désormais par un `return;` explicite après sa boucle de yields. Aucun `allow` Clippy n'est introduit.
## 4. Armement déterministe du timer Tokio
Dans le canari 60 s, `tokio::time::pause()` était suivi immédiatement de `advance(59 s)`. Rien ne garantissait alors que la tâche actor ait déjà été pollée et ait enregistré son `sleep_until(heartbeat_deadline)` sous l'horloge pausée.
Le fix insère un passage de stabilisation par `yield_runtime_steps().await` immédiatement après `pause()` et avant toute avance. Le canari vérifie ensuite toujours la vraie cadence runtime :
```text
t=59 s aucun Ping
t=60 s un Ping
t=119 s aucun second Ping
t=120 s second Ping
```
Aucune cadence spéciale de test n'est créée.
## 5. Sémantique du canari close
Le canari close est renforcé en deux étapes :
```text
t=30 s avant close -> observation channel obligatoirement Empty
après close -> aucun Ping `Ok(())` accepté
```
Après réception de la frame Close, le serveur fixture termine et détruit naturellement son sender. Le receiver peut donc rendre `Disconnected`, ce qui prouve toujours qu'aucun heartbeat n'a été émis après fermeture. Exiger uniquement `Empty` après close était une hypothèse incorrecte du test.
## 6. Runtime explicitement inchangé
Ce fix ne modifie pas :
```text
crates/ksp-onchain-transport-lib/src/ws_session.rs
HELIUS_WS_HEARTBEAT_INTERVAL = 60 s
WebSocket Ping control frame
WsProtocolKind
WsSessionSettings
Config / schema / env
transactionSubscribe / transactionNotification lifecycle
reconnect / remap / backpressure
```
Il ne rajoute aucune dépendance ni feature.
## 7. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.007-fix.001.md
```
## 8. Gate opérateur
```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
```
Critère de fermeture : zéro warning, audit clean, **331 unit / 40 public API / 29 completeness / 4 doctests** et workspace vert. `pre.008` reste bloqué jusque-là.

View File

@@ -0,0 +1,132 @@
<!-- file: deltas/0.2.8/pre.007-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.007-fix.002` — observation scheduler bornée du heartbeat
## 1. Base et défaut restant
Base appliquée :
```text
0.2.8-pre.7.fix.1
```
Le replay opérateur du `fix.001` ferme le lint Clippy et le canari close : `fmt`, audit Rust, `cargo check` et `cargo clippy` sont verts. Transport atteint **330/331 unit** ; un seul canari reste en échec :
```text
helius_heartbeat_sends_ping_at_sixty_seconds_and_rearms
```
Le reste des preuves heartbeat passe, notamment Helius-only, intervalle 60 s, absence standard, write failure/reconnect, close avant deadline et réarmement après reconnexion. Aucun défaut runtime supplémentaire n'est démontré.
## 2. Version technique
```text
workspace.package.version = 0.2.8-pre.7.fix.2
commit attendu = v0.2.8-pre.007-fix.002
Git tag = aucun tag prerelease
```
Le header root `Cargo.toml` passe en version `229`.
## 3. Cause exacte
Après :
```rust
tokio::time::advance(std::time::Duration::from_secs(1)).await;
```
le deadline heartbeat est bien expiré, mais l'observation du Ping nécessite encore plusieurs étapes asynchrones :
```text
actor WsSession
-> websocket.send(Ping)
-> socket local
-> serveur fixture websocket.next()
-> ping_tx.send(())
-> ping_rx.try_recv()
```
Un `try_recv()` après un nombre fixe faible de `yield_now()` reste une course scheduler. Le défaut est donc dans le protocole d'observation du test, pas dans la cadence runtime.
## 4. Correction déterministe
Le fichier de tests ajoute un helper privé local :
```rust
async fn wait_for_observed_ping(...)
```
Il effectue au maximum 256 tours :
```text
try_recv() == Ok(()) -> succès immédiat
try_recv() == Empty -> yield_now().await puis nouvelle tentative
try_recv() == Disconnected -> échec immédiat
```
Après épuisement de la borne, le test échoue explicitement si aucun Ping n'est observé.
Ce helper n'appelle volontairement :
```text
ni tokio::time::advance()
ni tokio::time::sleep()
ni tokio::time::timeout()
```
L'horloge virtuelle reste donc exactement à `t=60 s` ou `t=120 s` pendant l'attente de propagation. Le canari ne peut pas réussir en observant accidentellement un heartbeat ultérieur.
## 5. Cadence vérifiée inchangée
Le scénario reste :
```text
t=59 s aucun Ping
t=60 s premier Ping observé après propagation scheduler bornée
t=119 s aucun second Ping
t=120 s second Ping observé après propagation scheduler bornée
```
Aucune cadence de test spéciale n'est introduite.
## 6. Runtime explicitement inchangé
Ce fix ne modifie pas :
```text
crates/ksp-onchain-transport-lib/src/ws_session.rs
HELIUS_WS_HEARTBEAT_INTERVAL = 60 s
WebSocket Ping control frame
WsProtocolKind
WsSessionSettings
Config / schema / env
transactionSubscribe / transactionNotification lifecycle
reconnect / remap / backpressure
```
Il n'ajoute aucune dépendance, feature ou visibilité.
## 7. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.007-fix.002.md
```
## 8. Gate opérateur
```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
```
Critère de fermeture : zéro warning, audit clean, **331 unit / 40 public API / 29 completeness / 4 doctests** et workspace vert. `pre.008` reste bloqué jusque-là.

View File

@@ -0,0 +1,105 @@
<!-- file: deltas/0.2.8/pre.007-fix.003.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.007-fix.003` — driver I/O sous horloge Tokio pausée
## 1. Base et défaut restant
Base appliquée :
```text
0.2.8-pre.7.fix.2
```
Le replay opérateur du `fix.002` est propre pour fmt/audit/check/Clippy mais reste à **330/331 unit** sur le même canari :
```text
helius_heartbeat_sends_ping_at_sixty_seconds_and_rearms
```
Après 256 `yield_now()` bornés, le Ping n'est toujours pas visible par le canal de la fixture TCP locale.
## 2. Version technique
```text
workspace.package.version = 0.2.8-pre.7.fix.3
commit attendu = v0.2.8-pre.007-fix.003
Git tag = aucun tag prerelease
```
Le header root `Cargo.toml` passe en version `230`.
## 3. Diagnostic affiné
La revue du runtime confirme que `src/ws_session.rs` possède déjà le chemin attendu :
```text
heartbeat_deadline = now + 60 s
tokio::select! -> sleep_until(heartbeat_deadline)
send_helius_heartbeat() -> websocket.send(Ping)
succès -> heartbeat_deadline = now + 60 s
```
Le canari de reconnexion actor réel reste vert et observe un Ping après réarmement. Aucun défaut runtime nouveau n'est donc démontré.
Le défaut est dans le harness : avec l'horloge Tokio pausée, `yield_now()` fait progresser les tâches runnable mais ne garantit pas à lui seul un tour du driver I/O OS. Une frame écrite vers le socket loopback peut donc ne pas encore être remontée jusqu'au `websocket.next()` de la fixture.
## 4. Correction du canari
Le helper yield-only est remplacé par une observation bornée qui :
```text
1. reprend temporairement l'horloge Tokio ;
2. attend `ping_rx.recv()` avec un timeout réel de 1 s maximum ;
3. mesure le temps réel consommé ;
4. repause immédiatement l'horloge ;
5. exige qu'un Ping ait été reçu.
```
La fenêtre n'est ouverte **qu'après** que l'horloge virtuelle a atteint le deadline attendu. Une seconde réelle ne peut donc pas masquer l'absence d'un heartbeat dont la cadence nominale est 60 s.
Pour le second intervalle, le temps réel consommé lors de la première propagation est soustrait de la phase pré-deadline puis réinjecté lors du franchissement suivant. Cela garantit :
```text
avant contrôle négatif : moins de 59 s depuis le réarmement réel
après avance positive : au moins 60 s depuis le réarmement réel
```
## 5. Runtime inchangé
Ce fix ne modifie pas :
```text
crates/ksp-onchain-transport-lib/src/ws_session.rs
HELIUS_WS_HEARTBEAT_INTERVAL = 60 s
WebSocket Ping control frame
actor/reconnect/shutdown
WsSessionSettings
Config / schema / env
transactionSubscribe / transactionNotification lifecycle
```
Aucune dépendance, feature, API ou visibilité n'est ajoutée.
## 6. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.007-fix.003.md
```
## 7. Gate opérateur
```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
```
Critère de fermeture : zéro warning, audit clean, **331 unit / 40 public API / 29 completeness / 4 doctests** et workspace vert.

View File

@@ -0,0 +1,90 @@
<!-- file: deltas/0.2.8/pre.007-fix.004.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.007-fix.004` — alignement du canari dependency-firewall Tokio dev
## 1. Base et défaut restant
Base appliquée :
```text
0.2.8-pre.7.fix.3
```
Le replay opérateur du `fix.003` ferme entièrement Transport :
```text
331/331 unit
40/40 public API
29/29 release completeness
4/4 doctests
fmt/audit/check/Clippy verts
```
`cargo test --workspace` révèle toutefois un unique défaut dans le canari workspace `transport_manifest_preserves_ksp_dependency_firewall` : il attend encore l'ancienne déclaration Tokio dev `features = ["net", "rt"]`, alors que `pre.007` a légitimement ajouté `io-util` et `test-util`.
## 2. Version technique
```text
workspace.package.version = 0.2.8-pre.7.fix.4
commit attendu = v0.2.8-pre.007-fix.004
Git tag = aucun tag prerelease
```
Le header root `Cargo.toml` passe en version `231`.
## 3. Correction
Le manifest Transport n'est pas modifié. Le canari dans `crates/ksp-core-lib/tests/workspace_dependencies.rs` est mis en cohérence avec la déclaration réellement livrée :
```text
tokio = { workspace = true, features = ["io-util", "net", "rt", "test-util"] }
```
La liste des noms de dev-dependencies reste exactement :
```text
[tokio]
```
Le firewall KSP reste inchangé : aucune dépendance Config/Store/Program/tracing direct n'est autorisée dans Transport.
## 4. Portée explicitement inchangée
Ce fix ne modifie pas :
```text
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
heartbeat Helius 60 s
WebSocket Ping control frame
actor/reconnect/shutdown
Config / schema / env
transaction lifecycle
```
Aucune dépendance ni feature supplémentaire n'est ajoutée.
## 5. Fichiers modifiés
```text
Cargo.toml
crates/ksp-core-lib/tests/workspace_dependencies.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.007-fix.004.md
```
## 6. Gate opérateur
```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
```
Critère de fermeture : zéro warning, audit clean, Transport **331 unit / 40 API / 29 completeness / 4 doctests** et workspace entièrement vert.

211
deltas/0.2.8/pre.007.md Normal file
View File

@@ -0,0 +1,211 @@
<!-- file: deltas/0.2.8/pre.007.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.007` — heartbeat Helius WebSocket + idle lifecycle
## 1. Base et objet
Base appliquée :
```text
0.2.8-pre.6
```
Le checkpoint opérateur de `pre.006` est intégralement vert et sans warning :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
Transport unit 325/325
Transport public API 40/40
Transport release completeness 28/28
Transport doctests compile-fail 4/4
cargo test --workspace OK
```
Cette tranche ajoute uniquement la policy de heartbeat Helius LaserStream WebSocket dans l'actor physique commun. Elle ne rouvre pas le lifecycle transaction validé par `pre.006`.
## 2. Version technique
```text
workspace.package.version = 0.2.8-pre.7
commit attendu = v0.2.8-pre.007
Git tag = aucun tag prerelease
```
Le header root `Cargo.toml` passe en version `227`.
## 3. Policy heartbeat provider-owned
La décision d'architecture de `pre.001` est matérialisée sans nouveau réglage public :
```text
protocol WsProtocolKind::HeliusLaserStream uniquement
intervalle nominal 60 s
frame WebSocket Ping control frame vide
état actor Active uniquement
succès réarmement à now + 60 s
reconnect réussi réarmement depuis la connexion de remplacement
write timeout/failure WsActorIoOutcome::Failed
close/shutdown timer abandonné ; shutdown prioritaire
SolanaStandard aucun heartbeat provider
```
Le heartbeat n'est pas un appel JSON-RPC `ping`. KSP utilise la frame de contrôle WebSocket `Ping`, déjà compatible avec le traitement `Pong` de l'actor.
Aucun champ `heartbeat_*` n'est ajouté à :
```text
WsSessionSettings
WsEndpointSettings
Config
.env.example
schema Transport
```
La cadence est donc une policy Helius WebSocket interne, pas une option de configuration générique.
## 4. Intégration dans l'actor unique
`run_ws_session_actor` possède le deadline heartbeat avec les mêmes priorités de shutdown que le reste du moteur :
```text
shutdown
command
socket input
Helius heartbeat deadline
pending JSON-RPC timeout
```
Le branch heartbeat est désactivé pour `SolanaStandard`.
Sur succès du Ping, le prochain deadline est recalculé à partir de `Instant::now()`. Pendant reconnect, aucun heartbeat n'est émis ; lorsque `recover_websocket_session` rend une connexion de remplacement active, le deadline est réarmé à 60 s.
Le helper d'écriture sélectionne de façon bornée entre :
```text
shutdown signal
websocket.send(Ping)
command_timeout
```
Une erreur d'écriture ou un timeout retourne le même `WsActorIoOutcome::Failed` que les autres erreurs de connexion. Le chemin de reconnect, son budget, son backoff, les remaps et le shutdown restent donc uniques.
## 5. Timers déterministes de test
La cadence runtime ne doit pas être raccourcie pour rendre les tests rapides. La crate active donc uniquement côté dev/test la feature Tokio :
```text
test-util
```
avec `io-util` nécessaire au canari de socket cassé en mémoire.
Il ne s'agit pas d'une nouvelle dépendance ; aucune feature runtime de production n'est ajoutée au contrat KSP.
Les tests utilisent `tokio::time::pause()` / `advance()` pour vérifier la vraie constante de 60 s.
## 6. Canaris ajoutés
Six tests unitaires Transport sont ajoutés :
```text
Helius-only + constante 60 s
premier Ping à 60 s + second Ping après réarmement
absence totale de heartbeat provider sur SolanaStandard
close à 30 s sans Ping
échec d'écriture Ping -> WsActorIoOutcome::Failed / ERROR_CODE_WS_CONNECTION_FAILED
reconnect avant heartbeat -> nouveau deadline 60 s depuis la connexion de remplacement
```
Le test hérité `websocket_shutdown_interrupts_reconnect_backoff_without_new_connection` continue de couvrir l'interruption du backoff par shutdown, tandis que le nouveau branch heartbeat place également le signal shutdown en première priorité.
Le canari release-completeness vérifie que :
- la policy reste dans `ws_session.rs` ;
- le Ping est une frame WebSocket ;
- `WsSessionSettings` ne gagne aucun champ de heartbeat ;
- la façade Helius ne possède aucun timer séparé.
Comptages attendus :
```text
Transport unit 331
Transport public API 40
release completeness 29
doctests compile-fail 4
```
## 7. Non-régressions
Cette tranche ne modifie pas :
```text
surface Helius standard 6 familles
transactionSubscribe request/filter/options
transactionNotification Full/Signature/Unknown
WsSubscriptionKind::HeliusTransaction
remote/local ID remapping
late notification handling
backpressure logical subscription
standard Solana WebSocket 18/18
HTTP 52 current / 14 historical
Config Helius mainnet/devnet
dependency firewall
```
Aucune promesse de replay WebSocket ou de livraison lossless n'est introduite.
## 8. Documentation
Le plan `015` :
- ferme `pre.006` sur preuve opérateur ;
- marque `pre.007` PREPARED ;
- matérialise la policy Helius-only 60 s et ses canaris.
La validation `011` :
- transforme les critères lifecycle `pre.006` en preuves acquises ;
- ouvre la gate `pre.007` ;
- conserve les tests adversariaux élargis pour `pre.008`.
## 9. Hors scope
Restent hors de `pre.007` :
```text
provider adversarial lifecycle élargi pre.008
payload/security/redaction adversarial pre.008
compliance Helius + standard + HTTP pre.009
smoke Helius WebSocket live opt-in pre.010
LaserStream gRPC future backend distinct
```
## 10. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.007.md
```
## 11. Gate opérateur
```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
```
Critère de fermeture : zéro warning nouveau, audit Rust clean, **331 unit / 40 public API / 29 completeness / 4 doctests** et workspace vert.

238
deltas/0.2.8/pre.008.md Normal file
View File

@@ -0,0 +1,238 @@
<!-- file: deltas/0.2.8/pre.008.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.008` — adversarial Helius provider + limites + diagnostics sûrs
## 1. Base et objet
Base appliquée :
```text
0.2.8-pre.7.fix.4
```
Le checkpoint opérateur final de `pre.007` est intégralement vert :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
Transport unit 331/331
Transport public API 40/40
Transport release completeness 29/29
Transport doctests compile-fail 4/4
cargo test --workspace OK
```
`pre.008` durcit la surface Helius WebSocket contre des comportements provider adversariaux sans créer de nouveau runtime. La tranche réutilise l'actor physique, les limites frame/message, le reconnect, le registry et la backpressure existants.
## 2. Version technique
```text
workspace.package.version = 0.2.8-pre.8
commit attendu = v0.2.8-pre.008
Git tag = aucun tag prerelease
```
Le header root `Cargo.toml` passe en version `232`.
## 3. Ré-audit Helius courant
La documentation Helius réauditée le 2026-08-23 confirme :
```text
transactionSubscribe supporté
transactionUnsubscribe supporté
accountInclude/accountExclude/accountRequired 50 000 adresses max par liste
blockSubscribe non supporté Helius
slotsUpdatesSubscribe non supporté Helius
voteSubscribe non supporté Helius
endpoints WSS unifiés mainnet/devnet
```
La tranche n'introduit donc aucune nouvelle famille provider.
## 4. `Debug` sûr pour les notifications Helius
Le prompt `0.2.8` interdit les payloads arbitraires/massifs dans les diagnostics génériques. Avant cette tranche, les types de notification Helius dérivaient `Debug`, ce qui pouvait rendre :
```text
transaction JSON brut
signature complète
memo/error provider
fallback Unknown(serde_json::Value) brut
```
`pre.008` remplace ces dérivations par des implémentations explicites :
```text
HeliusFullTransactionNotification
transaction <omitted>
signature <omitted>
slot visible
transaction_index visible
HeliusTransactionSignatureNotification
signature <omitted>
slot/index visibles
err omitted/null/value seulement
memo omitted/null/value seulement
block_time omitted/null/value seulement
confirmation_status omitted/null/value seulement
HeliusTransactionNotification::Unknown
payload Value conservé fonctionnellement
Debug = Unknown("<omitted>")
```
Aucun accessor fonctionnel n'est supprimé. La donnée reste accessible explicitement au consommateur qui la demande ; seul le chemin diagnostic implicite est redacted.
## 5. Capability guard inverse
La façade Helius avait déjà quatre doctests compile-fail :
```text
Helius -X-> blockSubscribe
Helius -X-> slotsUpdatesSubscribe
Helius -X-> voteSubscribe
Helius -X-> into_inner
```
`pre.008` ajoute le canari inverse :
```text
SolanaStandardWsSession -X-> transactionSubscribe
```
La séparation standard/provider est donc prouvée dans les deux sens sans exposer le `WsSession` générique.
## 6. Provider RPC application error
Un serveur local adversarial retourne une erreur JSON-RPC `transactionSubscribe` avec :
```text
code provider
message canary sensible
payload data arbitraire de plusieurs KiB
```
Le canari exige :
```text
ERROR_CODE_RPC_APPLICATION_ERROR
context sûr = rpc_code + method
message/data provider non copiés dans KspError
api-key URL non rendue
filter signature non rendue
session physique reste Active
subscription_count revient à 0
rootSubscribe fonctionne ensuite normalement
```
Une erreur d'application provider ne doit donc pas être promue en panne de transport.
## 7. Notification method mismatch
Un remote ID enregistré comme `HeliusTransaction` reçoit volontairement un `rootNotification`.
Résultat attendu :
```text
logical transaction subscription -> Failed / WS_PROTOCOL_ERROR
remote binding transaction -> cleanup transactionUnsubscribe
session physique -> Active
subscription root saine -> Active et notification reçue
```
Le mismatch de famille ne doit pas contaminer les autres logical subscriptions.
## 8. Payload entrant oversized
Un endpoint Helius local envoie une frame texte supérieure aux limites configurées :
```text
max_message_size_bytes = 256
max_frame_size_bytes = 128
```
Le canari prouve que la limite tungstenite/actor s'applique avant tout décodage JSON provider, déclenche le reconnect borné existant et laisse la connexion Helius de remplacement utilisable pour un `rootSubscribe`/unsubscribe normal.
Aucun nouveau compteur ou chemin de reconnect n'est ajouté.
## 9. Backpressure et lifecycle conservés
La preuve provider-specific de `pre.006` reste autoritaire pour :
```text
queue capacity = 1
transaction notification overflow
échec de la seule transaction subscription
cleanup transactionUnsubscribe
root subscription saine non affectée
session physique Active
```
`pre.008` ne duplique pas ce scénario. Les canaris partagés hérités continuent aussi de couvrir unsubscribe pendant reconnect, decode failure isolé, pending bounds et shutdown pendant backoff.
## 10. Canaris ajoutés
Quatre unit tests Helius sont ajoutés :
```text
notification Debug redaction Full/Signature/Unknown
provider RPC application error sûre + session réutilisable
notification method mismatch isolé + cleanup transactionUnsubscribe
oversized inbound provider payload + reconnect + session réutilisable
```
Un release-completeness canary supplémentaire vérifie les guards, les `Debug` explicites et la réutilisation des mécanismes de bornage de l'actor.
Un doctest compile-fail supplémentaire interdit `transactionSubscribe` sur `SolanaStandardWsSession`.
Comptages attendus :
```text
Transport unit 335
Transport public API 40
release completeness 30
doctests compile-fail 5
```
## 11. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.008.md
```
## 12. Hors scope
Restent hors de `pre.008` :
```text
compliance finale Helius + standard/HTTP pre.009
smoke Helius live + cargo tree final pre.010
fermeture docs/indexes + prompt 0.2.9 pre.011
LaserStream gRPC futur backend séparé
Gatekeeper/preconf WebSocket hors scope 0.2.8
```
## 13. Gate opérateur
```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
```
Critère de fermeture : zéro warning, audit clean, **335 unit / 40 public API / 30 completeness / 5 doctests** et workspace vert.

262
deltas/0.2.8/pre.009.md Normal file
View File

@@ -0,0 +1,262 @@
<!-- file: deltas/0.2.8/pre.009.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.009` — compliance Helius courant + `slotsUpdates` + non-régressions finales
## 1. Base et objet
Base appliquée :
```text
0.2.8-pre.8
```
Le checkpoint opérateur `pre.008` est intégralement vert :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
Transport unit 335/335
Transport public API 40/40
Transport release completeness 30/30
Transport doctests 5/5
cargo test --workspace OK
```
`pre.009` réalise la compliance finale Helius WebSocket prévue, mais le réaudit courant révèle une évolution de la documentation provider qui impose une petite extension fonctionnelle : `slotsUpdatesSubscribe`/`slotsUpdatesUnsubscribe` est désormais retenu comme disponible sur Helius.
## 2. Version technique
```text
workspace.package.version = 0.2.8-pre.9
commit attendu = v0.2.8-pre.009
Git tag = aucun tag prerelease
```
Le header root `Cargo.toml` passe en version `233`.
## 3. Réconciliation documentaire Helius du 2026-08-23
Sources officielles relues :
```text
https://www.helius.dev/docs/rpc/websocket
https://www.helius.dev/docs/api-reference/rpc/websocket/slotsupdatessubscribe
https://www.helius.dev/docs/api-reference/rpc/websocket/slotsupdatesunsubscribe
https://www.helius.dev/docs/api-reference/rpc/websocket/blocksubscribe
https://www.helius.dev/docs/api-reference/rpc/websocket/votesubscribe
https://www.helius.dev/docs/api-reference/rpc/websocket/accountsubscribe
https://www.helius.dev/docs/api-reference/rpc/websocket/transactionsubscribe
```
État courant :
```text
overview LaserStream WebSocket
-> annonce le support du jeu complet des méthodes standard
blockSubscribe
-> page spécifique : unstable + explicitement non supporté Helius
voteSubscribe
-> page spécifique : unstable + explicitement non supporté Helius
slotsUpdatesSubscribe
-> page spécifique : unstable
-> endpoints Helius mainnet/devnet
-> request, notification et subscription ID documentés
-> aucune mention de non-support Helius
slotsUpdatesUnsubscribe
-> subscriptionId entier
-> result bool
```
Arbitrage KSP : lorsqu'une formule globale et une page de méthode divergent, **la page spécifique prévaut**.
Surface Helius retenue après réaudit :
```text
standard : account, logs, program, root, signature, slot, slotsUpdates
extension: transaction
absent : block, vote
```
## 4. `slotsUpdates` rejoint la façade Helius
`HeliusLaserStreamWsSession` expose désormais :
```rust
pub async fn slots_updates_subscribe(
&self,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>>
```
Aucun nouveau DTO ni nouveau wire n'est créé. La façade délègue au même chemin déjà stabilisé :
```text
HeliusLaserStreamWsSession
-> physical_session()
-> WsSession::slots_updates_subscribe()
-> WsSubscriptionKind::SlotsUpdates
-> slotsUpdatesSubscribe
-> slotsUpdatesNotification
-> slotsUpdatesUnsubscribe via handle
```
Le caractère unstable reste celui de la famille standard : le warning central `warn_if_unstable()` est donc conservé automatiquement.
## 5. Capability guards réconciliés
Le compile-fail historique :
```text
Helius -X-> slotsUpdatesSubscribe
```
est supprimé car il contredirait la surface courante.
Restent obligatoires :
```text
Helius -X-> blockSubscribe
Helius -X-> voteSubscribe
Helius -X-> into_inner
SolanaStandard -X-> transactionSubscribe
```
Aucun escape hatch vers le `WsSession` générique n'est ajouté.
## 6. `accountSubscribe` enhanced : pas de wire inventé
L'overview Helius mentionne désormais des « advanced filters » sur `accountSubscribe`, mais la page API spécifique ne publie actuellement que :
```text
pubkey
encoding
commitment
notifyOn deprecated/no-op
```
Aucun DTO provider-specific supplémentaire n'est ajouté tant qu'un wire exact n'est pas publié. `notifyOn` reste volontairement absent.
## 7. Canaris de compliance transversaux
Trois canaris sont ajoutés dans `tests/release_completeness.rs`.
### 7.1 HTTP + Standard WebSocket
Ils verrouillent :
```text
HTTP current 52
HTTP historical 14
Standard WS 9 familles
Standard operations 18 subscribe/unsubscribe
```
Les neuf méthodes typed de `SolanaStandardWsSession` restent adressables.
### 7.2 Helius courant
Ils verrouillent :
```text
7 familles standard :
account
logs
program
root
signature
slot
slotsUpdates
1 extension :
transaction
absentes :
block
vote
```
Le source canary vérifie également que le guard `unsupported_slots_updates` a disparu tandis que `unsupported_block` et `unsupported_vote` restent présents.
### 7.3 Config / secret / dependency firewall
La compliance finale vérifie durablement :
```text
Transport -X-> Config/Store/Program/tracing direct
Config -> Transport présent
Config mappe WsProtocolKind::HeliusLaserStream
example Transport contient kind = helius_laserstream
example Transport utilise ${KSP_SECRET_HELIUS_API_KEY...}
.env.example inventorie KSP_SECRET_HELIUS_API_KEY
```
Aucune nouvelle dépendance n'est introduite.
## 8. Public API et fixture Helius standard
Un public-API canary supplémentaire adresse `HeliusLaserStreamWsSession::slots_updates_subscribe` et le DTO partagé `SolanaSlotUpdate`.
Le fixture Helius standard existant passe de six à sept familles et vérifie la paire exacte :
```text
slotsUpdatesSubscribe params []
slotsUpdatesUnsubscribe params [remoteSubscriptionId]
```
Le lifecycle, le decoder, le remote-ID mapping et l'unsubscribe restent ceux du moteur commun.
## 9. Comptages attendus
```text
Transport unit 335
Transport public API 41
release completeness 33
doctests compile-fail 4
```
La diminution de 5 à 4 doctests est intentionnelle : le compile-fail Helius `slotsUpdatesSubscribe` est retiré parce que cette API devient supportée.
## 10. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_helius_standard.rs
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
deltas/0.2.8/pre.009.md
```
## 11. Hors scope
Restent hors `pre.009` :
```text
smoke Helius live opt-in + README/USAGE + cargo tree final pre.010
fermeture workspace/docs/indexes + prompt 0.2.9 pre.011
LaserStream gRPC futur backend distinct
Gatekeeper beta / preconfirmations hors 0.2.8
```
## 12. Gate opérateur
```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
```
Critère de fermeture : zéro warning, audit clean, **335 unit / 41 public API / 33 completeness / 4 doctests** et workspace vert.

73
deltas/0.2.8/pre.010.md Normal file
View File

@@ -0,0 +1,73 @@
<!-- file: deltas/0.2.8/pre.010.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.010` — documentation Helius finale + stratégie smoke live + cargo tree final
## Objet
Fermer la documentation dusage de la surface Helius WebSocket acquise en `pre.009`, décider explicitement si un smoke Helius live peut être committé sans violer les frontières KSP, puis demander le dernier audit du graphe Cargo avant la tranche de clôture.
## Décision smoke live
Aucun nouveau smoke Helius live nest ajouté dans cette tranche.
Motif :
```text
Transport ne peut ni lire KSP_SECRET_HELIUS_API_KEY ni dépendre de Config.
Config ne doit pas devenir la destination générale des futurs smokes Config + autre crate.
Le smoke Config -> Transport historique reste une exception transitoire déjà documentée.
```
Le propriétaire durable sera la future surface KSP dintégration/orchestration. Son scénario minimal devra utiliser `helius_devnet`, ouvrir `HeliusLaserStreamWsSession`, faire `slotSubscribe`, attendre une notification sous timeout, unsubscribe puis close. `transactionSubscribe` pourra rester un opt-in additionnel soumis aux droits provider.
## Documentation
`README.md` et `USAGE.md` de Transport documentent désormais :
- Helius = `account/logs/program/root/signature/slot/slotsUpdates` + `transaction` ;
- `block` et `vote` absents ;
- `slotsUpdates` unstable ;
- heartbeat Helius control-frame Ping à 60 s ;
- secret Helius résolu par Config, jamais lu par Transport ;
- LaserStream gRPC distinct du namespace WebSocket ;
- stratégie de smoke live et future ownership dintégration.
## Canari de release
Un canari `release_completeness` supplémentaire vérifie que :
- Transport ne dépend toujours pas de Config ;
- le smoke WebSocket Transport ne lit ni `KSP_SECRET_HELIUS_API_KEY` ni `ConfigEnvironment` ;
- aucun `helius_websocket_smoke.rs` nest ajouté à Config ;
- README/USAGE portent explicitement la décision et les commandes `cargo tree` ;
- `.env.example` conserve linventaire `KSP_SECRET_HELIUS_API_KEY`.
## Version et comptages attendus
```text
workspace.package.version = 0.2.8-pre.10
commit attendu = v0.2.8-pre.010
Git tag = aucun
Transport unit 335
Transport public API 41
release completeness 34
Transport doctests 4
```
## Audit opérateur requis
```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
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
cargo tree --duplicates
```
`pre.011` ne souvre quaprès gate vert et inspection des graphes résolus.

207
deltas/0.2.8/pre.011.md Normal file
View File

@@ -0,0 +1,207 @@
<!-- file: deltas/0.2.8/pre.011.md -->
<!-- version: 1 -->
# Delta `0.2.8-pre.011` — candidate finale + indexes + prompt `0.2.9`
## Objet
Fermer la candidate documentaire de `0.2.8` après le gate opérateur complet de `pre.010`, synchroniser les index qui décrivaient encore des états intermédiaires de la release et préparer un prompt de démarrage `0.2.9` autonome, conforme à `docs/rules/PROMPT_STRUCTURE.md`, avant la publication stable `rel.001`.
Cette tranche ne modifie aucune capacité runtime HTTP/WebSocket, aucun contrat Config et aucune dépendance Rust.
## Base et preuve opérateur héritée
Le gate `0.2.8-pre.10` fourni le 2026-08-23 est entièrement vert :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
General Rust rule audit clean
Rust export completeness audit 0 candidate(s)
KSP workspace Rust rule audit clean
cargo check --workspace OK / sans warning
cargo clippy --workspace --all-targets OK / sans warning
Transport unit 335/335
Transport public API 41/41
release completeness 34/34
Transport doctests 4/4
cargo test --workspace OK
cargo tree -p ksp-onchain-transport-lib exécuté
cargo tree -p ksp-onchain-transport-lib --duplicates exécuté
cargo tree --duplicates exécuté
```
Le graphe Transport conserve uniquement ses dépendances acquises. Les doublons ciblés visibles dans ce sous-graphe (`syn` 2/3 et `webpki-roots` 0.26/1.0) sont transitifs. Les autres duplications du workspace proviennent principalement des stacks Tauri/GTK, JSON Schema et Wallet/Solana crypto ; aucune dérive nouvelle propre à `0.2.8` n'est identifiée.
Verdict hérité : **`pre.010` DONE.**
## Candidate fonctionnelle `0.2.8`
L'état que `pre.011` fige au niveau candidate reste :
```text
HTTP 52 méthodes current + 14 historical
Solana Standard WebSocket 9 familles / 18 subscribe-unsubscribe
Helius standard réutilisé account/logs/program/root/signature/slot/slotsUpdates
Helius extension transactionSubscribe / transactionUnsubscribe
Helius absent block / vote
slotsUpdates unstable
heartbeat Helius WebSocket Ping control frame / 60 s
Config V2 helius_laserstream + provenance/redaction secret
moteur physique un seul WsSession actor partagé
smoke Helius live reporté vers future intégration/orchestration
LaserStream gRPC backend distinct, hors namespace WebSocket
```
Aucun changement runtime n'est nécessaire dans `pre.011`.
## Prompt `0.2.9`
Création de :
```text
prompts/014-V0_2_9_START_PROMPT.md
```
Titre :
```text
0.2.9 — Yellowstone gRPC standard/provider-neutral
```
Le prompt a été construit après relecture de :
```text
docs/rules/PROMPT_STRUCTURE.md
prompts/010-V0_2_5_START_PROMPT.md
prompts/011-V0_2_6_START_PROMPT.md
prompts/012-V0_2_7_START_PROMPT.md
prompts/013-V0_2_8_START_PROMPT.md
```
Le squelette reprend la structure stricte récente de `0.2.7`/`0.2.8`, tout en conservant la profondeur métier/threat-model/sizing des prompts `0.2.5`/`0.2.6`.
Le prompt contient explicitement les rubriques requises par `PROMPT_STRUCTURE.md` :
```text
identité/base exacte
mission/résultat
sources internes ordonnées
sources externes à réauditer
état stable à préserver
frontières architecturales/dependencies
décisions acquises/questions ouvertes
objectifs/livrables/hors périmètre
sécurité/API/lifecycle/resources
pre.001 détaillée + gate de sortie
prévision souple initiale détaillée
version/deltas/commits/archives/tags
validation opérateur
critères de clôture
release suivante
instruction d'ouverture
```
### Gate `0.2.9-pre.001`
Le prompt interdit l'implémentation lourde Yellowstone avant :
- audit du service/proto upstream réellement courant ;
- classification standard/provider-extension de chaque RPC/message ;
- audit de `Subscribe`, unary RPCs, replay/from_slot et extensions deshred ;
- comparaison des stratégies `yellowstone-grpc-client`, `yellowstone-grpc-proto + tonic` ou wire KSP borné ;
- audit licence/MSRV/features/build-dependencies/transitifs ;
- architecture gRPC distincte de HTTP/WS ;
- threat model ;
- sizing et recalibrage du forecast.
Le snapshot upstream du 2026-08-23 est uniquement informatif. Le repository/master observé comporte un workspace AGPL-3.0 et des versions Yellowstone/Tonic/Prost qui ne sont pas nécessairement synchronisées avec les releases/crates stables. Le prompt exige donc l'audit de la licence exacte de chaque crate/proto/source réellement utilisé avant toute dépendance, copie ou génération vendored dans KSP MIT.
### Forecast initial souple `0.2.9`
Le prompt fournit un forecast de départ détaillé :
```text
pre.001 audit/matrice/licences/dependencies/architecture/threat-model/sizing
pre.002 stratégie proto/client + settings/errors/façade provider-neutral
pre.003 connexion/TLS/metadata + lifecycle minimal + unary canaries
pre.004 SubscribeRequest foundation + validations/bounds communs
pre.005 Accounts + Slots
pre.006 Transactions + transaction_status
pre.007 Blocks + block_meta + entry / autres variantes retenues
pre.008 stream bidirectionnel + mutation/Ping/backpressure/shutdown
pre.009 reconnect/resubscribe/from_slot/replay/adversarial
pre.010 Config -> Transport gRPC si retenu par l'audit
pre.011 compliance Yellowstone + non-régressions HTTP/WS
pre.012 live opt-in si sûr + README/USAGE + cargo tree final
pre.013 workspace final + plan/matrice/indexes + prompt 0.2.10
rel.001 publication stable stricte
```
Ce forecast est explicitement recalibrable en `pre.001`, autorise fixes et prereleases supplémentaires, et impose un split avant client lourd si la surface réelle est trop grande pour une release concrète raisonnable.
## Synchronisation documentaire
Les index et la séquence fonctionnelle sont mis à jour pour refléter l'état candidate réel de `0.2.8` et l'existence du prompt `0.2.9` :
```text
docs/000-README.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/000-README.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
prompts/000-README.md
```
`ROADMAP.md` et `CHANGELOG.md` restent volontairement inchangés : leur synchronisation stable appartient à `rel.001`, conformément au workflow KSP.
## Version
```text
workspace.package.version = 0.2.8-pre.11
livraison = 0.2.8-pre.011
commit attendu = v0.2.8-pre.011
Git tag = aucun
```
## Fichiers de la livraison
Overlay attendu :
```text
Cargo.toml
deltas/0.2.8/pre.011.md
docs/000-README.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/000-README.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
prompts/000-README.md
prompts/014-V0_2_9_START_PROMPT.md
```
## Gate opérateur
```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
```
Comptages Transport attendus, inchangés :
```text
unit 335
public API 41
release completeness 34
doctests 4
```
Les `cargo tree` n'ont pas besoin d'être rejoués pour cette tranche documentaire/versionnée : aucun manifest de dépendances n'est modifié hors la version workspace, et les trois graphes finaux ont déjà été fournis au gate `pre.010`.
Si ce gate est vert, la prochaine tranche est `0.2.8-rel.001`. `pre.012+` reste possible uniquement si un défaut réel est découvert.

246
deltas/0.2.8/rel.001.md Normal file
View File

@@ -0,0 +1,246 @@
<!-- file: deltas/0.2.8/rel.001.md -->
<!-- version: 1 -->
# Delta `0.2.8-rel.001` — publication stable Helius LaserStream WebSocket
## 1. Base requise
Base directe attendue :
```text
0.2.8-pre.011
workspace.package.version = 0.2.8-pre.11
```
Commit attendu pour cette livraison :
```text
v0.2.8-rel.001
```
Tag stable attendu après validation :
```text
v0.2.8
```
## 2. Objectif
Publier `0.2.8 — Helius LaserStream WebSocket` sans ajouter de capacité fonctionnelle après la candidate.
`rel.001` :
- passe `workspace.package.version` de `0.2.8-pre.11` à `0.2.8` ;
- clôt `ROADMAP.md`, le plan `015` et la matrice `validation/011` ;
- ajoute lentrée stable `0.2.8` au `CHANGELOG.md` ;
- passe `Helius WS` à `Stable` dans linventaire composants ;
- synchronise les index/documentation de séquence vers le statut publié ;
- conserve `prompts/014-V0_2_9_START_PROMPT.md` inchangé comme contrat actif pour `0.2.9`.
Aucun fichier Rust, aucune API publique, aucun schema/config runtime, aucune dépendance et aucune feature ne changent dans cette livraison.
## 3. Preuve candidate finale acquise
Le checkpoint opérateur fourni le **23 août 2026** après application de `0.2.8-pre.011` est intégralement vert :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py OK
General Rust rule audit clean
Rust export completeness audit 0 candidate(s)
KSP workspace Rust rule audit clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-onchain-transport-lib OK
unit 335 passed
public API 41 passed
release completeness 34 passed
doctests compile-fail 4 passed
cargo test --workspace OK
```
Les suites Config/Core/Logging/Wallet/Desks passent également. Les smokes réseau, benchmarks et probes operator-only restent ignorés par défaut conformément à leur contrat.
Les trois audits Cargo ont été exécutés en `pre.010` puis conservés valides par `pre.011`, qui ne modifie aucune dépendance :
```text
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
cargo tree --duplicates
```
Transport ne présente aucune nouvelle dépendance Helius/gRPC/Config ; les doublons ciblés `syn 2/3` et `webpki-roots 0.26/1.0` sont transitifs et non bloquants.
## 4. Surface stable publiée
### HTTP hérité
```text
52/52 méthodes HTTP courantes typées
14/14 méthodes historiques Deprecated/Removed conservées
KSP-TRANSPORT-007 appliqué à la surface typed
write submissions : aucun resend après dispatch ambigu
```
### WebSocket Solana standard hérité
```text
9 familles standard
18/18 opérations subscribe/unsubscribe
stable : account, logs, program, root, signature, slot
unstable : block, slotsUpdates, vote
```
### Helius LaserStream WebSocket
Surface standard réutilisée :
```text
account
logs
program
root
signature
slot
slotsUpdates # unstable
```
Extension Helius typée :
```text
transactionSubscribe
transactionUnsubscribe
transactionNotification
```
Absents de la façade Helius :
```text
block
vote
```
Contrats stabilisés :
```text
HeliusLaserStreamWsSession façade publique dédiée
WsSession actor physique unique partagé avec le standard
aucun second client/socket/registry/scheduler
capability guards avant I/O
heartbeat Helius-only = WebSocket Ping control frame / 60 s
reconnect/resubscribe/remap/unsubscribe races bornés
backpressure isolé par subscription
payload oversized borné avant decode métier
erreurs RPC provider nabattent pas la session physique
Debug/snapshots Helius sans payload provider brut
```
### Config et secrets
```text
std.transport V2 comprend helius_laserstream
profiles Helius mainnet/devnet
KSP_SECRET_HELIUS_API_KEY appartient à Config
redaction segmentaire des URLs résolues
Config -> Transport autorisé
Transport -X-> Config / environnement
```
Le smoke Helius live cross-crates est reporté vers une future surface dintégration/orchestration dédiée afin de ne pas faire de Config une destination générale de smoke et de ne pas faire lire le secret par Transport.
LaserStream gRPC / Yellowstone reste un backend distinct : aucune promesse gRPC/replay nest attachée au contrat WebSocket `0.2.8`.
## 5. Fichiers ajoutés
```text
deltas/0.2.8/rel.001.md
```
## 6. Fichiers modifiés
```text
Cargo.toml
ROADMAP.md
CHANGELOG.md
docs/000-README.md
docs/architecture/004-COMPONENT_INVENTORY.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/015-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET_PLAN.md
docs/validation/000-README.md
docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md
prompts/000-README.md
```
Volontairement inchangé :
```text
prompts/014-V0_2_9_START_PROMPT.md
crates/**
config/**
.env.example
```
## 7. Décisions de clôture
Aucune nouvelle décision de protocole nest introduite par `rel.001`. La publication confirme :
1. Helius LaserStream WebSocket reste une façade provider dédiée sur le moteur/actor WebSocket partagé ;
2. la surface publiée est `7 familles standard + transaction`, avec `block/vote` absents ;
3. `slotsUpdates` reste exposé comme unstable ;
4. le heartbeat Ping 60 s est provider-owned et Helius-only ;
5. Config reste lunique propriétaire du secret Helius et Transport ne lit pas lenvironnement ;
6. le smoke live cross-crates Helius attend une surface dintégration/orchestration dédiée ;
7. HTTP 52+14 et Standard WS 18/18 restent des non-régressions obligatoires ;
8. `0.2.9` ouvre Yellowstone gRPC standard/provider-neutral par audit/sizing avant dépendances ou implémentation lourde.
## 8. Validation finale après application
Le passage du signal Cargo à la version stable doit être revalidé avant commit/tag :
```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
```
Les graphes Cargo nont pas besoin dêtre rejoués si loverlay appliqué est exactement `0.2.8-rel.001`, car cette livraison ne change aucune dépendance. Les smokes live nont pas besoin dêtre rejoués non plus : aucune source runtime nest modifiée.
Comptages Transport attendus :
```text
unit = 335
public API = 41
release completeness = 34
doctests compile-fail = 4
```
## 9. Commit et tag stable
Après succès du gate :
```text
commit : v0.2.8-rel.001
tag : v0.2.8
```
Aucun tag intermédiaire de prerelease/fix/rel nest requis.
## 10. Suite
Après création du tag stable `v0.2.8`, ouvrir :
```text
0.2.9-pre.001
```
avec :
```text
prompts/014-V0_2_9_START_PROMPT.md
```
La première tranche est un gate strict de relecture, audit Yellowstone courant, architecture, threat model, dépendances/licences/MSRV/features et sizing. La prévision `pre.001 -> pre.013` du prompt est explicitement souple et doit être recalibrée avant toute implémentation gRPC lourde.

View File

@@ -0,0 +1,139 @@
<!-- file: deltas/0.2.9/pre.001-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.001-fix.001` — sizing/session + architecture provider Yellowstone
## 1. Base requise
```text
0.2.9-pre.001
workspace.package.version = 0.2.9-pre.1
```
Ce correctif est **documentaire uniquement**. Conformément à `VER-ID-008`, il ne modifie pas `workspace.package.version`.
## 2. Motif du correctif
La première livraison `pre.001` contenait bien une mention générale du budget `1520 minutes`, mais elle ne rendait pas assez visible le contrat normatif complet de `PROMPT_STRUCTURE.md` :
```text
une prerelease estimée > 1520 min doit être scindée
une release concrète doit rester ouvrable et clôturable dans une seule session de chat
```
Elle traitait aussi PublicNode et OrbitFlare principalement comme des environnements de smoke derrière un seul backend Yellowstone provider-neutral. Cette représentation ne reprenait pas suffisamment la séparation déjà validée pour WebSocket entre moteur partagé, protocole standard et adaptation provider.
## 3. Décisions corrigées
### 3.1 Dimensionnement
Le forecast `0.2.9` affiche désormais explicitement pour **chaque prerelease** :
```text
budget nominal = 1520 minutes maximum de travail effectif
```
et le gate global :
```text
0.2.9 complète <= une session de chat
```
Si une tranche dépasse ce budget ou si la clôture de la release dans la session devient incertaine, le scope est scindé avant implémentation lourde supplémentaire.
Le forecast est recalibré à **11 prereleases prévues** avant `rel.001`, soit environ `165220 minutes` de travail effectif nominal hors temps d'attente des commandes.
### 3.2 Architecture Yellowstone
`0.2.9` distingue désormais trois niveaux dans `ksp-onchain-transport-lib` :
```text
N1 moteur client Yellowstone gRPC partagé
N2 façade Solana Yellowstone standard
N3 première intégration provider PublicNode / Allnodes-backed
```
Le moteur reste Yellowstone-specific ; KSP ne crée pas une abstraction gRPC universelle ni un serveur/plugin Geyser.
La règle provider est :
```text
aucun second actor/channel/stream par provider
aucune façade spécialisée vide
façade/type provider public uniquement si une différence réelle de contrat le justifie
```
PublicNode devient donc la **première intégration concrète** de `0.2.9`, avec Mainnet et Testnet comme cibles live opt-in. Il réutilise le moteur et le wire standard.
### 3.3 Releases providers suivantes
La séquence fonctionnelle est recalibrée :
```text
0.2.9 moteur Yellowstone + Solana standard + PublicNode
0.2.10 OrbitFlare Yellowstone gRPC
0.2.11 Helius LaserStream gRPC
0.2.12 eRPC Yellowstone gRPC, conditionnel après réaudit accès/capabilities
0.2.13 off-chain price transport
0.2.14 Price Desk + intégration prix Wallet Desk
0.2.15 interface/wire foundation
0.2.16 program-api foundation
```
Chaque future release provider réutilise `0.2.9`. Si le gate d'un provider montre qu'il ne diffère que par une URL interchangeable, il ne doit pas provoquer une façade artificielle ; son scope peut être réduit/fusionné avant implémentation.
### 3.4 Rust/toolchain
Les numéros de versions Rust upstream/MSRV ne sont plus suivis comme information de planification ordinaire.
KSP utilise la **Rust stable courante de l'opérateur**. Le seul gate utile est que les dépendances sélectionnées compilent avec cette stable. Un minimum Rust d'une dépendance n'est documenté que s'il devient un blocage réel.
## 4. Fichiers modifiés
```text
docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md
docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
ROADMAP.md
```
## 5. Fichier ajouté
```text
deltas/0.2.9/pre.001-fix.001.md
```
## 6. Fichiers non modifiés intentionnellement
```text
Cargo.toml
prompts/014-V0_2_9_START_PROMPT.md
crates/**
config/**
.env.example
```
Le prompt `014` reste l'autorité historique ayant ouvert `pre.001`; le présent fix trace le recalibrage résultant du gate et devient l'état de planification courant.
## 7. Validation
À exécuter pour le correctif documentaire :
```bash
python3 scripts/audit_rust_workspace_rules.py
```
Aucun `cargo check/clippy/test` supplémentaire n'est requis par le contenu du fix puisqu'aucun artefact code/build/runtime/config n'est modifié ; la baseline technique reste celle enregistrée par `pre.001`.
## 8. État de sortie
```text
forecast visible et conforme à PROMPT_STRUCTURE.md
1520 min max par prerelease explicités
release <= une session explicitée
moteur Yellowstone séparé de la façade standard
PublicNode promu de smoke à première intégration provider
OrbitFlare/Helius/eRPC reportés dans des releases dédiées
séquence prix/interface/program décalée en conséquence
aucun changement runtime/dependency
```

View File

@@ -0,0 +1,154 @@
<!-- file: deltas/0.2.9/pre.001-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.001-fix.002` — provider divergence + waitlist Yellowstone
## 1. Base requise
```text
0.2.9-pre.001-fix.001
workspace.package.version = 0.2.9-pre.1
```
Ce correctif est **documentaire uniquement**. Conformément au workflow de version, il ne modifie pas `workspace.package.version`.
## 2. Motif du correctif
Le cadrage `pre.001-fix.001` avait correctement séparé :
```text
N1 moteur Yellowstone partagé
N2 façade Solana Yellowstone standard
N3 intégration provider
```
mais la formulation « sans duplication du moteur ni du wire standard » pouvait être comprise comme une hypothèse d'équivalence wire/capabilities entre Yellowstone upstream et tous les providers.
Cette hypothèse n'est pas acceptable : un provider peut implémenter seulement un sous-ensemble du standard, imposer des limites/auth/lifecycle différents ou proposer des extensions propres.
Le correctif ferme aussi la liste des providers à implémenter à court terme :
```text
0.2.9 PublicNode
0.2.10 OrbitFlare
0.2.11 Helius LaserStream gRPC
```
Les autres providers ne reçoivent plus de numéro de release réservé.
## 3. Décisions corrigées
### 3.1 Règle N1 / N2 / N3
```text
N1 = moteur client Yellowstone unique
N2 = contrat Solana Yellowstone standard KSP
N3 = adaptation provider
```
Invariant N1 :
```text
aucun second actor/channel/stream moteur par provider
```
Règle N2/N3 :
```text
réutiliser N2 lorsqu'une capacité provider est réellement compatible
restreindre explicitement N2 lorsqu'une capacité standard est absente/non supportée
ajouter une extension typed N3 lorsqu'un provider étend le protocole/wire
porter en N3 les différences auth/metadata/compression/keepalive/replay/from_slot/limites/lifecycle
ne jamais présumer une équivalence provider/standard sans preuve
```
Une divergence réelle n'est donc ni dupliquée arbitrairement ni masquée derrière le standard.
### 3.2 Providers actuellement planifiés
```text
0.2.9 moteur Yellowstone + standard Solana + PublicNode
0.2.10 OrbitFlare Yellowstone gRPC
0.2.11 Helius LaserStream gRPC
```
Chaque release provider commence par un delta audit avec Yellowstone upstream courant et ne matérialise que ses différences réelles.
### 3.3 Providers en attente
Les providers suivants sont retirés de la séquence numérotée :
```text
TODO eRPC
TODO Triton
TODO Alchemy
TODO QuickNode
TODO Chainstack
IDEAS Tatum
IDEAS Shyft
IDEAS Solinfra
IDEAS NodeFlare
IDEAS autres providers
```
Aucun Config profile, type public, dépendance, smoke ou forecast n'est préparé pour eux tant qu'une décision explicite d'implémentation n'est pas prise.
### 3.4 Séquence fonctionnelle libérée
La suppression de la réservation `0.2.12 eRPC` ramène la suite active à :
```text
0.2.12 off-chain price transport
0.2.13 Price Desk + intégration prix Wallet Desk
0.2.14 interface/wire foundation
0.2.15 program-api foundation
```
## 4. Fichiers modifiés
```text
ROADMAP.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md
docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md
```
## 5. Fichier ajouté
```text
deltas/0.2.9/pre.001-fix.002.md
```
## 6. Fichiers non modifiés intentionnellement
```text
Cargo.toml
prompts/014-V0_2_9_START_PROMPT.md
crates/**
config/**
.env.example
```
Le prompt `014` reste l'autorité historique d'ouverture ; le plan et les deltas successifs enregistrent les décisions prises pendant le gate.
## 7. Validation
Correctif documentaire :
```bash
python3 scripts/audit_rust_workspace_rules.py
```
Aucun `cargo check/clippy/test` supplémentaire n'est requis par ce correctif tant qu'aucun artefact code/build/runtime/config n'est modifié.
## 8. État de sortie
```text
N1 moteur unique explicite
N2 standard non présumé universel
N3 peut réutiliser/restreindre/étendre
PublicNode/OrbitFlare/Helius seuls providers planifiés
reste des providers en TODO/IDEAS non numérotés
off-chain price revient en 0.2.12
aucun changement runtime/dependency
```

377
deltas/0.2.9/pre.001.md Normal file
View File

@@ -0,0 +1,377 @@
<!-- file: deltas/0.2.9/pre.001.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.001` — audit/sizing Yellowstone gRPC standard
## 1. Base requise et vérifiée
Archive autoritaire fournie :
```text
khadhroony-solana-project-v0.2.8-full-from-gitea.zip
```
État vérifié :
```text
workspace.package.version = 0.2.8
deltas/0.2.8/rel.001.md présent
prompts/014-V0_2_9_START_PROMPT.md présent
prompt fourni = byte-identique au prompt embarqué
metadata .git absente de l'archive
```
Cette livraison ouvre :
```text
workspace.package.version = 0.2.9-pre.1
commit attendu = v0.2.9-pre.001
aucun tag prerelease
```
## 2. Objet
`pre.001` exécute le gate obligatoire :
```text
lecture base/règles/architecture
baseline stable
réaudit Yellowstone actuel
matrice service/proto
licences/dependencies/MSRV
architecture provider-neutral
threat model/resources/backpressure/replay
sizing et forecast
récensement des providers gRPC gratuits durables
```
Aucune implémentation gRPC lourde n'est ajoutée.
## 3. Baseline opérateur acquise
Preuve fournie sur `v0.2.8` :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py OK / clean
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-onchain-transport-lib fourni
cargo tree --duplicates fourni
```
Transport pendant le workspace test :
```text
335 unit
41 public_api
34 release_completeness
4 doctests
```
Les live smokes restent opt-in/ignored par défaut.
## 4. Réaudit Yellowstone du 2026-08-23
Divergence importante par rapport au snapshot du start prompt :
```text
prompt snapshot release = v14.2.2+solana.4.1.0
release GitHub courante = v15.1.2+solana.4.2.0, publiée 2026-08-18, Rust 1.96.1
client crate courant = yellowstone-grpc-client 13.3.0
proto crate courant = yellowstone-grpc-proto 12.6.0
```
Service Geyser courant inventorié :
```text
Subscribe IN
SubscribeDeshred OUT — Triton extension / pre-execution
SubscribeReplayInfo IN
Ping IN
GetLatestBlockhash IN
GetBlockHeight IN
GetSlot IN
IsBlockhashValid IN
GetVersion IN
```
`SubscribeRequest` standard retenu :
```text
accounts
slots
transactions
transactions_status
blocks
blocks_meta
entry
commitment
accounts_data_slice
ping
from_slot
```
Capacités nouvelles/actuelles retenues car présentes dans le proto publié standard :
```text
CuckooFilter account/block/transaction include
TokenAccountExpansionControlFlag ALL/BALANCE_CHANGED
lamports eq/ne/lt/gt
slot interslot updates
```
`SubscribeUpdate` :
```text
account
slot
transaction
transaction_status
block
ping
pong
block_meta
entry
```
## 5. Licence et dépendances
Upstream :
```text
repository default = AGPL-3.0-only
client subtree = Apache-2.0
proto subtree = Apache-2.0
```
Stratégies comparées :
```text
A client upstream + proto -> rejetée comme default
B proto publié + client KSP autour de Tonic -> RETENUE
C proto vendored KSP -> fallback seulement
```
Raisons de B :
```text
pas de copie proto
wire officiel disponible
licence Apache compatible MIT
reconnect/backpressure/redaction KSP-owned
upstream raw types cachés
stack tonic 0.14 cohérente avec HTTP/2 moderne existant
```
Aucune dependency n'est ajoutée dans `pre.001`. `pre.002` doit matérialiser les features minimales et produire les graphes Cargo.
## 6. Replay/continuity
L'upstream a corrigé en juillet 2026 un cas où `from_slot` pour blocks pouvait être accepté puis reprendre live avec un **state gap** ; l'autoreconnect upstream a aussi été renforcé pour traiter l'equivocation entre nodes.
KSP ferme donc :
```text
reconnect/resubscribe = oui, borné et KSP-owned
from_slot = oui
ReplayInfo = oui
exactly-once = non garanti
lossless = non garanti
gaps/duplicates = observables
node divergence = couverture explicite seulement si prouvée
```
## 7. Config / architecture
Décisions :
```text
backend gRPC distinct de HTTP/WS
pas de WsProtocolKind gRPC
pas de raw Tonic client public
Transport owns Yellowstone
Config -> Transport seulement
metadata publique et metadata sensible séparées
Transport ne lit aucun env
```
Le schéma V2 actuel étant fermé, l'ajout gRPC cible :
```text
format_version = 3
globals.grpc_defaults
profiles[].grpc_endpoints[]
metadata[]
secret_metadata[]
```
V1 et V2 restent backward-readable.
## 8. Providers gRPC gratuits durables
### PublicNode / Allnodes-backed — priorité 1
Confirmé officiellement :
```text
Solana Mainnet Yellowstone GRPC
Solana Testnet GRPC
Mainnet endpoint = solana-yellowstone-grpc.publicnode.com:443
service annoncé free/public
```
Décision : **dans `0.2.9`**, mais pas sous forme d'adapter public spécifique. PublicNode sert de premier smoke live standard provider-neutral.
Le hostname Testnet exact n'est pas figé avant vérification officielle/live.
### OrbitFlare — priorité 2
Plan Free officiel :
```text
$0/mo
10 RPS
1 TPS
gRPC Devnet only
Unlimited credit limits
```
Décision : **dans `0.2.9` comme second smoke live**, via generic secret metadata Config -> Transport. Aucun type `OrbitFlareGrpc*` public.
### Tatum — tertiaire
```text
Mainnet Yellowstone gRPC
Free plan durable
3 RPS
100K lifetime credits
5 subscriptions
```
Décision : opérateur optionnel, pas gate durable principal à cause du quota lifetime.
### Vérifiés non gratuits / non applicables
```text
Helius Free sans gRPC
Shyft Free = No gRPC Access
Alchemy PAYG/Enterprise requis
QuickNode Scale/Business ou add-on payant
Chainstack add-on Yellowstone payant, Growth+
ERPC Geyser payant ; trial seulement 1 jour
NodeFlare plan Yellowstone mensuel payant
Bitquery CoreCast custom, pas Yellowstone standard ; stream free non garanti
```
Candidat à revalider :
```text
Solinfra = free tier + Yellowstone annoncés publiquement, mais accès gRPC inclus dans Free non confirmé
```
## 9. Threat model
Le plan ferme explicitement :
```text
metadata/URI credential leak
Tonic Status arbitraire
oversized messages
stream flood/backpressure
filter explosion / collisions de noms
unknown enum/oneof
server/client half-close
reconnect loop
node divergence
late updates après mutation
from_slot gaps/duplicates
TLS failures
unary timeout
```
## 10. Forecast recalibré
```text
pre.001 DONE audit/sizing/provider/deps/license
pre.002 deps + settings/errors/facade + cargo tree
pre.003 TLS/metadata + local fixture + 7 unary
pre.004 Subscribe common + Cuckoo/token/from_slot/bounds
pre.005 Accounts + Slots
pre.006 Transactions + transaction_status
pre.007 Blocks + block_meta + entry
pre.008 bidi lifecycle/backpressure/half-close/shutdown
pre.009 reconnect/replay/gaps/duplicates/equivocation
pre.010 Config V3
pre.011 PublicNode/OrbitFlare live + compliance + final cargo graphs
pre.012 docs/workspace final + prompt 0.2.10
rel.001 stable
```
Chaque tranche vise ~1520 minutes ; `pre.012` n'est pas une deadline.
Critères de split : conflit MSRV/stack réseau, explosion DTO transaction/block, nouvelle évolution proto matérielle, replay trop large, ou comportement provider-specific contaminant le contrat standard.
## 11. Fichiers ajoutés
```text
docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md
docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md
deltas/0.2.9/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
```
Aucun gRPC runtime/config/dependency n'est introduit avant le gate positif de cette tranche.
## 14. Validations du sandbox de préparation
Le sandbox a pu exécuter :
```text
inspection archive/version/rel/prompt OK
comparaison prompt fourni / prompt archive OK
lecture règles/architecture/plans/code/config OK
réaudit upstream Yellowstone/provider/dependencies OK
python3 scripts/audit_rust_workspace_rules.py OK / clean après overlay
```
Le sandbox ne dispose pas de Cargo ; aucun nouveau `cargo check/clippy/test/tree` n'est déclaré réussi localement après le changement de version/documentation.
## 15. Validation opérateur attendue avant commit
Comme `pre.001` ne modifie aucun Rust/dependency/config runtime, exécuter au minimum :
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
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 : aucune nouvelle dependency gRPC avant `pre.002`.

View File

@@ -0,0 +1,105 @@
<!-- file: deltas/0.2.9/pre.002-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.002-fix.001` — Clippy + runtime Tokio du channel lazy
## 1. Déclencheur
Le premier gate opérateur de `pre.002` a fourni les preuves suivantes :
```text
cargo fmt --all PASS
audit_rust_workspace_rules.py PASS / clean
cargo check --workspace PASS avec warning channel dead_code
cargo clippy --workspace --all-targets FAIL implicit_return dans grpc_settings test
cargo test -p ksp-onchain-transport-lib FAIL 344/345 : connect_lazy hors runtime Tokio
cargo test -p ksp-core-lib --test workspace_dependencies PASS 3/3
cargo tree Transport / duplicates fournis et inspectés
```
Le test gRPC a révélé que `tonic::transport::Endpoint::connect_lazy()` nécessite un reactor Tokio actif et peut paniquer s'il est appelé depuis un contexte synchrone sans runtime. Ce comportement doit être borné dans la surface KSP au lieu d'être laissé comme précondition implicite Tonic.
## 2. Corrections
### Clippy
La closure qui génère l'inventaire d'endpoints excessif dans `unit_tests/grpc_settings.rs` utilise désormais un `return` explicite, conformément à `clippy::implicit_return = deny`.
### Runtime Tokio
`YellowstoneGrpcChannel::prepare()` vérifie maintenant :
```text
tokio::runtime::Handle::try_current()
```
avant l'appel à `connect_lazy()`. En l'absence de runtime actif, KSP retourne :
```text
onchain_transport/grpc_channel_failed
```
avec uniquement `endpoint_name` comme contexte sûr. L'URL et le message interne Tonic ne sont pas réémis.
Le test positif devient un `#[tokio::test(flavor = "current_thread")]`. Un nouveau test synchrone prouve que l'absence de runtime est retournée comme erreur sans exposer l'URL canari.
### Champ Tonic
Le channel **reste un champ de production**. Il n'est pas placé sous `#[cfg(test)]`, car `pre.002` matérialise réellement la ressource physique N1 qui sera consommée en `pre.003`.
Le champ privé est temporairement nommé :
```text
_channel: tonic::transport::Channel
```
pour exprimer qu'il est intentionnellement conservé mais pas encore consommé par une opération de production dans cette tranche. Cela supprime le warning `dead_code` sans modifier la shape production/test.
## 3. Graphe Cargo observé
Le gate opérateur confirme :
```text
tonic 0.14.6
yellowstone-grpc-proto 12.6.0
prost/prost-types 0.14.4
solana-pubkey 4.3.0 unifié
http 1.5.0
hyper 1.11.0
hyper-util 0.1.20
tower 0.5.3
bytes 1.12.1
```
Les occurrences Prost 0.14.4 dans `--duplicates` viennent des unités runtime/build (`prost-build`, `tonic-prost-build`) de la même version ; aucune seconde version concurrente n'est introduite.
## 4. Version
Le fix touche le code/runtime ; la version workspace devient :
```text
0.2.9-pre.2.fix.1
```
Commit attendu après validation :
```text
v0.2.9-pre.002-fix.001
```
## 5. Validation requise
```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-core-lib --test workspace_dependencies
cargo test --workspace
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
cargo tree --duplicates
```
Le passage à `pre.003` est interdit tant que ces gates ne sont pas verts ou qu'un nouvel écart n'est pas traité par un fix supplémentaire.

View File

@@ -0,0 +1,68 @@
<!-- file: deltas/0.2.9/pre.002-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.002-fix.002` — canari public API sous runtime Tokio
## 1. Déclencheur
Le second gate opérateur, après `pre.002-fix.001`, confirme :
```text
cargo fmt --all PASS
audit_rust_workspace_rules.py PASS / clean
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test -p ksp-onchain-transport-lib unit PASS 346/346
public_api FAIL 41/42
dependency canary Core PASS 3/3
```
Le seul échec restant est le canari :
```text
public_v0_2_9_pre_002_yellowstone_engine_settings_and_lazy_channel_are_available_from_crate_root
```
Il appelle `YellowstoneGrpcChannel::prepare()` depuis un `#[test]` synchrone. Depuis `fix.001`, `prepare()` exige volontairement un runtime Tokio actif avant `tonic::transport::Endpoint::connect_lazy()` ; le canari public API n'avait pas encore été aligné sur cette précondition KSP explicite.
## 2. Correction
Le test d'intégration devient :
```text
#[tokio::test(flavor = "current_thread")]
async fn public_v0_2_9_pre_002_...
```
Aucun code N1 de production n'est modifié. En particulier :
```text
YellowstoneGrpcChannel::prepare() inchangé
_channel de production conservé
aucun cfg(test) ajouté au channel
aucune dépendance/feature Cargo ajoutée
aucun TLS/metadata/unary anticipé
```
## 3. Version
Le correctif touche le code de test et fait partie du signal technique de la prerelease :
```text
workspace.package.version = 0.2.9-pre.2.fix.2
commit attendu = v0.2.9-pre.002-fix.002
```
## 4. Validation requise
```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-core-lib --test workspace_dependencies
cargo test --workspace
```
Les graphes Cargo n'ont pas changé avec `fix.002`; ils ne nécessitent pas une nouvelle inspection structurelle, mais peuvent être relancés si l'opérateur veut conserver une preuve complète du gate final `pre.002`.

259
deltas/0.2.9/pre.002.md Normal file
View File

@@ -0,0 +1,259 @@
<!-- file: deltas/0.2.9/pre.002.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.002` — moteur Yellowstone : dépendances + settings/errors + channel minimal
## 1. Objet
Cette tranche matérialise uniquement la première fondation **N1 moteur Yellowstone gRPC** validée par `pre.001` :
```text
proto Yellowstone publié
+ dépendances Tonic minimales
+ settings runtime bornés/redacted
+ error code initial gRPC
+ channel Tonic lazy privé
```
Elle ne commence pas encore N2 fonctionnel : aucun unary Yellowstone, aucune metadata/auth, aucune fixture serveur gRPC, aucun `Subscribe`, aucun lifecycle bidi et aucune intégration PublicNode ne sont introduits ici.
Version workspace :
```text
0.2.9-pre.2
```
Commit attendu après validation opérateur :
```text
v0.2.9-pre.002
```
## 2. Dépendances matérialisées
Stratégie B conservée, mais avec le feature set le plus étroit utile à cette tranche :
```text
workspace root:
tonic = ^0.14, default-features = false
yellowstone-grpc-proto = ^12.6, default-features = false
ksp-onchain-transport-lib:
tonic workspace = true, features = ["channel"]
yellowstone-grpc-proto workspace = true, aucune feature locale
```
Explicitement absents :
```text
yellowstone-grpc-client
tonic-prost direct KSP
prost direct KSP
prost-types direct KSP
tokio-stream nouveau
feature tonic du proto
feature tonic-compression du proto
TLS Tonic
```
La crate publiée `yellowstone-grpc-proto` fournit les messages Protobuf générés utiles sans que KSP copie les `.proto`. Son build utilise son outillage `protoc` vendored ; aucun `protoc` système ni `build.rs` KSP n'est ajouté.
Le feature optionnel `tonic` de la crate proto n'est pas activé en `pre.002` : le moteur ne consomme encore aucun client RPC généré. `pre.003` devra justifier explicitement toute feature/dependency supplémentaire nécessaire aux unary, TLS et metadata.
## 3. Settings Yellowstone N1
Ajout de `grpc_settings.rs` avec les contrats publics crate-root :
```text
YellowstoneGrpcEndpointUrl
YellowstoneGrpcProviderName
YellowstoneGrpcClusterName
YellowstoneGrpcReconnectSettings
YellowstoneGrpcSessionSettings
YellowstoneGrpcEndpointSettings
YellowstoneGrpcTransportSettings
```
Principes :
```text
Transport-owned
aucun Config/env
aucun WsProtocolKind/WsEndpointSettings
URL http/https distincte de HTTP/WS et Debug redacted
provider/cluster = descripteurs ouverts, pas des enums commerciaux fermés
URL/descripteurs/nombre dendpoints bornés
connect/unary/close timeouts bornés
reconnect attempts/backoff bornés
request/update channel capacities bornées
max inbound/outbound message sizes bornées
au moins un endpoint configuré et enabled
endpoint names uniques
```
Les defaults servent uniquement de runtime defaults N1 ; ils ne deviennent pas des quotas provider.
## 4. Channel minimal
Ajout de `YellowstoneGrpcChannel` dans `grpc_channel.rs`.
`prepare()` :
```text
valide les settings avant I/O
refuse un endpoint disabled
construit tonic::transport::Endpoint depuis l'URL sensible
applique connect timeout / unary timeout / request buffer
utilise connect_lazy()
ne réalise aucune connexion réseau
ne configure encore ni TLS ni metadata
ne recopie pas les erreurs URI Tonic arbitraires dans KspError
```
Le `tonic::transport::Channel` réel est un champ privé et n'a aucun getter public. `Debug` n'expose ni URL, ni channel brut, ni credential potentiel.
Cette surface ne constitue pas encore `YellowstoneGrpcSession` : elle prépare seulement la ressource physique minimale qui sera utilisée par `pre.003+`.
## 5. Erreurs et observabilité
Nouveau code :
```text
onchain_transport/grpc_channel_failed
```
Les erreurs de préparation conservent uniquement des contexts allowlistés sûrs (`endpoint_name`) et n'injectent pas le message d'erreur Tonic/URI dans le contrat KSP.
Les logs passent exclusivement par `ksp-logging-lib` et n'émettent que :
```text
endpoint_name
provider
cluster
bornes numériques non sensibles
```
Aucune URL n'est loggée.
## 6. Canaries ajoutés
Unit tests Transport :
```text
gRPC URL http/https + Debug redacted
gRPC URL non-http ou surdimensionnée rejetée sans secret echo
descripteurs et endpoint inventory plafonnés
defaults session bornés
zero/excessive/reversed runtime settings rejetés
endpoint inventory unique/enabled
channel lazy préparé sans réseau
channel Debug sans URL/secret
endpoint disabled rejeté avant I/O
raw proto disponible uniquement côté interne/test
```
Public API :
```text
settings N1 constructibles depuis crate root
YellowstoneGrpcChannel constructible sans Config
nouvel error code public KSP
aucun raw client Tonic requis par le consumer
```
Release completeness / workspace dependency canaries :
```text
dépendances root/member exactes
yellowstone-grpc-client absent
Transport -X-> Config
aucun couplage Ws*
aucun pub use tonic/yellowstone_grpc_proto
channel = from_shared + connect_lazy
TLS/metadata explicitement absents de pre.002
```
## 7. Documentation
Les versions opérateur alignées de :
```text
docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md
docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md
```
sont conservées comme base de mise en forme et avancées pour enregistrer :
```text
feature set réellement matérialisé
séparation stricte pre.002 / pre.003
surface N1 réellement ouverte
état des canaries
Cargo gates encore opérateur
```
Le forecast reste inchangé en nombre de tranches : `pre.003` porte toujours TLS/metadata + fixture locale + 7 unary.
## 8. Validation exécutée dans l'environnement de préparation
Exécuté réellement :
```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
```
L'environnement de préparation ne possède pas Cargo/Rust. Les commandes Cargo ci-dessous ne sont donc **pas déclarées réussies**.
## 9. Gate opérateur requis 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
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
cargo tree --duplicates
```
Inspecter particulièrement :
```text
yellowstone-grpc-proto 12.6.x réellement résolu
tonic 0.14.x réellement résolu
prost / prost-types / tonic-prost transitifs
bytes / http / hyper / hyper-util / tower
rustls / tokio-rustls
solana-pubkey unifié ou doublons justifiés
aucune arrivée de yellowstone-grpc-client
features Tonic réellement activées
```
Si la compilation démontre que `yellowstone-grpc-proto` sans feature `tonic` ou Tonic `channel` ne suffit pas à la tranche actuelle, ne pas élargir silencieusement : produire un `pre.002-fix.001` avec la dépendance minimale démontrée.
## 10. Fichiers ajoutés/modifiés
```text
Cargo.toml
crates/ksp-core-lib/tests/workspace_dependencies.rs
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/src/error.rs
crates/ksp-onchain-transport-lib/src/grpc_channel.rs
crates/ksp-onchain-transport-lib/src/grpc_settings.rs
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs
crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs
docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md
docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md
deltas/0.2.9/pre.002.md
```

View File

@@ -0,0 +1,82 @@
<!-- file: deltas/0.2.9/pre.003-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.003-fix.001` — conformité Clippy de la fixture unary
## 1. Déclencheur
Le premier gate opérateur `pre.003` confirme :
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py PASS / clean
cargo check --workspace PASS
cargo clippy --workspace --all-targets FAIL
11 x clippy::implicit_return dans unit_tests/grpc_unary.rs
cargo test -p ksp-onchain-transport-lib PASS
unit 354/354
public_api 43/43
release_completeness 36/36
doctests 4/4
cargo test -p ksp-core-lib --test workspace_dependencies
PASS 3/3
cargo test --workspace PASS
```
Le problème est donc limité à la conformité Clippy de la fixture locale ; aucune défaillance fonctionnelle TLS/metadata/unary n'est observée.
## 2. Diagnostic
Neuf diagnostics pointent les méthodes de l'implémentation `Geyser` annotée `#[tonic::async_trait]`. Les corps source ont déjà leurs retours explicites ; le lint vise les wrappers/tails async issus de l'expansion de macro et propose un `return async fn ...` non applicable au code Rust source.
Deux diagnostics supplémentaires concernent les closures `and_then` utilisées pour lire les metadata de fixture ; ces retours sont directement sous contrôle KSP.
## 3. Correction
```text
impl FixtureGeyser :
allow(clippy::implicit_return) strictement local et commenté
aucune relaxation au niveau crate/workspace
closures metadata :
return explicite conforme à la politique workspace
```
Aucun changement de production :
```text
YellowstoneGrpcChannel inchangé
SolanaYellowstoneGrpcUnaryClient inchangé
TLS/metadata/settings inchangés
7 unary inchangés
aucune dépendance/feature Cargo ajoutée ou retirée
Subscribe/PublicNode/Config V3 toujours hors tranche
```
## 4. Version
```text
workspace.package.version = 0.2.9-pre.3.fix.1
commit attendu = v0.2.9-pre.003-fix.001
```
## 5. Validation requise
```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-core-lib --test workspace_dependencies
cargo test --workspace
```
Les dépendances et features ne changent pas avec ce fix. Les graphes Cargo demandés par `pre.003` restent néanmoins à fournir/inspecter si ce n'est pas déjà fait :
```bash
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib -e features
cargo tree -p ksp-onchain-transport-lib --duplicates
cargo tree --duplicates
```

294
deltas/0.2.9/pre.003.md Normal file
View File

@@ -0,0 +1,294 @@
<!-- file: deltas/0.2.9/pre.003.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.003` — TLS + metadata générique + fixture locale + 7 unary Yellowstone
## 1. Objet
Cette tranche poursuit le moteur Yellowstone après la fermeture opérateur complète de `pre.002-fix.002`. Elle matérialise exactement le forecast prévu :
```text
N1 : connexion HTTP/2 réelle + TLS WebPKI + metadata ASCII publique/secrète redacted
N2 : sept unary RPCs Yellowstone standard typed derrière une façade KSP
tests : fixture Geyser locale couvrant le wire réel, metadata, Status hostile et timeout
```
Restent hors tranche :
```text
Subscribe / streaming bidi
SubscribeDeshred
PublicNode / N3 provider
Config V3
reconnect/resubscribe/replay lifecycle complet
```
Version workspace :
```text
0.2.9-pre.3
```
Commit attendu après validation opérateur :
```text
v0.2.9-pre.003
```
## 2. Preuve héritée — fermeture de `pre.002`
Le gate opérateur final fourni pour `0.2.9-pre.2.fix.2` est intégralement vert :
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py PASS / clean
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test -p ksp-onchain-transport-lib PASS
unit 346/346
public_api 42/42
release_completeness 35/35
doctests 4/4
cargo test -p ksp-core-lib --test workspace_dependencies
PASS 3/3
cargo test --workspace PASS
```
`pre.003` part donc d'une base technique fermée, sans dette reportée de `pre.002`.
## 3. Dépendances et features
La stratégie B reste inchangée : le runtime KSP utilise les messages publiés `yellowstone-grpc-proto` et un client KSP autour de Tonic ; `yellowstone-grpc-client` reste absent.
Ajouts workspace :
```text
http = ^1.5, default-features = false
tonic-prost = ^0.14, default-features = false
```
Features Transport runtime :
```text
tonic = channel + tls-aws-lc + tls-webpki-roots
tonic-prost = runtime ProstCodec
yellowstone-grpc-proto = aucune feature locale
```
Features uniquement dev/test :
```text
tonic = codegen + server
yellowstone-grpc-proto = tonic
```
La feature `tonic` de la crate proto est donc utilisée uniquement pour générer `GeyserServer` dans la fixture locale. Le runtime ne consomme ni le client généré upstream ni sa sémantique de lifecycle.
Toujours absents comme dépendances KSP directes :
```text
yellowstone-grpc-client
prost
prost-types
```
Le choix `tls-aws-lc` reste aligné avec la stack Rustls déjà présente via Reqwest ; le gate Cargo opérateur doit confirmer l'absence de duplication injustifiée.
## 4. N1 — TLS, connexion réelle et metadata
`YellowstoneGrpcChannel` possède maintenant deux chemins explicites :
```text
prepare() -> connect_lazy(), aucun socket
connect() -> établissement réel du channel HTTP/2
```
Pour un endpoint `https://`, le builder applique :
```text
ClientTlsConfig
WebPKI roots
TLS handshake timeout borné
connect timeout borné
request buffer borné
```
Les erreurs d'établissement restent projetées vers `onchain_transport/grpc_channel_failed` sans recopier URI ou message arbitraire Tonic.
Ajout de `YellowstoneGrpcMetadataEntry` :
```text
ASCII uniquement
clé <= 128 bytes
valeur <= 8 KiB
<= 64 entrées par endpoint
clés grpc-* refusées
metadata binaire *-bin hors contrat pre.003
public et secret séparés explicitement
valeurs toujours absentes de Debug
secret -> MetadataValue::set_sensitive(true)
```
Transport ne connaît aucun nom `KSP_SECRET_*`, ne lit pas l'environnement et ne lie aucune clé provider particulière au standard N1/N2.
## 5. N2 — sept unary standard
Ajout de `SolanaYellowstoneGrpcUnaryClient`, construit uniquement depuis `YellowstoneGrpcChannel`. Le Tonic `Channel`, `Grpc`, les Requests et les messages protobuf restent privés.
Surface exacte :
```text
SubscribeReplayInfo
Ping
GetLatestBlockhash
GetBlockHeight
GetSlot
IsBlockhashValid
GetVersion
```
Les chemins wire sont les chemins `geyser.Geyser` officiels. `SolanaCommitment` existant est réutilisé et mappé vers `Processed / Confirmed / Finalized`.
DTOs publics KSP :
```text
YellowstoneReplayInfo
YellowstonePong
YellowstoneLatestBlockhash
YellowstoneBlockHeight
YellowstoneSlot
YellowstoneBlockhashValidity
YellowstoneVersionInfo
```
Bornes/sécurité :
```text
max inbound/outbound appliqués au dispatcher Tonic
une seule deadline KSP couvre readiness + metadata + unary dispatch
blockhash request localement borné/validé avant I/O
blockhash/version response textuellement bornés
Ping exige l'echo exact du count
```
Nouveau code d'erreur :
```text
onchain_transport/grpc_status
```
Un `tonic::Status` distant n'est jamais copié dans `KspError` : seuls l'opération et le code gRPC sont conservés. Message/details/metadata provider restent hors diagnostic public.
## 6. Fixture Geyser locale
Les tests dev activent le serveur généré officiel et démarrent un `GeyserServer` sur `127.0.0.1:0`. Les deux RPCs streaming obligatoires du trait retournent volontairement `Unimplemented`, car ils restent hors `pre.003`.
La fixture vérifie réellement :
```text
les 7 paths unary
commitment wire
metadata publique reçue
metadata secrète reçue
replay optional first_available
Ping exact
latest blockhash / height / slot / validity / version
Status hostile contenant un canari secret non propagé
timeout unary local déterministe
blockhash invalide rejeté avant I/O
Debug client/channel sans URL ni secret
```
La fixture est locale et déterministe ; aucun provider externe n'est requis pour fermer cette tranche.
## 7. Canaries structurels
Les canaries vérifient en plus :
```text
feature set runtime/dev exact
yellowstone-grpc-client absent
proto runtime sans feature tonic
server/codegen uniquement dev/test
aucun pub use tonic/yellowstone_grpc_proto
sept chemins unary exacts
aucun Subscribe standard ajouté
aucun SubscribeDeshred ajouté
aucun PublicNode ajouté
Transport -X-> Config/env/WS pour la surface gRPC
```
## 8. Documentation
Mise à jour de :
```text
docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md
docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md
```
La matrice enregistre la fermeture réelle de `pre.002` et place les sept unary/TLS/metadata/fixture en candidate `pre.003`. Le forecast reste inchangé : `pre.004` ouvre seulement la foundation `Subscribe`.
## 9. Validation exécutée dans l'environnement de préparation
Exécuté réellement après les modifications source/documentaires :
```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
```
L'environnement de préparation ne fournit pas Cargo/Rust ; aucune commande Cargo n'est déclarée réussie localement.
## 10. Gate opérateur requis
```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-core-lib --test workspace_dependencies
cargo test --workspace
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib -e features
cargo tree -p ksp-onchain-transport-lib --duplicates
cargo tree --duplicates
```
Les graphes doivent être réinspectés car `http`, `tonic-prost` et les features TLS/server/codegen changent le graphe de `pre.002`. Contrôler notamment :
```text
tonic / tonic-prost
prost / prost-types
http / hyper / hyper-util / tower
rustls / tokio-rustls / aws-lc
yellowstone-grpc-proto runtime vs dev feature unification
solana-pubkey
yellowstone-grpc-client absent
router/gzip/zstd non activés par la surface KSP ; server/codegen uniquement via dev/test
```
## 11. Fichiers ajoutés/modifiés
```text
Cargo.toml
crates/ksp-core-lib/tests/workspace_dependencies.rs
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/src/error.rs
crates/ksp-onchain-transport-lib/src/grpc_channel.rs
crates/ksp-onchain-transport-lib/src/grpc_settings.rs
crates/ksp-onchain-transport-lib/src/grpc_unary.rs
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs
crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs
crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs
docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md
docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md
deltas/0.2.9/pre.003.md
```

View File

@@ -0,0 +1,64 @@
<!-- file: deltas/0.2.9/pre.004-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.004-fix.001` — suppression des warnings `dead_code` des conversions wire test-only
## 1. Déclencheur
Le premier gate opérateur `pre.004` est fonctionnellement vert :
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py PASS / clean
cargo check --workspace PASS + 10 warnings dead_code
cargo clippy --workspace --all-targets PASS + 10 warnings dead_code
Transport unit 359/359 PASS
Transport public_api 44/44 PASS
Transport release_completeness 37/37 PASS
Transport doctests 4/4 PASS
Core workspace_dependencies 3/3 PASS
cargo test --workspace PASS + mêmes warnings de compilation
```
Les dix warnings concernent exclusivement les conversions protobuf privées de `grpc_subscribe.rs` (`to_wire` et `commitment_to_wire`).
## 2. Diagnostic
`pre.004` matérialise le contrat `SubscribeRequest` et vérifie son wire dans les unit tests, mais le stream bidi n'est volontairement ouvert qu'en `pre.008`. Ces helpers ont donc un consommateur test aujourd'hui et aucun consommateur runtime encore.
Contrairement au channel N1 de `pre.002`, ces fonctions ne représentent pas un état runtime devant exister avant leur consommation. Les compiler en production maintenant ne fait qu'introduire du code mort temporaire.
## 3. Correction
```text
#[cfg(test)] sur les 9 méthodes to_wire de grpc_subscribe.rs
#[cfg(test)] sur commitment_to_wire
aucune modification des types publics
aucune modification des validations/bornes
aucune modification du protobuf produit par les tests
aucune dépendance/feature Cargo modifiée
```
Lors de `pre.008`, quand la session bidi consommera réellement ces conversions, le `cfg(test)` sera retiré des helpers nécessaires au runtime.
## 4. Version
```text
workspace.package.version = 0.2.9-pre.4.fix.1
commit attendu = v0.2.9-pre.004-fix.001
aucun tag prerelease
```
## 5. Validation requise
```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-core-lib --test workspace_dependencies
cargo test --workspace
```
Aucun `cargo tree` supplémentaire n'est requis : le fix ne modifie ni dépendance ni feature.

250
deltas/0.2.9/pre.004.md Normal file
View File

@@ -0,0 +1,250 @@
<!-- file: deltas/0.2.9/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.004` — fondation `SubscribeRequest` Yellowstone standard
## 1. Base et signal de version
Base obligatoire :
```text
0.2.9-pre.3.fix.1
```
Le gate opérateur fourni ferme `pre.003-fix.001` :
```text
cargo fmt --all PASS
python3 scripts/audit_rust_workspace_rules.py PASS / clean
cargo check --workspace PASS
cargo clippy --workspace --all-targets PASS
cargo test --workspace PASS
Transport unit 354/354
Transport public_api 43/43
Transport release_completeness 36/36
Transport doctests 4/4
cargo tree -p ... fourni/relu
cargo tree -p ... -e features fourni/relu
cargo tree -p ... --duplicates fourni/relu
cargo tree --duplicates fourni/relu
```
Le graphe pertinent reste unifié sur `tonic 0.14.6`, `tonic-prost 0.14.6`, `prost/prost-types 0.14.4`, `yellowstone-grpc-proto 12.6.0` et `solana-pubkey 4.3.0`.
Cette tranche non-fix synchronise :
```text
workspace.package.version = 0.2.9-pre.4
commit attendu = v0.2.9-pre.004
aucun tag prerelease
```
## 2. Scope exact
`pre.004` matérialise uniquement la fondation N2 du `SubscribeRequest` standard publié :
```text
accounts
slots
transactions
transactions_status
blocks
blocks_meta
entry
commitment
accounts_data_slice
ping
from_slot
```
Le proto `yellowstone-grpc-proto 12.6.0` a été recontrôlé avant implémentation et conserve exactement ces champs top-level.
Sont explicitement hors tranche :
```text
champs détaillés Accounts / Slots -> pre.005
champs détaillés Transactions / transaction_status -> pre.006
champs détaillés Blocks / block_meta / entry updates -> pre.007
stream bidi / mutation / Ping-Pong runtime -> pre.008
reconnect / replay / gaps / duplicates -> pre.009
Config V3 / PublicNode -> pre.010/011
SubscribeDeshred -> OUT 0.2.9 standard
```
## 3. Contrat KSP ajouté
Nouveaux types publics :
```text
YellowstoneSubscribeFilterName
YellowstoneAccountsDataSlice
YellowstoneSubscribePing
YellowstoneSubscribeAccountFilter
YellowstoneSubscribeSlotFilter
YellowstoneSubscribeTransactionFilter
YellowstoneSubscribeBlockFilter
YellowstoneSubscribeBlocksMetaFilter
YellowstoneSubscribeEntryFilter
YellowstoneSubscribeRequest
```
Les quatre filtres de famille riches sont volontairement des shells typés en `pre.004`. Ils permettent de matérialiser les sept maps et les entrées nommées vides sans anticiper les champs propres aux tranches suivantes.
Le wire `yellowstone_grpc_proto::geyser::SubscribeRequest` reste privé : aucun type Tonic/Prost/Yellowstone n'est réexporté au crate root.
## 4. Sémantique maps / noms
KSP conserve sept maps logiques indépendantes.
```text
map vide = aucun filtre actif dans la famille
entrée nommée + message vide = activation explicite d'un groupe vide supporté par le proto
```
Le Protobuf ne distingue pas une map top-level omise d'une map vide ; KSP ne prétend donc pas préserver une différence wire inexistante.
Les noms sont :
```text
non vides
trim exact
sans caractère de contrôle
<= 128 octets UTF-8
uniques globalement entre les sept maps
```
L'unicité globale évite l'ambiguïté lorsque Yellowstone renvoie seulement les noms correspondants dans `SubscribeUpdate.filters[]`.
Le nombre total de groupes nommés est plafonné à `1024` sur l'ensemble des sept maps.
## 5. Champs communs et bounds
`commitment` réutilise `SolanaCommitment` et mappe exactement :
```text
Processed -> PROCESSED
Confirmed -> CONFIRMED
Finalized -> FINALIZED
```
`accounts_data_slice` :
```text
ordre d'insertion conservé
nombre <= 128
length <= 64 MiB
offset + length doit rester représentable en u64
length = 0 reste représentable
```
`ping` conserve l'identifiant `i32` exact.
`from_slot` conserve l'optional `u64` sans lui attribuer encore une promesse de replay/lossless ; sa sémantique lifecycle reste en `pre.009`.
## 6. Diagnostics et sécurité
`Debug` de `YellowstoneSubscribeFilterName` est redacted.
`Debug` de `YellowstoneSubscribeRequest` expose uniquement :
```text
compteurs par famille
commitment
nombre de data slices
ping
from_slot
```
Il n'expose aucun nom de filtre ni futur payload account/transaction/block.
Les erreurs déterministes utilisent `ERROR_CODE_INVALID_RPC_PARAMETERS` et n'incluent pas les valeurs de noms/payloads rejetés.
## 7. Dépendances
Aucune dépendance ni feature Cargo n'est modifiée dans `pre.004`.
Les graphes fournis pour `pre.003-fix.001` restent l'autorité courante ; aucun nouveau `cargo tree` n'est requis spécifiquement par ce delta sauf anomalie de compilation.
## 8. Tests/canaries ajoutés
Unit tests :
```text
nom vide/whitespace/control/oversized rejeté
Debug nom redacted
unicité globale cross-family
request vide -> sept maps vides + common absent
sept entrées nommées vides -> wire exact
commitment/ping/from_slot exacts
ordre data slices conservé
zero-length slice conservée
slice length/overflow/count bornés
filter-group count borné
request Debug sans filter name
```
Public API : construction des sept maps et champs communs depuis le crate root.
Release completeness :
```text
onze champs top-level présents dans l'adapter
bounds communs matérialisés
aucun SubscribeDeshred
aucun PublicNode/OrbitFlare/Helius runtime
aucun raw Tonic/Yellowstone reexport
to_wire protobuf non public
```
## 9. Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs
crates/ksp-onchain-transport-lib/unit_tests/grpc_subscribe.rs
deltas/0.2.9/pre.004.md
```
## 10. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md
docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md
```
## 11. Validation de préparation
```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
```
L'environnement de préparation ne fournit pas Cargo/Rust ; aucune compilation n'est déclarée réussie localement.
## 12. Gate opérateur attendu
```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-core-lib --test workspace_dependencies
cargo test --workspace
```
Attendu si aucun fix n'est nécessaire :
```text
Transport unit 359
Transport public_api 44
Transport release_completeness 37
Transport doctests 4
```
Les live smokes restent ignored/opt-in.

View File

@@ -0,0 +1,61 @@
<!-- file: deltas/0.2.9/pre.005-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.9-pre.005-fix.001`
## Objet
Corriger le premier gate opérateur de `0.2.9-pre.005` sans modifier le contrat fonctionnel Accounts + Slots.
## Preuve opérateur avant fix
Le gate `pre.005` confirme :
```text
cargo fmt --all PASS
workspace Rust audit PASS / clean
cargo check --workspace PASS + 4 dead_code warnings
cargo clippy --workspace --all-targets FAIL: implicit_return + wrong_self_convention
Transport unit 364/364 PASS
Transport public_api 45/45 PASS
Transport release_completeness 38/38 PASS
Transport doctests 4/4 PASS
Core dependency canary 3/3 PASS
cargo test --workspace PASS + 4 dead_code warnings
```
## Corrections
| Surface | Correction | Justification |
|-------------------------------------------|--------------------------------------------|--------------------------------------------------------------------------------|
| quatre constantes de bounds update | ajout `#[cfg(test)]` | uniquement consommées par les décodeurs Account/Slot test-only avant `pre.008` |
| `YellowstoneSubscribeSlotFilter::to_wire` | receiver `self` | type `Copy`, conformité `clippy::wrong_self_convention` |
| `dead_error.as_ref().is_some_and(...)` | `return` explicite dans la closure | conformité `-D clippy::implicit-return` |
| `016` + `012` | réalignement de tous les tableaux Markdown | préserve le format visuel manuel demandé par l'opérateur |
| workspace version | `0.2.9-pre.5.fix.1` | signal technique de fix |
## Non-changements
```text
aucun changement du contrat public Accounts/Slots
aucun changement du wire protobuf
aucune dépendance/feature Cargo
aucun stream bidi
aucune famille Transactions/Blocks
aucun PublicNode / Config V3
aucun allow(dead_code)
```
## Gate attendu
```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-core-lib --test workspace_dependencies
cargo test --workspace
```
Critère de fermeture : `check`, Clippy et workspace passent sans les quatre warnings `dead_code` de `pre.005`.

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