53 Commits

Author SHA1 Message Date
d70c3a1672 v0.2.8-rel.001 2026-08-23 23:14:58 +02:00
7cdf5e80c9 v0.2.8-pre.011 2026-08-23 20:01:28 +02:00
d8bfd7cd2e v0.2.8-pre.010 2026-08-23 18:35:44 +02:00
9c0d4fc197 v0.2.8-pre.009 2026-08-23 18:16:03 +02:00
7c12ec886b v0.2.8-pre.008 2026-08-23 17:19:44 +02:00
7eb6dec809 v0.2.8-pre.007-fix.004 2026-08-23 16:54:00 +02:00
68f4384c5b v0.2.8-pre.007-fix.003 2026-08-23 16:44:31 +02:00
9cc140fb84 v0.2.8-pre.007-fix.002 2026-08-23 16:16:39 +02:00
3b64d1e0ec v0.2.8-pre.007-fix.001 2026-08-23 16:11:44 +02:00
56b9ce6abc v0.2.8-pre.007 2026-08-23 16:05:41 +02:00
1c8d69778b v0.2.8-pre.006 2026-08-23 15:41:54 +02:00
8e739b9e55 v0.2.8-pre.005-fix.002 2026-08-23 15:13:03 +02:00
53dbb5bccd v0.2.8-pre.005-fix.001 2026-08-23 15:02:41 +02:00
92224e5ac6 v0.2.8-pre.005 2026-08-23 14:49:56 +02:00
cbb4e7b0de v0.2.8-pre.004-fix.002 2026-08-23 14:27:25 +02:00
38fd62c256 v0.2.8-pre.004-fix.001 2026-08-23 14:20:24 +02:00
f2a3ec62aa v0.2.8-pre.004 2026-08-23 14:08:12 +02:00
b3363073c4 v0.2.8-pre.003 2026-08-23 13:52:57 +02:00
c94a54f3e3 v0.2.8-pre.002-fix.001 2026-08-23 13:22:19 +02:00
0871b85df9 v0.2.8-pre.002 2026-08-23 13:15:42 +02:00
315e7e67e5 v0.2.8-pre.001-fix.002 2026-08-23 12:53:13 +02:00
df95f2f558 v0.2.8-pre.001-fix.001 2026-08-23 12:46:17 +02:00
f0865d5137 v0.2.8-pre.001 2026-08-23 12:36:54 +02:00
4d77b607ea v0.2.7-rel.001 2026-08-23 11:38:52 +02:00
307711f873 v0.2.7-pre.014-fix.001 2026-08-23 11:28:01 +02:00
5aa7b45840 v0.2.7-pre.014 2026-08-23 11:15:57 +02:00
3c5786f273 v0.2.7-pre.013 2026-08-23 10:24:28 +02:00
3b4d355537 v0.2.7-pre.012-fix.001 2026-08-23 09:55:34 +02:00
628b4f12f2 v0.2.7-pre.012 2026-08-23 09:40:44 +02:00
66deaf8245 v0.2.7-pre.011-fix.001 2026-08-23 09:17:34 +02:00
0e256a8ecf v0.2.7-pre.011-fix.001 2026-08-23 09:17:13 +02:00
9eb0e19d81 v0.2.7-pre.011 2026-08-23 08:58:03 +02:00
74686892e9 v0.2.7-pre.010-fix.001 2026-08-23 00:26:17 +02:00
1391858972 v0.2.7-pre.010 2026-08-23 00:12:39 +02:00
98bf88e431 v0.2.7-pre.009-fix.002 2026-08-22 23:26:09 +02:00
f0f444bc86 v0.2.7-pre.009-fix.001 2026-08-22 23:14:32 +02:00
d17161234a v0.2.7-pre.009 2026-08-22 23:08:47 +02:00
93199d1856 v0.2.7-pre.008-fix.001 2026-08-22 21:54:27 +02:00
6fefc64e75 v0.2.7-pre.008 2026-08-22 21:44:18 +02:00
4c540d67a7 v0.2.7-pre.007-fix.001 2026-08-22 21:00:29 +02:00
b67fa89f44 v0.2.7-pre.007 2026-08-22 20:42:06 +02:00
435126f67a v0.2.7-pre.006-fix.001 2026-08-22 20:08:55 +02:00
8721e54b18 v0.2.7-pre.006 2026-08-22 19:56:20 +02:00
6e3a0fa034 v0.2.7-pre.005 2026-08-22 19:16:41 +02:00
34637848eb v0.2.7-pre.004-fix.001 2026-08-22 18:34:52 +02:00
778ea58ee1 v0.2.7-pre.004 2026-08-22 18:21:20 +02:00
b0461f15ec v0.2.7-pre.003 2026-08-22 17:56:17 +02:00
d5df0fe9af v0.2.7-pre.003 2026-08-22 17:56:05 +02:00
ab29dc51bb v0.2.7-pre.002-fix.001 2026-08-22 16:55:21 +02:00
b6908cb573 v0.2.7-pre.002-fix.001 2026-08-22 16:49:43 +02:00
b64a799c85 v0.2.7-pre.002 2026-08-22 16:39:13 +02:00
cf4b28df2b v0.2.7-pre.001-fix.001 2026-08-22 16:00:56 +02:00
9e8fd53291 v0.2.7-pre.001 2026-08-22 15:34:03 +02:00
124 changed files with 27972 additions and 231 deletions

View File

@@ -1,5 +1,5 @@
# file: .env.example # file: .env.example
# version: 4 # version: 6
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths. # 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. # The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
@@ -22,10 +22,22 @@ KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
# The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent. # The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent.
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
# Optional public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
# The committed Transport document falls back to wss://api.mainnet-beta.solana.com when this variable is absent.
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected. # Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON. # 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 # KSP_SECRET_SOLANA_HTTP_URL=https://provider.example/?api-key=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. # Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
KSP_DESK_SPLASH_FADE_IN_MS=300 KSP_DESK_SPLASH_FADE_IN_MS=300

View File

@@ -1,10 +1,22 @@
<!-- file: CHANGELOG.md --> <!-- file: CHANGELOG.md -->
<!-- version: 10 --> <!-- version: 12 -->
# Changelog KSP # 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/`. 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.8 — Helius LaserStream WebSocket — 2026-08-23
`0.2.8` étend `ksp-onchain-transport-lib` avec une façade `HeliusLaserStreamWsSession` dédiée qui réutilise le même `WsSession` physique/actor que le WebSocket Solana standard, sans second client, socket, registry ou scheduler. La surface stable Helius réutilise les sept familles standard actuellement retenues (`account`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates`) et ajoute lextension typée `transactionSubscribe` / `transactionUnsubscribe`; `block` et `vote` restent absents de la façade Helius et `slotsUpdates` conserve son statut unstable. Le heartbeat provider est possédé par lactor partagé et émet un WebSocket Ping control frame toutes les 60 secondes uniquement pour `WsProtocolKind::HeliusLaserStream`.
La release ajoute le mapping Config V2 `helius_laserstream`, les profils Helius mainnet/devnet et le secret `KSP_SECRET_HELIUS_API_KEY` avec provenance/redaction segmentaire, sans dépendance inverse Transport -> Config ni lecture directe de lenvironnement par Transport. Les canaris couvrent erreurs RPC provider, payload oversized, mismatch de notification, reconnect/remap/unsubscribe races, backpressure isolé et diagnostics sans payload brut. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, **9 familles / 18 opérations WebSocket Solana standard**, et la surface Helius `7 standard + transaction`. Le smoke Helius live cross-crates est volontairement reporté vers une future surface dintégration/orchestration afin de préserver lownership Config du secret. Les graphes Cargo finaux nintroduisent aucun SDK Helius/gRPC ni nouvelle duplication bloquante. `prompts/014-V0_2_9_START_PROMPT.md` ouvre ensuite `0.2.9 — Yellowstone gRPC standard/provider-neutral` uniquement depuis le tag stable `v0.2.8`, avec audit service/proto/crates/licences/MSRV/features et sizing strict en `pre.001` avant toute implémentation lourde.
## 0.2.7 — WebSocket Solana standard — 2026-08-23
`0.2.7` stabilise dans `ksp-onchain-transport-lib` le moteur WebSocket Solana standard en complément de la surface HTTP déjà complète. La release couvre exactement les **9 familles subscribe + 9 unsubscribe** de linventaire officiel ciblé : `account`, `block`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates` et `vote`. Les wrappers sont typés, les IDs KSP de session/subscription restent locaux et stables, les IDs serveur restent internes/remappables, et plusieurs sessions physiques peuvent coexister explicitement sur la même URL sans introduire de pool/scheduler automatique. Les familles `block`, `slotsUpdates` et `vote` restent identifiées comme unstable selon laudit normatif courant et utilisent le warning KSP centralisé.
Le lifecycle WebSocket est borné : actor unique propriétaire du socket, pending JSON-RPC et queues de notifications bornés, `WsSession::close().await`, control frames Ping/Pong/Close, reconnect fini avec backoff borné, resubscribe déterministe par ID local, `continuity_gap_count`, isolation des erreurs applicatives et du backpressure par subscription, nettoyage des late ACK/notifications et terminaison one-shot de `signatureSubscribe`. Config passe à `std.transport` V2 pour composer HTTP + WebSocket tout en gardant la lecture V1 HTTP-only ; la direction reste `Config -> Transport`. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, les canaries Transport (`309` unit, `36` public API, `24` release completeness), le smoke WebSocket Devnet opt-in `slotSubscribe -> notification -> unsubscribe -> close`, les frontières de dépendances et la redaction des URLs/credentials. `prompts/013-V0_2_8_START_PROMPT.md`, renforcé par `0.2.7-pre.014-fix.001`, ouvre ensuite `0.2.8 — Helius LaserStream WebSocket` uniquement depuis le tag stable `v0.2.7`.
## 0.2.6 — Wallet Desk + `.kspwallet` V2 — 2026-08-22 ## 0.2.6 — Wallet Desk + `.kspwallet` V2 — 2026-08-22
`0.2.6` stabilise `ksp-app-wallet-desk` comme seconde application Tauri KSP spécialisée et étend `ksp-wallet-lib` avec le wire binaire `.kspwallet` V2. Wallet Desk compose Config, Wallet, Transport HTTP et Logging sans déplacer leurs responsabilités : inventory root-scoped et symlink-safe, création/import V2 par défaut, ouverture VIEW/OWNER V1/V2, candidats secrets résolus exclusivement par Config, `getBalance` Devnet via Transport, administration alias/notes, rotations OWNER/VIEW, disable/recreate VIEW fort et export Solana CLI JSON/Base58. Les secrets, keypairs, handles, chemins complets import/export et credentials Config restent côté Rust ; le frontend ne reçoit que des projections sûres et utilise des modals Bootstrap pour les opérations privilégiées. `0.2.6` stabilise `ksp-app-wallet-desk` comme seconde application Tauri KSP spécialisée et étend `ksp-wallet-lib` avec le wire binaire `.kspwallet` V2. Wallet Desk compose Config, Wallet, Transport HTTP et Logging sans déplacer leurs responsabilités : inventory root-scoped et symlink-safe, création/import V2 par défaut, ouverture VIEW/OWNER V1/V2, candidats secrets résolus exclusivement par Config, `getBalance` Devnet via Transport, administration alias/notes, rotations OWNER/VIEW, disable/recreate VIEW fort et export Solana CLI JSON/Base58. Les secrets, keypairs, handles, chemins complets import/export et credentials Config restent côté Rust ; le frontend ne reçoit que des projections sûres et utilise des modals Bootstrap pour les opérations privilégiées.

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml # file: Cargo.toml
# version: 191 # version: 236
[workspace] [workspace]
resolver = "3" 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"] 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] [workspace.package]
version = "0.2.6" version = "0.2.8"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
@@ -21,9 +21,10 @@ ed25519-dalek = { version = "^3.0", default-features = false }
getrandom = { version = "^0.4", default-features = false } getrandom = { version = "^0.4", default-features = false }
base64 = { version = "^0.23" } base64 = { version = "^0.23" }
fs2 = { version = "^0.4" } fs2 = { version = "^0.4" }
futures-util = { version = "^0.3", default-features = false }
serde = { version = "^1.0" } serde = { version = "^1.0" }
serde_json = { version = "^1.0" } serde_json = { version = "^1.0" }
jsonschema = { version = "^0.50", default-features = false } jsonschema = { version = "^0.51", default-features = false }
reqwest = { version = "^0.13", default-features = false } reqwest = { version = "^0.13", default-features = false }
solana-keypair = { version = "^3.1", default-features = false } solana-keypair = { version = "^3.1", default-features = false }
solana-pubkey = { version = "^4.3", default-features = false } solana-pubkey = { version = "^4.3", default-features = false }
@@ -31,6 +32,7 @@ tracing = { version = "^0.1", default-features = false }
tracing-subscriber = { version = "^0.3", default-features = false } tracing-subscriber = { version = "^0.3", default-features = false }
tracing-appender = { version = "^0.2", default-features = false } tracing-appender = { version = "^0.2", default-features = false }
tokio = { version = "^1.53", default-features = false } tokio = { version = "^1.53", default-features = false }
tokio-tungstenite = { version = "^0.30", default-features = false }
tempfile = { version = "^3.27" } tempfile = { version = "^3.27" }
chrono = { version = "^0.4", default-features = false } chrono = { version = "^0.4", default-features = false }
tauri = { version = "^2.11" } tauri = { version = "^2.11" }

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md --> <!-- file: ROADMAP.md -->
<!-- version: 80 --> <!-- version: 83 -->
# Roadmap KSP # Roadmap KSP
@@ -51,8 +51,8 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
- [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication. - [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication.
- [X] `0.2.5` — Wallet foundation stable : `.kspwallet` V1, VIEW/OWNER indépendants, Argon2id/XChaCha20-Poly1305, autorité Ed25519 OWNER, persistence no-clobber, signature, administration/rotations/révocation VIEW forte, import/export Solana CLI JSON + Base58, canaris adversariaux, interop externe et documentation durable publiés. La clôture `pre.010-fix.001``fix.003` ajoute `ed25519-dalek 3.0.0` direct, normalise le Rust workspace et installe laudit structurel Python complémentaire à rustfmt/Clippy. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. - [X] `0.2.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.6``ksp-app-wallet-desk` + `.kspwallet` V2 stables : composition Config/Wallet/HTTP/Logging, lifecycle VIEW/OWNER, balance, administration/import/export, wire binaire V2, APIs multi-version, migration V1 -> V2 explicite et runtime Tauri packagé user-writable validés ; bundles Linux `.deb`/`.rpm`/`.AppImage` produits avant publication. Plan clôturé : `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`.
- [ ] `0.2.7`Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe. - [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. - [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.
- [ ] `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.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.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.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é.

View File

@@ -1,10 +1,27 @@
{ {
"format_version": 1, "format_version": 2,
"retry": { "retry": {
"max_retries": 3, "max_retries": 3,
"initial_backoff_ms": 150, "initial_backoff_ms": 150,
"max_backoff_ms": 3000 "max_backoff_ms": 3000
}, },
"ws_defaults": {
"command_timeout_ms": 10000,
"close_timeout_ms": 5000,
"reconnect": {
"max_retries": 5,
"initial_backoff_ms": 250,
"max_backoff_ms": 5000
},
"resubscribe": "active_subscriptions",
"command_queue_capacity": 128,
"notification_queue_capacity": 256,
"max_active_subscriptions": 1024,
"max_pending_requests": 128,
"max_message_size_bytes": 67108864,
"max_frame_size_bytes": 16777216,
"max_write_buffer_size_bytes": 1048576
},
"default_profile": "mainnet_mixed", "default_profile": "mainnet_mixed",
"profiles": [ "profiles": [
{ {
@@ -23,7 +40,9 @@
{ {
"role": "default", "role": "default",
"enabled": true, "enabled": true,
"request_kinds": ["*"], "request_kinds": [
"*"
],
"priority": 200, "priority": 200,
"limits": { "limits": {
"requests_per_second": 5, "requests_per_second": 5,
@@ -47,7 +66,9 @@
{ {
"role": "default", "role": "default",
"enabled": true, "enabled": true,
"request_kinds": ["*"], "request_kinds": [
"*"
],
"priority": 100, "priority": 100,
"limits": { "limits": {
"requests_per_second": 20, "requests_per_second": 20,
@@ -58,6 +79,69 @@
} }
] ]
} }
],
"ws_endpoints": [
{
"name": "mainnet_public_ws",
"enabled": true,
"provider": "solana-public",
"cluster": "mainnet-beta",
"kind": "solana_standard",
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
},
{
"name": "mainnet_helius_ws",
"enabled": true,
"provider": "helius",
"cluster": "mainnet-beta",
"kind": "helius_laserstream",
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
"session": {
"notification_queue_capacity": 512,
"max_active_subscriptions": 2048
}
}
]
},
{
"profile_id": "devnet_helius",
"endpoints": [
{
"name": "devnet_public",
"enabled": true,
"provider": "solana-public",
"cluster": "devnet",
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
"connect_timeout_ms": 5000,
"request_timeout_ms": 15000,
"max_idle_connections_per_host": 8,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": [
"*"
],
"priority": 100,
"limits": {
"requests_per_second": 5,
"burst_capacity": 10,
"max_concurrent_requests": 8,
"pause_after_rate_limit_ms": 1000
}
}
]
}
],
"ws_endpoints": [
{
"name": "devnet_helius_ws",
"enabled": true,
"provider": "helius",
"cluster": "devnet",
"kind": "helius_laserstream",
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}"
}
] ]
} }
] ]

View File

@@ -1,20 +1,15 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:ksp:schema:std.transport:v1", "$id": "urn:ksp:schema:std.transport:v2",
"title": "KSP standard HTTP Transport configuration", "title": "KSP standard HTTP + WebSocket Transport configuration",
"type": "object", "oneOf": [
"additionalProperties": false, {
"required": ["format_version", "retry", "default_profile", "profiles"], "$ref": "#/$defs/documentV1"
"properties": { },
"format_version": {"const": 1}, {
"retry": {"$ref": "#/$defs/retry"}, "$ref": "#/$defs/documentV2"
"default_profile": {"$ref": "#/$defs/profileId"},
"profiles": {
"type": "array",
"minItems": 1,
"items": {"$ref": "#/$defs/profile"}
} }
}, ],
"$defs": { "$defs": {
"profileId": { "profileId": {
"type": "string", "type": "string",
@@ -35,50 +30,99 @@
"minimum": 1, "minimum": 1,
"maximum": 4294967295 "maximum": 4294967295
}, },
"positiveUsize": {
"type": "integer",
"minimum": 1,
"maximum": 4294967295
},
"retry": { "retry": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["max_retries", "initial_backoff_ms", "max_backoff_ms"], "required": [
"max_retries",
"initial_backoff_ms",
"max_backoff_ms"
],
"properties": { "properties": {
"max_retries": {"type": "integer", "minimum": 0, "maximum": 100}, "max_retries": {
"initial_backoff_ms": {"$ref": "#/$defs/positiveMs"}, "type": "integer",
"max_backoff_ms": {"$ref": "#/$defs/positiveMs"} "minimum": 0,
"maximum": 100
},
"initial_backoff_ms": {
"$ref": "#/$defs/positiveMs"
},
"max_backoff_ms": {
"$ref": "#/$defs/positiveMs"
}
} }
}, },
"limits": { "limits": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"requests_per_second": {"$ref": "#/$defs/positiveU32"}, "requests_per_second": {
"burst_capacity": {"$ref": "#/$defs/positiveU32"}, "$ref": "#/$defs/positiveU32"
"max_concurrent_requests": {"$ref": "#/$defs/positiveU32"}, },
"pause_after_rate_limit_ms": {"$ref": "#/$defs/positiveMs"} "burst_capacity": {
"$ref": "#/$defs/positiveU32"
},
"max_concurrent_requests": {
"$ref": "#/$defs/positiveU32"
},
"pause_after_rate_limit_ms": {
"$ref": "#/$defs/positiveMs"
}
} }
}, },
"role": { "role": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["role", "enabled", "request_kinds", "priority", "limits"], "required": [
"role",
"enabled",
"request_kinds",
"priority",
"limits"
],
"properties": { "properties": {
"role": {"$ref": "#/$defs/descriptor"}, "role": {
"enabled": {"type": "boolean"}, "$ref": "#/$defs/descriptor"
},
"enabled": {
"type": "boolean"
},
"request_kinds": { "request_kinds": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,
"uniqueItems": true, "uniqueItems": true,
"items": {"$ref": "#/$defs/descriptor"}, "items": {
"$ref": "#/$defs/descriptor"
},
"allOf": [ "allOf": [
{ {
"if": {"contains": {"const": "*"}}, "if": {
"then": {"maxItems": 1} "contains": {
"const": "*"
}
},
"then": {
"maxItems": 1
}
} }
] ]
}, },
"priority": {"type": "integer", "minimum": 0, "maximum": 4294967295}, "priority": {
"limits": {"$ref": "#/$defs/limits"} "type": "integer",
"minimum": 0,
"maximum": 4294967295
},
"limits": {
"$ref": "#/$defs/limits"
}
} }
}, },
"endpoint": { "httpEndpoint": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
@@ -92,31 +136,323 @@
"roles" "roles"
], ],
"properties": { "properties": {
"name": {"$ref": "#/$defs/descriptor"}, "name": {
"enabled": {"type": "boolean"}, "$ref": "#/$defs/descriptor"
"provider": {"$ref": "#/$defs/descriptor"}, },
"cluster": {"$ref": "#/$defs/descriptor"}, "enabled": {
"url": {"type": "string", "minLength": 1}, "type": "boolean"
"connect_timeout_ms": {"$ref": "#/$defs/positiveMs"}, },
"request_timeout_ms": {"$ref": "#/$defs/positiveMs"}, "provider": {
"max_idle_connections_per_host": {"type": "integer", "minimum": 1}, "$ref": "#/$defs/descriptor"
},
"cluster": {
"$ref": "#/$defs/descriptor"
},
"url": {
"type": "string",
"minLength": 1
},
"connect_timeout_ms": {
"$ref": "#/$defs/positiveMs"
},
"request_timeout_ms": {
"$ref": "#/$defs/positiveMs"
},
"max_idle_connections_per_host": {
"type": "integer",
"minimum": 1
},
"roles": { "roles": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,
"items": {"$ref": "#/$defs/role"} "items": {
"$ref": "#/$defs/role"
}
} }
} }
}, },
"profile": { "wsReconnect": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["profile_id", "endpoints"], "required": [
"max_retries",
"initial_backoff_ms",
"max_backoff_ms"
],
"properties": { "properties": {
"profile_id": {"$ref": "#/$defs/profileId"}, "max_retries": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"initial_backoff_ms": {
"$ref": "#/$defs/positiveMs"
},
"max_backoff_ms": {
"$ref": "#/$defs/positiveMs"
}
}
},
"wsReconnectOverride": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"max_retries": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"initial_backoff_ms": {
"$ref": "#/$defs/positiveMs"
},
"max_backoff_ms": {
"$ref": "#/$defs/positiveMs"
}
}
},
"wsSession": {
"type": "object",
"additionalProperties": false,
"required": [
"command_timeout_ms",
"close_timeout_ms",
"reconnect",
"resubscribe",
"command_queue_capacity",
"notification_queue_capacity",
"max_active_subscriptions",
"max_pending_requests",
"max_message_size_bytes",
"max_frame_size_bytes",
"max_write_buffer_size_bytes"
],
"properties": {
"command_timeout_ms": {
"$ref": "#/$defs/positiveMs"
},
"close_timeout_ms": {
"$ref": "#/$defs/positiveMs"
},
"reconnect": {
"$ref": "#/$defs/wsReconnect"
},
"resubscribe": {
"enum": [
"never",
"active_subscriptions"
]
},
"command_queue_capacity": {
"$ref": "#/$defs/positiveUsize"
},
"notification_queue_capacity": {
"$ref": "#/$defs/positiveUsize"
},
"max_active_subscriptions": {
"$ref": "#/$defs/positiveUsize"
},
"max_pending_requests": {
"$ref": "#/$defs/positiveUsize"
},
"max_message_size_bytes": {
"$ref": "#/$defs/positiveUsize"
},
"max_frame_size_bytes": {
"$ref": "#/$defs/positiveUsize"
},
"max_write_buffer_size_bytes": {
"$ref": "#/$defs/positiveUsize"
}
}
},
"wsSessionOverride": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"command_timeout_ms": {
"$ref": "#/$defs/positiveMs"
},
"close_timeout_ms": {
"$ref": "#/$defs/positiveMs"
},
"reconnect": {
"$ref": "#/$defs/wsReconnectOverride"
},
"resubscribe": {
"enum": [
"never",
"active_subscriptions"
]
},
"command_queue_capacity": {
"$ref": "#/$defs/positiveUsize"
},
"notification_queue_capacity": {
"$ref": "#/$defs/positiveUsize"
},
"max_active_subscriptions": {
"$ref": "#/$defs/positiveUsize"
},
"max_pending_requests": {
"$ref": "#/$defs/positiveUsize"
},
"max_message_size_bytes": {
"$ref": "#/$defs/positiveUsize"
},
"max_frame_size_bytes": {
"$ref": "#/$defs/positiveUsize"
},
"max_write_buffer_size_bytes": {
"$ref": "#/$defs/positiveUsize"
}
}
},
"wsEndpoint": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"enabled",
"provider",
"cluster",
"kind",
"url"
],
"properties": {
"name": {
"$ref": "#/$defs/descriptor"
},
"enabled": {
"type": "boolean"
},
"provider": {
"$ref": "#/$defs/descriptor"
},
"cluster": {
"$ref": "#/$defs/descriptor"
},
"kind": {
"enum": [
"solana_standard",
"helius_laserstream"
]
},
"url": {
"type": "string",
"minLength": 1
},
"session": {
"$ref": "#/$defs/wsSessionOverride"
}
}
},
"profileV1": {
"type": "object",
"additionalProperties": false,
"required": [
"profile_id",
"endpoints"
],
"properties": {
"profile_id": {
"$ref": "#/$defs/profileId"
},
"endpoints": { "endpoints": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,
"items": {"$ref": "#/$defs/endpoint"} "items": {
"$ref": "#/$defs/httpEndpoint"
}
}
}
},
"profileV2": {
"type": "object",
"additionalProperties": false,
"required": [
"profile_id",
"endpoints",
"ws_endpoints"
],
"properties": {
"profile_id": {
"$ref": "#/$defs/profileId"
},
"endpoints": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/httpEndpoint"
}
},
"ws_endpoints": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/wsEndpoint"
}
}
}
},
"documentV1": {
"type": "object",
"additionalProperties": false,
"required": [
"format_version",
"retry",
"default_profile",
"profiles"
],
"properties": {
"format_version": {
"const": 1
},
"retry": {
"$ref": "#/$defs/retry"
},
"default_profile": {
"$ref": "#/$defs/profileId"
},
"profiles": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/profileV1"
}
}
}
},
"documentV2": {
"type": "object",
"additionalProperties": false,
"required": [
"format_version",
"retry",
"ws_defaults",
"default_profile",
"profiles"
],
"properties": {
"format_version": {
"const": 2
},
"retry": {
"$ref": "#/$defs/retry"
},
"ws_defaults": {
"$ref": "#/$defs/wsSession"
},
"default_profile": {
"$ref": "#/$defs/profileId"
},
"profiles": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/profileV2"
}
} }
} }
} }

View File

@@ -1,10 +1,27 @@
{ {
"format_version": 1, "format_version": 2,
"retry": { "retry": {
"max_retries": 2, "max_retries": 2,
"initial_backoff_ms": 100, "initial_backoff_ms": 100,
"max_backoff_ms": 2000 "max_backoff_ms": 2000
}, },
"ws_defaults": {
"command_timeout_ms": 10000,
"close_timeout_ms": 5000,
"reconnect": {
"max_retries": 5,
"initial_backoff_ms": 250,
"max_backoff_ms": 5000
},
"resubscribe": "active_subscriptions",
"command_queue_capacity": 128,
"notification_queue_capacity": 256,
"max_active_subscriptions": 1024,
"max_pending_requests": 128,
"max_message_size_bytes": 67108864,
"max_frame_size_bytes": 16777216,
"max_write_buffer_size_bytes": 1048576
},
"default_profile": "devnet_public", "default_profile": "devnet_public",
"profiles": [ "profiles": [
{ {
@@ -23,7 +40,9 @@
{ {
"role": "default", "role": "default",
"enabled": true, "enabled": true,
"request_kinds": ["*"], "request_kinds": [
"*"
],
"priority": 100, "priority": 100,
"limits": { "limits": {
"requests_per_second": 5, "requests_per_second": 5,
@@ -34,6 +53,16 @@
} }
] ]
} }
],
"ws_endpoints": [
{
"name": "solana_devnet_public_ws",
"enabled": true,
"provider": "solana-public",
"cluster": "devnet",
"kind": "solana_standard",
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
}
] ]
}, },
{ {
@@ -52,7 +81,9 @@
{ {
"role": "default", "role": "default",
"enabled": true, "enabled": true,
"request_kinds": ["*"], "request_kinds": [
"*"
],
"priority": 100, "priority": 100,
"limits": { "limits": {
"requests_per_second": 5, "requests_per_second": 5,
@@ -63,6 +94,16 @@
} }
] ]
} }
],
"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}"
}
] ]
} }
] ]

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/README.md --> <!-- file: crates/ksp-app-config-desk/README.md -->
<!-- version: 27 --> <!-- version: 28 -->
# `ksp-app-config-desk` # `ksp-app-config-desk`
@@ -78,7 +78,7 @@ Les DTO Rust applicatifs restent la source de vérité et les bindings généré
## Robustesse desktop et extensibilité ## Robustesse desktop et extensibilité
`pre.018` rend le fallback de bootstrap Logging testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink. Le fallback de bootstrap Logging est testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink.
Le frontend possède désormais `shell_registry.ts`. Le registre décrit les vues du shell et les adapters d'éditeurs spécialisés par `file_id`. Le panneau Documents reste générique ; lorsqu'un document possède un adapter enregistré, **Ouvrir l'éditeur spécialisé** déclenche la navigation par événement de registre. `cfg.std.logging -> Logging` est le premier adapter. Ajouter un futur éditeur ne demande donc pas de réécrire le moteur Documents ni la logique générale d'activation des panneaux. Le frontend possède désormais `shell_registry.ts`. Le registre décrit les vues du shell et les adapters d'éditeurs spécialisés par `file_id`. Le panneau Documents reste générique ; lorsqu'un document possède un adapter enregistré, **Ouvrir l'éditeur spécialisé** déclenche la navigation par événement de registre. `cfg.std.logging -> Logging` est le premier adapter. Ajouter un futur éditeur ne demande donc pas de réécrire le moteur Documents ni la logique générale d'activation des panneaux.
@@ -123,11 +123,11 @@ main -> ksp-app-config-desk.frontend.main
splash -> ksp-app-config-desk.frontend.splash splash -> ksp-app-config-desk.frontend.splash
``` ```
Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit est volontairement hors périmètre de `0.1.4` : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes. Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit reste volontairement hors de la surface actuelle : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes.
## Panneau Test Logging ## Panneau Test Logging
`pre.017` ajoute une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime. Le panneau fournit une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime.
**Log via bridge frontend** réutilise le bridge déjà installé. Son contrat reste volontairement fixe : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. Cela permet de comparer dans le même panneau le routing backend et le chemin WebView -> Rust -> `ksp-logging-lib`, avant et après modification/hot reload des filtres. **Log via bridge frontend** réutilise le bridge déjà installé. Son contrat reste volontairement fixe : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. Cela permet de comparer dans le même panneau le routing backend et le chemin WebView -> Rust -> `ksp-logging-lib`, avant et après modification/hot reload des filtres.
@@ -171,13 +171,13 @@ La vue **Logging** charge le document standard exclusivement avec `ConfigManagem
Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. Le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder et appliquer**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Après persistence, Config Desk recharge un `ConfigEnvironment` frais, résout le `default_profile`, puis appelle `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` actif. Le hot reload est immédiat et `logging_generation` avance uniquement après succès. Si l'application runtime échoue, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** ne modifie que le brouillon/source persistée. Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. Le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder et appliquer**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Après persistence, Config Desk recharge un `ConfigEnvironment` frais, résout le `default_profile`, puis appelle `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` actif. Le hot reload est immédiat et `logging_generation` avance uniquement après succès. Si l'application runtime échoue, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** ne modifie que le brouillon/source persistée.
`pre.016` distingue en plus le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés. Le panneau distingue le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés.
Chaque lancement de Config Desk crée aussi une `LoggingRuntimeIdentity` stable : `application_id` + timestamp UTC de démarrage + PID. `ksp-logging-lib` utilise cette identité pour préfixer les noms des fichiers actifs et la conserve pendant tous les hot reloads du même processus. Deux lancements distincts ne partagent donc plus le même fichier persistant, même avec une rotation `daily`. Avec la configuration de release `info/ksp-info.log`, un prefix effectif peut être `ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log`. Le path Config reste inchangé ; l'identité appartient au runtime, pas au document source. Chaque lancement de Config Desk crée aussi une `LoggingRuntimeIdentity` stable : `application_id` + timestamp UTC de démarrage + PID. `ksp-logging-lib` utilise cette identité pour préfixer les noms des fichiers actifs et la conserve pendant tous les hot reloads du même processus. Deux lancements distincts ne partagent donc plus le même fichier persistant, même avec une rotation `daily`. Avec la configuration de release `info/ksp-info.log`, un prefix effectif peut être `ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log`. Le path Config reste inchangé ; l'identité appartient au runtime, pas au document source.
## Baseline Logging de release ## Baseline Logging de release
La configuration canonique livrée avec `0.1.4` revient à une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors dun développement ou diagnostic, puis redescendus avant la release suivante. La configuration canonique de release utilise une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors dun développement ou diagnostic, puis redescendus avant la release suivante.
## Traçabilité frontend ## Traçabilité frontend

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/USAGE.md --> <!-- file: crates/ksp-app-config-desk/USAGE.md -->
<!-- version: 27 --> <!-- version: 28 -->
# Utilisation de `ksp-app-config-desk` # Utilisation de `ksp-app-config-desk`
@@ -85,7 +85,7 @@ splash
Ils sont convertis côté Rust vers des targets KSP statiques `ksp-app-config-desk.frontend*` et émis uniquement via `ksp-logging-lib`. Un niveau différent de `trace`, `debug`, `info`, `warn` ou `error`, ou un `targetId` non whitelisté, est rejeté avec un `CommandErrorDto` sûr. Ils sont convertis côté Rust vers des targets KSP statiques `ksp-app-config-desk.frontend*` et émis uniquement via `ksp-logging-lib`. Un niveau différent de `trace`, `debug`, `info`, `warn` ou `error`, ou un `targetId` non whitelisté, est rejeté avec un `CommandErrorDto` sûr.
`main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit est reporté hors `0.1.4`; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge. `main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit reste hors de la surface actuelle ; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge.
## Bindings TS-RS ## Bindings TS-RS
@@ -287,7 +287,7 @@ Si la résolution ou la préparation du profil explicite échoue, `ksp_logging_l
## Baseline Logging de release ## Baseline Logging de release
Après les tests `debug`/`trace`, la configuration canonique `0.1.4` revient à : Après les tests `debug`/`trace`, la configuration canonique revient à :
```text ```text
default_filter = warn default_filter = warn

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs // file: crates/ksp-app-config-desk/tests/desktop_contract.rs
// version: 6 // version: 7
//! Desktop build/shell contract audits for Config Desk. //! Desktop build/shell contract audits for Config Desk.
@@ -26,6 +26,43 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
}; };
} }
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
let core = match value.split_once('-') {
std::option::Option::Some((core, _)) => core,
std::option::Option::None => value,
};
let mut parts = core.split('.');
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
if parts.next().is_some() {
return std::option::Option::None;
}
return std::option::Option::Some((major, minor, patch));
}
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
assert!(value.is_some(), "{field} packaged version must exist");
let value = match value {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let parsed = parse_semver_core(value);
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
if let std::option::Option::Some(parsed) = parsed {
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
}
}
#[test] #[test]
fn tauri_and_frontend_build_contracts_remain_explicit() { fn tauri_and_frontend_build_contracts_remain_explicit() {
let root = app_root(); let root = app_root();
@@ -103,11 +140,13 @@ fn pre_014_template_uses_sidebar_navigation_and_kbot_style_splash_contract() {
#[test] #[test]
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() { fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
let root = app_root(); let root = app_root();
let expected_version = env!("CARGO_PKG_VERSION");
let tauri = read_json(root.join("tauri.conf.json").as_path()); let tauri = read_json(root.join("tauri.conf.json").as_path());
assert_eq!(tauri.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version)); let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
let package = read_json(root.join("package.json").as_path()); let package = read_json(root.join("package.json").as_path());
assert_eq!(package.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version)); let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
assert_packaged_version_floor(package_version, "package.json");
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object); let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
assert!(resources.is_some(), "packaged Config resources map must exist"); assert!(resources.is_some(), "packaged Config resources map must exist");
if let std::option::Option::Some(resources) = resources { if let std::option::Option::Some(resources) = resources {

View File

@@ -1,9 +1,9 @@
<!-- file: crates/ksp-app-wallet-desk/README.md --> <!-- file: crates/ksp-app-wallet-desk/README.md -->
<!-- version: 2 --> <!-- version: 3 -->
# `ksp-app-wallet-desk` # `ksp-app-wallet-desk`
`ksp-app-wallet-desk` est l'application desktop spécialisée stable d'administration et de validation des wallets KSP depuis KSP `0.2.6`. `ksp-app-wallet-desk` est l'application desktop spécialisée stable d'administration et de validation des wallets KSP.
La crate est un package Tauri mixte : La crate est un package Tauri mixte :
@@ -29,7 +29,7 @@ Le frontend ne reçoit jamais les keypairs, ciphertexts, passwords Config, paths
## `.kspwallet` V1/V2 ## `.kspwallet` V1/V2
La release `0.2.6` conserve V1 et ajoute V2 : La surface courante conserve V1 et utilise V2 comme format natif par défaut :
```text ```text
V1 : JSON UTF-8 historique, lecture explicite toujours supportée V1 : JSON UTF-8 historique, lecture explicite toujours supportée
@@ -45,9 +45,9 @@ Wallet Desk consomme uniquement les APIs non versionnées de `ksp-wallet-lib` :
`ksp-wallet-lib` conserve parallèlement les APIs explicites `_v1` / `_v2` pour les consumers qui doivent imposer un format. `DEFAULT_WALLET_FORMAT` et `LATEST_SUPPORTED_WALLET_FORMAT` sont des politiques distinctes ; l'apparition future d'un V3 n'impose donc pas de modifier automatiquement le format créé par les APIs génériques. `ksp-wallet-lib` conserve parallèlement les APIs explicites `_v1` / `_v2` pour les consumers qui doivent imposer un format. `DEFAULT_WALLET_FORMAT` et `LATEST_SUPPORTED_WALLET_FORMAT` sont des politiques distinctes ; l'apparition future d'un V3 n'impose donc pas de modifier automatiquement le format créé par les APIs génériques.
La migration V1 -> V2 est une opération explicite OWNER-authentifiée possédée par `ksp-wallet-lib`. Wallet Desk `0.2.6` ne migre jamais silencieusement un wallet lors de sa sélection, inspection ou ouverture. La migration V1 -> V2 est une opération explicite OWNER-authentifiée possédée par `ksp-wallet-lib`. Wallet Desk ne migre jamais silencieusement un wallet lors de sa sélection, inspection ou ouverture.
## Capacités fonctionnelles `0.2.6` ## Capacités fonctionnelles
La surface validée comprend : La surface validée comprend :

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs // file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
// version: 21 // version: 22
//! Desktop build, shell and Config-status contract audits for Wallet Desk. //! Desktop build, shell and Config-status contract audits for Wallet Desk.
@@ -31,6 +31,43 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
}; };
} }
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
let core = match value.split_once('-') {
std::option::Option::Some((core, _)) => core,
std::option::Option::None => value,
};
let mut parts = core.split('.');
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
if parts.next().is_some() {
return std::option::Option::None;
}
return std::option::Option::Some((major, minor, patch));
}
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
assert!(value.is_some(), "{field} packaged version must exist");
let value = match value {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let parsed = parse_semver_core(value);
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
if let std::option::Option::Some(parsed) = parsed {
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
}
}
#[test] #[test]
fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() { fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() {
let root = app_root(); let root = app_root();
@@ -419,13 +456,19 @@ fn pre_017_wallet_desk_open_paths_remain_non_migrating() {
} }
#[test] #[test]
fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_current() { fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_coherent() {
let root = app_root(); let root = app_root();
let expected_version = env!("CARGO_PKG_VERSION");
let tauri = read_json(root.join("tauri.conf.json").as_path()); let tauri = read_json(root.join("tauri.conf.json").as_path());
assert_eq!(tauri.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version)); let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
let package = read_json(root.join("package.json").as_path()); let package = read_json(root.join("package.json").as_path());
assert_eq!(package.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version)); let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
assert_packaged_version_floor(package_version, "package.json");
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
let packaged_version = match package_version {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object); let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist"); assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
if let std::option::Option::Some(resources) = resources { if let std::option::Option::Some(resources) = resources {
@@ -440,7 +483,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
); );
} }
let main = read_text(root.join("frontend/main.html").as_path()); let main = read_text(root.join("frontend/main.html").as_path());
assert!(main.contains(expected_version)); assert!(main.contains(packaged_version));
let tauri_source = read_text(root.join("src/tauri.rs").as_path()); let tauri_source = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime")); assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
assert!(tauri_source.contains("tauri::utils::platform::resource_dir")); assert!(tauri_source.contains("tauri::utils::platform::resource_dir"));

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/README.md --> <!-- file: crates/ksp-config-lib/README.md -->
<!-- version: 6 --> <!-- version: 8 -->
# ksp-config-lib # ksp-config-lib
@@ -23,7 +23,7 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
- la classification `Public`, `Internal`, `Secret` ; - la classification `Public`, `Internal`, `Secret` ;
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ; - les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ; - l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
- l'adapter du document HTTP Transport effectif vers `ksp_onchain_transport_lib::HttpTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ; - l'adapter du document Transport V1/V2 vers `HttpTransportSettings` et, en V2, `WsTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ; - la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ; - les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`. - les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
@@ -81,15 +81,15 @@ Un secret reste accessible au runtime ou au management lorsqu'un consumer autori
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées. Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport les accepte pour les URLs endpoint : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON. Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport 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.
## Documentation ## Documentation
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ; - [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ; - [`TODO.md`](TODO.md) — points explicitement différés ;
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ; - [`../../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.logging.json`](../../config/std.logging.json) — document standard Logging ;
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard HTTP Transport ; - [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport V2 HTTP + WebSocket, avec lecture backward du V1 HTTP-only ;
- [`../../config/std.wallet.json`](../../config/std.wallet.json) — racine Wallet globale et sous-répertoire optionnel par profil ; - [`../../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 ; - [`../../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. - [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/USAGE.md --> <!-- file: crates/ksp-config-lib/USAGE.md -->
<!-- version: 6 --> <!-- version: 11 -->
# Utilisation de ksp-config-lib # Utilisation de ksp-config-lib
@@ -125,7 +125,7 @@ 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. Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
### 4.1 Construire le Transport HTTP depuis Config ### 4.1 Construire le Transport HTTP + WebSocket depuis Config
Config possède également l'adapter du document `std.transport` vers le contrat runtime de `ksp-onchain-transport-lib` : Config possède également l'adapter du document `std.transport` vers le contrat runtime de `ksp-onchain-transport-lib` :
@@ -135,12 +135,20 @@ let transport = match engine.load_resolved_transport_config(std::option::Option:
std::result::Result::Err(error) => return std::result::Result::Err(error), std::result::Result::Err(error) => return std::result::Result::Err(error),
}; };
let transport_settings = transport.into_settings(); let http_settings = transport.http_settings();
let ws_settings = transport.ws_settings();
let _ = (http_settings, ws_settings);
``` ```
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. Les URLs peuvent provenir de `KSP_PUBLIC_*` ou de `KSP_SECRET_*`; dans ce dernier cas la valeur réelle reste disponible au runtime Transport, mais `ResolvedTransportConfig::effective().safe_value()` et les représentations `Debug` sont redacted. `std.transport` V2 conserve `retry` et `profiles[].endpoints[]` pour HTTP, ajoute `ws_defaults` et `profiles[].ws_endpoints[]`, puis accepte les protocoles WebSocket `kind = "solana_standard"` et `kind = "helius_laserstream"`. Ce second discriminateur appartient exclusivement au namespace WebSocket et mappe vers `WsProtocolKind::HeliusLaserStream`; il ne préfigure aucun contrat LaserStream gRPC. Un `ws_endpoints[].session` optionnel surcharge seulement les paramètres génériques de `WsSessionSettings`.
La dépendance reste unidirectionnelle : Config connaît le contrat Transport pour le construire ; Transport ne connaît ni Config, ni `.env`, ni les variables KSP. Pour Helius LaserStream WebSocket, l'exemple versionné couvre explicitement les deux réseaux supportés par ce contrat : mainnet via `wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}` et devnet via `wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}`. Les deux réseaux vivent dans des profils Config distincts afin de ne pas mélanger des clusters dans un même profil logique. Config reste l'unique propriétaire de `KSP_SECRET_HELIUS_API_KEY` : il résout la clé dans l'URL effective et transmet au Transport un `WsEndpointUrl` utilisable au runtime. Dans `safe_value`, Config conserve les segments littéraux non sensibles d'une chaîne composée et remplace uniquement chaque segment secret par `********` ; les projections deviennent donc respectivement `wss://mainnet.helius-rpc.com/?api-key=********` et `wss://devnet.helius-rpc.com/?api-key=********`. Les représentations `Debug` restent sûres et n'exposent jamais la clé réelle. Transport ne lit jamais directement l'environnement.
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.
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.
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 ### 4.2 Résoudre le répertoire Wallet depuis Config

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/lib.rs // file: crates/ksp-config-lib/src/lib.rs
// version: 16 // version: 17
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -154,9 +154,9 @@ pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME; pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
/// Default physical filename for the standard Logging JSON Schema document. /// Default physical filename for the standard Logging JSON Schema document.
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME; pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
/// Default physical filename for the standard HTTP Transport configuration document. /// Default physical filename for the standard HTTP + WebSocket Transport configuration document.
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME; pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
/// Default physical filename for the standard HTTP Transport JSON Schema document. /// Default physical filename for the standard HTTP + WebSocket Transport JSON Schema document.
pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME; pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
/// Default physical filename for the standard Wallet configuration document. /// Default physical filename for the standard Wallet configuration document.
pub use self::registry::DEFAULT_STD_WALLET_FILENAME; pub use self::registry::DEFAULT_STD_WALLET_FILENAME;
@@ -168,13 +168,13 @@ pub use self::registry::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE; pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
/// Logical file identifier for the standard Logging JSON Schema document. /// Logical file identifier for the standard Logging JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING; pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
/// Logical file identifier for the standard HTTP Transport JSON Schema document. /// Logical file identifier for the standard HTTP + WebSocket Transport JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT; pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
/// Logical file identifier for the standard Wallet JSON Schema document. /// Logical file identifier for the standard Wallet JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_WALLET; pub use self::registry::FILE_ID_SCHEMA_STD_WALLET;
/// Logical file identifier for the standard Logging configuration document. /// Logical file identifier for the standard Logging configuration document.
pub use self::registry::FILE_ID_STD_LOGGING; pub use self::registry::FILE_ID_STD_LOGGING;
/// Logical file identifier for the standard HTTP Transport configuration document. /// Logical file identifier for the standard HTTP + WebSocket Transport configuration document.
pub use self::registry::FILE_ID_STD_TRANSPORT; pub use self::registry::FILE_ID_STD_TRANSPORT;
/// Logical file identifier for the standard Wallet configuration document. /// Logical file identifier for the standard Wallet configuration document.
pub use self::registry::FILE_ID_STD_WALLET; pub use self::registry::FILE_ID_STD_WALLET;
@@ -188,7 +188,7 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
pub use self::sensitivity::ResolvedConfigJson; pub use self::sensitivity::ResolvedConfigJson;
/// One resolved Config string preserving real/safe representations and provenance. /// One resolved Config string preserving real/safe representations and provenance.
pub use self::sensitivity::ResolvedConfigText; pub use self::sensitivity::ResolvedConfigText;
/// Effective standard HTTP Transport configuration mapped to `ksp_onchain_transport_lib::HttpTransportSettings`. /// Effective standard Transport configuration mapped to HTTP and optional WebSocket runtime settings.
pub use self::transport::ResolvedTransportConfig; pub use self::transport::ResolvedTransportConfig;
/// Effective standard Wallet configuration resolved to validated filesystem roots. /// Effective standard Wallet configuration resolved to validated filesystem roots.
pub use self::wallet::ResolvedWalletConfig; pub use self::wallet::ResolvedWalletConfig;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/registry.rs // file: crates/ksp-config-lib/src/registry.rs
// version: 8 // version: 9
/// Bootstrap argument used to replace a known Config filename mapping. /// Bootstrap argument used to replace a known Config filename mapping.
pub const ARG_FILE_MAP: &str = "--filemap"; 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"; pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
/// Default physical filename for the standard Logging JSON Schema document. /// Default physical filename for the standard Logging JSON Schema document.
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json"; pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
/// Default physical filename for the standard HTTP Transport configuration document. /// Default physical filename for the standard HTTP + WebSocket Transport configuration document.
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json"; pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
/// Default physical filename for the standard HTTP Transport JSON Schema document. /// Default physical filename for the standard HTTP + WebSocket Transport JSON Schema document.
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json"; pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
/// Default physical filename for the standard Wallet configuration document. /// Default physical filename for the standard Wallet configuration document.
pub const DEFAULT_STD_WALLET_FILENAME: &str = "std.wallet.json"; 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"; pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
/// Logical file identifier for the standard Logging JSON Schema document. /// Logical file identifier for the standard Logging JSON Schema document.
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging"; pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
/// Logical file identifier for the standard HTTP Transport JSON Schema document. /// Logical file identifier for the standard HTTP + WebSocket Transport JSON Schema document.
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport"; pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
/// Logical file identifier for the standard Wallet JSON Schema document. /// Logical file identifier for the standard Wallet JSON Schema document.
pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet"; pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet";
/// Logical file identifier for the standard Logging configuration document. /// Logical file identifier for the standard Logging configuration document.
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging"; pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
/// Logical file identifier for the standard HTTP Transport configuration document. /// Logical file identifier for the standard HTTP + WebSocket Transport configuration document.
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport"; pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
/// Logical file identifier for the standard Wallet configuration document. /// Logical file identifier for the standard Wallet configuration document.
pub const FILE_ID_STD_WALLET: &str = "cfg.std.wallet"; pub const FILE_ID_STD_WALLET: &str = "cfg.std.wallet";

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-config-lib/src/transport.rs // file: crates/ksp-config-lib/src/transport.rs
// version: 2 // version: 4
/// Effective standard HTTP Transport configuration resolved from Config and mapped to the Transport runtime contract. /// Effective standard on-chain Transport configuration resolved from Config and mapped to HTTP and optional WebSocket runtime contracts.
#[derive(Clone, Eq, PartialEq)] #[derive(Clone, Eq, PartialEq)]
pub struct ResolvedTransportConfig { pub struct ResolvedTransportConfig {
file_id: crate::ConfigFileId, file_id: crate::ConfigFileId,
@@ -10,6 +10,7 @@ pub struct ResolvedTransportConfig {
selection_source: crate::ConfigProfileSelectionSource, selection_source: crate::ConfigProfileSelectionSource,
effective: crate::ResolvedConfigJson, effective: crate::ResolvedConfigJson,
settings: ksp_onchain_transport_lib::HttpTransportSettings, settings: ksp_onchain_transport_lib::HttpTransportSettings,
ws_settings: std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>,
} }
impl ResolvedTransportConfig { impl ResolvedTransportConfig {
@@ -47,16 +48,40 @@ impl ResolvedTransportConfig {
} }
/// Returns the validated runtime HTTP Transport settings. /// Returns the validated runtime HTTP Transport settings.
///
/// This compatibility accessor keeps the HTTP contract introduced before Transport V2.
#[must_use] #[must_use]
pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings { pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
return &self.settings; return &self.settings;
} }
/// Returns the validated runtime HTTP Transport settings.
#[must_use]
pub const fn http_settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
return &self.settings;
}
/// Returns validated WebSocket Transport settings when the selected document uses format V2.
///
/// Backward-compatible V1 HTTP-only documents return [`std::option::Option::None`].
#[must_use]
pub fn ws_settings(&self) -> std::option::Option<&ksp_onchain_transport_lib::WsTransportSettings> {
return self.ws_settings.as_ref();
}
/// Consumes this resolved Config and returns the mapped runtime HTTP Transport settings. /// Consumes this resolved Config and returns the mapped runtime HTTP Transport settings.
#[must_use] #[must_use]
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings { pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
return self.settings; return self.settings;
} }
/// Consumes this resolved Config and returns both HTTP and optional WebSocket runtime settings.
#[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);
}
} }
impl std::fmt::Debug for ResolvedTransportConfig { impl std::fmt::Debug for ResolvedTransportConfig {
@@ -68,16 +93,16 @@ impl std::fmt::Debug for ResolvedTransportConfig {
.field("profile_id", &self.profile_id) .field("profile_id", &self.profile_id)
.field("selection_source", &self.selection_source) .field("selection_source", &self.selection_source)
.field("effective", &self.effective) .field("effective", &self.effective)
.field("has_ws_settings", &self.ws_settings.is_some())
.finish_non_exhaustive(); .finish_non_exhaustive();
} }
} }
impl crate::ConfigDocumentEngine { impl crate::ConfigDocumentEngine {
/// Loads the standard HTTP Transport document, selects a profile, resolves environment placeholders and maps it to Transport runtime settings. /// Loads the standard Transport document, selects a profile, resolves environment placeholders and maps HTTP plus optional WebSocket runtime settings.
/// ///
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed /// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed
/// because the Transport URL wrapper owns runtime redaction. Invalid environment-resolved values are reported as /// because Transport URL wrappers own runtime redaction. V1 documents remain HTTP-only; V2 documents require WebSocket defaults and endpoints.
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] without copying endpoint URL values into ordinary error context.
pub fn load_resolved_transport_config( pub fn load_resolved_transport_config(
&self, &self,
requested_profile: std::option::Option<&str>, requested_profile: std::option::Option<&str>,
@@ -96,7 +121,7 @@ impl crate::ConfigDocumentEngine {
return resolve_transport_profile(&profile, environment); return resolve_transport_profile(&profile, environment);
} }
/// Maps an already resolved standard Transport profile to the runtime HTTP Transport adapter while preserving its selection provenance. /// Maps an already resolved standard Transport profile to HTTP plus optional WebSocket runtime adapters while preserving selection provenance.
/// ///
/// This entry point is intended for profiles selected by a composite. The profile must reference `cfg.std.transport`. /// This entry point is intended for profiles selected by a composite. The profile must reference `cfg.std.transport`.
pub fn resolve_transport_config_profile( pub fn resolve_transport_config_profile(
@@ -121,7 +146,11 @@ struct EffectiveTransportSource {
format_version: u32, format_version: u32,
profile_id: String, profile_id: String,
retry: EffectiveRetrySource, retry: EffectiveRetrySource,
#[serde(default)]
ws_defaults: std::option::Option<EffectiveWsSessionSource>,
endpoints: std::vec::Vec<EffectiveEndpointSource>, endpoints: std::vec::Vec<EffectiveEndpointSource>,
#[serde(default)]
ws_endpoints: std::option::Option<std::vec::Vec<EffectiveWsEndpointSource>>,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -165,7 +194,69 @@ struct EffectiveLimitsSource {
pause_after_rate_limit_ms: std::option::Option<u64>, pause_after_rate_limit_ms: std::option::Option<u64>,
} }
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveWsSessionSource {
command_timeout_ms: u64,
close_timeout_ms: u64,
reconnect: EffectiveWsReconnectSource,
resubscribe: String,
command_queue_capacity: usize,
notification_queue_capacity: usize,
max_active_subscriptions: usize,
max_pending_requests: usize,
max_message_size_bytes: usize,
max_frame_size_bytes: usize,
max_write_buffer_size_bytes: usize,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveWsReconnectSource {
max_retries: u32,
initial_backoff_ms: u64,
max_backoff_ms: u64,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveWsEndpointSource {
name: String,
enabled: bool,
provider: String,
cluster: String,
kind: String,
url: String,
#[serde(default)]
session: std::option::Option<EffectiveWsSessionOverrideSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveWsSessionOverrideSource {
command_timeout_ms: std::option::Option<u64>,
close_timeout_ms: std::option::Option<u64>,
reconnect: std::option::Option<EffectiveWsReconnectOverrideSource>,
resubscribe: std::option::Option<String>,
command_queue_capacity: std::option::Option<usize>,
notification_queue_capacity: std::option::Option<usize>,
max_active_subscriptions: std::option::Option<usize>,
max_pending_requests: std::option::Option<usize>,
max_message_size_bytes: std::option::Option<usize>,
max_frame_size_bytes: std::option::Option<usize>,
max_write_buffer_size_bytes: std::option::Option<usize>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveWsReconnectOverrideSource {
max_retries: std::option::Option<u32>,
initial_backoff_ms: std::option::Option<u64>,
max_backoff_ms: std::option::Option<u64>,
}
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> { fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapping standard Transport Config profile");
let effective = profile.resolve_effective_environment_detailed(environment); let effective = profile.resolve_effective_environment_detailed(environment);
let effective = match effective { let effective = match effective {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
@@ -180,12 +271,10 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
); );
}, },
}; };
if source.format_version != 1 {
return std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported"));
}
if source.profile_id != profile.profile_id() { if source.profile_id != profile.profile_id() {
return std::result::Result::Err(effective_error(profile, "effective Transport profile_id does not match the selected profile")); return std::result::Result::Err(effective_error(profile, "effective Transport profile_id does not match the selected profile"));
} }
let format_version = source.format_version;
let retry = ksp_onchain_transport_lib::HttpRetrySettings::new( let retry = ksp_onchain_transport_lib::HttpRetrySettings::new(
source.retry.max_retries, source.retry.max_retries,
std::time::Duration::from_millis(source.retry.initial_backoff_ms), std::time::Duration::from_millis(source.retry.initial_backoff_ms),
@@ -197,14 +286,24 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
std::result::Result::Err(error) => return std::result::Result::Err(error), std::result::Result::Err(error) => return std::result::Result::Err(error),
}; };
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(endpoints, retry); let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(endpoints, retry);
let validation = settings.validate(); if let std::result::Result::Err(error) = settings.validate() {
if let std::result::Result::Err(error) = validation { return std::result::Result::Err(transport_contract_error(profile, "effective HTTP Transport settings fail the Transport runtime contract", &error));
return std::result::Result::Err(transport_contract_error(profile, "effective Transport settings fail the Transport runtime contract", &error));
} }
let ws_settings = map_optional_ws_settings(format_version, source.ws_defaults, source.ws_endpoints, profile);
let ws_settings = match ws_settings {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let ws_endpoint_count = match ws_settings.as_ref() {
std::option::Option::Some(value) => value.endpoints().len(),
std::option::Option::None => 0_usize,
};
ksp_logging_lib::debug!( ksp_logging_lib::debug!(
target: crate::TRACING_TARGET, target: crate::TRACING_TARGET,
profile_id = profile.profile_id(), profile_id = profile.profile_id(),
endpoint_count = settings.endpoints().len(), format_version,
http_endpoint_count = settings.endpoints().len(),
ws_endpoint_count,
"mapped standard Transport Config to runtime settings" "mapped standard Transport Config to runtime settings"
); );
return std::result::Result::Ok(ResolvedTransportConfig { return std::result::Result::Ok(ResolvedTransportConfig {
@@ -214,9 +313,53 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
selection_source: profile.selection_source(), selection_source: profile.selection_source(),
effective, effective,
settings, settings,
ws_settings,
}); });
} }
fn map_optional_ws_settings(
format_version: u32,
defaults: std::option::Option<EffectiveWsSessionSource>,
sources: std::option::Option<std::vec::Vec<EffectiveWsEndpointSource>>,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>> {
return match format_version {
1 => {
if defaults.is_some() || sources.is_some() {
std::result::Result::Err(effective_error(profile, "Transport V1 must remain HTTP-only"))
} else {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapped backward-compatible Transport V1 without WebSocket settings");
std::result::Result::Ok(std::option::Option::None)
}
},
2 => {
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")),
};
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")),
};
let endpoints = map_ws_endpoints(sources, &defaults, profile);
let endpoints = match endpoints {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let settings = ksp_onchain_transport_lib::WsTransportSettings::new(endpoints);
if let std::result::Result::Err(error) = settings.validate() {
return std::result::Result::Err(transport_contract_error(
profile,
"effective WebSocket Transport settings fail the Transport runtime contract",
&error,
));
}
std::result::Result::Ok(std::option::Option::Some(settings))
},
_ => std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported")),
};
}
fn map_endpoints( fn map_endpoints(
sources: std::vec::Vec<EffectiveEndpointSource>, sources: std::vec::Vec<EffectiveEndpointSource>,
profile: &crate::ResolvedConfigProfile, profile: &crate::ResolvedConfigProfile,
@@ -229,7 +372,7 @@ fn map_endpoints(
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => { std::result::Result::Err(error) => {
return std::result::Result::Err( return std::result::Result::Err(
transport_contract_error(profile, "effective endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name), transport_contract_error(profile, "effective HTTP endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
); );
}, },
}; };
@@ -253,6 +396,172 @@ fn map_endpoints(
return std::result::Result::Ok(endpoints); return std::result::Result::Ok(endpoints);
} }
fn map_ws_endpoints(
sources: std::vec::Vec<EffectiveWsEndpointSource>,
defaults: &EffectiveWsSessionSource,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::WsEndpointSettings>> {
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::WsEndpointSettings>::with_capacity(sources.len());
for source in sources {
let endpoint_name = source.name.clone();
let protocol = map_ws_protocol_kind(source.kind.as_str(), profile, endpoint_name.as_str());
let protocol = match protocol {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let url = ksp_onchain_transport_lib::WsEndpointUrl::parse(source.url);
let url = match url {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
transport_contract_error(profile, "effective WebSocket endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
);
},
};
let session = map_ws_session_settings(defaults, source.session.as_ref(), profile, endpoint_name.as_str());
let session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
endpoints.push(ksp_onchain_transport_lib::WsEndpointSettings::new(
source.name,
source.enabled,
ksp_onchain_transport_lib::WsProviderName::new(source.provider),
ksp_onchain_transport_lib::WsClusterName::new(source.cluster),
protocol,
url,
session,
));
}
return std::result::Result::Ok(endpoints);
}
fn map_ws_protocol_kind(
value: &str,
profile: &crate::ResolvedConfigProfile,
endpoint_name: &str,
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsProtocolKind> {
return match value {
"solana_standard" => std::result::Result::Ok(ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard),
"helius_laserstream" => std::result::Result::Ok(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream),
_ => std::result::Result::Err(
effective_error(profile, "effective WebSocket protocol kind is unsupported")
.with_context("endpoint_name", endpoint_name)
.with_context("ws_kind", value),
),
};
}
fn map_ws_session_settings(
defaults: &EffectiveWsSessionSource,
overrides: std::option::Option<&EffectiveWsSessionOverrideSource>,
profile: &crate::ResolvedConfigProfile,
endpoint_name: &str,
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsSessionSettings> {
let mut command_timeout_ms = defaults.command_timeout_ms;
let mut close_timeout_ms = defaults.close_timeout_ms;
let mut reconnect_max_retries = defaults.reconnect.max_retries;
let mut reconnect_initial_backoff_ms = defaults.reconnect.initial_backoff_ms;
let mut reconnect_max_backoff_ms = defaults.reconnect.max_backoff_ms;
let mut resubscribe_text = defaults.resubscribe.clone();
let mut command_queue_capacity = defaults.command_queue_capacity;
let mut notification_queue_capacity = defaults.notification_queue_capacity;
let mut max_active_subscriptions = defaults.max_active_subscriptions;
let mut max_pending_requests = defaults.max_pending_requests;
let mut max_message_size_bytes = defaults.max_message_size_bytes;
let mut max_frame_size_bytes = defaults.max_frame_size_bytes;
let mut max_write_buffer_size_bytes = defaults.max_write_buffer_size_bytes;
if let std::option::Option::Some(overrides) = overrides {
if let std::option::Option::Some(value) = overrides.command_timeout_ms {
command_timeout_ms = value;
}
if let std::option::Option::Some(value) = overrides.close_timeout_ms {
close_timeout_ms = value;
}
if let std::option::Option::Some(reconnect) = overrides.reconnect.as_ref() {
if let std::option::Option::Some(value) = reconnect.max_retries {
reconnect_max_retries = value;
}
if let std::option::Option::Some(value) = reconnect.initial_backoff_ms {
reconnect_initial_backoff_ms = value;
}
if let std::option::Option::Some(value) = reconnect.max_backoff_ms {
reconnect_max_backoff_ms = value;
}
}
if let std::option::Option::Some(value) = overrides.resubscribe.as_ref() {
resubscribe_text = value.clone();
}
if let std::option::Option::Some(value) = overrides.command_queue_capacity {
command_queue_capacity = value;
}
if let std::option::Option::Some(value) = overrides.notification_queue_capacity {
notification_queue_capacity = value;
}
if let std::option::Option::Some(value) = overrides.max_active_subscriptions {
max_active_subscriptions = value;
}
if let std::option::Option::Some(value) = overrides.max_pending_requests {
max_pending_requests = value;
}
if let std::option::Option::Some(value) = overrides.max_message_size_bytes {
max_message_size_bytes = value;
}
if let std::option::Option::Some(value) = overrides.max_frame_size_bytes {
max_frame_size_bytes = value;
}
if let std::option::Option::Some(value) = overrides.max_write_buffer_size_bytes {
max_write_buffer_size_bytes = value;
}
}
let reconnect = ksp_onchain_transport_lib::WsReconnectSettings::new(
reconnect_max_retries,
std::time::Duration::from_millis(reconnect_initial_backoff_ms),
std::time::Duration::from_millis(reconnect_max_backoff_ms),
);
let resubscribe = map_ws_resubscribe_policy(resubscribe_text.as_str(), profile, endpoint_name);
let resubscribe = match resubscribe {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let settings = ksp_onchain_transport_lib::WsSessionSettings::new(
std::time::Duration::from_millis(command_timeout_ms),
std::time::Duration::from_millis(close_timeout_ms),
reconnect,
resubscribe,
command_queue_capacity,
notification_queue_capacity,
max_active_subscriptions,
max_pending_requests,
max_message_size_bytes,
max_frame_size_bytes,
max_write_buffer_size_bytes,
);
if let std::result::Result::Err(error) = settings.validate() {
return std::result::Result::Err(
transport_contract_error(profile, "effective WebSocket session settings fail the Transport runtime contract", &error)
.with_context("endpoint_name", endpoint_name),
);
}
return std::result::Result::Ok(settings);
}
fn map_ws_resubscribe_policy(
value: &str,
profile: &crate::ResolvedConfigProfile,
endpoint_name: &str,
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsResubscribePolicy> {
return match value {
"never" => std::result::Result::Ok(ksp_onchain_transport_lib::WsResubscribePolicy::Never),
"active_subscriptions" => std::result::Result::Ok(ksp_onchain_transport_lib::WsResubscribePolicy::ActiveSubscriptions),
_ => std::result::Result::Err(
effective_error(profile, "effective WebSocket resubscribe policy is unsupported")
.with_context("endpoint_name", endpoint_name)
.with_context("resubscribe", value),
),
};
}
fn map_roles( fn map_roles(
sources: std::vec::Vec<EffectiveRoleSource>, sources: std::vec::Vec<EffectiveRoleSource>,
profile: &crate::ResolvedConfigProfile, profile: &crate::ResolvedConfigProfile,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/public_api.rs // file: crates/ksp-config-lib/tests/public_api.rs
// version: 21 // version: 22
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, //! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
//! Logging/Transport adapters and management contracts. //! Logging/Transport adapters and management contracts.
@@ -252,6 +252,9 @@ fn transport_adapter_contract_is_available_from_crate_root() {
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_transport_config; let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_transport_config;
let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_transport_config_profile; let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_transport_config_profile;
assert!(std::mem::size_of::<ksp_config_lib::ResolvedTransportConfig>() > 0); 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 _into_transport_settings = ksp_config_lib::ResolvedTransportConfig::into_transport_settings;
assert_eq!(ksp_config_lib::FILE_ID_STD_TRANSPORT, "cfg.std.transport"); 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::FILE_ID_SCHEMA_STD_TRANSPORT, "schema.std.transport");
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json"); assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json");

View File

@@ -1,10 +1,27 @@
{ {
"format_version": 1, "format_version": 2,
"retry": { "retry": {
"max_retries": 4, "max_retries": 4,
"initial_backoff_ms": 125, "initial_backoff_ms": 125,
"max_backoff_ms": 2500 "max_backoff_ms": 2500
}, },
"ws_defaults": {
"command_timeout_ms": 8000,
"close_timeout_ms": 4000,
"reconnect": {
"max_retries": 6,
"initial_backoff_ms": 200,
"max_backoff_ms": 4000
},
"resubscribe": "active_subscriptions",
"command_queue_capacity": 64,
"notification_queue_capacity": 96,
"max_active_subscriptions": 256,
"max_pending_requests": 48,
"max_message_size_bytes": 33554432,
"max_frame_size_bytes": 8388608,
"max_write_buffer_size_bytes": 524288
},
"default_profile": "secret_test", "default_profile": "secret_test",
"profiles": [ "profiles": [
{ {
@@ -23,7 +40,9 @@
{ {
"role": "default", "role": "default",
"enabled": true, "enabled": true,
"request_kinds": ["*"], "request_kinds": [
"*"
],
"priority": 7, "priority": 7,
"limits": { "limits": {
"requests_per_second": 9, "requests_per_second": 9,
@@ -34,6 +53,74 @@
} }
] ]
} }
],
"ws_endpoints": [
{
"name": "fixture_private_ws",
"enabled": true,
"provider": "fixture-provider",
"cluster": "fixture-cluster",
"kind": "solana_standard",
"url": "${KSP_SECRET_TRANSPORT_TEST_WS_URL:-wss://fallback.invalid}",
"session": {
"command_timeout_ms": 4500,
"reconnect": {
"max_retries": 7
},
"resubscribe": "never",
"notification_queue_capacity": 48,
"max_pending_requests": 24
}
},
{
"name": "fixture_helius_ws",
"enabled": true,
"provider": "helius",
"cluster": "mainnet-beta",
"kind": "helius_laserstream",
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}"
}
]
},
{
"profile_id": "helius_devnet",
"endpoints": [
{
"name": "fixture_devnet_http",
"enabled": true,
"provider": "solana-public",
"cluster": "devnet",
"url": "https://api.devnet.solana.com",
"connect_timeout_ms": 750,
"request_timeout_ms": 2500,
"max_idle_connections_per_host": 3,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": [
"*"
],
"priority": 7,
"limits": {
"requests_per_second": 9,
"burst_capacity": 12,
"max_concurrent_requests": 4,
"pause_after_rate_limit_ms": 650
}
}
]
}
],
"ws_endpoints": [
{
"name": "fixture_helius_devnet_ws",
"enabled": true,
"provider": "helius",
"cluster": "devnet",
"kind": "helius_laserstream",
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}"
}
] ]
} }
] ]

View File

@@ -0,0 +1,42 @@
{
"format_version": 1,
"retry": {
"max_retries": 1,
"initial_backoff_ms": 90,
"max_backoff_ms": 900
},
"default_profile": "legacy_http",
"profiles": [
{
"profile_id": "legacy_http",
"endpoints": [
{
"name": "legacy_http",
"enabled": true,
"provider": "legacy-provider",
"cluster": "devnet",
"url": "https://legacy.invalid",
"connect_timeout_ms": 5000,
"request_timeout_ms": 2200,
"max_idle_connections_per_host": 2,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": [
"*"
],
"priority": 10,
"limits": {
"requests_per_second": 2,
"burst_capacity": 3,
"max_concurrent_requests": 2,
"pause_after_rate_limit_ms": 250
}
}
]
}
]
}
]
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/transport.rs // file: crates/ksp-config-lib/unit_tests/transport.rs
// version: 2 // version: 6
#[test] #[test]
fn fixture_transport_profile_maps_complete_runtime_contract() { fn fixture_transport_profile_maps_complete_runtime_contract() {
@@ -41,6 +41,56 @@ 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().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().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_eq!(role.limits().pause_after_rate_limit(), std::option::Option::Some(std::time::Duration::from_millis(650)));
let ws = resolved.ws_settings();
assert!(ws.is_some(), "V2 fixture should expose WebSocket settings");
if let std::option::Option::Some(ws) = ws {
assert_eq!(ws.endpoints().len(), 2);
let endpoint = &ws.endpoints()[0];
assert_eq!(endpoint.name(), "fixture_private_ws");
assert_eq!(endpoint.provider().as_str(), "fixture-provider");
assert_eq!(endpoint.cluster().as_str(), "fixture-cluster");
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
assert_eq!(endpoint.url().as_str(), "wss://fallback.invalid");
assert_eq!(endpoint.session().command_timeout(), std::time::Duration::from_millis(4500));
assert_eq!(endpoint.session().close_timeout(), std::time::Duration::from_millis(4000));
assert_eq!(endpoint.session().reconnect().max_retries(), 7);
assert_eq!(endpoint.session().reconnect().initial_backoff(), std::time::Duration::from_millis(200));
assert_eq!(endpoint.session().reconnect().max_backoff(), std::time::Duration::from_millis(4000));
assert_eq!(endpoint.session().resubscribe(), ksp_onchain_transport_lib::WsResubscribePolicy::Never);
assert_eq!(endpoint.session().command_queue_capacity(), 64);
assert_eq!(endpoint.session().notification_queue_capacity(), 48);
assert_eq!(endpoint.session().max_active_subscriptions(), 256);
assert_eq!(endpoint.session().max_pending_requests(), 24);
assert_eq!(endpoint.session().max_message_size_bytes(), 33_554_432);
assert_eq!(endpoint.session().max_frame_size_bytes(), 8_388_608);
assert_eq!(endpoint.session().max_write_buffer_size_bytes(), 524_288);
let helius = &ws.endpoints()[1];
assert_eq!(helius.name(), "fixture_helius_ws");
assert_eq!(helius.provider().as_str(), "helius");
assert_eq!(helius.cluster().as_str(), "mainnet-beta");
assert_eq!(helius.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
assert_eq!(helius.url().as_str(), "wss://mainnet.helius-rpc.com/?api-key=fixture-helius-key");
let _connect_future = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(helius.clone());
}
}
#[test]
fn v1_transport_fixture_remains_backward_readable_and_http_only() {
let engine = v1_fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
assert!(resolved.is_ok(), "strict Transport V1 fixture should remain readable: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "legacy_http");
assert_eq!(resolved.settings().retry().max_retries(), 1);
assert_eq!(resolved.settings().endpoints().len(), 1);
assert_eq!(resolved.settings().endpoints()[0].url().as_str(), "https://legacy.invalid");
assert!(resolved.ws_settings().is_none(), "V1 must not invent WebSocket runtime settings");
}
} }
#[test] #[test]
@@ -59,12 +109,47 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
assert_eq!(default.profile_id(), "devnet_public"); assert_eq!(default.profile_id(), "devnet_public");
assert_eq!(default.settings().endpoints()[0].cluster().as_str(), "devnet"); 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_eq!(default.settings().endpoints()[0].url().as_str(), "https://api.devnet.solana.com");
let ws = default.ws_settings();
assert!(ws.is_some(), "committed V2 Devnet profile should expose WebSocket settings");
if let std::option::Option::Some(ws) = ws {
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.devnet.solana.com");
assert_eq!(ws.endpoints()[0].protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
}
} }
if let std::result::Result::Ok(mainnet) = mainnet { if let std::result::Result::Ok(mainnet) = mainnet {
assert_eq!(mainnet.profile_id(), "mainnet_public"); assert_eq!(mainnet.profile_id(), "mainnet_public");
assert_eq!(mainnet.selection_source(), crate::ConfigProfileSelectionSource::Explicit); 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].cluster().as_str(), "mainnet-beta");
assert_eq!(mainnet.settings().endpoints()[0].url().as_str(), "https://api.mainnet-beta.solana.com"); assert_eq!(mainnet.settings().endpoints()[0].url().as_str(), "https://api.mainnet-beta.solana.com");
let ws = mainnet.ws_settings();
assert!(ws.is_some(), "committed V2 Mainnet profile should expose WebSocket settings");
if let std::option::Option::Some(ws) = ws {
assert_eq!(ws.endpoints()[0].url().as_str(), "wss://api.mainnet-beta.solana.com");
}
}
}
#[test]
fn committed_v2_websocket_endpoint_composes_with_public_session_constructor_without_polling() {
let engine = committed_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = engine.load_resolved_transport_config(std::option::Option::Some("devnet_public"), &environment);
assert!(resolved.is_ok(), "committed V2 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");
if let std::option::Option::Some(ws) = ws {
assert!(ws.validate().is_ok(), "Config-produced WebSocket settings should satisfy Transport validation");
assert_eq!(ws.endpoints().len(), 1);
let endpoint = ws.endpoints()[0].clone();
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
let _connect_future = ksp_onchain_transport_lib::WsSession::connect(endpoint);
}
} }
} }
@@ -84,7 +169,9 @@ fn transport_profile_preserves_global_and_profile_origin() {
assert!(profile.is_ok(), "committed Transport profile should resolve: {profile:?}"); assert!(profile.is_ok(), "committed Transport profile should resolve: {profile:?}");
if let std::result::Result::Ok(profile) = profile { if let std::result::Result::Ok(profile) = profile {
assert_eq!(profile.origin("retry"), std::option::Option::Some(crate::ConfigValueOrigin::Global)); assert_eq!(profile.origin("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("endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile)); 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)); assert_eq!(profile.origin("format_version"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
} }
} }
@@ -149,6 +236,108 @@ fn secret_transport_url_is_runtime_available_but_safe_projection_is_redacted() {
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE)); assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
} }
#[test]
fn secret_websocket_url_is_runtime_available_but_safe_projection_is_redacted() {
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let canary = "wss://user:pass@secret-provider.invalid/path?api-key=transport-ws-secret-canary";
let mut process = std::collections::BTreeMap::<String, String>::new();
process.insert("KSP_SECRET_TRANSPORT_TEST_WS_URL".to_owned(), canary.to_owned());
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
assert!(resolved.is_ok(), "secret WebSocket endpoint should map without being exposed: {resolved:?}");
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let ws = resolved.ws_settings();
assert!(ws.is_some(), "V2 fixture should expose WebSocket settings");
if let std::option::Option::Some(ws) = ws {
assert_eq!(ws.endpoints()[0].url().as_str(), canary);
}
let safe_url = resolved.effective().safe_value().pointer("/ws_endpoints/0/url").and_then(serde_json::Value::as_str);
assert_eq!(safe_url, std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
let debug = format!("{resolved:?}");
assert!(!debug.contains("transport-ws-secret-canary"));
assert!(!debug.contains("user:pass"));
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
}
#[test]
fn helius_laserstream_mainnet_and_devnet_api_key_map_to_protocol_and_safe_redaction() {
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let canary = "helius-api-key-canary";
let mut process = std::collections::BTreeMap::<String, String>::new();
process.insert("KSP_SECRET_HELIUS_API_KEY".to_owned(), canary.to_owned());
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
let mainnet = engine.load_resolved_transport_config(std::option::Option::None, &environment);
assert!(mainnet.is_ok(), "Helius mainnet WebSocket endpoint should map without exposing its API key: {mainnet:?}");
let mainnet = match mainnet {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let mainnet_ws = mainnet.ws_settings();
assert!(mainnet_ws.is_some(), "V2 fixture should expose mainnet WebSocket settings");
if let std::option::Option::Some(ws) = mainnet_ws {
assert_eq!(ws.endpoints().len(), 2);
let endpoint = &ws.endpoints()[1];
assert_eq!(endpoint.provider().as_str(), "helius");
assert_eq!(endpoint.cluster().as_str(), "mainnet-beta");
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
assert_eq!(endpoint.url().as_str(), "wss://mainnet.helius-rpc.com/?api-key=helius-api-key-canary");
}
let mainnet_safe_url = mainnet.effective().safe_value().pointer("/ws_endpoints/1/url").and_then(serde_json::Value::as_str);
assert_eq!(mainnet_safe_url, std::option::Option::Some("wss://mainnet.helius-rpc.com/?api-key=********"));
let mainnet_provenance = mainnet.effective().provenance_at("/ws_endpoints/1/url");
assert!(mainnet_provenance.is_some(), "Helius mainnet endpoint URL should retain secret environment provenance");
if let std::option::Option::Some(provenance) = mainnet_provenance {
assert_eq!(provenance.len(), 2);
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
assert_eq!(provenance[1].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_HELIUS_API_KEY"));
}
let devnet = engine.load_resolved_transport_config(std::option::Option::Some("helius_devnet"), &environment);
assert!(devnet.is_ok(), "Helius devnet WebSocket endpoint should map without exposing its API key: {devnet:?}");
let devnet = match devnet {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let devnet_ws = devnet.ws_settings();
assert!(devnet_ws.is_some(), "V2 fixture should expose devnet WebSocket settings");
if let std::option::Option::Some(ws) = devnet_ws {
assert_eq!(ws.endpoints().len(), 1);
let endpoint = &ws.endpoints()[0];
assert_eq!(endpoint.provider().as_str(), "helius");
assert_eq!(endpoint.cluster().as_str(), "devnet");
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
assert_eq!(endpoint.url().as_str(), "wss://devnet.helius-rpc.com/?api-key=helius-api-key-canary");
let _connect_future = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(endpoint.clone());
}
let devnet_safe_url = devnet.effective().safe_value().pointer("/ws_endpoints/0/url").and_then(serde_json::Value::as_str);
assert_eq!(devnet_safe_url, std::option::Option::Some("wss://devnet.helius-rpc.com/?api-key=********"));
let devnet_provenance = devnet.effective().provenance_at("/ws_endpoints/0/url");
assert!(devnet_provenance.is_some(), "Helius devnet endpoint URL should retain secret environment provenance");
if let std::option::Option::Some(provenance) = devnet_provenance {
assert_eq!(provenance.len(), 2);
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
assert_eq!(provenance[1].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_HELIUS_API_KEY"));
}
let mainnet_debug = format!("{mainnet:?}");
let devnet_debug = format!("{devnet:?}");
assert!(!mainnet_debug.contains(canary));
assert!(!devnet_debug.contains(canary));
assert!(mainnet_debug.contains(crate::REDACTED_CONFIG_VALUE));
assert!(devnet_debug.contains(crate::REDACTED_CONFIG_VALUE));
}
#[test] #[test]
fn transport_secret_url_provenance_uses_process_and_process_beats_dotenv() { fn transport_secret_url_provenance_uses_process_and_process_beats_dotenv() {
let engine = fixture_engine(); let engine = fixture_engine();
@@ -216,6 +405,22 @@ fn fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry)); return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
} }
fn v1_fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
let workspace = workspace_root();
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures_v1");
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = crate::ConfigFileRegistry::defaults();
let registry = match registry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
}
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> { fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
let workspace = workspace_root(); let workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));

View File

@@ -0,0 +1,135 @@
<!-- file: crates/ksp-core-lib/README.md -->
<!-- version: 1 -->
# `ksp-core-lib`
`ksp-core-lib` porte les contrats fondamentaux partagés par les couches KSP sans dépendre des domaines de plus haut niveau.
La crate possède actuellement trois responsabilités :
- le type d'erreur commun KSP ;
- le type Solana `Pubkey` réexporté comme primitive d'adresse canonique ;
- le registre KSP des Program IDs Solana fondamentaux et leur taxonomie.
## Frontière architecturale
Core reste une couche basse. Elle ne possède ni configuration, ni logging runtime, ni transport réseau, ni wallet, ni stockage, ni logique de décodage/exécution.
Les crates de niveau supérieur peuvent dépendre de Core et réutiliser ses contrats ; Core ne doit pas introduire de dépendance inverse vers ces couches.
La seule dépendance runtime externe directe actuelle est `solana-pubkey`, utilisée pour la primitive `Pubkey`.
## Erreur commune KSP
Le contrat d'erreur public repose sur :
```text
ErrorCode
ErrorContext
Error
Result<T>
```
`ErrorCode` sépare un `domain` stable d'un `code` stable. `Error` ajoute :
- un message humain ;
- des champs de contexte ordonnés ;
- une source d'erreur standard optionnelle compatible `Send + Sync`.
L'affichage d'une erreur reste compact :
```text
<domain>.<code>: <message>
```
Les consommateurs ajoutent uniquement des contextes sûrs. Les secrets, credentials, key material, URLs sensibles ou payloads massifs ne doivent pas être copiés dans le message ou le contexte d'une erreur.
## `Pubkey`
La crate réexporte :
```rust
ksp_core_lib::Pubkey
```
Les autres crates KSP utilisent cette primitive lorsqu'un contrat public a besoin d'une adresse Solana générique. Elles évitent ainsi de multiplier les propriétaires de type pour la même notion.
## Program IDs fondamentaux
Core possède 18 Program IDs Solana fondamentaux sous deux formes cohérentes :
```text
PRGID_* -> Base58 &str
PRGIDPK_* -> Pubkey typé
```
Les deux formes sont déclarées depuis une même valeur grâce à `declare_program_id!`.
Le registre couvre notamment :
- System, Vote, Stake, Config et Feature ;
- Address Lookup Table et Compute Budget ;
- les loaders natif, BPF v1, BPF v2, BPF upgradeable et Loader v4 ;
- les precompiles Ed25519, Secp256k1 et Secp256r1 ;
- Slashing ;
- ZK ElGamal Proof et ZK Token Proof.
Le registre n'est pas un catalogue général de tous les programmes Solana. Les protocoles, applications et Program IDs de domaines futurs sont ajoutés dans les couches propriétaires appropriées lorsqu'un besoin réel existe.
## Registre et taxonomie
Chaque entrée est exposée sous `ProgramIdEntry` avec :
```text
code
name
program_id
pubkey
domain
family
protocol
subfamily
program_version
kind
```
`ProgramIdKind` distingue actuellement :
```text
Program
Loader
Precompile
EnshrinedProgram
```
La lecture du registre s'effectue par :
```text
entries()
native_program_ids()
program_ids(filter)
program_ids_by_domain(...)
program_ids_by_family(...)
program_ids_by_protocol(...)
find_program_id(...)
find_program_pubkey(...)
```
`ProgramIdFilter` permet de combiner les axes `domain`, `family`, `protocol`, `subfamily`, `program_version` et `kind`.
## Garanties validées
Les tests publics et unitaires verrouillent notamment :
- la correspondance Base58 / `Pubkey` des constantes ;
- l'unicité des codes, Program IDs et pubkeys du registre ;
- les 18 entrées fondamentales ;
- les recherches textuelles et typées ;
- les vues et filtres taxonomiques ;
- l'absence des comptes well-known qui ne sont pas des programmes ;
- le contrat `Error`, son ordre de contexte et sa chaîne `source()` ;
- `Error: Send + Sync`.
## Documentation
- [`USAGE.md`](USAGE.md) — exemples d'utilisation de l'erreur commune, de `Pubkey`, des Program IDs et du registre.

View File

@@ -0,0 +1,265 @@
<!-- file: crates/ksp-core-lib/USAGE.md -->
<!-- version: 1 -->
# Utilisation de `ksp-core-lib`
## Utiliser le type d'erreur commun
Une crate KSP définit des codes stables puis retourne `ksp_core_lib::Result<T>` :
```rust
const ERROR_CODE_LOAD_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("store", "load_failed");
fn load_value() -> ksp_core_lib::Result<u64> {
let error = ksp_core_lib::Error::new(
ERROR_CODE_LOAD_FAILED,
"unable to load value",
)
.with_context("operation", "load_value");
return std::result::Result::Err(error);
}
```
Le domaine et le code sont accessibles séparément :
```rust
let code = ERROR_CODE_LOAD_FAILED;
assert_eq!(code.domain(), "store");
assert_eq!(code.code(), "load_failed");
```
Le message et les champs de contexte restent accessibles sans parser `Display` :
```rust
let error = ksp_core_lib::Error::new(
ERROR_CODE_LOAD_FAILED,
"unable to load value",
)
.with_context("component", "postgres")
.with_context("operation", "load_value");
assert_eq!(error.code(), ERROR_CODE_LOAD_FAILED);
assert_eq!(error.message(), "unable to load value");
assert_eq!(error.context()[0].key(), "component");
assert_eq!(error.context()[0].value(), "postgres");
```
Un `ErrorContext` peut aussi être construit indépendamment lorsquun caller prépare explicitement son contexte :
```rust
let context = ksp_core_lib::ErrorContext::new(
"operation",
"load_value",
);
assert_eq!(context.key(), "operation");
assert_eq!(context.value(), "load_value");
```
Les valeurs de contexte doivent être sûres à exposer. Ne pas y placer de secret, credential, seed, key material, URL contenant une clé API ou payload brut volumineux.
## Conserver une erreur source
Une erreur externe compatible `Send + Sync + 'static` peut rester dans la chaîne standard :
```rust
#[derive(Debug)]
struct SourceError;
impl std::fmt::Display for SourceError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("source failure");
}
}
impl std::error::Error for SourceError {}
let error = ksp_core_lib::Error::new(
ERROR_CODE_LOAD_FAILED,
"unable to load value",
)
.with_source(SourceError);
let source = std::error::Error::source(&error);
assert!(source.is_some());
```
La source sert au chaînage d'erreurs ; son contenu ne doit pas être recopié sans contrôle dans un contexte ou un log public.
## Utiliser `Pubkey`
Core réexporte la primitive Solana utilisée par les contrats KSP :
```rust
let system = ksp_core_lib::Pubkey::from_str_const(
ksp_core_lib::PRGID_SOLANA_SYSTEM,
);
assert_eq!(system, ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
```
Une crate consommatrice peut donc utiliser `ksp_core_lib::Pubkey` dans sa propre API sans dépendre directement de `solana-pubkey` lorsque Core est déjà le propriétaire architectural de cette primitive.
## Utiliser les constantes Program ID
Chaque Program ID Core possède une forme texte et une forme typée :
```rust
let system_text: &str = ksp_core_lib::PRGID_SOLANA_SYSTEM;
let system_pubkey: ksp_core_lib::Pubkey =
ksp_core_lib::PRGIDPK_SOLANA_SYSTEM;
assert_eq!(
system_pubkey,
ksp_core_lib::Pubkey::from_str_const(system_text),
);
```
Les constantes `PRGID_*` sont utiles pour les wires, diagnostics sûrs ou comparaisons texte. Les constantes `PRGIDPK_*` sont préférées dès qu'un contrat manipule une adresse Solana typée.
## Déclarer une paire texte / `Pubkey`
`declare_program_id!` permet à une couche KSP propriétaire d'un Program ID de déclarer les deux représentations depuis une seule valeur Base58 :
```rust
ksp_core_lib::declare_program_id!(
PRGID_EXAMPLE,
PRGIDPK_EXAMPLE,
"11111111111111111111111111111111"
);
assert_eq!(PRGID_EXAMPLE, ksp_core_lib::PRGID_SOLANA_SYSTEM);
assert_eq!(PRGIDPK_EXAMPLE, ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
```
La macro ne signifie pas que toute nouvelle constante doit être ajoutée au registre Core. La couche propriétaire du domaine décide où vit le nouveau Program ID.
## Parcourir le registre canonique
`entries()` retourne le registre complet en ordre déterministe :
```rust
for entry in ksp_core_lib::entries() {
let code = entry.code();
let name = entry.name();
let text = entry.program_id();
let pubkey = entry.pubkey();
let domain = entry.domain();
let family = entry.family();
let protocol = entry.protocol();
let subfamily = entry.subfamily();
let program_version = entry.program_version();
let kind = entry.kind();
let _ = (
code,
name,
text,
pubkey,
domain,
family,
protocol,
subfamily,
program_version,
kind,
);
}
```
`native_program_ids()` fournit la vue des Program IDs Solana fondamentaux actuellement enregistrés :
```rust
let count = ksp_core_lib::native_program_ids().count();
assert_eq!(count, 18);
```
## Rechercher une entrée
Recherche par représentation Base58 :
```rust
let entry = ksp_core_lib::find_program_id(
ksp_core_lib::PRGID_SOLANA_SYSTEM,
);
let entry = match entry {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert_eq!(entry.code(), "solana.system");
```
Recherche par `Pubkey` :
```rust
let entry = ksp_core_lib::find_program_pubkey(
&ksp_core_lib::PRGIDPK_SOLANA_VOTE,
);
assert!(entry.is_some());
```
Une recherche inconnue retourne `None`; le registre n'invente pas une entrée générique.
## Filtrer par axe simple
Les helpers spécialisés conviennent aux filtres simples :
```rust
let loaders = ksp_core_lib::program_ids_by_family("loader");
for loader in loaders {
assert_eq!(loader.family(), "loader");
}
```
Les vues disponibles peuvent être consommées directement :
```rust
let solana_entries = ksp_core_lib::program_ids_by_domain("solana").count();
let loaders = ksp_core_lib::program_ids_by_family("loader").count();
let solana_protocol = ksp_core_lib::program_ids_by_protocol("solana").count();
assert_eq!(solana_entries, 18);
assert_eq!(loaders, 5);
assert_eq!(solana_protocol, 18);
```
## Combiner plusieurs axes
`ProgramIdFilter` compose les contraintes sans allocation de collection intermédiaire :
```rust
let filter = ksp_core_lib::ProgramIdFilter::new()
.with_domain("solana")
.with_family("loader")
.with_protocol("solana")
.with_subfamily("bpf")
.with_program_version("v2")
.with_kind(ksp_core_lib::ProgramIdKind::Loader);
for entry in ksp_core_lib::program_ids(filter) {
assert_eq!(entry.domain(), "solana");
assert_eq!(entry.family(), "loader");
assert_eq!(entry.protocol(), "solana");
assert_eq!(entry.subfamily(), std::option::Option::Some("bpf"));
assert_eq!(entry.program_version(), std::option::Option::Some("v2"));
assert_eq!(entry.kind(), ksp_core_lib::ProgramIdKind::Loader);
}
```
Un `ProgramIdFilter::new()` vide correspond à toutes les entrées du registre.
## Choisir entre texte, `Pubkey` et descriptor
Utiliser :
```text
PRGID_* / &str pour une représentation Base58 canonique
PRGIDPK_* / Pubkey pour un contrat Solana typé
ProgramIdEntry lorsqu'il faut aussi la taxonomie KSP
```
Ne pas reparcourir les constantes ou reconstruire une taxonomie parallèle dans une crate consommatrice lorsque le registre Core fournit déjà l'information requise.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs // file: crates/ksp-core-lib/tests/workspace_dependencies.rs
// version: 1 // version: 5
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface. //! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
@@ -61,8 +61,64 @@ fn transport_manifest_preserves_ksp_dependency_firewall() {
} }
assert!(manifest.contains("ksp-core-lib")); assert!(manifest.contains("ksp-core-lib"));
assert!(manifest.contains("ksp-logging-lib")); assert!(manifest.contains("ksp-logging-lib"));
assert!(manifest.contains("futures-util = { workspace = true, features = [\"sink\", \"std\"] }"));
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }")); assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }")); assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"net\", \"rt\", \"sync\", \"time\"] }"));
assert!(manifest.contains("tokio-tungstenite = { workspace = true, features = [\"connect\", \"rustls-tls-webpki-roots\"] }"));
assert!(manifest.contains("[dev-dependencies]")); assert!(manifest.contains("[dev-dependencies]"));
assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }")); assert!(manifest.contains("tokio = { workspace = true, features = [\"io-util\", \"net\", \"rt\", \"test-util\"] }"));
}
#[test]
fn transport_manifest_runtime_and_dev_dependency_names_are_exact() {
let manifest_path = workspace_root().join("crates/ksp-onchain-transport-lib/Cargo.toml");
let manifest = std::fs::read_to_string(manifest_path).expect("transport manifest must be readable during workspace integration tests");
let dependencies_tail = manifest.split("[dependencies]").nth(1);
assert!(dependencies_tail.is_some(), "transport dependencies section must exist");
let dependencies_tail = match dependencies_tail {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let dependencies = match dependencies_tail.split("[dev-dependencies]").next() {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let dependency_names = manifest_dependency_names(dependencies);
assert_eq!(
dependency_names,
std::vec!["futures-util", "ksp-core-lib", "ksp-logging-lib", "reqwest", "serde", "serde_json", "tokio", "tokio-tungstenite"]
);
let dev_dependencies_tail = manifest.split("[dev-dependencies]").nth(1);
assert!(dev_dependencies_tail.is_some(), "transport dev-dependencies section must exist");
let dev_dependencies_tail = match dev_dependencies_tail {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let dev_dependencies = match dev_dependencies_tail.split("[lints]").next() {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert_eq!(manifest_dependency_names(dev_dependencies), std::vec!["tokio"]);
}
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
let mut names = std::vec::Vec::new();
for line in section.lines() {
let content = match line.split('#').next() {
std::option::Option::Some(value) => value.trim(),
std::option::Option::None => continue,
};
if content.is_empty() {
continue;
}
let name = match content.split('=').next() {
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
std::option::Option::None => continue,
};
if !name.is_empty() {
names.push(name);
}
}
names.sort_unstable();
return names;
} }

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-logging-lib/USAGE.md --> <!-- file: crates/ksp-logging-lib/USAGE.md -->
<!-- version: 7 --> <!-- version: 8 -->
# Utilisation de ksp-logging-lib # Utilisation de ksp-logging-lib
@@ -48,7 +48,7 @@ Les fichiers persistants interdisent `ansi = true`.
## Runtime multi-output et routing `domain` ## Runtime multi-output et routing `domain`
`0.1.3-pre.005` active le multi-sink, les formats et le routing niveau/target ; `0.1.3-pre.006` complète le routing structuré `domain`. Le runtime supporte donc réellement : Le runtime supporte le multi-sink, les formats, le routing niveau/target et le routing structuré `domain` :
- plusieurs fichiers simultanés ; - plusieurs fichiers simultanés ;
- les formats `Human`, `Compact`, `Pretty` et `Json` ; - les formats `Human`, `Compact`, `Pretty` et `Json` ;

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-onchain-transport-lib/Cargo.toml # file: crates/ksp-onchain-transport-lib/Cargo.toml
# version: 3 # version: 6
[package] [package]
name = "ksp-onchain-transport-lib" name = "ksp-onchain-transport-lib"
@@ -10,13 +10,15 @@ repository.workspace = true
[dependencies] [dependencies]
ksp-core-lib = { path = "../ksp-core-lib" } ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" }
futures-util = { workspace = true, features = ["sink", "std"] }
reqwest = { workspace = true, features = ["rustls"] } reqwest = { workspace = true, features = ["rustls"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
tokio = { workspace = true, features = ["macros", "sync", "time"] } tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] }
tokio-tungstenite = { workspace = true, features = ["connect", "rustls-tls-webpki-roots"] }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["rt"] } tokio = { workspace = true, features = ["io-util", "net", "rt", "test-util"] }
[lints] [lints]
workspace = true workspace = true

View File

@@ -1,9 +1,9 @@
<!-- file: crates/ksp-onchain-transport-lib/README.md --> <!-- file: crates/ksp-onchain-transport-lib/README.md -->
<!-- version: 9 --> <!-- version: 20 -->
# `ksp-onchain-transport-lib` # `ksp-onchain-transport-lib`
`ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Sa première surface est le transport HTTP JSON-RPC ; les extensions WebSocket et gRPC sont introduites séparément lorsque leur release les cible. `ksp-onchain-transport-lib` est la bibliothèque KSP propriétaire du transport on-chain Solana. Elle fournit le transport HTTP JSON-RPC complet et le moteur WebSocket Solana standard ; les extensions provider-specific et gRPC sont ajoutées séparément lorsquune release les cible.
## Responsabilités ## Responsabilités
@@ -19,7 +19,8 @@ La crate possède :
- les enveloppes JSON-RPC 2.0 et leur validation ; - les enveloppes JSON-RPC 2.0 et leur validation ;
- le registre audité des méthodes Solana HTTP ; - le registre audité des méthodes Solana HTTP ;
- l'exécution générique des méthodes standard supportées ; - l'exécution générique des méthodes standard supportées ;
- les wrappers typés explicitement livrés par KSP ; - les wrappers typés HTTP et WebSocket explicitement livrés par KSP ;
- les sessions physiques WebSocket, subscriptions logiques, reconnect/resubscribe et backpressure bornés ;
- les snapshots runtime sûrs ; - les snapshots runtime sûrs ;
- l'observabilité Transport via `ksp-logging-lib`. - l'observabilité Transport via `ksp-logging-lib`.
@@ -35,6 +36,7 @@ ksp-config-lib
-> ksp-core-lib -> ksp-core-lib
-> ksp-logging-lib -> ksp-logging-lib
-> reqwest / tokio / serde -> reqwest / tokio / serde
-> tokio-tungstenite / futures-util
``` ```
La direction inverse est interdite : La direction inverse est interdite :
@@ -69,22 +71,152 @@ Le registre porte notamment :
- remplacement historique éventuel ; - remplacement historique éventuel ;
- release de couverture typée KSP. - release de couverture typée KSP.
La release stable `0.2.4` complète la surface typée des **52 méthodes courantes** : La surface HTTP typée couvre les **52 méthodes courantes** :
```text ```text
0.2.1 foundation : 4 foundation : 4
0.2.2 Accounts/Tokens/Cluster : 22 Accounts/Tokens/Cluster : 22
0.2.3 Transactions : 11 Transactions : 11
0.2.4 Blocks/Economics : 15 Blocks/Economics : 15
total : 52 total : 52
``` ```
`0.2.4` ajoute les dix wrappers Blocks et les cinq wrappers Economics. Les points sensibles restent notamment `getBlock` moderne + bare encoding legacy deprecated, les quatre `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions`, `commissionBps`, les overloads de `getBlocks`, les ranges de production, les valeurs d'inflation/minimum de délégation fournies par le runtime et les `null` positionnels de `getInflationReward`. Les dix wrappers Blocks et les cinq wrappers Economics couvrent notamment `getBlock` moderne + bare encoding legacy deprecated, les quatre `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions`, `commissionBps`, les overloads de `getBlocks`, les ranges de production, les valeurs d'inflation/minimum de délégation fournies par le runtime et les `null` positionnels de `getInflationReward`.
`KSP-TRANSPORT-007` impose qu'un wrapper typé couvre toutes les possibilités RPC supportées retenues par l'audit : paramètres/options, overloads et formes legacy encore supportées, contraintes déterministes utiles et variantes de réponse pertinentes sans perte. Le réaudit final `0.2.4` agrège le réaudit 37/37 de `0.2.3` avec les 15 nouveaux wrappers et porte la preuve stable à **52/52**. `KSP-TRANSPORT-007` impose qu'un wrapper typé couvre toutes les possibilités RPC supportées retenues par l'audit : paramètres/options, overloads et formes legacy encore supportées, contraintes déterministes utiles et variantes de réponse pertinentes sans perte. Les canaris de release portent la preuve globale à **52/52** méthodes courantes typées.
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables. Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
## Moteur WebSocket standard
La première session physique WebSocket est matérialisée sans introduire de pool/scheduler automatique ni de registry de subscriptions anticipé.
`WsSession::connect(WsEndpointSettings)` :
- ouvre exactement une connexion physique pour un appel ;
- confie le socket à une tâche actor unique ;
- sérialise les commandes internes par un canal `mpsc` borné ;
- maintient une map bornée de requests JSON-RPC en attente ;
- publie `WsSessionSnapshot` via un état compact `watch` ;
- applique aux sockets les plafonds KSP de message, frame et write buffer ;
- ne projette jamais l'URL dans `Debug`, snapshot, erreurs KSP ou logs ;
- répond aux `Ping` reçus et tolère les `Pong`; le lifecycle `Close`/shutdown est borné et explicite.
Le chemin JSON-RPC générique reste `pub(crate)`. Il sert de primitive au moteur typed de subscriptions et **ne constitue pas une API publique raw provider-extension**. Le registry de subscriptions et le mapping remote/local, le reconnect/resubscribe et le backpressure borné par subscription font partie du moteur standard.
Les tests déterministes utilisent un serveur WebSocket local et prouvent le handshake, le round-trip JSON-RPC, le dispatch de réponses hors ordre, l'isolation des erreurs RPC applicatives, deux sessions physiques distinctes sur la même URL et la redaction des erreurs de connexion.
### Limites, control frames et shutdown
La session physique dispose désormais de `WsSession::close().await`. Le signal de shutdown est distinct de la command queue, passe l'état en `Closing`, annule les requests JSON-RPC en attente, envoie un Close WebSocket best-effort sous `close_timeout`, puis publie `Closed`. Un peer qui ne répond pas au Close ne peut donc pas bloquer indéfiniment le shutdown.
Les limites `max_message_size`, `max_frame_size`, `max_write_buffer_size` et `max_pending_requests` sont couvertes par des fixtures adversariales locales. Les requests outbound qui dépassent les bornes message/frame sont rejetées avant écriture ; les frames/messages inbound surdimensionnés sont rejetés par Tungstenite avant parse JSON. Les timeouts pending libèrent leur capacité sans faire tomber une session encore saine.
Ping/Pong/Close sont traités comme control frames : le Pong automatique Tungstenite est flushé et aucun heartbeat applicatif périodique n'est ajouté. Un Close distant inattendu, EOF, erreur I/O/TLS/WebSocket ou violation protocolaire structurelle entre dans le reconnect borné ; `Closed` reste réservé au shutdown local explicite ou à la disparition des handles.
### Registry de subscriptions
Le même actor possède maintenant le registre des subscriptions logiques, sans exposer les IDs numériques distants. Chaque subscription reçoit un `WsSubscriptionId` local stable, et le mapping `remote_subscription_id -> WsSubscriptionId` reste strictement runtime/interne.
La création générique typed reste `pub(crate)` et n'est jamais exposée comme API raw provider-extension. Les wrappers standard publics lutilisent derrière leurs DTOs et paramètres typés. Le handle public `WsSubscription<T>` expose uniquement :
- `id()` et `kind()` ;
- `state()` ;
- `recv()` sur un canal typed borné ;
- `unsubscribe()` qui conserve le booléen retourné par l'unsubscribe Solana standard.
L'ACK de subscribe est traité atomiquement dans l'actor : le remote ID est lié au local ID avant que la notification suivante puisse être dispatchée. Les notifications inconnues/stale sont ignorées avec un diagnostic sûr. Un mismatch de méthode de notification ou un échec de décodage typed termine uniquement la subscription concernée ; la session physique reste `Active`.
### Reconnect et resubscribe
Une perte de connexion physique invalide immédiatement les remote subscription IDs et incrémente `continuity_gap_count`. Le runtime utilise `WsReconnectSettings` pour appliquer un nombre fini de tentatives avec backoff exponentiel borné et sans jitter. Le shutdown surveille les phases de backoff et de handshake et interrompt la reprise sans reconnecter uniquement pour nettoyer des subscriptions.
Avec `WsResubscribePolicy::ActiveSubscriptions`, les subscriptions encore désirées passent en `Resubscribing` et sont restaurées dans l'ordre croissant de leur `WsSubscriptionId`. Les paramètres de subscribe conservés par l'actor sont rejoués, puis chaque nouvel ACK remappe un remote ID sans changer l'identité locale. Le retour à `Active` ne se produit qu'après la fin de cette restauration, ce qui réinitialise alors le budget de reconnect.
Avec `WsResubscribePolicy::Never`, la session physique peut se reconnecter mais les subscriptions précédentes deviennent terminales. Une cancellation locale reçue pendant reconnect gagne toujours : elle retire la subscription de la restauration ; si un ACK distant arrive après cette cancellation, l'actor envoie un unsubscribe best-effort du nouvel ID sans réactiver le handle local.
Le compteur de continuity gaps est un signal d'observabilité, pas une garantie de livraison. Transport n'ajoute aucun backfill HTTP et ne promet aucune continuité lossless pendant l'intervalle de déconnexion.
### Backpressure et libération de capacité
Chaque subscription dispose de sa propre queue typed bornée par `notification_queue_capacity`. Le runtime ne droppe jamais silencieusement une notification lorsque cette queue est pleine : il incrémente `WsSessionSnapshot::overflow_count()`, fait passer uniquement le handle lent à `Failed`, publie `ERROR_CODE_WS_BACKPRESSURE_OVERFLOW` via `WsSubscription::terminal_error_code()` et programme un `*Unsubscribe` distant best-effort. Les autres subscriptions et la session physique restent utilisables.
Les autres terminaisons en échec publient également un code KSP sûr sur le handle : erreur protocolaire, timeout, erreur RPC applicative ou perte physique terminale. Les fermetures normales et les unsubscriptions réussis conservent `terminal_error_code() == None`. Aucun payload distant, remote subscription ID ou endpoint URL n'est projeté dans cette cause.
`max_active_subscriptions` reste une limite d'admission distincte du compteur d'overflow de notifications : un rejet de création ne l'incrémente pas. Lorsqu'une subscription est fermée, échoue ou que son receiver est abandonné puis détecté sur la notification suivante, son entrée runtime et son binding distant sont nettoyés et la capacité locale redevient réutilisable.
Les fixtures adversariales prouvent l'isolation d'un consumer lent, la survie d'une subscription saine, le cleanup distant best-effort, la réutilisation de capacité après unsubscribe ou abandon du receiver et la conservation du compteur d'overflow à travers les snapshots. Aucune promesse de livraison lossless n'est ajoutée.
### Wrappers stables : account, program et logs
Les trois premiers wrappers WebSocket standards sont publics sur `WsSession` :
```text
account_subscribe -> WsSubscription<SolanaRpcResponse<SolanaAccount>>
program_subscribe -> WsSubscription<SolanaProgramNotification>
logs_subscribe -> WsSubscription<SolanaRpcResponse<SolanaLogsNotification>>
```
`SolanaAccountSubscribeConfig` expose uniquement les options réellement effectives du PubSub audité : `encoding`, `dataSlice` et `commitment`. `minContextSlot` reste volontairement absent car le handler Agave ciblé l'ignore pour `accountSubscribe`; KSP ne transforme donc pas un champ partagé mais inopérant en promesse WebSocket.
`SolanaProgramSubscribeConfig` réutilise les encodings, slices et commitments account, ajoute les filtres programme et conserve `withContext`. Le décodeur `SolanaProgramNotification` accepte aussi bien le keyed account non contexté que la forme `RpcResponse` contextée afin de préserver les deux formes retenues par l'audit sans perte. `sortResults`, présent sur la surface HTTP `getProgramAccounts`, n'est pas exposé ici car le handler PubSub audité ne le consomme pas.
`SolanaLogsSubscribeFilter` rend les trois filtres upstream explicites : `All`, `AllWithVotes` et `Mentions(Pubkey)`. La variante `Mentions` encode par construction exactement une adresse. `SolanaLogsNotification` conserve la signature opaque, le `err` nullable et l'ordre des messages `logs`, enveloppés dans `SolanaRpcResponse`.
L'unsubscribe de ces trois familles passe toujours par `WsSubscription::unsubscribe()`: le caller ne voit ni ne fournit l'ID serveur. Les paramètres initiaux restent conservés par lactor pour le resubscribe déterministe, et toutes les règles de backpressure/terminal error sappliquent sans branche spéciale aux DTOs publics.
### Wrappers stables : signature, slot et root
Le second lot stable complète les subscriptions standard non instables :
```text
signature_subscribe -> WsSubscription<SolanaRpcResponse<SolanaSignatureNotification>>
slot_subscribe -> WsSubscription<SolanaSlotNotification>
root_subscribe -> WsSubscription<u64>
```
`SolanaSignatureSubscribeConfig` conserve séparément `commitment` et `enableReceivedNotification`, y compris la différence entre option omise et booléen explicitement faux. `SolanaSignatureNotification` représente les deux formes wire : `ReceivedSignature` pour l'événement précoce optionnel et `Processed { err }` pour la notification terminale. Après livraison de `Processed`, l'actor ferme localement la subscription avec `terminal_error_code() == None`, retire son binding et ne la remet jamais dans le set de resubscribe, conformément au caractère one-shot du serveur Solana.
Une cancellation effectuée avant cette terminaison continue d'utiliser `WsSubscription::unsubscribe()` et émet `signatureUnsubscribe` avec le remote ID détenu uniquement par l'actor. Après la notification terminale, `unsubscribe()` devient local-only et retourne `false`, puisque la subscription est déjà fermée côté serveur et côté KSP.
`slot_subscribe()` et `root_subscribe()` n'acceptent aucun paramètre. `SolanaSlotNotification` conserve exactement `slot`, `parent` et `root`; `root_subscribe()` délivre directement le root `u64`. Ces deux subscriptions restent continues et utilisent donc le reconnect/resubscribe standard.
### Wrappers unstable : block, slotsUpdates et vote
Les trois familles standard restantes complètent désormais l'inventaire **9/9 subscribe + 9/9 unsubscribe via handles** :
```text
block_subscribe -> WsSubscription<SolanaRpcResponse<SolanaBlockNotification>>
slots_updates_subscribe -> WsSubscription<SolanaSlotUpdate>
vote_subscribe -> WsSubscription<SolanaVoteNotification>
```
Ces familles restent explicitement **unstable**. Le moteur commun `subscribe_typed_with_completion` émet un warning KSP centralisé pour `Block`, `SlotsUpdates` et `Vote` avant l'ouverture logique, sans recopier filtres, pubkeys, payloads ou URL dans les logs.
`SolanaBlockSubscribeConfig` conserve `commitment`, `encoding`, `transactionDetails`, `maxSupportedTransactionVersion` et `showRewards`. Le filtre représente `All` ou `MentionsAccountOrProgram(Pubkey)`. Un commitment `processed` explicitement fourni est rejeté avant I/O ; la notification réutilise `SolanaConfirmedBlock` pour le block nullable et conserve l'erreur publication nullable sans interprétation métier. Le numéro de version transaction supporté reste un `u8` générique et n'est pas durci à `0`.
`SolanaSlotUpdate` représente les sept variantes courantes `firstShredReceived`, `completed`, `createdBank`, `frozen`, `dead`, `optimisticConfirmation` et `root`. Une variante upstream inconnue devient `Unknown { update_type, raw }` au lieu de faire tomber la session. Le `raw` reste borné par `max_message_size_bytes` avant le parse JSON.
`SolanaVoteNotification` conserve `votePubkey`, `slots`, `hash`, `timestamp` et `signature`. Le timestamp reste optionnel : omission et `null` deviennent `None`, tandis qu'une valeur `i64` est préservée. Transport ne transforme pas ces votes gossip pre-consensus en vérité ledger.
## Helius LaserStream WebSocket
La façade `HeliusLaserStreamWsSession` utilise le même actor physique `WsSession` mais expose uniquement la surface provider actuellement retenue par laudit Helius :
```text
standard réutilisé : account / logs / program / root / signature / slot / slotsUpdates
extension Helius : transactionSubscribe / transactionUnsubscribe
absent Helius : block / vote
```
`slotsUpdates` reste **unstable** et conserve le warning centralisé du moteur standard. `block` et `vote` restent absents de la façade Helius même sils existent sur la façade Solana standard. `transactionSubscribe` reste provider-specific et nest jamais ajouté à `SolanaStandardWsSession`.
Les endpoints Helius mainnet/devnet utilisent un `api-key` dans lURL. KSP recommande de les construire via `ksp-config-lib` et `KSP_SECRET_HELIUS_API_KEY`; la valeur réelle atteint Transport mais les projections sûres, `Debug`, snapshots, erreurs et diagnostics nexposent pas le credential. Transport ne lit jamais lenvironnement et ne dépend jamais de Config.
Pour Helius, lactor envoie automatiquement un control frame WebSocket `Ping` toutes les 60 secondes sur une session active. Cette policy est provider-owned, non configurable et ne sapplique pas aux sessions `SolanaStandard`. Une perte physique suit le reconnect/resubscribe borné déjà décrit; aucun replay/lossless nest promis par la couche WebSocket.
LaserStream **gRPC** reste un backend distinct, hors de cette façade, de `WsProtocolKind` et de la Config WebSocket `helius_laserstream`.
## Résilience ## Résilience
L'admission est calculée par couple endpoint/rôle. Le pool applique : L'admission est calculée par couple endpoint/rôle. Le pool applique :
@@ -121,31 +253,41 @@ 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. Les tests par défaut sont déterministes et n'exigent pas Internet : fixtures JSON et serveur HTTP local couvrent requêtes, réponses, retry, 429, timeout, redaction et routing.
Deux smokes Devnet opt-in sont séparés par responsabilité : Trois smokes Devnet opt-in sont séparés par responsabilité :
```text ```text
Transport pur : settings programmatiques -> HttpTransportPool Transport HTTP pur : settings programmatiques -> HttpTransportPool
-> Accounts/Tokens/Cluster représentatifs -> Accounts/Tokens/Cluster représentatifs
-> trois reads Transactions -> trois reads Transactions
-> getBlockHeight -> getBlockHeight
-> getInflationRate/getStakeMinimumDelegation -> getInflationRate/getStakeMinimumDelegation
Transport WebSocket pur : settings programmatiques -> WsSession
-> slotSubscribe
-> une slotNotification sous timeout
-> slotUnsubscribe
-> close
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
-> getHealth/getGenesisHash/getVersion/getBalance -> getHealth/getGenesisHash/getVersion/getBalance
``` ```
Le smoke Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide. Le smoke HTTP Transport utilise pour sa branche Token la forme Devnet documentée `getTokenAccountsByOwner(owner, { programId }, { commitment: finalized, encoding: jsonParsed })`. L'owner est une Pubkey ordinaire de l'exemple officiel ; aucune présence de token account n'est exigée, donc une liste vide reste valide.
Les deux sont `ignored` par défaut. Le smoke Transport appartient durablement à cette crate ; le smoke cross-crates hébergé dans Config reste transitoire jusqu'à l'existence d'une surface KSP d'intégration/orchestration appropriée. Le smoke WebSocket Transport cible uniquement la famille stable `slotSubscribe` sur l'endpoint public Devnet `wss://api.devnet.solana.com`. Il borne connexion, attente de notification, unsubscribe et fermeture ; il ne transforme aucune famille unstable en gate live.
Les 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.
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 ## Documentation
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw, smokes et inspection runtime ; - [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw, smokes et inspection runtime ;
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — foundation HTTP stable ; - [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — foundation HTTP stable ;
- [`../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — extension typed Accounts/Tokens/Cluster ; - [`../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — extension typed Accounts/Tokens/Cluster ;
- [`../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée `0.2.2` ; - [`../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée Accounts/Tokens/Cluster ;
- [`../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé de `0.2.3` ; - [`../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé Transactions ;
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée `0.2.3` ; - [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée 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/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 ; - [`../../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. - [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP + WebSocket V2, avec lecture backward V1 HTTP-only.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md --> <!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
<!-- version: 9 --> <!-- version: 20 -->
# Utilisation de `ksp-onchain-transport-lib` # Utilisation de `ksp-onchain-transport-lib`
@@ -65,7 +65,199 @@ let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into
Le document standard peut contenir une URL provenant d'un `KSP_SECRET_*`. La valeur réelle est transmise au runtime, mais les projections sûres et `Debug` restent redacted. Le document standard peut contenir une URL provenant d'un `KSP_SECRET_*`. La valeur réelle est transmise au runtime, mais les projections sûres et `Debug` restent redacted.
## 3. Appels typés ## 3. Session physique WebSocket
Un consumer peut créer explicitement une session physique WebSocket :
```rust
let ws_url = match ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com") {
Ok(value) => value,
Err(error) => return Err(error),
};
let endpoint = ksp_onchain_transport_lib::WsEndpointSettings::new(
"devnet_public",
true,
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
ws_url,
ksp_onchain_transport_lib::WsSessionSettings::default(),
);
let session = match ksp_onchain_transport_lib::WsSession::connect(endpoint).await {
Ok(value) => value,
Err(error) => return Err(error),
};
let snapshot = session.snapshot();
```
Deux appels `WsSession::connect` avec le même endpoint créent volontairement deux connexions physiques distinctes. Il n'existe encore aucun pool de sessions automatique.
Le socket brut et la primitive JSON-RPC générique ne sont pas publics. Le moteur générique de subscription typed reste `pub(crate)` ; il ne constitue donc pas une escape hatch provider-specific.
`WsSubscription<T>` est le handle public commun retourné par les wrappers standards. Il porte un `WsSubscriptionId` local stable, jamais le remote ID numérique du serveur. Les notifications arrivent via un receiver typed borné et `unsubscribe().await` exécute le `*Unsubscribe` correspondant en préservant son résultat booléen.
Le snapshot de session expose les subscriptions actuellement enregistrées via `WsSubscriptionSnapshot`, avec `remote_bound: bool` seulement. Le remote ID réel n'est jamais projeté.
### Premiers wrappers standards publics
Trois familles stables peuvent être créées directement sur la session :
```rust
let account = match "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>() {
Ok(value) => value,
Err(error) => return Err(error.into()),
};
let account_config = ksp_onchain_transport_lib::SolanaAccountSubscribeConfig::new(
Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64),
None,
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
);
let mut account_subscription = match session.account_subscribe(&account, Some(&account_config)).await {
Ok(value) => value,
Err(error) => return Err(error),
};
let next = account_subscription.recv().await;
let removed = account_subscription.unsubscribe().await;
```
La même session expose `program_subscribe()` avec `SolanaProgramSubscribeConfig` et `logs_subscribe()` avec `SolanaLogsSubscribeFilter` plus `SolanaCommitmentConfig`. Pour `logsSubscribe`, `Mentions(pubkey)` représente exactement une adresse, conformément à la contrainte upstream retenue par l'audit.
`accountSubscribe` ne propose pas `minContextSlot`: le champ existe dans un config partagé upstream mais est ignoré par le handler PubSub audité. `programSubscribe` conserve en revanche `withContext`; `SolanaProgramNotification` permet au consumer de traiter explicitement une notification contextée ou non contextée.
Les trois wrappers retournent le même handle `WsSubscription<T>` : reconnect, resubscribe, overflow, cause terminale et unsubscribe restent donc uniformes. Aucun wrapper public n'accepte un nom de méthode JSON-RPC arbitraire ni un remote subscription ID.
### Lot stable B : signature, slot et root
La session expose également les familles stables suivantes :
```rust
let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new(
Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
Some(true),
);
let mut signature_subscription = match session.signature_subscribe("<base58-signature>", Some(&signature_config)).await {
Ok(value) => value,
Err(error) => return Err(error),
};
while let Some(notification) = signature_subscription.recv().await {
let notification = match notification {
Ok(value) => value,
Err(error) => return Err(error),
};
if notification.value().is_terminal() {
break;
}
}
```
Avec `enableReceivedNotification = true`, `ReceivedSignature` peut arriver avant la variante terminale `Processed { err }`. La variante terminale ferme automatiquement le handle KSP, sans `signatureUnsubscribe` supplémentaire et sans resubscribe lors d'une reconnexion ultérieure. Une cancellation explicite avant cette notification terminale reste possible via `unsubscribe().await`.
`slot_subscribe().await` retourne un `WsSubscription<SolanaSlotNotification>` dont les getters exposent `slot`, `parent` et `root`. `root_subscribe().await` retourne un `WsSubscription<u64>`. Ces deux méthodes n'acceptent aucune configuration ni aucun paramètre RPC.
### Familles unstable : block, slotsUpdates et vote
Les trois familles unstable standard sont également typées. Leur utilisation déclenche un warning KSP centralisé :
```rust
let block_config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new(
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures),
Some(0),
Some(false),
);
let mut blocks = match session
.block_subscribe(&ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::All, Some(&block_config))
.await
{
Ok(value) => value,
Err(error) => return Err(error),
};
```
`blockSubscribe` requiert un validator qui active la capability upstream correspondante. Une erreur applicative RPC liée à cette capability est renvoyée au caller sans reconnect de la session. `processed` est refusé localement ; `confirmed` et `finalized` sont admis. `maxSupportedTransactionVersion` n'est pas limité artificiellement à `0`.
`slots_updates_subscribe().await` délivre `SolanaSlotUpdate`. Les sept variantes courantes sont structurées ; une variante inconnue reste consommable via `Unknown` et `unknown_raw()`, sous la borne de taille WebSocket déjà appliquée avant décodage.
`vote_subscribe().await` délivre `SolanaVoteNotification`. `timestamp()` retourne `Option<i64>` pour conserver omission/null/value. Ce flux reste gossip et pre-consensus : le consumer ne doit pas l'assimiler à une confirmation ledger.
Les trois familles utilisent le même `WsSubscription::unsubscribe().await`; aucun remote subscription ID n'entre dans l'API publique.
### Façade Helius LaserStream WebSocket
Pour un endpoint Config `kind = "helius_laserstream"`, le consumer doit sélectionner lendpoint WebSocket résolu puis ouvrir la façade Helius, sans reconstruire ni journaliser lURL contenant lAPI key :
```rust
let resolved = match engine.load_resolved_transport_config(Some("helius_devnet"), &environment) {
Ok(value) => value,
Err(error) => return Err(error),
};
let ws_settings = match resolved.ws_settings() {
Some(value) => value,
None => return Err(ksp_core_lib::Error::new(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS, "Helius profile requires WebSocket settings")),
};
let endpoint = match ws_settings
.endpoints()
.iter()
.find(|candidate| candidate.enabled() && candidate.protocol() == ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream)
{
Some(value) => value.clone(),
None => return Err(ksp_core_lib::Error::new(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS, "Helius WebSocket endpoint is unavailable")),
};
let session = match ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(endpoint).await {
Ok(value) => value,
Err(error) => return Err(error),
};
let mut slots = match session.slot_subscribe().await {
Ok(value) => value,
Err(error) => return Err(error),
};
let notification = slots.recv().await;
let removed = slots.unsubscribe().await;
let closed = session.close().await;
```
La façade Helius réutilise `account`, `logs`, `program`, `root`, `signature`, `slot` et `slotsUpdates`. `slotsUpdates` reste unstable. `block` et `vote` ne sont pas exposés. `transaction_subscribe()` prend `HeliusTransactionSubscribeRequest` et retourne `WsSubscription<HeliusTransactionNotification>` ; son unsubscribe reste porté par le handle et produit `transactionUnsubscribe` sans exposer lID distant.
Le heartbeat Helius est automatique : `WsSession` envoie un control frame `Ping` toutes les 60 secondes tant que la session Helius est active. Le consumer ne configure pas un second timer et ne lance pas un task heartbeat parallèle. Cette règle ne vaut pas pour `SolanaStandardWsSession`.
`KSP_SECRET_HELIUS_API_KEY` appartient à Config. Ne pas lire lenvironnement dans Transport, ne pas recopier lURL résolue dans un log et ne pas ajouter un dev-dependency inverse `Transport -> Config`. LaserStream gRPC reste un backend différent et ne doit pas réutiliser `WsProtocolKind::HeliusLaserStream`.
### Reconnect automatique borné
Les settings de session contrôlent le reconnect physique. Une perte de socket publie `Reconnecting { attempt }`, invalide les remote IDs et incrémente `continuity_gap_count`. Avec la policy par défaut `ActiveSubscriptions`, les handles logiques gardent leur `WsSubscriptionId` et passent temporairement en `Resubscribing`; l'actor recrée leurs subscriptions dans l'ordre local avant de republier `Active`.
`WsResubscribePolicy::Never` reconnecte uniquement la session physique : les subscriptions existantes deviennent terminales et doivent être recréées explicitement par le consumer. Dans les deux modes, les requests applicatives qui étaient en vol lors de la coupure échouent et ne sont pas rejouées implicitement.
`unsubscribe().await` peut être appelé pendant `Reconnecting` ou `Resubscribing`. La cancellation locale gagne et le handle ne redevient jamais `Active`. Un ACK distant tardif est nettoyé best-effort par l'actor. Aucun backfill HTTP n'est déclenché automatiquement ; le consumer doit traiter `continuity_gap_count` comme un signal de réconciliation éventuelle.
### Backpressure par subscription
`WsSessionSettings::notification_queue_capacity()` borne la queue de chaque `WsSubscription<T>`. Le consumer doit donc drainer `recv()` selon son débit métier. Une queue pleine ne bloque pas l'actor et n'affecte pas les autres subscriptions : la subscription lente devient terminale avec `state() == Failed` et `terminal_error_code() == Some(ERROR_CODE_WS_BACKPRESSURE_OVERFLOW)`, tandis que `WsSessionSnapshot::overflow_count()` est incrémenté.
Un échec terminal non lié à l'overflow expose lui aussi un `ErrorCode` KSP sûr via `terminal_error_code()`. Une fermeture normale conserve `None`. Cette projection ne contient ni payload de notification, ni remote subscription ID, ni URL d'endpoint.
`max_active_subscriptions` borne séparément le nombre d'entrées logiques enregistrées. Son rejet utilise le même domaine d'erreur de capacité mais n'incrémente pas `overflow_count`, réservé aux queues de notifications saturées. Une subscription fermée ou nettoyée après abandon de son receiver libère sa capacité locale ; l'actor tente aussi de supprimer son binding distant sans rendre ce cleanup bloquant.
Le consumer doit traiter `overflow_count` et `continuity_gap_count` comme deux signaux distincts : le premier indique une perte locale par saturation d'un consumer, le second une interruption de continuité liée à une reconnexion. Aucun des deux n'implique un replay ou un backfill automatique.
### Fermeture explicite
Fermer explicitement la session est la voie normale de shutdown :
```rust
let session = ksp_onchain_transport_lib::WsSession::connect(endpoint).await?;
// ... wrappers standard puis recv()/unsubscribe() ...
session.close().await?;
```
`close()` agit sur toute la session physique, y compris les clones du handle. Il annule les requests en attente, publie `Closing`, tente le Close WebSocket dans le budget configuré, puis publie `Closed`. Une session `Closed` refuse les nouvelles requests internes.
Les limites de taille et de capacité sont des policies KSP configurables par `WsSessionSettings`; elles ne doivent pas être interprétées comme des limites protocolaires Solana officielles.
Le snapshot expose seulement l'identité locale, les metadata logiques de l'endpoint, l'état, les compteurs sûrs et les projections locales de subscriptions. L'URL et les remote subscription IDs ne sont jamais projetés. La disparition de tous les handles de session déclenche le cleanup actor best-effort ; `close().await` reste la voie normale de shutdown.
## 4. Appels typés
Les wrappers typés se trouvent directement sur `HttpTransportPool`. Les wrappers typés se trouvent directement sur `HttpTransportPool`.
@@ -86,7 +278,7 @@ let balance = pool
.await; .await;
``` ```
Les quatre canaris `0.2.1` restent disponibles. `0.2.2` ajoute les wrappers typés Accounts, Tokens et Cluster. Exemples représentatifs : Les wrappers foundation, Accounts, Tokens et Cluster sont disponibles directement sur le pool. Exemples représentatifs :
```rust ```rust
let account = pool let account = pool
@@ -96,7 +288,7 @@ let epoch = pool.get_epoch_info(&role, None).await;
let vote_accounts = pool.get_vote_accounts(&role, None).await; let vote_accounts = pool.get_vote_accounts(&role, None).await;
``` ```
La release stable `0.2.4` contient les **52 wrappers typés courants** : 4 foundation + 22 Accounts/Tokens/Cluster + 11 Transactions + 10 Blocks + 5 Economics. Les DTOs Transport conservent les `null`, options, overloads et formes wire sans décodage Program/SPL métier. La surface HTTP contient **52 wrappers typés courants** : 4 foundation + 22 Accounts/Tokens/Cluster + 11 Transactions + 10 Blocks + 5 Economics. Les DTOs Transport conservent les `null`, options, overloads et formes wire sans décodage Program/SPL métier.
Exemples Transaction représentatifs : Exemples Transaction représentatifs :
@@ -125,7 +317,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. `getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant.
## 4. Exécution JSON-RPC standard générique ## 5. Exécution JSON-RPC standard générique
Une méthode courante auditée peut être appelée via son descriptor : Une méthode courante auditée peut être appelée via son descriptor :
@@ -139,7 +331,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. Avant exécution, `ensure_runtime_supported()` est appliqué. Une méthode historique `Removed` retourne `ERROR_CODE_METHOD_REMOVED` au lieu d'émettre un appel réseau fictif.
## 5. Sélection et admission sans exécuter la requête ## 6. Sélection et admission sans exécuter la requête
Pour inspecter le routing : Pour inspecter le routing :
@@ -154,13 +346,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. `HttpRequestPermit` détient la capacité de concurrence jusqu'à sa destruction. Aucun verrou synchrone n'est conservé pendant l'attente réseau.
## 6. Snapshots runtime ## 7. Snapshots runtime
`HttpTransportPool::snapshot()` fournit une vue sûre des endpoints/rôles : disponibilité, limites, requêtes en vol, cooldown restant et compteurs 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. Les URLs d'endpoint n'y apparaissent jamais.
## 7. Retry et write submissions ## 8. Retry et write submissions
La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`. La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`.
@@ -168,7 +360,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. Pour une opération `WriteSubmission / NeverAfterDispatch`, un timeout ou autre résultat ambigu après dispatch arrête la resoumission automatique. Le consumer métier ne doit pas contourner cette protection avec une boucle de retry externe aveugle.
## 8. Logging ## 9. Logging
Les événements Transport utilisent le target : Les événements Transport utilisent le target :
@@ -180,9 +372,9 @@ Ne jamais journaliser l'URL complète, un token provider, un body massif, une tr
La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement. La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement.
## 9. Smokes Devnet opt-in ## 10. Smokes Devnet opt-in
Le smoke **Transport pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif d'Accounts/Tokens/Cluster, trois reads Transactions, puis des reads Blocks/Economics de la release stable `0.2.4` : Le smoke **Transport HTTP pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif d'Accounts/Tokens/Cluster, trois reads Transactions, puis des reads Blocks/Economics :
```bash ```bash
cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignored --nocapture
@@ -190,7 +382,15 @@ cargo test -p ksp-onchain-transport-lib --test transport_devnet_smoke -- --ignor
Il appelle `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo`, `getVoteAccounts`, puis `getLatestBlockhash`, `isBlockhashValid`, `getTransactionCount`, `getBlockHeight`, `getInflationRate` et `getStakeMinimumDelegation`. La branche Token suit la forme Devnet documentée : owner Pubkey ordinaire de l'exemple officiel, selector `programId` avec l'ID canonique du programme SPL Token, puis config explicite `commitment: finalized` + `encoding: jsonParsed`. Une réponse vide reste acceptable. La branche Transaction reste read-only : elle ne déclenche ni airdrop ni soumission de transaction et ne remplace pas les fixtures déterministes couvrant les 11 wrappers. Il appelle `getAccountInfo`, `getTokenAccountsByOwner`, `getEpochInfo`, `getVoteAccounts`, puis `getLatestBlockhash`, `isBlockhashValid`, `getTransactionCount`, `getBlockHeight`, `getInflationRate` et `getStakeMinimumDelegation`. La branche Token suit la forme Devnet documentée : owner Pubkey ordinaire de l'exemple officiel, selector `programId` avec l'ID canonique du programme SPL Token, puis config explicite `commitment: finalized` + `encoding: jsonParsed`. Une réponse vide reste acceptable. La branche Transaction reste read-only : elle ne déclenche ni airdrop ni soumission de transaction et ne remplace pas les fixtures déterministes couvrant les 11 wrappers.
Le smoke historique de **composition Config -> Transport** reste également disponible : Le smoke **Transport WebSocket pur** utilise l'endpoint public Devnet standard avec des settings programmatiques, ouvre une session physique, crée une subscription stable `slotSubscribe`, attend une notification bornée, vérifie une valeur de slot non nulle, annule la subscription avec son handle puis ferme explicitement la session :
```bash
cargo test -p ksp-onchain-transport-lib --test websocket_devnet_smoke -- --ignored --nocapture
```
Il n'utilise ni `blockSubscribe`, ni `slotsUpdatesSubscribe`, ni `voteSubscribe` : ces familles restent unstable et leur disponibilité dépend des capabilities du validator. Le smoke live n'est donc pas un gate de disponibilité de ces extensions.
Le smoke de **composition Config -> Transport** reste également disponible :
```bash ```bash
cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapture
@@ -198,4 +398,32 @@ cargo test -p ksp-config-lib --test transport_devnet_smoke -- --ignored --nocapt
Il valide le profil committé `devnet_public` et les quatre canaris foundation. Il reste transitoirement hébergé dans Config : les futurs smokes cross-crates ne doivent pas faire de Config leur destination générale et devront migrer vers une surface d'intégration/orchestration dédiée lorsqu'elle existera. Il valide le profil committé `devnet_public` et les quatre canaris foundation. Il reste transitoirement hébergé dans Config : les futurs smokes cross-crates ne doivent pas faire de Config leur destination générale et devront migrer vers une surface d'intégration/orchestration dédiée lorsqu'elle existera.
Les endpoints publics Solana sont rate-limités et non destinés à la production. Un échec réseau externe n'est pas assimilé automatiquement à une régression locale ; les fixtures HTTP locales restent les gates reproductibles. ### Smoke Helius live
Aucun nouveau test Helius live nest committé en `0.2.8-pre.010`. La raison est architecturale : Transport ne peut pas lire `KSP_SECRET_HELIUS_API_KEY` ni dépendre de Config, et Config ne doit pas devenir la destination générale des futurs smokes `Config + autre crate`. Créer un quatrième smoke 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 locales et les gates déterministes restent autoritaires.
Pour auditer les dépendances, inspecter également le graphe effectif après résolution Cargo :
```bash
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
cargo tree --duplicates
```
Le premier graphe doit conserver la frontière `Transport -> Core + Logging + crates techniques`; il ne doit introduire aucune dépendance Config, Wallet, Store, Program ou `tracing` directe. Les sorties `--duplicates` sont un diagnostic de résolution transitive : une duplication n'est pas supprimée aveuglément si elle est imposée par des dépendances upstream incompatibles.

View File

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

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/error.rs // file: crates/ksp-onchain-transport-lib/src/error.rs
// version: 3 // version: 4
/// Error code used when no logical endpoint can satisfy a request. /// 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"); pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
@@ -27,3 +27,11 @@ pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::Error
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error"); pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
/// Error code used when a transport deadline expires. /// Error code used when a transport deadline expires.
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout"); pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
/// Error code used when a bounded WebSocket runtime queue or pending-request capacity is exhausted.
pub const ERROR_CODE_WS_BACKPRESSURE_OVERFLOW: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_backpressure_overflow");
/// Error code used when a physical WebSocket connection or handshake fails.
pub const ERROR_CODE_WS_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_connection_failed");
/// Error code used when WebSocket wire data violates the KSP protocol contract.
pub const ERROR_CODE_WS_PROTOCOL_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_protocol_error");
/// Error code used when a WebSocket session is no longer available to a caller.
pub const ERROR_CODE_WS_SESSION_CLOSED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_session_closed");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs // file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 21 // version: 34
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -16,6 +16,21 @@
//! modern/legacy `getTransaction` coverage. `0.2.4` completes the HTTP surface with all ten Blocks and five Economics wrappers, including complete //! modern/legacy `getTransaction` coverage. `0.2.4` completes the HTTP surface with all ten Blocks and five Economics wrappers, including complete
//! modern/legacy `getBlock`, positional inflation rewards, runtime-provided economics values and the final `KSP-TRANSPORT-007` compliance target. //! modern/legacy `getBlock`, positional inflation rewards, runtime-provided economics values and the final `KSP-TRANSPORT-007` compliance target.
//! The candidate surface therefore exposes typed wrappers for all 52 current audited Solana HTTP methods while retaining 14 removed historical descriptors. //! The candidate surface therefore exposes typed wrappers for all 52 current audited Solana HTTP methods while retaining 14 removed historical descriptors.
//! `0.2.7-pre.002` adds the provider-neutral WebSocket settings foundation, redacted endpoint URLs, explicit protocol-family discrimination, local session and
//! subscription identities, observable lifecycle states and safe snapshots. `0.2.7-pre.004` adds the first physical WebSocket runtime with one
//! actor-owned socket,
//! bounded handshake, command/pending JSON-RPC flow and deterministic local-server fixtures. `0.2.7-pre.005` adds explicit bounded shutdown, adversarial
//! request/frame/message limits and control-frame handling. `0.2.7-pre.006` adds the typed subscription registry with stable local IDs and internal remote-ID
//! routing. `0.2.7-pre.007` adds finite reconnect, deterministic resubscribe and continuity-gap tracking. `0.2.7-pre.008` makes per-subscription notification
//! backpressure terminal and observable, preserves safe terminal error codes, performs best-effort remote cleanup and proves bounded capacity reuse.
//! `0.2.7-pre.009` opens the first stable typed WebSocket wrappers for account, program-account and transaction-log subscriptions without exposing a raw
//! provider-extension subscription API. `0.2.8-pre.002` adds a Helius LaserStream WebSocket protocol discriminator and two typed protocol facades while
//! keeping the `WsSession` actor/socket implementation unique and the historical generic constructor standard-only.
//! `0.2.8-pre.003` initially exposed the six standard families unambiguously supported by the audited Helius pages; `0.2.8-pre.009` reconciles the current
//! Helius documentation and adds the now-documented unstable `slotsUpdatesSubscribe` pair while keeping explicitly unsupported block/vote pairs absent.
//! `0.2.8-pre.005` adds the typed Helius `transactionSubscribe` request contract and provider filter/options validation. `0.2.8-pre.006` integrates the live
//! transaction handle and typed `transactionNotification` union into the same actor-owned registry, remote-ID remap, unsubscribe-race handling and
//! per-subscription backpressure path.
mod client; mod client;
mod constants; mod constants;
@@ -34,6 +49,16 @@ mod rpc_method;
mod rpc_tokens; mod rpc_tokens;
mod rpc_transactions; mod rpc_transactions;
mod settings; 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. /// Passive runtime availability reported for one logical HTTP endpoint.
pub use self::client::HttpEndpointAvailability; pub use self::client::HttpEndpointAvailability;
@@ -69,6 +94,14 @@ pub use self::error::ERROR_CODE_RATE_LIMITED;
pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR; pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR;
/// Error code used when a transport deadline expires. /// Error code used when a transport deadline expires.
pub use self::error::ERROR_CODE_TIMEOUT; pub use self::error::ERROR_CODE_TIMEOUT;
/// Error code used when bounded WebSocket runtime capacity is exhausted.
pub use self::error::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW;
/// Error code used when a physical WebSocket connection or handshake fails.
pub use self::error::ERROR_CODE_WS_CONNECTION_FAILED;
/// Error code used when WebSocket wire data violates protocol invariants.
pub use self::error::ERROR_CODE_WS_PROTOCOL_ERROR;
/// Error code used when a WebSocket session is no longer available.
pub use self::error::ERROR_CODE_WS_SESSION_CLOSED;
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint. /// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
pub use self::json_rpc::JsonRpcErrorObject; pub use self::json_rpc::JsonRpcErrorObject;
/// Validated JSON-RPC 2.0 error response. /// Validated JSON-RPC 2.0 error response.
@@ -103,9 +136,9 @@ pub use self::resilience::evaluate_transport_retry;
pub use self::rpc_accounts::SolanaAccount; pub use self::rpc_accounts::SolanaAccount;
/// Address and lamport balance returned by `getLargestAccounts`. /// Address and lamport balance returned by `getLargestAccounts`.
pub use self::rpc_accounts::SolanaAccountBalance; pub use self::rpc_accounts::SolanaAccountBalance;
/// Wire-preserving account data returned by Solana HTTP account methods. /// Wire-preserving account data returned by Solana account HTTP and WebSocket methods.
pub use self::rpc_accounts::SolanaAccountData; pub use self::rpc_accounts::SolanaAccountData;
/// Account-data encoding accepted by Solana HTTP account methods. /// Account-data encoding accepted by Solana account HTTP and WebSocket methods.
pub use self::rpc_accounts::SolanaAccountEncoding; pub use self::rpc_accounts::SolanaAccountEncoding;
/// Shared account configuration used by account-info and token-account list methods. /// Shared account configuration used by account-info and token-account list methods.
pub use self::rpc_accounts::SolanaAccountInfoConfig; pub use self::rpc_accounts::SolanaAccountInfoConfig;
@@ -183,15 +216,15 @@ pub use self::rpc_cluster::SolanaVoteAccountInfo;
pub use self::rpc_cluster::SolanaVoteAccountStatus; pub use self::rpc_cluster::SolanaVoteAccountStatus;
/// Configuration accepted by `getVoteAccounts`. /// Configuration accepted by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountsConfig; pub use self::rpc_cluster::SolanaVoteAccountsConfig;
/// Commitment level accepted by typed Solana HTTP RPC adapters. /// Commitment level accepted by typed Solana HTTP and WebSocket adapters.
pub use self::rpc_common::SolanaCommitment; pub use self::rpc_common::SolanaCommitment;
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods. /// Optional commitment-only configuration shared by typed Solana RPC methods.
pub use self::rpc_common::SolanaCommitmentConfig; pub use self::rpc_common::SolanaCommitmentConfig;
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods. /// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
pub use self::rpc_common::SolanaContextConfig; pub use self::rpc_common::SolanaContextConfig;
/// Typed Solana RPC context shared by contextual HTTP responses. /// Typed Solana RPC context shared by contextual HTTP and WebSocket responses.
pub use self::rpc_common::SolanaRpcContext; pub use self::rpc_common::SolanaRpcContext;
/// Generic contextual result returned by typed Solana HTTP RPC adapters. /// Generic contextual result returned by typed Solana HTTP and WebSocket adapters.
pub use self::rpc_common::SolanaRpcResponse; pub use self::rpc_common::SolanaRpcResponse;
/// Inflation-governor values returned by `getInflationGovernor`. /// Inflation-governor values returned by `getInflationGovernor`.
pub use self::rpc_economics::SolanaInflationGovernor; pub use self::rpc_economics::SolanaInflationGovernor;
@@ -291,6 +324,90 @@ pub use self::settings::HttpRoleLimits;
pub use self::settings::HttpRoleName; pub use self::settings::HttpRoleName;
/// Complete runtime settings consumed by the Solana HTTP transport foundation. /// Complete runtime settings consumed by the Solana HTTP transport foundation.
pub use self::settings::HttpTransportSettings; pub use self::settings::HttpTransportSettings;
/// Configuration accepted by the standard Solana `accountSubscribe` WebSocket method.
pub use self::ws_accounts::SolanaAccountSubscribeConfig;
/// One `programNotification` payload preserving contextual and non-contextual upstream forms.
pub use self::ws_accounts::SolanaProgramNotification;
/// Configuration accepted by the standard Solana `programSubscribe` WebSocket method.
pub use self::ws_accounts::SolanaProgramSubscribeConfig;
/// Typed value carried inside an unstable Solana `blockNotification` response.
pub use self::ws_blocks::SolanaBlockNotification;
/// Optional configuration accepted by unstable Solana `blockSubscribe`.
pub use self::ws_blocks::SolanaBlockSubscribeConfig;
/// Filter accepted by unstable Solana `blockSubscribe`.
pub use self::ws_blocks::SolanaBlockSubscribeFilter;
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
pub use self::ws_cluster::SolanaSlotNotification;
/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback.
pub use self::ws_cluster::SolanaSlotUpdate;
/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates.
pub use self::ws_cluster::SolanaSlotUpdateStats;
/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`.
pub use self::ws_cluster::SolanaVoteNotification;
/// Full/accounts-mode notification delivered by Helius `transactionSubscribe`.
pub use self::ws_helius_transactions::HeliusFullTransactionNotification;
/// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`.
pub use self::ws_helius_transactions::HeliusTokenAccountsFilter;
/// Typed Helius `transactionNotification` payload union.
pub use self::ws_helius_transactions::HeliusTransactionNotification;
/// Signatures-mode notification delivered by Helius `transactionSubscribe`.
pub use self::ws_helius_transactions::HeliusTransactionSignatureNotification;
/// Transaction encoding accepted by Helius `transactionSubscribe`.
pub use self::ws_helius_transactions::HeliusTransactionSubscribeEncoding;
/// Helius-specific filter object accepted as the first `transactionSubscribe` parameter.
pub use self::ws_helius_transactions::HeliusTransactionSubscribeFilter;
/// Optional Helius `transactionSubscribe` result-shaping configuration.
pub use self::ws_helius_transactions::HeliusTransactionSubscribeOptions;
/// Complete typed request contract for Helius `transactionSubscribe` before actor registration.
pub use self::ws_helius_transactions::HeliusTransactionSubscribeRequest;
/// Stable local identity assigned to one physical WebSocket session.
pub use self::ws_lifecycle::WsSessionId;
/// Safe runtime snapshot for one physical WebSocket session.
pub use self::ws_lifecycle::WsSessionSnapshot;
/// Observable lifecycle state of one physical WebSocket session.
pub use self::ws_lifecycle::WsSessionState;
/// Stable local identity assigned to one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionId;
/// WebSocket subscription family represented by one logical subscription.
pub use self::ws_lifecycle::WsSubscriptionKind;
/// Safe lifecycle projection for one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionSnapshot;
/// Observable lifecycle state of one logical WebSocket subscription.
pub use self::ws_lifecycle::WsSubscriptionState;
/// Typed facade for one Helius LaserStream WebSocket physical session.
pub use self::ws_protocol_session::HeliusLaserStreamWsSession;
/// Typed facade for one standard Solana WebSocket physical session.
pub use self::ws_protocol_session::SolanaStandardWsSession;
/// Shareable compatibility handle for one explicitly created standard Solana physical WebSocket session.
pub use self::ws_session::WsSession;
/// Open cluster or network descriptor used by WebSocket endpoint settings.
pub use self::ws_settings::WsClusterName;
/// Runtime settings for one named WebSocket endpoint.
pub use self::ws_settings::WsEndpointSettings;
/// Runtime WebSocket endpoint URL with redacted diagnostics.
pub use self::ws_settings::WsEndpointUrl;
/// WebSocket protocol family understood by KSP Transport.
pub use self::ws_settings::WsProtocolKind;
/// Open provider descriptor used by WebSocket endpoint settings.
pub use self::ws_settings::WsProviderName;
/// Bounded reconnect settings owned by the WebSocket transport runtime.
pub use self::ws_settings::WsReconnectSettings;
/// Policy controlling logical resubscription after reconnect.
pub use self::ws_settings::WsResubscribePolicy;
/// Runtime limits and lifecycle settings for one physical WebSocket session.
pub use self::ws_settings::WsSessionSettings;
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
pub use self::ws_settings::WsTransportSettings;
/// Typed handle for one logical WebSocket subscription.
pub use self::ws_subscription::WsSubscription;
/// Typed value carried by a contextual Solana `logsNotification`.
pub use self::ws_transactions::SolanaLogsNotification;
/// Filter accepted by the standard Solana `logsSubscribe` WebSocket method.
pub use self::ws_transactions::SolanaLogsSubscribeFilter;
/// Typed value carried by standard Solana `signatureNotification` messages.
pub use self::ws_transactions::SolanaSignatureNotification;
/// Optional configuration accepted by standard Solana `signatureSubscribe`.
pub use self::ws_transactions::SolanaSignatureSubscribeConfig;
/// Owning tracing target for events emitted by the on-chain transport crate. /// Owning tracing target for events emitted by the on-chain transport crate.
pub(crate) use self::constants::TRACING_TARGET; pub(crate) use self::constants::TRACING_TARGET;
@@ -306,3 +423,15 @@ pub(crate) use self::rpc_common::decode_wire_json;
pub(crate) use self::rpc_common::parse_wire_pubkey; pub(crate) use self::rpc_common::parse_wire_pubkey;
/// Validates endpoint settings. /// Validates endpoint settings.
pub(crate) use self::settings::validate_endpoint_settings; pub(crate) use self::settings::validate_endpoint_settings;
/// Crate-internal command surface shared by the physical session and typed subscription handle.
pub(crate) use self::ws_session::WsSessionCommand;
/// Crate-internal notification dispatch result.
pub(crate) use self::ws_subscription::WsNotificationDispatchOutcome;
/// Crate-internal type-erased notification dispatcher.
pub(crate) use self::ws_subscription::WsNotificationDispatcher;
/// Crate-internal actor registration returned after subscribe acknowledgement.
pub(crate) use self::ws_subscription::WsSubscriptionRegistration;
/// Crate-internal actor-owned logical subscription runtime entry.
pub(crate) use self::ws_subscription::WsSubscriptionRuntime;
/// Crate-internal constructor for bounded typed notification channels with terminal-value classification.
pub(crate) use self::ws_subscription::typed_notification_channel_with_completion;

View File

@@ -1,11 +1,11 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs // file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
// version: 6 // version: 7
const MAX_MEMCMP_BYTES: usize = 128; const MAX_MEMCMP_BYTES: usize = 128;
const MAX_MULTIPLE_ACCOUNTS: usize = 100; const MAX_MULTIPLE_ACCOUNTS: usize = 100;
const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4; const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4;
/// Account-data encoding accepted by Solana HTTP account methods. /// Account-data encoding accepted by Solana account HTTP and WebSocket methods.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaAccountEncoding { pub enum SolanaAccountEncoding {
/// Legacy binary/base58 request encoding. /// Legacy binary/base58 request encoding.
@@ -71,7 +71,8 @@ impl SolanaDataSliceConfig {
return self.length; return self.length;
} }
fn to_json_value(self) -> serde_json::Value { /// Serializes this data-slice configuration to the Solana JSON-RPC wire object.
pub(crate) fn to_json_value(self) -> serde_json::Value {
return serde_json::json!({"offset": self.offset, "length": self.length}); return serde_json::json!({"offset": self.offset, "length": self.length});
} }
} }
@@ -277,7 +278,8 @@ pub enum SolanaProgramAccountFilter {
} }
impl SolanaProgramAccountFilter { impl SolanaProgramAccountFilter {
fn to_json_value(&self) -> serde_json::Value { /// Serializes this program-account filter to the Solana JSON-RPC wire representation.
pub(crate) fn to_json_value(&self) -> serde_json::Value {
return match self { return match self {
Self::DataSize(size) => serde_json::json!({"dataSize": size}), Self::DataSize(size) => serde_json::json!({"dataSize": size}),
Self::Memcmp(filter) => serde_json::json!({"memcmp": filter.to_json_value()}), Self::Memcmp(filter) => serde_json::json!({"memcmp": filter.to_json_value()}),
@@ -385,7 +387,7 @@ impl SolanaParsedAccountData {
} }
} }
/// Wire-preserving account data returned by Solana HTTP account methods. /// Wire-preserving account data returned by Solana account HTTP and WebSocket methods.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum SolanaAccountData { pub enum SolanaAccountData {
/// Legacy single-string binary form retained for backwards compatibility. /// Legacy single-string binary form retained for backwards compatibility.

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs // file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
// version: 5 // version: 6
/// Commitment level accepted by typed Solana HTTP RPC adapters. /// Commitment level accepted by typed Solana HTTP and WebSocket adapters.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaCommitment { pub enum SolanaCommitment {
/// Query the most recent processed bank. /// Query the most recent processed bank.
@@ -24,7 +24,7 @@ impl SolanaCommitment {
} }
} }
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods. /// Optional commitment-only configuration shared by typed Solana RPC methods.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaCommitmentConfig { pub struct SolanaCommitmentConfig {
commitment: std::option::Option<crate::SolanaCommitment>, commitment: std::option::Option<crate::SolanaCommitment>,
@@ -94,7 +94,7 @@ impl SolanaContextConfig {
} }
} }
/// Typed Solana RPC context shared by contextual HTTP responses. /// Typed Solana RPC context shared by contextual HTTP and WebSocket responses.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaRpcContext { pub struct SolanaRpcContext {
slot: u64, slot: u64,
@@ -128,7 +128,7 @@ impl SolanaRpcContext {
} }
} }
/// Generic contextual result returned by typed Solana HTTP RPC adapters. /// Generic contextual result returned by typed Solana HTTP and WebSocket adapters.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct SolanaRpcResponse<T> { pub struct SolanaRpcResponse<T> {
context: crate::SolanaRpcContext, context: crate::SolanaRpcContext,
@@ -168,7 +168,7 @@ pub(crate) fn decode_wire_json<T: serde::de::DeserializeOwned>(method: &str, val
return match decoded { return match decoded {
std::result::Result::Ok(decoded) => std::result::Result::Ok(decoded), std::result::Result::Ok(decoded) => std::result::Result::Ok(decoded),
std::result::Result::Err(error) => std::result::Result::Err( std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response has an invalid wire shape") ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana RPC response has an invalid wire shape")
.with_context("rpc_method", method) .with_context("rpc_method", method)
.with_source(error), .with_source(error),
), ),
@@ -181,7 +181,7 @@ pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_c
return match parsed { return match parsed {
std::result::Result::Ok(pubkey) => std::result::Result::Ok(pubkey), std::result::Result::Ok(pubkey) => std::result::Result::Ok(pubkey),
std::result::Result::Err(_) => std::result::Result::Err( std::result::Result::Err(_) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response contains an invalid public key") ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana RPC response contains an invalid public key")
.with_context("rpc_method", method) .with_context("rpc_method", method)
.with_context("field", field), .with_context("field", field),
), ),

View File

@@ -0,0 +1,322 @@
// file: crates/ksp-onchain-transport-lib/src/ws_accounts.rs
// version: 3
const MAX_PROGRAM_SUBSCRIBE_FILTERS: usize = 4;
const MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES: usize = 128;
/// Configuration accepted by the standard Solana `accountSubscribe` WebSocket method.
///
/// `minContextSlot` is deliberately absent: Agave `v4.2.1` carries that field in the shared account config but the PubSub handler ignores it, so KSP does
/// not expose it as an effective WebSocket option.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaAccountSubscribeConfig {
encoding: std::option::Option<crate::SolanaAccountEncoding>,
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
commitment: std::option::Option<crate::SolanaCommitment>,
}
impl SolanaAccountSubscribeConfig {
/// Creates an explicit `accountSubscribe` configuration.
#[must_use]
pub const fn new(
encoding: std::option::Option<crate::SolanaAccountEncoding>,
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
commitment: std::option::Option<crate::SolanaCommitment>,
) -> Self {
return Self { encoding, data_slice, commitment };
}
/// Returns the optional account-data encoding.
#[must_use]
pub const fn encoding(&self) -> std::option::Option<crate::SolanaAccountEncoding> {
return self.encoding;
}
/// Returns the optional account-data slice.
#[must_use]
pub const fn data_slice(&self) -> std::option::Option<crate::SolanaDataSliceConfig> {
return self.data_slice;
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
fn is_empty(&self) -> bool {
return self.encoding.is_none() && self.data_slice.is_none() && self.commitment.is_none();
}
fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(encoding) = self.encoding {
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
}
if let std::option::Option::Some(data_slice) = self.data_slice {
object.insert("dataSlice".to_owned(), data_slice.to_json_value());
}
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
return serde_json::Value::Object(object);
}
}
/// Configuration accepted by the standard Solana `programSubscribe` WebSocket method.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaProgramSubscribeConfig {
account: crate::SolanaAccountSubscribeConfig,
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
with_context: std::option::Option<bool>,
}
impl SolanaProgramSubscribeConfig {
/// Creates an explicit `programSubscribe` configuration.
#[must_use]
pub fn new(
account: crate::SolanaAccountSubscribeConfig,
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
with_context: std::option::Option<bool>,
) -> Self {
return Self { account, filters, with_context };
}
/// Returns the shared WebSocket account configuration.
#[must_use]
pub const fn account(&self) -> &crate::SolanaAccountSubscribeConfig {
return &self.account;
}
/// Returns the ordered program-account filters.
#[must_use]
pub fn filters(&self) -> &[crate::SolanaProgramAccountFilter] {
return self.filters.as_slice();
}
/// Returns the optional `withContext` request; omission uses the upstream default `false`.
#[must_use]
pub const fn with_context(&self) -> std::option::Option<bool> {
return self.with_context;
}
fn is_empty(&self) -> bool {
return self.account.is_empty() && self.filters.is_empty() && self.with_context.is_none();
}
fn to_json_value(&self) -> serde_json::Value {
let account = self.account.to_json_value();
let mut object = match account {
serde_json::Value::Object(object) => object,
_ => serde_json::Map::new(),
};
if !self.filters.is_empty() {
let filters = self.filters.iter().map(crate::SolanaProgramAccountFilter::to_json_value).collect::<std::vec::Vec<_>>();
object.insert("filters".to_owned(), serde_json::Value::Array(filters));
}
if let std::option::Option::Some(with_context) = self.with_context {
object.insert("withContext".to_owned(), serde_json::Value::Bool(with_context));
}
return serde_json::Value::Object(object);
}
}
/// One `programNotification` payload, preserving whether the upstream wire result was contextualized.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaProgramNotification {
/// Program account payload without a surrounding RPC context.
Account(crate::SolanaKeyedAccount),
/// Program account payload wrapped in an RPC context.
Context(crate::SolanaRpcResponse<crate::SolanaKeyedAccount>),
}
impl SolanaProgramNotification {
/// Returns the program account regardless of the upstream context-wrapper form.
#[must_use]
pub const fn account(&self) -> &crate::SolanaKeyedAccount {
return match self {
Self::Account(account) => account,
Self::Context(response) => response.value(),
};
}
/// Returns the RPC context when the upstream notification included one.
#[must_use]
pub const fn context(&self) -> std::option::Option<&crate::SolanaRpcContext> {
return match self {
Self::Account(_) => std::option::Option::None,
Self::Context(response) => std::option::Option::Some(response.context()),
};
}
}
impl crate::WsSession {
/// Subscribes to changes for one Solana account through standard `accountSubscribe`.
pub async fn account_subscribe(
&self,
account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
let mut params = std::vec![serde_json::Value::String(account.to_string())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
return self
.subscribe_typed(crate::WsSubscriptionKind::Account, params, |value| return decode_account_notification("accountSubscribe", value))
.await;
}
/// Subscribes to account changes owned by one Solana program through standard `programSubscribe`.
pub async fn program_subscribe(
&self,
program_id: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
if let std::option::Option::Some(config) = config {
let validation = validate_program_subscribe_filters(config.filters());
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
}
let mut params = std::vec![serde_json::Value::String(program_id.to_string())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
return self
.subscribe_typed(crate::WsSubscriptionKind::Program, params, |value| return decode_program_notification("programSubscribe", value))
.await;
}
}
#[derive(serde::Deserialize)]
struct WireRpcResponse {
context: serde_json::Value,
value: serde_json::Value,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireProgramNotification {
Context(WireRpcResponse),
Account(serde_json::Value),
}
fn decode_account_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaAccount>> {
let decoded = crate::decode_wire_json::<WireRpcResponse>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let account = crate::SolanaAccount::decode_wire(method, wire.value);
let account = match account {
std::result::Result::Ok(account) => account,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, account));
}
fn decode_program_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaProgramNotification> {
let decoded = crate::decode_wire_json::<WireProgramNotification>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return match wire {
WireProgramNotification::Account(value) => {
let account = crate::SolanaKeyedAccount::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => std::result::Result::Ok(crate::SolanaProgramNotification::Account(account)),
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
WireProgramNotification::Context(wire) => {
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let account = crate::SolanaKeyedAccount::decode_wire(method, wire.value);
let account = match account {
std::result::Result::Ok(account) => account,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
std::result::Result::Ok(crate::SolanaProgramNotification::Context(crate::SolanaRpcResponse::new(context, account)))
},
};
}
fn validate_program_subscribe_filters(filters: &[crate::SolanaProgramAccountFilter]) -> ksp_core_lib::Result<()> {
if filters.len() > MAX_PROGRAM_SUBSCRIBE_FILTERS {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "programSubscribe accepts at most 4 filters on the targeted Agave runtime")
.with_context("rpc_method", "programSubscribe")
.with_context("filter_count", filters.len().to_string()),
);
}
for filter in filters {
if let crate::SolanaProgramAccountFilter::Memcmp(memcmp) = filter
&& let crate::SolanaMemcmpBytes::Bytes(bytes) = memcmp.bytes()
&& bytes.len() > MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES
{
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "raw programSubscribe memcmp data accepts at most 128 bytes")
.with_context("rpc_method", "programSubscribe")
.with_context("memcmp_byte_count", bytes.len().to_string()),
);
}
}
return std::result::Result::Ok(());
}
impl crate::SolanaStandardWsSession {
/// Subscribes to changes for one Solana account through standard `accountSubscribe`.
pub async fn account_subscribe(
&self,
account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
return self.physical_session().account_subscribe(account, config).await;
}
/// Subscribes to account changes owned by one Solana program through standard `programSubscribe`.
pub async fn program_subscribe(
&self,
program_id: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
return self.physical_session().program_subscribe(program_id, config).await;
}
}
impl crate::HeliusLaserStreamWsSession {
/// Subscribes to account changes through the standard `accountSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn account_subscribe(
&self,
account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
return self.physical_session().account_subscribe(account, config).await;
}
/// Subscribes to program-owned account changes through the standard `programSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn program_subscribe(
&self,
program_id: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
return self.physical_session().program_subscribe(program_id, config).await;
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_accounts.rs"]
mod tests;

View File

@@ -0,0 +1,226 @@
// file: crates/ksp-onchain-transport-lib/src/ws_blocks.rs
// version: 2
/// Filter accepted by unstable Solana `blockSubscribe`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaBlockSubscribeFilter {
/// Subscribe to every block that reaches the configured commitment.
All,
/// Subscribe only to blocks containing a transaction that mentions the account or program.
MentionsAccountOrProgram(ksp_core_lib::Pubkey),
}
impl SolanaBlockSubscribeFilter {
fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::All => serde_json::Value::String("all".to_owned()),
Self::MentionsAccountOrProgram(pubkey) => serde_json::json!({"mentionsAccountOrProgram": pubkey.to_string()}),
};
}
}
/// Optional configuration accepted by unstable Solana `blockSubscribe`.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaBlockSubscribeConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
encoding: std::option::Option<crate::SolanaTransactionEncoding>,
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
max_supported_transaction_version: std::option::Option<u8>,
show_rewards: std::option::Option<bool>,
}
impl SolanaBlockSubscribeConfig {
/// Creates an explicit unstable block-subscription configuration.
#[must_use]
pub const fn new(
commitment: std::option::Option<crate::SolanaCommitment>,
encoding: std::option::Option<crate::SolanaTransactionEncoding>,
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
max_supported_transaction_version: std::option::Option<u8>,
show_rewards: std::option::Option<bool>,
) -> Self {
return Self { commitment, encoding, transaction_details, max_supported_transaction_version, show_rewards };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional transaction encoding.
#[must_use]
pub const fn encoding(&self) -> std::option::Option<crate::SolanaTransactionEncoding> {
return self.encoding;
}
/// Returns the optional transaction detail level.
#[must_use]
pub const fn transaction_details(&self) -> std::option::Option<crate::SolanaTransactionDetails> {
return self.transaction_details;
}
/// Returns the highest transaction version the caller declares it can consume.
#[must_use]
pub const fn max_supported_transaction_version(&self) -> std::option::Option<u8> {
return self.max_supported_transaction_version;
}
/// Returns whether rewards were explicitly requested for block notifications.
#[must_use]
pub const fn show_rewards(&self) -> std::option::Option<bool> {
return self.show_rewards;
}
fn is_empty(&self) -> bool {
return self.commitment.is_none()
&& self.encoding.is_none()
&& self.transaction_details.is_none()
&& self.max_supported_transaction_version.is_none()
&& self.show_rewards.is_none();
}
fn validate(&self) -> ksp_core_lib::Result<()> {
if self.commitment == std::option::Option::Some(crate::SolanaCommitment::Processed) {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
"blockSubscribe commitment must be confirmed or finalized when explicitly provided",
)
.with_context("rpc_method", "blockSubscribe")
.with_context("commitment", "processed"),
);
}
return std::result::Result::Ok(());
}
fn to_json_value(self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(encoding) = self.encoding {
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
}
if let std::option::Option::Some(transaction_details) = self.transaction_details {
object.insert("transactionDetails".to_owned(), serde_json::Value::String(transaction_details.as_str().to_owned()));
}
if let std::option::Option::Some(version) = self.max_supported_transaction_version {
object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into()));
}
if let std::option::Option::Some(show_rewards) = self.show_rewards {
object.insert("showRewards".to_owned(), serde_json::Value::Bool(show_rewards));
}
return serde_json::Value::Object(object);
}
}
/// Typed value carried inside an unstable Solana `blockNotification` response.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaBlockNotification {
slot: u64,
block: std::option::Option<crate::SolanaConfirmedBlock>,
err: std::option::Option<serde_json::Value>,
}
impl SolanaBlockNotification {
/// Returns the slot associated with this block update.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the decoded block when the unstable notification contains one.
#[must_use]
pub const fn block(&self) -> std::option::Option<&crate::SolanaConfirmedBlock> {
return self.block.as_ref();
}
/// Returns the nullable publication error without interpreting its unstable wire shape.
#[must_use]
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
return self.err.as_ref();
}
}
impl crate::WsSession {
/// Subscribes to unstable standard Solana block notifications through `blockSubscribe`.
///
/// Solana documents this method as unstable and requires validator-side block-subscription support. KSP emits a warning through its logging facade when
/// this family is requested. An explicitly supplied commitment must be `confirmed` or `finalized`.
pub async fn block_subscribe(
&self,
filter: &crate::SolanaBlockSubscribeFilter,
config: std::option::Option<&crate::SolanaBlockSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaBlockNotification>>> {
if let std::option::Option::Some(config) = config {
let validated = config.validate();
if let std::result::Result::Err(error) = validated {
return std::result::Result::Err(error);
}
}
let mut params = std::vec![filter.to_json_value()];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push((*config).to_json_value());
}
return self.subscribe_typed(crate::WsSubscriptionKind::Block, params, |value| return decode_block_notification("blockSubscribe", value)).await;
}
}
#[derive(serde::Deserialize)]
struct WireBlockNotification {
slot: u64,
block: std::option::Option<serde_json::Value>,
err: serde_json::Value,
}
fn decode_block_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaBlockNotification>> {
let decoded = crate::decode_wire_json::<WireRpcResponse>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let block = match wire.value.block {
std::option::Option::Some(value) => {
let decoded = crate::SolanaConfirmedBlock::decode_wire(method, value);
match decoded {
std::result::Result::Ok(block) => std::option::Option::Some(block),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
std::option::Option::None => std::option::Option::None,
};
let err = match wire.value.err {
serde_json::Value::Null => std::option::Option::None,
value => std::option::Option::Some(value),
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, crate::SolanaBlockNotification { slot: wire.value.slot, block, err }));
}
#[derive(serde::Deserialize)]
struct WireRpcResponse {
context: serde_json::Value,
value: WireBlockNotification,
}
impl crate::SolanaStandardWsSession {
/// Subscribes to unstable standard Solana block notifications through `blockSubscribe`.
pub async fn block_subscribe(
&self,
filter: &crate::SolanaBlockSubscribeFilter,
config: std::option::Option<&crate::SolanaBlockSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaBlockNotification>>> {
return self.physical_session().block_subscribe(filter, config).await;
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_blocks.rs"]
mod tests;

View File

@@ -0,0 +1,446 @@
// file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs
// version: 5
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SolanaSlotNotification {
slot: u64,
parent: u64,
root: u64,
}
impl SolanaSlotNotification {
/// Returns the newly processed slot.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the parent slot reported by the validator.
#[must_use]
pub const fn parent(&self) -> u64 {
return self.parent;
}
/// Returns the current root slot reported alongside this slot update.
#[must_use]
pub const fn root(&self) -> u64 {
return self.root;
}
}
/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SolanaSlotUpdateStats {
max_transactions_per_entry: u64,
num_failed_transactions: u64,
num_successful_transactions: u64,
num_transaction_entries: u64,
}
impl SolanaSlotUpdateStats {
/// Returns the maximum transactions per entry observed for the frozen bank.
#[must_use]
pub const fn max_transactions_per_entry(&self) -> u64 {
return self.max_transactions_per_entry;
}
/// Returns the failed transaction count.
#[must_use]
pub const fn num_failed_transactions(&self) -> u64 {
return self.num_failed_transactions;
}
/// Returns the successful transaction count.
#[must_use]
pub const fn num_successful_transactions(&self) -> u64 {
return self.num_successful_transactions;
}
/// Returns the transaction-entry count.
#[must_use]
pub const fn num_transaction_entries(&self) -> u64 {
return self.num_transaction_entries;
}
}
/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaSlotUpdate {
/// The first shred for a slot was received.
FirstShredReceived {
/// Slot whose first shred was observed.
slot: u64,
/// Millisecond Unix timestamp reported by the validator.
timestamp: i64,
},
/// All shreds for a slot were received.
Completed {
/// Slot whose shred set completed.
slot: u64,
/// Millisecond Unix timestamp reported by the validator.
timestamp: i64,
},
/// A bank was created for the slot.
CreatedBank {
/// Slot whose bank was created.
slot: u64,
/// Millisecond Unix timestamp reported by the validator.
timestamp: i64,
/// Parent slot used to create the bank.
parent: u64,
},
/// A bank was frozen and execution statistics are available.
Frozen {
/// Slot whose bank was frozen.
slot: u64,
/// Millisecond Unix timestamp reported by the validator.
timestamp: i64,
/// Execution statistics reported for the frozen bank.
stats: crate::SolanaSlotUpdateStats,
},
/// The slot was marked dead.
Dead {
/// Slot marked dead by the validator.
slot: u64,
/// Millisecond Unix timestamp reported by the validator.
timestamp: i64,
/// Upstream diagnostic string explaining why the slot was marked dead.
error: std::string::String,
},
/// The slot reached the current unstable optimistic-confirmation marker.
OptimisticConfirmation {
/// Slot that reached optimistic confirmation.
slot: u64,
/// Millisecond Unix timestamp reported by the validator.
timestamp: i64,
},
/// The slot became root.
Root {
/// Slot that became root.
slot: u64,
/// Millisecond Unix timestamp reported by the validator.
timestamp: i64,
},
/// A future upstream variant that KSP does not yet interpret.
///
/// `raw` is bounded by the physical session's configured inbound WebSocket message limit before JSON decoding.
Unknown {
/// Upstream `type` discriminator that KSP does not yet recognize.
update_type: std::string::String,
/// Complete bounded JSON object preserved for forward-compatible inspection.
raw: serde_json::Value,
},
}
impl SolanaSlotUpdate {
/// Returns the slot for known variants, or the optional slot found in an unknown raw variant.
#[must_use]
pub fn slot(&self) -> std::option::Option<u64> {
return match self {
Self::FirstShredReceived { slot, .. }
| Self::Completed { slot, .. }
| Self::CreatedBank { slot, .. }
| Self::Frozen { slot, .. }
| Self::Dead { slot, .. }
| Self::OptimisticConfirmation { slot, .. }
| Self::Root { slot, .. } => std::option::Option::Some(*slot),
Self::Unknown { raw, .. } => raw.get("slot").and_then(serde_json::Value::as_u64),
};
}
/// Returns the millisecond Unix timestamp for known variants, or an optional timestamp from an unknown raw variant.
#[must_use]
pub fn timestamp(&self) -> std::option::Option<i64> {
return match self {
Self::FirstShredReceived { timestamp, .. }
| Self::Completed { timestamp, .. }
| Self::CreatedBank { timestamp, .. }
| Self::Frozen { timestamp, .. }
| Self::Dead { timestamp, .. }
| Self::OptimisticConfirmation { timestamp, .. }
| Self::Root { timestamp, .. } => std::option::Option::Some(*timestamp),
Self::Unknown { raw, .. } => raw.get("timestamp").and_then(serde_json::Value::as_i64),
};
}
/// Returns the upstream `type` string, including unknown future values.
#[must_use]
pub fn update_type(&self) -> &str {
return match self {
Self::FirstShredReceived { .. } => "firstShredReceived",
Self::Completed { .. } => "completed",
Self::CreatedBank { .. } => "createdBank",
Self::Frozen { .. } => "frozen",
Self::Dead { .. } => "dead",
Self::OptimisticConfirmation { .. } => "optimisticConfirmation",
Self::Root { .. } => "root",
Self::Unknown { update_type, .. } => update_type.as_str(),
};
}
/// Returns the bounded raw object only for an unknown future upstream variant.
#[must_use]
pub const fn unknown_raw(&self) -> std::option::Option<&serde_json::Value> {
return match self {
Self::Unknown { raw, .. } => std::option::Option::Some(raw),
_ => std::option::Option::None,
};
}
}
/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaVoteNotification {
vote_pubkey: ksp_core_lib::Pubkey,
slots: std::vec::Vec<u64>,
hash: std::string::String,
timestamp: std::option::Option<i64>,
signature: std::string::String,
}
impl SolanaVoteNotification {
/// Returns the vote-account public key.
#[must_use]
pub const fn vote_pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.vote_pubkey;
}
/// Returns the ordered slots covered by the observed vote.
#[must_use]
pub fn slots(&self) -> &[u64] {
return self.slots.as_slice();
}
/// Returns the vote hash exactly as reported by the unstable upstream wire.
#[must_use]
pub fn hash(&self) -> &str {
return self.hash.as_str();
}
/// Returns the optional vote timestamp, preserving omitted and explicit-null wire forms as `None`.
#[must_use]
pub const fn timestamp(&self) -> std::option::Option<i64> {
return self.timestamp;
}
/// Returns the vote transaction signature exactly as reported by the unstable upstream wire.
#[must_use]
pub fn signature(&self) -> &str {
return self.signature.as_str();
}
}
impl crate::WsSession {
/// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`.
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
return self
.subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| {
return decode_slot_notification("slotSubscribe", value);
})
.await;
}
/// Subscribes to standard Solana root-slot notifications through `rootSubscribe`.
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
return self
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
return crate::decode_wire_json::<u64>("rootSubscribe", value);
})
.await;
}
/// Subscribes to unstable standard Solana slot-lifecycle notifications through `slotsUpdatesSubscribe`.
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
return self
.subscribe_typed(crate::WsSubscriptionKind::SlotsUpdates, std::vec::Vec::new(), |value| {
return decode_slots_update_notification("slotsUpdatesSubscribe", value);
})
.await;
}
/// Subscribes to unstable pre-consensus gossip vote notifications through `voteSubscribe`.
pub async fn vote_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaVoteNotification>> {
return self
.subscribe_typed(crate::WsSubscriptionKind::Vote, std::vec::Vec::new(), |value| {
return decode_vote_notification("voteSubscribe", value);
})
.await;
}
}
#[derive(serde::Deserialize)]
struct WireSlotNotification {
slot: u64,
parent: u64,
root: u64,
}
fn decode_slot_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaSlotNotification> {
let decoded = crate::decode_wire_json::<WireSlotNotification>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::SolanaSlotNotification { slot: wire.slot, parent: wire.parent, root: wire.root });
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSlotUpdateStats {
max_transactions_per_entry: u64,
num_failed_transactions: u64,
num_successful_transactions: u64,
num_transaction_entries: u64,
}
#[derive(serde::Deserialize)]
struct WireVoteNotification {
#[serde(rename = "votePubkey")]
vote_pubkey: std::string::String,
slots: std::vec::Vec<u64>,
hash: std::string::String,
#[serde(default)]
timestamp: std::option::Option<i64>,
signature: std::string::String,
}
fn decode_slots_update_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaSlotUpdate> {
let object = match value.as_object() {
std::option::Option::Some(object) => object,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification must be an object")
.with_context("rpc_method", method),
);
},
};
let update_type = match object.get("type").and_then(serde_json::Value::as_str) {
std::option::Option::Some(update_type) => update_type,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification is missing a string type")
.with_context("rpc_method", method),
);
},
};
if !matches!(update_type, "firstShredReceived" | "completed" | "createdBank" | "frozen" | "dead" | "optimisticConfirmation" | "root") {
return std::result::Result::Ok(crate::SolanaSlotUpdate::Unknown { update_type: update_type.to_owned(), raw: value.clone() });
}
let slot = match object.get("slot").and_then(serde_json::Value::as_u64) {
std::option::Option::Some(slot) => slot,
std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric slot"),
};
let timestamp = match object.get("timestamp").and_then(serde_json::Value::as_i64) {
std::option::Option::Some(timestamp) => timestamp,
std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric timestamp"),
};
return match update_type {
"firstShredReceived" => std::result::Result::Ok(crate::SolanaSlotUpdate::FirstShredReceived { slot, timestamp }),
"completed" => std::result::Result::Ok(crate::SolanaSlotUpdate::Completed { slot, timestamp }),
"createdBank" => match object.get("parent").and_then(serde_json::Value::as_u64) {
std::option::Option::Some(parent) => std::result::Result::Ok(crate::SolanaSlotUpdate::CreatedBank { slot, timestamp, parent }),
std::option::Option::None => invalid_slots_update(method, "createdBank update is missing numeric parent"),
},
"frozen" => {
let stats = match object.get("stats") {
std::option::Option::Some(stats) => crate::decode_wire_json::<WireSlotUpdateStats>(method, stats.clone()),
std::option::Option::None => return invalid_slots_update(method, "frozen update is missing stats"),
};
let stats = match stats {
std::result::Result::Ok(stats) => stats,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
std::result::Result::Ok(crate::SolanaSlotUpdate::Frozen {
slot,
timestamp,
stats: crate::SolanaSlotUpdateStats {
max_transactions_per_entry: stats.max_transactions_per_entry,
num_failed_transactions: stats.num_failed_transactions,
num_successful_transactions: stats.num_successful_transactions,
num_transaction_entries: stats.num_transaction_entries,
},
})
},
"dead" => match object.get("err").and_then(serde_json::Value::as_str) {
std::option::Option::Some(error) => std::result::Result::Ok(crate::SolanaSlotUpdate::Dead { slot, timestamp, error: error.to_owned() }),
std::option::Option::None => invalid_slots_update(method, "dead update is missing string err"),
},
"optimisticConfirmation" => std::result::Result::Ok(crate::SolanaSlotUpdate::OptimisticConfirmation { slot, timestamp }),
"root" => std::result::Result::Ok(crate::SolanaSlotUpdate::Root { slot, timestamp }),
_ => invalid_slots_update(method, "known slots update type dispatch failed"),
};
}
fn invalid_slots_update(method: &str, message: &'static str) -> ksp_core_lib::Result<crate::SolanaSlotUpdate> {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method));
}
fn decode_vote_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaVoteNotification> {
let decoded = crate::decode_wire_json::<WireVoteNotification>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let vote_pubkey = wire.vote_pubkey.parse::<ksp_core_lib::Pubkey>();
let vote_pubkey = match vote_pubkey {
std::result::Result::Ok(vote_pubkey) => vote_pubkey,
std::result::Result::Err(_) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "voteSubscribe notification contains an invalid votePubkey")
.with_context("rpc_method", method),
);
},
};
return std::result::Result::Ok(crate::SolanaVoteNotification {
vote_pubkey,
slots: wire.slots,
hash: wire.hash,
timestamp: wire.timestamp,
signature: wire.signature,
});
}
impl crate::SolanaStandardWsSession {
/// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`.
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
return self.physical_session().slot_subscribe().await;
}
/// Subscribes to standard Solana root-slot notifications through `rootSubscribe`.
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
return self.physical_session().root_subscribe().await;
}
/// Subscribes to unstable standard Solana slot-lifecycle notifications through `slotsUpdatesSubscribe`.
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
return self.physical_session().slots_updates_subscribe().await;
}
/// Subscribes to unstable pre-consensus gossip vote notifications through `voteSubscribe`.
pub async fn vote_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaVoteNotification>> {
return self.physical_session().vote_subscribe().await;
}
}
impl crate::HeliusLaserStreamWsSession {
/// Subscribes to slot-processing notifications through the standard `slotSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
return self.physical_session().slot_subscribe().await;
}
/// Subscribes to root-slot notifications through the standard `rootSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
return self.physical_session().root_subscribe().await;
}
/// Subscribes to unstable slot-lifecycle notifications through the standard `slotsUpdatesSubscribe` wire currently documented by Helius LaserStream
/// WebSocket.
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
return self.physical_session().slots_updates_subscribe().await;
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_cluster.rs"]
mod tests;

View File

@@ -0,0 +1,608 @@
// file: crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
// version: 5
const MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS: usize = 50_000;
/// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum HeliusTokenAccountsFilter {
/// Disable token-account owner expansion explicitly; equivalent to omitting `tokenAccounts`.
None,
/// Match transactions where a token balance owned by an included account changes or its token account closes.
BalanceChanged,
/// Match transactions referencing any token account owned by an included account, even if the balance does not change.
All,
}
impl HeliusTokenAccountsFilter {
/// Returns the exact Helius WebSocket wire string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::None => "none",
Self::BalanceChanged => "balanceChanged",
Self::All => "all",
};
}
}
/// Transaction encoding accepted by Helius `transactionSubscribe`.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum HeliusTransactionSubscribeEncoding {
/// Base58 encoded transaction bytes.
Base58,
/// Base64 encoded transaction bytes.
Base64,
/// Parsed JSON transaction representation.
JsonParsed,
}
impl HeliusTransactionSubscribeEncoding {
/// Returns the exact Helius WebSocket wire string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Base58 => "base58",
Self::Base64 => "base64",
Self::JsonParsed => "jsonParsed",
};
}
}
/// Helius-specific filter object accepted as the first `transactionSubscribe` parameter.
///
/// Debug output intentionally exposes only filter presence, modes and account counts. Transaction signatures and account values are omitted so routine
/// diagnostics cannot accidentally disclose the caller's complete provider filter payload.
#[derive(Clone, Default, Eq, PartialEq)]
pub struct HeliusTransactionSubscribeFilter {
vote: std::option::Option<bool>,
failed: std::option::Option<bool>,
signature: std::option::Option<std::string::String>,
account_include: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
account_exclude: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
account_required: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
token_accounts: std::option::Option<crate::HeliusTokenAccountsFilter>,
}
impl HeliusTransactionSubscribeFilter {
/// Creates a complete Helius transaction filter while preserving omitted versus explicitly empty account arrays.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
vote: std::option::Option<bool>,
failed: std::option::Option<bool>,
signature: std::option::Option<std::string::String>,
account_include: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
account_exclude: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
account_required: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
token_accounts: std::option::Option<crate::HeliusTokenAccountsFilter>,
) -> Self {
return Self { vote, failed, signature, account_include, account_exclude, account_required, token_accounts };
}
/// Returns the optional vote-transaction filter flag.
#[must_use]
pub const fn vote(&self) -> std::option::Option<bool> {
return self.vote;
}
/// Returns the optional failed-transaction filter flag.
#[must_use]
pub const fn failed(&self) -> std::option::Option<bool> {
return self.failed;
}
/// Returns the optional exact transaction signature filter.
#[must_use]
pub fn signature(&self) -> std::option::Option<&str> {
return match self.signature.as_ref() {
std::option::Option::Some(signature) => std::option::Option::Some(signature.as_str()),
std::option::Option::None => std::option::Option::None,
};
}
/// Returns the optional OR-style account inclusion list.
#[must_use]
pub fn account_include(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> {
return match self.account_include.as_ref() {
std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()),
std::option::Option::None => std::option::Option::None,
};
}
/// Returns the optional account exclusion list.
#[must_use]
pub fn account_exclude(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> {
return match self.account_exclude.as_ref() {
std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()),
std::option::Option::None => std::option::Option::None,
};
}
/// Returns the optional AND-style required-account list.
#[must_use]
pub fn account_required(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> {
return match self.account_required.as_ref() {
std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()),
std::option::Option::None => std::option::Option::None,
};
}
/// Returns the optional Helius token-account owner-expansion mode.
#[must_use]
pub const fn token_accounts(&self) -> std::option::Option<crate::HeliusTokenAccountsFilter> {
return self.token_accounts;
}
fn validate(&self) -> ksp_core_lib::Result<()> {
let include = validate_account_list("accountInclude", self.account_include.as_deref());
if let std::result::Result::Err(error) = include {
return std::result::Result::Err(error);
}
let exclude = validate_account_list("accountExclude", self.account_exclude.as_deref());
if let std::result::Result::Err(error) = exclude {
return std::result::Result::Err(error);
}
let required = validate_account_list("accountRequired", self.account_required.as_deref());
if let std::result::Result::Err(error) = required {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(vote) = self.vote {
object.insert("vote".to_owned(), serde_json::Value::Bool(vote));
}
if let std::option::Option::Some(failed) = self.failed {
object.insert("failed".to_owned(), serde_json::Value::Bool(failed));
}
if let std::option::Option::Some(signature) = self.signature.as_ref() {
object.insert("signature".to_owned(), serde_json::Value::String(signature.clone()));
}
insert_account_list(&mut object, "accountInclude", self.account_include.as_deref());
insert_account_list(&mut object, "accountExclude", self.account_exclude.as_deref());
insert_account_list(&mut object, "accountRequired", self.account_required.as_deref());
if let std::option::Option::Some(token_accounts) = self.token_accounts {
object.insert("tokenAccounts".to_owned(), serde_json::Value::String(token_accounts.as_str().to_owned()));
}
return serde_json::Value::Object(object);
}
}
impl std::fmt::Debug for HeliusTransactionSubscribeFilter {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("HeliusTransactionSubscribeFilter")
.field("vote", &self.vote)
.field("failed", &self.failed)
.field("signature_present", &self.signature.is_some())
.field("account_include_count", &self.account_include.as_ref().map(std::vec::Vec::len))
.field("account_exclude_count", &self.account_exclude.as_ref().map(std::vec::Vec::len))
.field("account_required_count", &self.account_required.as_ref().map(std::vec::Vec::len))
.field("token_accounts", &self.token_accounts)
.finish();
}
}
/// Optional Helius `transactionSubscribe` result-shaping configuration.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct HeliusTransactionSubscribeOptions {
commitment: std::option::Option<crate::SolanaCommitment>,
encoding: std::option::Option<crate::HeliusTransactionSubscribeEncoding>,
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
show_rewards: std::option::Option<bool>,
max_supported_transaction_version: std::option::Option<u8>,
}
impl HeliusTransactionSubscribeOptions {
/// Creates a complete optional Helius transaction-subscription configuration.
#[must_use]
pub const fn new(
commitment: std::option::Option<crate::SolanaCommitment>,
encoding: std::option::Option<crate::HeliusTransactionSubscribeEncoding>,
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
show_rewards: std::option::Option<bool>,
max_supported_transaction_version: std::option::Option<u8>,
) -> Self {
return Self { commitment, encoding, transaction_details, show_rewards, max_supported_transaction_version };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional Helius transaction encoding.
#[must_use]
pub const fn encoding(&self) -> std::option::Option<crate::HeliusTransactionSubscribeEncoding> {
return self.encoding;
}
/// Returns the optional transaction detail level.
#[must_use]
pub const fn transaction_details(&self) -> std::option::Option<crate::SolanaTransactionDetails> {
return self.transaction_details;
}
/// Returns whether rewards were explicitly requested.
#[must_use]
pub const fn show_rewards(&self) -> std::option::Option<bool> {
return self.show_rewards;
}
/// Returns the highest transaction version the caller declares it can consume.
#[must_use]
pub const fn max_supported_transaction_version(&self) -> std::option::Option<u8> {
return self.max_supported_transaction_version;
}
fn validate(&self) -> ksp_core_lib::Result<()> {
let requires_version =
matches!(self.transaction_details, std::option::Option::Some(crate::SolanaTransactionDetails::Full | crate::SolanaTransactionDetails::Accounts));
if requires_version && self.max_supported_transaction_version.is_none() {
let detail = match self.transaction_details {
std::option::Option::Some(detail) => detail.as_str(),
std::option::Option::None => "omitted",
};
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
"Helius transactionSubscribe requires maxSupportedTransactionVersion for full or accounts transaction details",
)
.with_context("rpc_method", "transactionSubscribe")
.with_context("field", "maxSupportedTransactionVersion")
.with_context("transaction_details", detail),
);
}
return std::result::Result::Ok(());
}
fn to_json_value(self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(encoding) = self.encoding {
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
}
if let std::option::Option::Some(transaction_details) = self.transaction_details {
object.insert("transactionDetails".to_owned(), serde_json::Value::String(transaction_details.as_str().to_owned()));
}
if let std::option::Option::Some(show_rewards) = self.show_rewards {
object.insert("showRewards".to_owned(), serde_json::Value::Bool(show_rewards));
}
if let std::option::Option::Some(version) = self.max_supported_transaction_version {
object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into()));
}
return serde_json::Value::Object(object);
}
}
/// Complete typed request contract for Helius `transactionSubscribe`.
///
/// The request owns the exact provider filter and optional result-shaping object. Validation and serialization occur before actor registration so deterministic
/// provider constraints fail without WebSocket I/O.
#[derive(Clone, Eq, PartialEq)]
pub struct HeliusTransactionSubscribeRequest {
filter: crate::HeliusTransactionSubscribeFilter,
options: std::option::Option<crate::HeliusTransactionSubscribeOptions>,
}
impl HeliusTransactionSubscribeRequest {
/// Creates one typed Helius transaction-subscription request.
#[must_use]
pub fn new(filter: crate::HeliusTransactionSubscribeFilter, options: std::option::Option<crate::HeliusTransactionSubscribeOptions>) -> Self {
return Self { filter, options };
}
/// Returns the provider transaction filter.
#[must_use]
pub const fn filter(&self) -> &crate::HeliusTransactionSubscribeFilter {
return &self.filter;
}
/// Returns the optional provider result-shaping configuration.
#[must_use]
pub const fn options(&self) -> std::option::Option<&crate::HeliusTransactionSubscribeOptions> {
return self.options.as_ref();
}
/// Validates deterministic Helius request constraints before any WebSocket I/O.
pub fn validate(&self) -> ksp_core_lib::Result<()> {
let filter = self.filter.validate();
if let std::result::Result::Err(error) = filter {
return std::result::Result::Err(error);
}
if let std::option::Option::Some(options) = self.options {
let options = options.validate();
if let std::result::Result::Err(error) = options {
return std::result::Result::Err(error);
}
}
return std::result::Result::Ok(());
}
}
impl std::fmt::Debug for HeliusTransactionSubscribeRequest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("HeliusTransactionSubscribeRequest").field("filter", &self.filter).field("options", &self.options).finish();
}
}
fn helius_transaction_subscribe_params(request: &crate::HeliusTransactionSubscribeRequest) -> ksp_core_lib::Result<std::vec::Vec<serde_json::Value>> {
let validation = request.validate();
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
let mut params = std::vec![request.filter.to_json_value()];
if let std::option::Option::Some(options) = request.options {
params.push(options.to_json_value());
}
return std::result::Result::Ok(params);
}
/// Full/accounts-mode notification delivered by Helius `transactionSubscribe`.
///
/// The nested transaction payload is deliberately retained as JSON because its exact Solana wire representation depends on the requested encoding and detail
/// mode. KSP types the stable provider envelope while preserving the full nested payload without Program-specific decoding.
#[derive(Clone, PartialEq)]
pub struct HeliusFullTransactionNotification {
transaction: serde_json::Value,
signature: std::string::String,
slot: u64,
transaction_index: u64,
}
impl HeliusFullTransactionNotification {
/// Returns the provider transaction/status payload without interpreting Program-specific contents.
#[must_use]
pub const fn transaction(&self) -> &serde_json::Value {
return &self.transaction;
}
/// Returns the base58 transaction signature reported by Helius.
#[must_use]
pub fn signature(&self) -> &str {
return self.signature.as_str();
}
/// Returns the slot in which the transaction was processed.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the zero-based transaction position within the block.
#[must_use]
pub const fn transaction_index(&self) -> u64 {
return self.transaction_index;
}
}
impl std::fmt::Debug for HeliusFullTransactionNotification {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("HeliusFullTransactionNotification")
.field("transaction", &"<omitted>")
.field("signature", &"<omitted>")
.field("slot", &self.slot)
.field("transaction_index", &self.transaction_index)
.finish();
}
}
/// Signatures-mode notification delivered by Helius `transactionSubscribe`.
#[derive(Clone, PartialEq)]
pub struct HeliusTransactionSignatureNotification {
signature: std::string::String,
slot: u64,
transaction_index: u64,
err: crate::SolanaWireField<serde_json::Value>,
memo: crate::SolanaWireField<std::string::String>,
block_time: crate::SolanaWireField<i64>,
confirmation_status: crate::SolanaWireField<std::string::String>,
}
impl HeliusTransactionSignatureNotification {
/// Returns the base58 transaction signature reported by Helius.
#[must_use]
pub fn signature(&self) -> &str {
return self.signature.as_str();
}
/// Returns the slot in which the transaction was processed.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the zero-based transaction position within the block.
#[must_use]
pub const fn transaction_index(&self) -> u64 {
return self.transaction_index;
}
/// Returns the optional transaction error while preserving omitted/null/value wire states.
#[must_use]
pub const fn err(&self) -> &crate::SolanaWireField<serde_json::Value> {
return &self.err;
}
/// Returns the optional memo while preserving omitted/null/value wire states.
#[must_use]
pub const fn memo(&self) -> &crate::SolanaWireField<std::string::String> {
return &self.memo;
}
/// Returns the optional block time while preserving omitted/null/value wire states.
#[must_use]
pub const fn block_time(&self) -> &crate::SolanaWireField<i64> {
return &self.block_time;
}
/// Returns the optional confirmation-status label while preserving omitted/null/value wire states.
#[must_use]
pub const fn confirmation_status(&self) -> &crate::SolanaWireField<std::string::String> {
return &self.confirmation_status;
}
}
impl std::fmt::Debug for HeliusTransactionSignatureNotification {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("HeliusTransactionSignatureNotification")
.field("signature", &"<omitted>")
.field("slot", &self.slot)
.field("transaction_index", &self.transaction_index)
.field("err", &wire_field_debug_state(&self.err))
.field("memo", &wire_field_debug_state(&self.memo))
.field("block_time", &wire_field_debug_state(&self.block_time))
.field("confirmation_status", &wire_field_debug_state(&self.confirmation_status))
.finish();
}
}
fn wire_field_debug_state<T>(field: &crate::SolanaWireField<T>) -> &'static str {
if field.is_omitted() {
return "omitted";
}
if field.is_null() {
return "null";
}
return "value";
}
/// Typed Helius `transactionNotification` payload union.
///
/// `Full` also covers the provider `accounts` detail mode because both contain the nested `transaction` member. `Signature` covers the lightweight
/// signatures mode. `Unknown` preserves `none` mode and forward-compatible provider shapes instead of failing the logical subscription.
#[derive(Clone, PartialEq)]
#[non_exhaustive]
pub enum HeliusTransactionNotification {
/// Full/accounts notification carrying the nested transaction payload.
Full(crate::HeliusFullTransactionNotification),
/// Lightweight signatures notification.
Signature(crate::HeliusTransactionSignatureNotification),
/// Provider shape not currently typed by KSP, preserved losslessly.
Unknown(serde_json::Value),
}
impl std::fmt::Debug for HeliusTransactionNotification {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return match self {
Self::Full(notification) => formatter.debug_tuple("Full").field(notification).finish(),
Self::Signature(notification) => formatter.debug_tuple("Signature").field(notification).finish(),
Self::Unknown(_) => formatter.debug_tuple("Unknown").field(&"<omitted>").finish(),
};
}
}
impl crate::HeliusLaserStreamWsSession {
/// Opens one Helius `transactionSubscribe` logical subscription through the shared physical actor.
///
/// The returned handle keeps a stable local identity across physical reconnects. Helius remote subscription IDs stay actor-private and are remapped after
/// resubscribe. Calling [`crate::WsSubscription::unsubscribe`] removes the remote mapping before sending `transactionUnsubscribe`, so provider messages
/// already in flight after cancellation are ignored without reactivating the logical subscription.
pub async fn transaction_subscribe(
&self,
request: &crate::HeliusTransactionSubscribeRequest,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::HeliusTransactionNotification>> {
let params = helius_transaction_subscribe_params(request);
let params = match params {
std::result::Result::Ok(params) => params,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return self
.physical_session()
.subscribe_typed(crate::WsSubscriptionKind::HeliusTransaction, params, |value| return decode_helius_transaction_notification(value))
.await;
}
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireHeliusFullTransactionNotification {
transaction: serde_json::Value,
signature: std::string::String,
slot: u64,
transaction_index: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireHeliusTransactionSignatureNotification {
signature: std::string::String,
slot: u64,
transaction_index: u64,
#[serde(default)]
err: crate::SolanaWireField<serde_json::Value>,
#[serde(default)]
memo: crate::SolanaWireField<std::string::String>,
#[serde(default)]
block_time: crate::SolanaWireField<i64>,
#[serde(default)]
confirmation_status: crate::SolanaWireField<std::string::String>,
}
fn decode_helius_transaction_notification(value: serde_json::Value) -> ksp_core_lib::Result<crate::HeliusTransactionNotification> {
if value.get("transaction").is_some() {
let decoded = crate::decode_wire_json::<WireHeliusFullTransactionNotification>("transactionNotification", value.clone());
if let std::result::Result::Ok(decoded) = decoded {
return std::result::Result::Ok(crate::HeliusTransactionNotification::Full(crate::HeliusFullTransactionNotification {
transaction: decoded.transaction,
signature: decoded.signature,
slot: decoded.slot,
transaction_index: decoded.transaction_index,
}));
}
}
if value.get("signature").is_some() && value.get("slot").is_some() && value.get("transactionIndex").is_some() {
let decoded = crate::decode_wire_json::<WireHeliusTransactionSignatureNotification>("transactionNotification", value.clone());
if let std::result::Result::Ok(decoded) = decoded {
return std::result::Result::Ok(crate::HeliusTransactionNotification::Signature(crate::HeliusTransactionSignatureNotification {
signature: decoded.signature,
slot: decoded.slot,
transaction_index: decoded.transaction_index,
err: decoded.err,
memo: decoded.memo,
block_time: decoded.block_time,
confirmation_status: decoded.confirmation_status,
}));
}
}
return std::result::Result::Ok(crate::HeliusTransactionNotification::Unknown(value));
}
fn validate_account_list(field: &'static str, accounts: std::option::Option<&[ksp_core_lib::Pubkey]>) -> ksp_core_lib::Result<()> {
if let std::option::Option::Some(accounts) = accounts
&& accounts.len() > MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS
{
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "Helius transactionSubscribe account filter exceeds the provider limit")
.with_context("rpc_method", "transactionSubscribe")
.with_context("field", field)
.with_context("actual_count", accounts.len().to_string())
.with_context("max_count", MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS.to_string()),
);
}
return std::result::Result::Ok(());
}
fn insert_account_list(
object: &mut serde_json::Map<std::string::String, serde_json::Value>,
field: &'static str,
accounts: std::option::Option<&[ksp_core_lib::Pubkey]>,
) {
if let std::option::Option::Some(accounts) = accounts {
let values = accounts.iter().map(|account| return serde_json::Value::String(account.to_string())).collect::<std::vec::Vec<_>>();
object.insert(field.to_owned(), serde_json::Value::Array(values));
}
return;
}
#[cfg(test)]
#[path = "../unit_tests/ws_helius_transactions.rs"]
mod tests;

View File

@@ -0,0 +1,366 @@
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
// version: 7
/// Stable local identity assigned to one physical WebSocket session.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WsSessionId(std::num::NonZeroU64);
impl WsSessionId {
/// Creates a session identity from a non-zero local value.
#[must_use]
pub const fn new(value: std::num::NonZeroU64) -> Self {
return Self(value);
}
/// Returns the stable local numeric value.
#[must_use]
pub const fn get(self) -> u64 {
return self.0.get();
}
}
/// Stable local identity assigned to one logical WebSocket subscription.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WsSubscriptionId(std::num::NonZeroU64);
impl WsSubscriptionId {
/// Creates a subscription identity from a non-zero local value.
#[must_use]
pub const fn new(value: std::num::NonZeroU64) -> Self {
return Self(value);
}
/// Returns the stable local numeric value.
#[must_use]
pub const fn get(self) -> u64 {
return self.0.get();
}
}
/// Observable lifecycle state of one physical WebSocket session.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WsSessionState {
/// No physical connection is currently active and no connection attempt is running.
Disconnected,
/// The actor is establishing the physical connection.
Connecting,
/// The physical connection is active.
Active,
/// The actor is reconnecting after an unexpected physical disconnect.
Reconnecting {
/// One-based reconnect attempt currently in progress or waiting for backoff.
attempt: u32,
},
/// Explicit shutdown has started and new subscriptions are refused.
Closing,
/// Explicit shutdown completed.
Closed,
/// The session reached a terminal failure state.
Failed,
}
/// Observable lifecycle state of one logical WebSocket subscription.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WsSubscriptionState {
/// The local subscription exists but its initial subscribe request has not completed.
Requested,
/// The logical subscription is bound to an active remote subscription.
Active,
/// The logical subscription is being restored after reconnect.
Resubscribing,
/// Local cancellation has won and remote cleanup is in progress when possible.
Cancelling,
/// The logical subscription reached a non-error terminal state.
Closed,
/// The logical subscription reached a terminal failure state.
Failed,
}
/// WebSocket subscription family represented by one logical subscription.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum WsSubscriptionKind {
/// `accountSubscribe` family.
Account,
/// `blockSubscribe` family.
Block,
/// `logsSubscribe` family.
Logs,
/// `programSubscribe` family.
Program,
/// `rootSubscribe` family.
Root,
/// `signatureSubscribe` family.
Signature,
/// `slotSubscribe` family.
Slot,
/// `slotsUpdatesSubscribe` family.
SlotsUpdates,
/// `voteSubscribe` family.
Vote,
/// Helius LaserStream WebSocket `transactionSubscribe` extension family.
HeliusTransaction,
}
impl WsSubscriptionKind {
/// Returns the stable KSP descriptor for this WebSocket subscription family.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Account => "account",
Self::Block => "block",
Self::Logs => "logs",
Self::Program => "program",
Self::Root => "root",
Self::Signature => "signature",
Self::Slot => "slot",
Self::SlotsUpdates => "slots_updates",
Self::Vote => "vote",
Self::HeliusTransaction => "helius_transaction",
};
}
/// Returns the exact subscribe JSON-RPC method for this family.
pub(crate) const fn subscribe_method(self) -> &'static str {
return match self {
Self::Account => "accountSubscribe",
Self::Block => "blockSubscribe",
Self::Logs => "logsSubscribe",
Self::Program => "programSubscribe",
Self::Root => "rootSubscribe",
Self::Signature => "signatureSubscribe",
Self::Slot => "slotSubscribe",
Self::SlotsUpdates => "slotsUpdatesSubscribe",
Self::Vote => "voteSubscribe",
Self::HeliusTransaction => "transactionSubscribe",
};
}
/// Returns the exact unsubscribe JSON-RPC method for this family.
pub(crate) const fn unsubscribe_method(self) -> &'static str {
return match self {
Self::Account => "accountUnsubscribe",
Self::Block => "blockUnsubscribe",
Self::Logs => "logsUnsubscribe",
Self::Program => "programUnsubscribe",
Self::Root => "rootUnsubscribe",
Self::Signature => "signatureUnsubscribe",
Self::Slot => "slotUnsubscribe",
Self::SlotsUpdates => "slotsUpdatesUnsubscribe",
Self::Vote => "voteUnsubscribe",
Self::HeliusTransaction => "transactionUnsubscribe",
};
}
/// Returns the exact notification method emitted for this family.
pub(crate) const fn notification_method(self) -> &'static str {
return match self {
Self::Account => "accountNotification",
Self::Block => "blockNotification",
Self::Logs => "logsNotification",
Self::Program => "programNotification",
Self::Root => "rootNotification",
Self::Signature => "signatureNotification",
Self::Slot => "slotNotification",
Self::SlotsUpdates => "slotsUpdatesNotification",
Self::Vote => "voteNotification",
Self::HeliusTransaction => "transactionNotification",
};
}
/// Returns whether Solana documents this standard subscription family as unstable.
///
/// Provider extensions are stable here unless explicitly classified otherwise.
pub(crate) const fn is_unstable(self) -> bool {
return matches!(self, Self::Block | Self::SlotsUpdates | Self::Vote);
}
/// Emits the centralized KSP warning required before opening an unstable standard subscription.
pub(crate) fn warn_if_unstable(self) {
if self.is_unstable() {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
rpc_method = self.subscribe_method(),
subscription_kind = self.as_str(),
documentation_status = "unstable",
"unstable Solana WebSocket subscription requested"
);
}
return;
}
}
/// Safe lifecycle projection for one logical WebSocket subscription.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsSubscriptionSnapshot {
id: crate::WsSubscriptionId,
kind: crate::WsSubscriptionKind,
state: crate::WsSubscriptionState,
remote_bound: bool,
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
}
impl WsSubscriptionSnapshot {
/// Creates one safe subscription lifecycle projection for Transport runtime internals.
#[must_use]
pub(crate) const fn new(
id: crate::WsSubscriptionId,
kind: crate::WsSubscriptionKind,
state: crate::WsSubscriptionState,
remote_bound: bool,
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
) -> Self {
return Self { id, kind, state, remote_bound, terminal_error_code };
}
/// Returns the stable local subscription identity.
#[must_use]
pub const fn id(&self) -> crate::WsSubscriptionId {
return self.id;
}
/// Returns the logical WebSocket subscription family.
#[must_use]
pub const fn kind(&self) -> crate::WsSubscriptionKind {
return self.kind;
}
/// Returns the current logical lifecycle state.
#[must_use]
pub const fn state(&self) -> crate::WsSubscriptionState {
return self.state;
}
/// Returns whether a current remote subscription ID is bound internally.
///
/// The remote ID itself is deliberately absent because it is ephemeral across reconnects.
#[must_use]
pub const fn remote_bound(&self) -> bool {
return self.remote_bound;
}
/// Returns the safe terminal error code when this subscription ended because of a failure.
#[must_use]
pub const fn terminal_error_code(&self) -> std::option::Option<ksp_core_lib::ErrorCode> {
return self.terminal_error_code;
}
}
/// Safe runtime snapshot for one physical WebSocket session.
///
/// The snapshot deliberately contains logical endpoint metadata and local identities only. It never stores the endpoint URL, credentials, request payloads or
/// raw notifications.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsSessionSnapshot {
id: crate::WsSessionId,
endpoint_name: std::string::String,
provider: crate::WsProviderName,
cluster: crate::WsClusterName,
protocol: crate::WsProtocolKind,
state: crate::WsSessionState,
pending_request_count: usize,
continuity_gap_count: u64,
overflow_count: u64,
subscriptions: std::vec::Vec<crate::WsSubscriptionSnapshot>,
}
impl WsSessionSnapshot {
/// Creates one safe session projection for Transport runtime internals.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
id: crate::WsSessionId,
endpoint_name: impl std::convert::Into<std::string::String>,
provider: crate::WsProviderName,
cluster: crate::WsClusterName,
protocol: crate::WsProtocolKind,
state: crate::WsSessionState,
pending_request_count: usize,
continuity_gap_count: u64,
overflow_count: u64,
subscriptions: std::vec::Vec<crate::WsSubscriptionSnapshot>,
) -> Self {
return Self {
id,
endpoint_name: endpoint_name.into(),
provider,
cluster,
protocol,
state,
pending_request_count,
continuity_gap_count,
overflow_count,
subscriptions,
};
}
/// Returns the stable local session identity.
#[must_use]
pub const fn id(&self) -> crate::WsSessionId {
return self.id;
}
/// Returns the safe logical endpoint name.
#[must_use]
pub fn endpoint_name(&self) -> &str {
return self.endpoint_name.as_str();
}
/// Returns the provider descriptor without endpoint credentials.
#[must_use]
pub const fn provider(&self) -> &crate::WsProviderName {
return &self.provider;
}
/// Returns the cluster descriptor.
#[must_use]
pub const fn cluster(&self) -> &crate::WsClusterName {
return &self.cluster;
}
/// Returns the WebSocket protocol family.
#[must_use]
pub const fn protocol(&self) -> crate::WsProtocolKind {
return self.protocol;
}
/// Returns the current physical session lifecycle state.
#[must_use]
pub const fn state(&self) -> crate::WsSessionState {
return self.state;
}
/// Returns the number of JSON-RPC requests currently awaiting responses.
#[must_use]
pub const fn pending_request_count(&self) -> usize {
return self.pending_request_count;
}
/// Returns the number of observed physical continuity gaps for this session.
#[must_use]
pub const fn continuity_gap_count(&self) -> u64 {
return self.continuity_gap_count;
}
/// Returns the cumulative number of notification queue overflows observed by this session.
#[must_use]
pub const fn overflow_count(&self) -> u64 {
return self.overflow_count;
}
/// Returns safe lifecycle projections for logical subscriptions owned by this session.
#[must_use]
pub fn subscriptions(&self) -> &[crate::WsSubscriptionSnapshot] {
return self.subscriptions.as_slice();
}
/// Returns the number of logical subscriptions currently projected by the session.
#[must_use]
pub fn subscription_count(&self) -> usize {
return self.subscriptions.len();
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_lifecycle.rs"]
mod tests;

View File

@@ -0,0 +1,149 @@
// file: crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
// version: 6
/// Typed facade for one standard Solana WebSocket physical session.
///
/// The facade delegates to the same [`crate::WsSession`] actor used by the compatibility API. It owns no socket, registry, reconnect loop or queue of its
/// own and therefore does not duplicate the physical WebSocket runtime. Subscription wrappers are implemented beside their wire owners in the
/// `ws_accounts`, `ws_blocks`, `ws_cluster` and `ws_transactions` modules.
///
/// ```compile_fail
/// async fn unsupported_helius_transaction(
/// session: &ksp_onchain_transport_lib::SolanaStandardWsSession,
/// request: &ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest,
/// ) {
/// let _ = session.transaction_subscribe(request).await;
/// }
/// ```
#[derive(Clone)]
pub struct SolanaStandardWsSession {
inner: crate::WsSession,
}
impl SolanaStandardWsSession {
/// Opens one standard Solana WebSocket session through the shared physical actor.
pub async fn connect(endpoint: crate::WsEndpointSettings) -> ksp_core_lib::Result<Self> {
let connected = crate::WsSession::connect_for_protocol(endpoint, crate::WsProtocolKind::SolanaStandard).await;
return match connected {
std::result::Result::Ok(inner) => std::result::Result::Ok(Self { inner }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Returns the stable local session identity.
#[must_use]
pub const fn id(&self) -> crate::WsSessionId {
return self.inner.id();
}
/// Returns the latest safe runtime snapshot published by the shared actor.
#[must_use]
pub fn snapshot(&self) -> crate::WsSessionSnapshot {
return self.inner.snapshot();
}
/// Returns the latest observable physical-session state.
#[must_use]
pub fn state(&self) -> crate::WsSessionState {
return self.inner.state();
}
/// Explicitly closes the shared physical session under its configured close timeout.
pub async fn close(&self) -> ksp_core_lib::Result<()> {
return self.inner.close().await;
}
/// Returns the crate-private shared physical session used by domain-specific facade wrappers.
pub(crate) fn physical_session(&self) -> &crate::WsSession {
return &self.inner;
}
}
impl std::fmt::Debug for SolanaStandardWsSession {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("SolanaStandardWsSession").field("id", &self.id()).field("snapshot", &self.snapshot()).finish();
}
}
/// Typed facade for one Helius LaserStream WebSocket physical session.
///
/// The facade exposes the seven standard Solana subscription families that the current Helius method pages support or document as available plus the
/// Helius-specific typed
/// `transactionSubscribe` lifecycle. Transaction notifications, reconnect/resubscribe, unsubscribe races and bounded backpressure all delegate to the same
/// shared [`crate::WsSession`] actor; the facade owns no second socket, registry or queue. No public inner handle is exposed, so callers cannot bypass the
/// provider-specific surface by recovering a generic [`crate::WsSession`].
///
/// ```compile_fail
/// async fn unsupported_block(session: &ksp_onchain_transport_lib::HeliusLaserStreamWsSession) {
/// let _ = session.block_subscribe().await;
/// }
/// ```
///
/// ```compile_fail
/// async fn unsupported_vote(session: &ksp_onchain_transport_lib::HeliusLaserStreamWsSession) {
/// let _ = session.vote_subscribe().await;
/// }
/// ```
///
/// ```compile_fail
/// fn no_escape_hatch(session: ksp_onchain_transport_lib::HeliusLaserStreamWsSession) {
/// let _ = session.into_inner();
/// }
/// ```
#[derive(Clone)]
pub struct HeliusLaserStreamWsSession {
inner: crate::WsSession,
}
impl HeliusLaserStreamWsSession {
/// Opens one Helius LaserStream WebSocket session through the shared physical actor.
pub async fn connect(endpoint: crate::WsEndpointSettings) -> ksp_core_lib::Result<Self> {
let connected = crate::WsSession::connect_for_protocol(endpoint, crate::WsProtocolKind::HeliusLaserStream).await;
return match connected {
std::result::Result::Ok(inner) => std::result::Result::Ok(Self { inner }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Returns the stable local session identity.
#[must_use]
pub const fn id(&self) -> crate::WsSessionId {
return self.inner.id();
}
/// Returns the latest safe runtime snapshot published by the shared actor.
#[must_use]
pub fn snapshot(&self) -> crate::WsSessionSnapshot {
return self.inner.snapshot();
}
/// Returns the latest observable physical-session state.
#[must_use]
pub fn state(&self) -> crate::WsSessionState {
return self.inner.state();
}
/// Explicitly closes the shared physical session under its configured close timeout.
pub async fn close(&self) -> ksp_core_lib::Result<()> {
return self.inner.close().await;
}
/// Returns the crate-private shared physical session used by domain-specific facade wrappers.
pub(crate) fn physical_session(&self) -> &crate::WsSession {
return &self.inner;
}
}
impl std::fmt::Debug for HeliusLaserStreamWsSession {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("HeliusLaserStreamWsSession").field("id", &self.id()).field("snapshot", &self.snapshot()).finish();
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_helius_standard.rs"]
mod helius_standard_tests;
#[cfg(test)]
#[path = "../unit_tests/ws_protocol_session.rs"]
mod tests;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,574 @@
// file: crates/ksp-onchain-transport-lib/src/ws_settings.rs
// version: 4
const DEFAULT_WS_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const DEFAULT_WS_COMMAND_QUEUE_CAPACITY: usize = 128;
const DEFAULT_WS_COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const DEFAULT_WS_MAX_ACTIVE_SUBSCRIPTIONS: usize = 1_024;
const DEFAULT_WS_MAX_FRAME_SIZE_BYTES: usize = 16 * 1024 * 1024;
const DEFAULT_WS_MAX_MESSAGE_SIZE_BYTES: usize = 64 * 1024 * 1024;
const DEFAULT_WS_MAX_PENDING_REQUESTS: usize = 128;
const DEFAULT_WS_MAX_WRITE_BUFFER_SIZE_BYTES: usize = 1024 * 1024;
const DEFAULT_WS_NOTIFICATION_QUEUE_CAPACITY: usize = 256;
const DEFAULT_WS_RECONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
const DEFAULT_WS_RECONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
const DEFAULT_WS_RECONNECT_MAX_RETRIES: u32 = 5;
/// Runtime WebSocket endpoint URL owned by Transport.
///
/// The actual URL can contain provider credentials. Its [`std::fmt::Debug`] implementation is intentionally redacted.
#[derive(Clone, Eq, PartialEq)]
pub struct WsEndpointUrl {
value: std::string::String,
}
impl WsEndpointUrl {
/// Parses and validates one WebSocket endpoint URL.
pub fn parse(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating WebSocket endpoint URL");
let value = value.into();
let parsed = match reqwest::Url::parse(value.as_str()) {
std::result::Result::Ok(parsed) => parsed,
std::result::Result::Err(error) => {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "ws_endpoints.url", "rejected invalid WebSocket endpoint URL");
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL is invalid")
.with_context("field", "ws_endpoints.url")
.with_source(error),
);
},
};
if parsed.scheme() != "ws" && parsed.scheme() != "wss" {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
field = "ws_endpoints.url",
scheme = parsed.scheme(),
"rejected WebSocket endpoint URL with unsupported scheme"
);
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL must use ws or wss")
.with_context("field", "ws_endpoints.url")
.with_context("scheme", parsed.scheme()),
);
}
if parsed.host_str().is_none() {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "ws_endpoints.url", "rejected WebSocket endpoint URL without host");
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL must contain a host")
.with_context("field", "ws_endpoints.url"),
);
}
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, scheme = parsed.scheme(), "validated WebSocket endpoint URL syntax");
return std::result::Result::Ok(Self { value });
}
/// Returns the sensitive runtime URL text.
///
/// Callers must not write this value to logs, generic diagnostics or snapshots.
#[must_use]
pub fn as_str(&self) -> &str {
return self.value.as_str();
}
}
impl std::fmt::Debug for WsEndpointUrl {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("WsEndpointUrl(<redacted>)");
}
}
/// Open provider descriptor used by WebSocket endpoint settings.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct WsProviderName {
value: std::string::String,
}
impl WsProviderName {
/// Creates an open provider descriptor. Validation is performed by [`WsTransportSettings::validate`].
#[must_use]
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
return Self { value: value.into() };
}
/// Returns the provider descriptor text.
#[must_use]
pub fn as_str(&self) -> &str {
return self.value.as_str();
}
}
/// Open cluster or network descriptor used by WebSocket endpoint settings.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct WsClusterName {
value: std::string::String,
}
impl WsClusterName {
/// Creates an open cluster descriptor. Validation is performed by [`WsTransportSettings::validate`].
#[must_use]
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
return Self { value: value.into() };
}
/// Returns the cluster descriptor text.
#[must_use]
pub fn as_str(&self) -> &str {
return self.value.as_str();
}
}
/// WebSocket protocol family understood by KSP Transport.
///
/// The protocol discriminator belongs specifically to the WebSocket runtime. Provider products using another transport, including a future Helius
/// LaserStream gRPC backend, require a distinct transport-owned descriptor instead of reusing this enum.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum WsProtocolKind {
/// Standard Solana JSON-RPC WebSocket PubSub.
SolanaStandard,
/// Helius LaserStream WebSocket protocol surface.
HeliusLaserStream,
}
impl WsProtocolKind {
/// Returns the stable KSP descriptor for this protocol family.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::SolanaStandard => "solana_standard",
Self::HeliusLaserStream => "helius_laserstream",
};
}
}
/// Bounded reconnect settings owned by the WebSocket transport runtime.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsReconnectSettings {
max_retries: u32,
initial_backoff: std::time::Duration,
max_backoff: std::time::Duration,
}
impl WsReconnectSettings {
/// Creates bounded reconnect settings.
#[must_use]
pub const fn new(max_retries: u32, initial_backoff: std::time::Duration, max_backoff: std::time::Duration) -> Self {
return Self { max_retries, initial_backoff, max_backoff };
}
/// Returns the number of reconnect attempts allowed after the connection is lost.
#[must_use]
pub const fn max_retries(&self) -> u32 {
return self.max_retries;
}
/// Returns the initial reconnect backoff.
#[must_use]
pub const fn initial_backoff(&self) -> std::time::Duration {
return self.initial_backoff;
}
/// Returns the maximum reconnect backoff.
#[must_use]
pub const fn max_backoff(&self) -> std::time::Duration {
return self.max_backoff;
}
}
impl std::default::Default for WsReconnectSettings {
fn default() -> Self {
return Self::new(DEFAULT_WS_RECONNECT_MAX_RETRIES, DEFAULT_WS_RECONNECT_INITIAL_BACKOFF, DEFAULT_WS_RECONNECT_MAX_BACKOFF);
}
}
/// Policy controlling whether logical subscriptions are restored after a successful reconnect.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum WsResubscribePolicy {
/// Never restore subscriptions automatically after the physical connection is replaced.
Never,
/// Restore subscriptions that are still logically desired when reconnect completes.
#[default]
ActiveSubscriptions,
}
/// Runtime limits and lifecycle settings for one physical WebSocket session.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsSessionSettings {
command_timeout: std::time::Duration,
close_timeout: std::time::Duration,
reconnect: crate::WsReconnectSettings,
resubscribe: crate::WsResubscribePolicy,
command_queue_capacity: usize,
notification_queue_capacity: usize,
max_active_subscriptions: usize,
max_pending_requests: usize,
max_message_size_bytes: usize,
max_frame_size_bytes: usize,
max_write_buffer_size_bytes: usize,
}
impl WsSessionSettings {
/// Creates complete runtime settings for one physical WebSocket session.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub const fn new(
command_timeout: std::time::Duration,
close_timeout: std::time::Duration,
reconnect: crate::WsReconnectSettings,
resubscribe: crate::WsResubscribePolicy,
command_queue_capacity: usize,
notification_queue_capacity: usize,
max_active_subscriptions: usize,
max_pending_requests: usize,
max_message_size_bytes: usize,
max_frame_size_bytes: usize,
max_write_buffer_size_bytes: usize,
) -> Self {
return Self {
command_timeout,
close_timeout,
reconnect,
resubscribe,
command_queue_capacity,
notification_queue_capacity,
max_active_subscriptions,
max_pending_requests,
max_message_size_bytes,
max_frame_size_bytes,
max_write_buffer_size_bytes,
};
}
/// Returns the deadline applied to bounded session commands and JSON-RPC control requests.
#[must_use]
pub const fn command_timeout(&self) -> std::time::Duration {
return self.command_timeout;
}
/// Returns the total bounded close/shutdown deadline.
#[must_use]
pub const fn close_timeout(&self) -> std::time::Duration {
return self.close_timeout;
}
/// Returns the reconnect policy.
#[must_use]
pub const fn reconnect(&self) -> &crate::WsReconnectSettings {
return &self.reconnect;
}
/// Returns the resubscribe policy.
#[must_use]
pub const fn resubscribe(&self) -> crate::WsResubscribePolicy {
return self.resubscribe;
}
/// Returns the bounded session-command queue capacity.
#[must_use]
pub const fn command_queue_capacity(&self) -> usize {
return self.command_queue_capacity;
}
/// Returns the bounded notification queue capacity allocated per logical subscription.
#[must_use]
pub const fn notification_queue_capacity(&self) -> usize {
return self.notification_queue_capacity;
}
/// Returns the maximum number of logical subscriptions allowed on one physical session.
#[must_use]
pub const fn max_active_subscriptions(&self) -> usize {
return self.max_active_subscriptions;
}
/// Returns the maximum number of JSON-RPC requests allowed to await responses concurrently.
#[must_use]
pub const fn max_pending_requests(&self) -> usize {
return self.max_pending_requests;
}
/// Returns the maximum accepted complete WebSocket message size in bytes.
#[must_use]
pub const fn max_message_size_bytes(&self) -> usize {
return self.max_message_size_bytes;
}
/// Returns the maximum accepted WebSocket frame size in bytes.
#[must_use]
pub const fn max_frame_size_bytes(&self) -> usize {
return self.max_frame_size_bytes;
}
/// Returns the maximum WebSocket write-buffer size in bytes.
#[must_use]
pub const fn max_write_buffer_size_bytes(&self) -> usize {
return self.max_write_buffer_size_bytes;
}
/// Validates runtime bounds without reading Config or environment state.
pub fn validate(&self) -> ksp_core_lib::Result<()> {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating WebSocket session settings");
if self.command_timeout.is_zero() {
return ws_invalid_settings("WebSocket command timeout must be greater than zero", "ws_session.command_timeout");
}
if self.close_timeout.is_zero() {
return ws_invalid_settings("WebSocket close timeout must be greater than zero", "ws_session.close_timeout");
}
if self.reconnect.initial_backoff().is_zero() {
return ws_invalid_settings("initial WebSocket reconnect backoff must be greater than zero", "ws_session.reconnect.initial_backoff");
}
if self.reconnect.max_backoff().is_zero() {
return ws_invalid_settings("maximum WebSocket reconnect backoff must be greater than zero", "ws_session.reconnect.max_backoff");
}
if self.reconnect.max_backoff() < self.reconnect.initial_backoff() {
return ws_invalid_settings(
"maximum WebSocket reconnect backoff must not be lower than initial reconnect backoff",
"ws_session.reconnect.max_backoff",
);
}
if let std::result::Result::Err(error) = validate_non_zero_bound(self.command_queue_capacity, "ws_session.command_queue_capacity") {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = validate_non_zero_bound(self.notification_queue_capacity, "ws_session.notification_queue_capacity") {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_active_subscriptions, "ws_session.max_active_subscriptions") {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_pending_requests, "ws_session.max_pending_requests") {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_message_size_bytes, "ws_session.max_message_size_bytes") {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_frame_size_bytes, "ws_session.max_frame_size_bytes") {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_write_buffer_size_bytes, "ws_session.max_write_buffer_size_bytes") {
return std::result::Result::Err(error);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
command_queue_capacity = self.command_queue_capacity,
notification_queue_capacity = self.notification_queue_capacity,
max_active_subscriptions = self.max_active_subscriptions,
max_pending_requests = self.max_pending_requests,
max_message_size_bytes = self.max_message_size_bytes,
max_frame_size_bytes = self.max_frame_size_bytes,
max_write_buffer_size_bytes = self.max_write_buffer_size_bytes,
reconnect_max_retries = self.reconnect.max_retries(),
resubscribe = self.resubscribe.as_str(),
"validated WebSocket session settings"
);
return std::result::Result::Ok(());
}
}
impl std::default::Default for WsSessionSettings {
fn default() -> Self {
return Self::new(
DEFAULT_WS_COMMAND_TIMEOUT,
DEFAULT_WS_CLOSE_TIMEOUT,
crate::WsReconnectSettings::default(),
crate::WsResubscribePolicy::default(),
DEFAULT_WS_COMMAND_QUEUE_CAPACITY,
DEFAULT_WS_NOTIFICATION_QUEUE_CAPACITY,
DEFAULT_WS_MAX_ACTIVE_SUBSCRIPTIONS,
DEFAULT_WS_MAX_PENDING_REQUESTS,
DEFAULT_WS_MAX_MESSAGE_SIZE_BYTES,
DEFAULT_WS_MAX_FRAME_SIZE_BYTES,
DEFAULT_WS_MAX_WRITE_BUFFER_SIZE_BYTES,
);
}
}
impl WsResubscribePolicy {
/// Returns the stable KSP descriptor for this policy.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Never => "never",
Self::ActiveSubscriptions => "active_subscriptions",
};
}
}
/// Runtime settings for one named WebSocket endpoint.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsEndpointSettings {
name: std::string::String,
enabled: bool,
provider: crate::WsProviderName,
cluster: crate::WsClusterName,
protocol: crate::WsProtocolKind,
url: crate::WsEndpointUrl,
session: crate::WsSessionSettings,
}
impl WsEndpointSettings {
/// Creates explicit settings for one logical WebSocket endpoint.
#[must_use]
pub fn new(
name: impl std::convert::Into<std::string::String>,
enabled: bool,
provider: crate::WsProviderName,
cluster: crate::WsClusterName,
protocol: crate::WsProtocolKind,
url: crate::WsEndpointUrl,
session: crate::WsSessionSettings,
) -> Self {
return Self { name: name.into(), enabled, provider, cluster, protocol, url, session };
}
/// Returns the logical endpoint name.
#[must_use]
pub fn name(&self) -> &str {
return self.name.as_str();
}
/// Returns whether this endpoint can be used to create physical sessions.
#[must_use]
pub const fn enabled(&self) -> bool {
return self.enabled;
}
/// Returns the open provider descriptor.
#[must_use]
pub const fn provider(&self) -> &crate::WsProviderName {
return &self.provider;
}
/// Returns the open cluster descriptor.
#[must_use]
pub const fn cluster(&self) -> &crate::WsClusterName {
return &self.cluster;
}
/// Returns the WebSocket protocol family.
#[must_use]
pub const fn protocol(&self) -> crate::WsProtocolKind {
return self.protocol;
}
/// Returns the sensitive WebSocket endpoint URL wrapper.
#[must_use]
pub const fn url(&self) -> &crate::WsEndpointUrl {
return &self.url;
}
/// Returns the effective settings applied to every physical session explicitly created from this endpoint.
#[must_use]
pub const fn session(&self) -> &crate::WsSessionSettings {
return &self.session;
}
}
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WsTransportSettings {
endpoints: std::vec::Vec<crate::WsEndpointSettings>,
}
impl WsTransportSettings {
/// Creates complete WebSocket transport runtime settings.
#[must_use]
pub fn new(endpoints: std::vec::Vec<crate::WsEndpointSettings>) -> Self {
return Self { endpoints };
}
/// Returns configured WebSocket endpoints in declaration order.
#[must_use]
pub fn endpoints(&self) -> &[crate::WsEndpointSettings] {
return self.endpoints.as_slice();
}
/// Validates structural runtime invariants without reading Config or environment state.
pub fn validate(&self) -> ksp_core_lib::Result<()> {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, endpoint_count = self.endpoints.len(), "validating WebSocket transport settings");
if self.endpoints.is_empty() {
return ws_invalid_settings("at least one WebSocket endpoint must be configured", "ws_endpoints");
}
let mut enabled_endpoint_count = 0_usize;
for (endpoint_index, endpoint) in self.endpoints.iter().enumerate() {
if let std::result::Result::Err(error) = validate_ws_endpoint(endpoint, endpoint_index) {
return std::result::Result::Err(error);
}
if endpoint.enabled() {
enabled_endpoint_count += 1;
}
for previous in &self.endpoints[..endpoint_index] {
if previous.name() == endpoint.name() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint names must be unique")
.with_context("field", format!("ws_endpoints[{endpoint_index}].name"))
.with_context("endpoint_name", endpoint.name()),
);
}
}
}
if enabled_endpoint_count == 0 {
return ws_invalid_settings("at least one WebSocket endpoint must be enabled", "ws_endpoints.enabled");
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
endpoint_count = self.endpoints.len(),
enabled_endpoint_count,
"validated WebSocket transport settings"
);
return std::result::Result::Ok(());
}
}
fn validate_ws_endpoint(endpoint: &crate::WsEndpointSettings, endpoint_index: usize) -> ksp_core_lib::Result<()> {
let name_field = format!("ws_endpoints[{endpoint_index}].name");
if let std::result::Result::Err(error) = validate_ws_descriptor(endpoint.name(), name_field.as_str()) {
return std::result::Result::Err(error);
}
let provider_field = format!("ws_endpoints[{endpoint_index}].provider");
if let std::result::Result::Err(error) = validate_ws_descriptor(endpoint.provider().as_str(), provider_field.as_str()) {
return std::result::Result::Err(error);
}
let cluster_field = format!("ws_endpoints[{endpoint_index}].cluster");
if let std::result::Result::Err(error) = validate_ws_descriptor(endpoint.cluster().as_str(), cluster_field.as_str()) {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = endpoint.session().validate() {
return std::result::Result::Err(error);
}
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
endpoint_name = endpoint.name(),
provider = endpoint.provider().as_str(),
cluster = endpoint.cluster().as_str(),
protocol = endpoint.protocol().as_str(),
enabled = endpoint.enabled(),
"validated WebSocket endpoint settings"
);
return std::result::Result::Ok(());
}
fn validate_ws_descriptor(value: &str, field: &str) -> ksp_core_lib::Result<()> {
if value.trim().is_empty() {
return ws_invalid_settings("WebSocket transport descriptor must not be empty", field);
}
if value.trim() != value {
return ws_invalid_settings("WebSocket transport descriptor must not contain leading or trailing whitespace", field);
}
return std::result::Result::Ok(());
}
fn validate_non_zero_bound(value: usize, field: &str) -> ksp_core_lib::Result<()> {
if value == 0 {
return ws_invalid_settings("WebSocket runtime bound must be greater than zero", field);
}
return std::result::Result::Ok(());
}
fn ws_invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, reason = message, "rejected WebSocket transport settings");
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field));
}
#[cfg(test)]
#[path = "../unit_tests/ws_settings.rs"]
mod tests;

View File

@@ -0,0 +1,246 @@
// file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs
// version: 6
/// Typed handle for one logical WebSocket subscription.
///
/// The handle owns the bounded typed notification receiver while the physical session actor owns the remote subscription identity and socket. The remote
/// numeric subscription identifier is intentionally never exposed because it is transient and is remapped by the session actor after reconnect.
pub struct WsSubscription<T> {
session_id: crate::WsSessionId,
id: crate::WsSubscriptionId,
kind: crate::WsSubscriptionKind,
notification_rx: tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>,
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
command_tx: tokio::sync::mpsc::Sender<crate::WsSessionCommand>,
command_timeout: std::time::Duration,
}
impl<T> WsSubscription<T> {
/// Creates a typed subscription handle from one actor registration.
pub(crate) fn new(
session_id: crate::WsSessionId,
registration: WsSubscriptionRegistration,
notification_rx: tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>,
command_tx: tokio::sync::mpsc::Sender<crate::WsSessionCommand>,
command_timeout: std::time::Duration,
) -> Self {
return Self {
session_id,
id: registration.id,
kind: registration.kind,
notification_rx,
state_rx: registration.state_rx,
terminal_error_rx: registration.terminal_error_rx,
command_tx,
command_timeout,
};
}
/// Returns the stable local logical subscription identity.
#[must_use]
pub const fn id(&self) -> crate::WsSubscriptionId {
return self.id;
}
/// Returns the logical WebSocket subscription family.
#[must_use]
pub const fn kind(&self) -> crate::WsSubscriptionKind {
return self.kind;
}
/// Returns the latest observable lifecycle state for this logical subscription.
#[must_use]
pub fn state(&self) -> crate::WsSubscriptionState {
return *self.state_rx.borrow();
}
/// Returns the safe terminal error code when this logical subscription failed.
///
/// Successful local cancellation and normal completion use `None`. The value never contains remote payloads or endpoint credentials.
#[must_use]
pub fn terminal_error_code(&self) -> std::option::Option<ksp_core_lib::ErrorCode> {
return *self.terminal_error_rx.borrow();
}
/// Receives the next typed notification or terminal typed-decoding error.
///
/// The underlying queue is bounded by `WsSessionSettings::notification_queue_capacity`. `None` means the actor closed this logical subscription and no
/// further notifications can arrive.
pub async fn recv(&mut self) -> std::option::Option<ksp_core_lib::Result<T>> {
return self.notification_rx.recv().await;
}
/// Cancels this logical subscription and sends the matching protocol unsubscribe request when a remote binding still exists.
///
/// The returned boolean preserves the standard Solana unsubscribe result when the current remote binding is reachable. Local cancellation is terminal
/// for this handle; during reconnect it wins before resubscribe selection, and a late remote acknowledgement is cleaned up best-effort without
/// reactivation.
pub async fn unsubscribe(&mut self) -> ksp_core_lib::Result<bool> {
match self.state() {
crate::WsSubscriptionState::Closed => return std::result::Result::Ok(false),
crate::WsSubscriptionState::Failed => {
return std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket subscription is already failed"));
},
_ => {},
}
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let command = crate::WsSessionCommand::Unsubscribe { subscription_id: self.id, response_tx };
let send_wait = tokio::time::timeout(self.command_timeout, self.command_tx.send(command)).await;
match send_wait {
std::result::Result::Ok(std::result::Result::Ok(())) => {},
std::result::Result::Ok(std::result::Result::Err(_)) => {
return std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket session command channel is closed"));
},
std::result::Result::Err(_) => {
return std::result::Result::Err(subscription_timeout_error(
self.session_id,
self.id,
"WebSocket unsubscribe command queue remained unavailable until timeout",
));
},
}
return match response_rx.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => {
std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket session ended before unsubscribe completion"))
},
};
}
}
impl<T> std::fmt::Debug for WsSubscription<T> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("WsSubscription")
.field("session_id", &self.session_id)
.field("id", &self.id)
.field("kind", &self.kind)
.field("state", &self.state())
.finish();
}
}
/// Internal result of dispatching one decoded wire notification into a bounded typed channel.
pub(crate) enum WsNotificationDispatchOutcome {
Delivered,
DeliveredTerminal,
ReceiverClosed,
QueueFull,
DecodeFailed { code: ksp_core_lib::ErrorCode },
}
/// Type-erased actor-owned dispatcher for one heterogeneous typed notification channel.
pub(crate) type WsNotificationDispatcher = std::boxed::Box<dyn Fn(serde_json::Value) -> WsNotificationDispatchOutcome + std::marker::Send + std::marker::Sync>;
/// Creates one bounded typed notification receiver whose dispatcher can mark a successfully delivered value as terminal.
pub(crate) fn typed_notification_channel_with_completion<T, F, C>(
capacity: usize,
decoder: F,
is_terminal: C,
) -> (WsNotificationDispatcher, tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>)
where
T: std::marker::Send + 'static,
F: Fn(serde_json::Value) -> ksp_core_lib::Result<T> + std::marker::Send + std::marker::Sync + 'static,
C: Fn(&T) -> bool + std::marker::Send + std::marker::Sync + 'static,
{
let (notification_tx, notification_rx) = tokio::sync::mpsc::channel(capacity);
let dispatcher = move |value: serde_json::Value| -> WsNotificationDispatchOutcome {
let permit = match notification_tx.try_reserve() {
std::result::Result::Ok(permit) => permit,
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
return WsNotificationDispatchOutcome::ReceiverClosed;
},
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
return WsNotificationDispatchOutcome::QueueFull;
},
};
return match decoder(value) {
std::result::Result::Ok(notification) => {
let terminal = is_terminal(&notification);
permit.send(std::result::Result::Ok(notification));
if terminal {
return WsNotificationDispatchOutcome::DeliveredTerminal;
}
WsNotificationDispatchOutcome::Delivered
},
std::result::Result::Err(error) => {
let code = error.code();
permit.send(std::result::Result::Err(error));
WsNotificationDispatchOutcome::DecodeFailed { code }
},
};
};
return (std::boxed::Box::new(dispatcher), notification_rx);
}
/// Internal registration returned after a remote subscribe acknowledgement becomes atomically bound.
pub(crate) struct WsSubscriptionRegistration {
id: crate::WsSubscriptionId,
kind: crate::WsSubscriptionKind,
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
}
impl WsSubscriptionRegistration {
/// Creates one successful actor registration without exposing the transient remote identifier.
pub(crate) fn new(
id: crate::WsSubscriptionId,
kind: crate::WsSubscriptionKind,
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
) -> Self {
return Self { id, kind, state_rx, terminal_error_rx };
}
}
/// Actor-owned runtime entry for one local logical WebSocket subscription.
pub(crate) struct WsSubscriptionRuntime {
/// Stable local identity.
pub(crate) id: crate::WsSubscriptionId,
/// WebSocket subscription family.
pub(crate) kind: crate::WsSubscriptionKind,
/// Current logical lifecycle state.
pub(crate) state: crate::WsSubscriptionState,
/// Original subscribe parameters retained internally for deterministic resubscribe.
pub(crate) params: std::vec::Vec<serde_json::Value>,
/// Current transient remote subscription identity when bound.
pub(crate) remote_id: std::option::Option<u64>,
/// Lifecycle publisher observed by the public typed handle.
pub(crate) state_tx: tokio::sync::watch::Sender<crate::WsSubscriptionState>,
/// Safe terminal failure code publisher observed by the public typed handle.
pub(crate) terminal_error_tx: tokio::sync::watch::Sender<std::option::Option<ksp_core_lib::ErrorCode>>,
/// Type-erased dispatcher into the bounded typed notification channel.
pub(crate) dispatcher: WsNotificationDispatcher,
}
impl WsSubscriptionRuntime {
/// Builds the safe session-snapshot projection for this runtime entry.
pub(crate) fn snapshot(&self) -> crate::WsSubscriptionSnapshot {
return crate::WsSubscriptionSnapshot::new(self.id, self.kind, self.state, self.remote_id.is_some(), *self.terminal_error_tx.borrow());
}
/// Updates the runtime state and publishes it to the typed handle.
pub(crate) fn set_state(&mut self, state: crate::WsSubscriptionState) {
self.state = state;
self.state_tx.send_replace(state);
}
/// Publishes a terminal failure code before moving the logical subscription to `Failed`.
pub(crate) fn fail_with_code(&mut self, code: ksp_core_lib::ErrorCode) {
self.terminal_error_tx.send_replace(std::option::Option::Some(code));
self.set_state(crate::WsSubscriptionState::Failed);
}
}
fn subscription_closed_error(session_id: crate::WsSessionId, subscription_id: crate::WsSubscriptionId, message: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_WS_SESSION_CLOSED, message)
.with_context("session_id", session_id.get().to_string())
.with_context("subscription_id", subscription_id.get().to_string());
}
fn subscription_timeout_error(session_id: crate::WsSessionId, subscription_id: crate::WsSubscriptionId, message: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message)
.with_context("session_id", session_id.get().to_string())
.with_context("subscription_id", subscription_id.get().to_string());
}

View File

@@ -0,0 +1,291 @@
// file: crates/ksp-onchain-transport-lib/src/ws_transactions.rs
// version: 4
/// Optional configuration accepted by standard Solana `signatureSubscribe`.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaSignatureSubscribeConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
enable_received_notification: std::option::Option<bool>,
}
impl SolanaSignatureSubscribeConfig {
/// Creates an explicit signature-subscription configuration.
#[must_use]
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, enable_received_notification: std::option::Option<bool>) -> Self {
return Self { commitment, enable_received_notification };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns whether the server should emit the early `receivedSignature` notification when explicitly configured.
#[must_use]
pub const fn enable_received_notification(&self) -> std::option::Option<bool> {
return self.enable_received_notification;
}
fn is_empty(&self) -> bool {
return self.commitment.is_none() && self.enable_received_notification.is_none();
}
fn to_json_value(self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(enable_received_notification) = self.enable_received_notification {
object.insert("enableReceivedNotification".to_owned(), serde_json::Value::Bool(enable_received_notification));
}
return serde_json::Value::Object(object);
}
}
/// Typed value carried by standard Solana `signatureNotification` messages.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaSignatureNotification {
/// Early notification emitted when the RPC node first receives the signature and `enableReceivedNotification` is enabled.
ReceivedSignature,
/// Terminal processing notification emitted when the configured commitment is reached.
Processed {
/// Nullable transaction-error wire value; `None` means the transaction succeeded at the requested commitment.
err: std::option::Option<serde_json::Value>,
},
}
impl SolanaSignatureNotification {
/// Returns whether this notification terminates the server-side one-shot subscription.
#[must_use]
pub const fn is_terminal(&self) -> bool {
return match self {
Self::ReceivedSignature => false,
Self::Processed { .. } => true,
};
}
/// Returns the transaction-error wire value for a terminal processing notification when present.
#[must_use]
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
return match self {
Self::ReceivedSignature | Self::Processed { err: std::option::Option::None } => std::option::Option::None,
Self::Processed { err: std::option::Option::Some(err) } => std::option::Option::Some(err),
};
}
}
/// Filter accepted by the standard Solana `logsSubscribe` WebSocket method.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaLogsSubscribeFilter {
/// Subscribe to all transactions except simple vote transactions.
All,
/// Subscribe to all transactions including simple vote transactions.
AllWithVotes,
/// Subscribe only to transactions mentioning exactly one public key.
Mentions(ksp_core_lib::Pubkey),
}
impl SolanaLogsSubscribeFilter {
fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::All => serde_json::Value::String("all".to_owned()),
Self::AllWithVotes => serde_json::Value::String("allWithVotes".to_owned()),
Self::Mentions(pubkey) => serde_json::json!({"mentions": [pubkey.to_string()]}),
};
}
}
/// Typed value carried by a contextual Solana `logsNotification`.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaLogsNotification {
signature: std::string::String,
err: std::option::Option<serde_json::Value>,
logs: std::vec::Vec<std::string::String>,
}
impl SolanaLogsNotification {
/// Returns the base58 transaction signature exactly as reported by the RPC node.
#[must_use]
pub fn signature(&self) -> &str {
return self.signature.as_str();
}
/// Returns the nullable transaction-error wire value without interpreting Program/runtime error semantics.
#[must_use]
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
return self.err.as_ref();
}
/// Returns the ordered transaction log messages.
#[must_use]
pub fn logs(&self) -> &[std::string::String] {
return self.logs.as_slice();
}
}
impl crate::WsSession {
/// Subscribes to one Solana transaction signature through standard `signatureSubscribe`.
///
/// The server automatically terminates this subscription after the terminal processed notification. When
/// `enableReceivedNotification` is enabled, an earlier `ReceivedSignature` value may be delivered first without closing the logical handle.
pub async fn signature_subscribe(
&self,
signature: &str,
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
let mut params = std::vec![serde_json::Value::String(signature.to_owned())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push((*config).to_json_value());
}
return self
.subscribe_typed_with_completion(
crate::WsSubscriptionKind::Signature,
params,
|value| return decode_signature_notification("signatureSubscribe", value),
|notification| return notification.value().is_terminal(),
)
.await;
}
/// Subscribes to Solana transaction logs through standard `logsSubscribe`.
pub async fn logs_subscribe(
&self,
filter: &crate::SolanaLogsSubscribeFilter,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaLogsNotification>>> {
let mut params = std::vec![filter.to_json_value()];
if let std::option::Option::Some(config) = config
&& config.commitment().is_some()
{
params.push(config.to_json_value());
}
return self.subscribe_typed(crate::WsSubscriptionKind::Logs, params, |value| return decode_logs_notification("logsSubscribe", value)).await;
}
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireSignatureNotification {
Received(std::string::String),
Processed(WireSignatureProcessed),
}
#[derive(serde::Deserialize)]
struct WireSignatureProcessed {
err: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct WireRpcResponseSignature {
context: serde_json::Value,
value: WireSignatureNotification,
}
#[derive(serde::Deserialize)]
struct WireRpcResponse {
context: serde_json::Value,
value: WireLogsNotification,
}
#[derive(serde::Deserialize)]
struct WireLogsNotification {
signature: std::string::String,
err: serde_json::Value,
logs: std::vec::Vec<std::string::String>,
}
fn decode_signature_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>> {
let decoded = crate::decode_wire_json::<WireRpcResponseSignature>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let notification = match wire.value {
WireSignatureNotification::Received(value) if value == "receivedSignature" => crate::SolanaSignatureNotification::ReceivedSignature,
WireSignatureNotification::Received(_) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "signatureSubscribe notification contains an unknown string variant")
.with_context("rpc_method", method),
);
},
WireSignatureNotification::Processed(processed) => {
let err = match processed.err {
serde_json::Value::Null => std::option::Option::None,
value => std::option::Option::Some(value),
};
crate::SolanaSignatureNotification::Processed { err }
},
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, notification));
}
fn decode_logs_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaLogsNotification>> {
let decoded = crate::decode_wire_json::<WireRpcResponse>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let err = match wire.value.err {
serde_json::Value::Null => std::option::Option::None,
value => std::option::Option::Some(value),
};
let notification = crate::SolanaLogsNotification { signature: wire.value.signature, err, logs: wire.value.logs };
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, notification));
}
impl crate::SolanaStandardWsSession {
/// Subscribes to one Solana transaction signature through standard `signatureSubscribe`.
pub async fn signature_subscribe(
&self,
signature: &str,
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
return self.physical_session().signature_subscribe(signature, config).await;
}
/// Subscribes to Solana transaction logs through standard `logsSubscribe`.
pub async fn logs_subscribe(
&self,
filter: &crate::SolanaLogsSubscribeFilter,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaLogsNotification>>> {
return self.physical_session().logs_subscribe(filter, config).await;
}
}
impl crate::HeliusLaserStreamWsSession {
/// Subscribes to one transaction signature through the standard `signatureSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn signature_subscribe(
&self,
signature: &str,
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
return self.physical_session().signature_subscribe(signature, config).await;
}
/// Subscribes to transaction logs through the standard `logsSubscribe` wire supported by Helius LaserStream WebSocket.
pub async fn logs_subscribe(
&self,
filter: &crate::SolanaLogsSubscribeFilter,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaLogsNotification>>> {
return self.physical_session().logs_subscribe(filter, config).await;
}
}
#[cfg(test)]
#[path = "../unit_tests/ws_transactions.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs // file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 24 // version: 38
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -521,3 +521,242 @@ fn public_v0_2_4_pre_009_all_52_current_typed_wrappers_and_legacy_forms_are_avai
let _get_block_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_block_legacy; let _get_block_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_block_legacy;
let _get_transaction_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_legacy; let _get_transaction_legacy = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_legacy;
} }
#[test]
fn public_v0_2_7_pre_002_websocket_settings_and_lifecycle_contracts_are_available_from_crate_root() {
let url = ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com").expect("public WebSocket URL fixture must parse");
let session_settings = ksp_onchain_transport_lib::WsSessionSettings::default();
let endpoint = ksp_onchain_transport_lib::WsEndpointSettings::new(
"devnet_public",
true,
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
url,
session_settings,
);
let settings = ksp_onchain_transport_lib::WsTransportSettings::new(std::vec![endpoint]);
assert!(settings.validate().is_ok());
assert_eq!(settings.endpoints()[0].protocol(), ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard);
let session_id = ksp_onchain_transport_lib::WsSessionId::new(std::num::NonZeroU64::new(1).expect("public test ID must be non-zero"));
let subscription_id = ksp_onchain_transport_lib::WsSubscriptionId::new(std::num::NonZeroU64::new(2).expect("public test ID must be non-zero"));
assert_eq!(session_id.get(), 1);
assert_eq!(subscription_id.get(), 2);
assert_eq!(
ksp_onchain_transport_lib::WsSessionState::Reconnecting { attempt: 1 },
ksp_onchain_transport_lib::WsSessionState::Reconnecting { attempt: 1 }
);
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::Slot.as_str(), "slot");
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionState::Requested, ksp_onchain_transport_lib::WsSubscriptionState::Requested);
}
#[test]
fn public_v0_2_7_pre_004_physical_websocket_session_contract_is_available_from_crate_root() {
let type_name = std::any::type_name::<ksp_onchain_transport_lib::WsSession>();
assert!(type_name.ends_with("WsSession"));
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW.code(), "ws_backpressure_overflow");
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_CONNECTION_FAILED.code(), "ws_connection_failed");
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_PROTOCOL_ERROR.code(), "ws_protocol_error");
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_SESSION_CLOSED.code(), "ws_session_closed");
}
#[test]
fn public_v0_2_7_pre_005_bounded_websocket_close_contract_is_available_from_crate_root() {
let _close = ksp_onchain_transport_lib::WsSession::close;
assert_eq!(ksp_onchain_transport_lib::WsSessionState::Closing, ksp_onchain_transport_lib::WsSessionState::Closing);
assert_eq!(ksp_onchain_transport_lib::WsSessionState::Closed, ksp_onchain_transport_lib::WsSessionState::Closed);
}
#[test]
fn public_v0_2_7_pre_006_typed_websocket_subscription_handle_is_available_from_crate_root() {
let type_name = std::any::type_name::<ksp_onchain_transport_lib::WsSubscription<serde_json::Value>>();
assert!(type_name.contains("WsSubscription"));
let _id = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::id;
let _kind = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::kind;
let _state = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::state;
let _recv = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::recv;
let _unsubscribe = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::unsubscribe;
}
#[test]
fn public_v0_2_7_pre_008_backpressure_observability_contract_is_available_from_crate_root() {
let _terminal_error = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::terminal_error_code;
let _snapshot_terminal_error = ksp_onchain_transport_lib::WsSubscriptionSnapshot::terminal_error_code;
let _overflow_count = ksp_onchain_transport_lib::WsSessionSnapshot::overflow_count;
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW.code(), "ws_backpressure_overflow");
}
#[test]
fn public_v0_2_7_pre_009_stable_websocket_lot_a_wrappers_and_dtos_are_available_from_crate_root() {
let _account_subscribe = ksp_onchain_transport_lib::WsSession::account_subscribe;
let _program_subscribe = ksp_onchain_transport_lib::WsSession::program_subscribe;
let _logs_subscribe = ksp_onchain_transport_lib::WsSession::logs_subscribe;
let account_config = ksp_onchain_transport_lib::SolanaAccountSubscribeConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaDataSliceConfig::new(0, 32)),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
);
assert_eq!(account_config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
let program_config = ksp_onchain_transport_lib::SolanaProgramSubscribeConfig::new(
account_config,
std::vec![ksp_onchain_transport_lib::SolanaProgramAccountFilter::DataSize(80)],
std::option::Option::Some(true),
);
assert_eq!(program_config.with_context(), std::option::Option::Some(true));
let mention = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("public logs mention fixture must parse");
let filter = ksp_onchain_transport_lib::SolanaLogsSubscribeFilter::Mentions(mention);
let filter_name = std::any::type_name_of_val(&filter);
assert!(filter_name.ends_with("SolanaLogsSubscribeFilter"));
let _program_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaProgramNotification>();
let _logs_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaLogsNotification>();
}
#[test]
fn public_v0_2_7_pre_010_stable_websocket_lot_b_wrappers_and_dtos_are_available_from_crate_root() {
let _signature_subscribe = ksp_onchain_transport_lib::WsSession::signature_subscribe;
let _slot_subscribe = ksp_onchain_transport_lib::WsSession::slot_subscribe;
let _root_subscribe = ksp_onchain_transport_lib::WsSession::root_subscribe;
let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::Some(true),
);
assert_eq!(signature_config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized));
assert_eq!(signature_config.enable_received_notification(), std::option::Option::Some(true));
let terminal = ksp_onchain_transport_lib::SolanaSignatureNotification::Processed { err: std::option::Option::None };
assert!(terminal.is_terminal());
assert!(terminal.err().is_none());
let _slot_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotNotification>();
}
#[test]
fn public_v0_2_7_pre_011_unstable_websocket_wrappers_and_dtos_are_available_from_crate_root() {
let _block_subscribe = ksp_onchain_transport_lib::WsSession::block_subscribe;
let _slots_updates_subscribe = ksp_onchain_transport_lib::WsSession::slots_updates_subscribe;
let _vote_subscribe = ksp_onchain_transport_lib::WsSession::vote_subscribe;
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let filter = ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(pubkey);
assert!(matches!(filter, ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(_)));
let config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full),
std::option::Option::Some(0),
std::option::Option::Some(true),
);
assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
assert_eq!(config.show_rewards(), std::option::Option::Some(true));
let _block_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaBlockNotification>();
let _slot_update = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotUpdate>();
let _slot_update_stats = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotUpdateStats>();
let _vote_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaVoteNotification>();
}
#[test]
fn public_v0_2_7_pre_012_complete_standard_websocket_surface_is_available_from_crate_root() {
let _account = ksp_onchain_transport_lib::WsSession::account_subscribe;
let _block = ksp_onchain_transport_lib::WsSession::block_subscribe;
let _logs = ksp_onchain_transport_lib::WsSession::logs_subscribe;
let _program = ksp_onchain_transport_lib::WsSession::program_subscribe;
let _root = ksp_onchain_transport_lib::WsSession::root_subscribe;
let _signature = ksp_onchain_transport_lib::WsSession::signature_subscribe;
let _slot = ksp_onchain_transport_lib::WsSession::slot_subscribe;
let _slots_updates = ksp_onchain_transport_lib::WsSession::slots_updates_subscribe;
let _vote = ksp_onchain_transport_lib::WsSession::vote_subscribe;
let _unsubscribe = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::unsubscribe;
let kinds = [
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
];
assert_eq!(kinds.len(), 9);
}
#[test]
fn public_v0_2_8_pre_002_protocol_facades_are_available_without_replacing_the_standard_session_contract() {
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
let _historical_connect = ksp_onchain_transport_lib::WsSession::connect;
let _standard_connect = ksp_onchain_transport_lib::SolanaStandardWsSession::connect;
let _standard_close = ksp_onchain_transport_lib::SolanaStandardWsSession::close;
let _standard_snapshot = ksp_onchain_transport_lib::SolanaStandardWsSession::snapshot;
let _standard_account = ksp_onchain_transport_lib::SolanaStandardWsSession::account_subscribe;
let _standard_block = ksp_onchain_transport_lib::SolanaStandardWsSession::block_subscribe;
let _standard_logs = ksp_onchain_transport_lib::SolanaStandardWsSession::logs_subscribe;
let _standard_program = ksp_onchain_transport_lib::SolanaStandardWsSession::program_subscribe;
let _standard_root = ksp_onchain_transport_lib::SolanaStandardWsSession::root_subscribe;
let _standard_signature = ksp_onchain_transport_lib::SolanaStandardWsSession::signature_subscribe;
let _standard_slot = ksp_onchain_transport_lib::SolanaStandardWsSession::slot_subscribe;
let _standard_slots_updates = ksp_onchain_transport_lib::SolanaStandardWsSession::slots_updates_subscribe;
let _standard_vote = ksp_onchain_transport_lib::SolanaStandardWsSession::vote_subscribe;
let _helius_connect = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect;
let _helius_close = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::close;
let _helius_snapshot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::snapshot;
}
#[test]
fn public_v0_2_8_pre_003_helius_standard_surface_reuses_shared_typed_contracts() {
let _account = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::account_subscribe;
let _program = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::program_subscribe;
let _logs = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::logs_subscribe;
let _signature = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::signature_subscribe;
let _slot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slot_subscribe;
let _root = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::root_subscribe;
let _shared_account_config = std::any::type_name::<ksp_onchain_transport_lib::SolanaAccountSubscribeConfig>();
let _shared_program_config = std::any::type_name::<ksp_onchain_transport_lib::SolanaProgramSubscribeConfig>();
let _shared_logs_filter = std::any::type_name::<ksp_onchain_transport_lib::SolanaLogsSubscribeFilter>();
let _shared_signature_config = std::any::type_name::<ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig>();
let _shared_slot_notification = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotNotification>();
}
#[test]
fn public_v0_2_8_pre_005_helius_transaction_request_contract_is_available_without_live_handle() {
let account = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let filter = ksp_onchain_transport_lib::HeliusTransactionSubscribeFilter::new(
std::option::Option::Some(false),
std::option::Option::Some(false),
std::option::Option::Some("fixture-signature".to_owned()),
std::option::Option::Some(std::vec![account]),
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged),
);
let options = ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::JsonParsed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full),
std::option::Option::Some(false),
std::option::Option::Some(0),
);
let request = ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
assert!(request.validate().is_ok());
assert_eq!(request.filter().token_accounts(), std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged));
assert_eq!(
request.options().and_then(ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::encoding),
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::JsonParsed)
);
assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::None.as_str(), "none");
assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::BalanceChanged.as_str(), "balanceChanged");
assert_eq!(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::All.as_str(), "all");
}
#[test]
fn public_v0_2_8_pre_006_helius_transaction_live_handle_and_notification_types_are_available() {
let _subscribe = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe;
let _notification = std::any::type_name::<ksp_onchain_transport_lib::HeliusTransactionNotification>();
let _full = std::any::type_name::<ksp_onchain_transport_lib::HeliusFullTransactionNotification>();
let _signature = std::any::type_name::<ksp_onchain_transport_lib::HeliusTransactionSignatureNotification>();
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction");
}
#[test]
fn public_v0_2_8_pre_009_helius_slots_updates_surface_reuses_shared_typed_contract() {
let _slots_updates = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slots_updates_subscribe;
let _shared_slot_update = std::any::type_name::<ksp_onchain_transport_lib::SolanaSlotUpdate>();
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates.as_str(), "slots_updates");
}

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs // file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 22 // version: 32
//! Release-level completeness canaries for the staged HTTP wrapper sequence. //! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage.
#[test] #[test]
fn release_registry_partition_matches_the_audited_http_plan() { fn release_registry_partition_matches_the_audited_http_plan() {
@@ -730,3 +730,269 @@ fn release_v0_2_4_pre_009_final_http_inventory_and_coverage_partition_are_exact(
assert_eq!((v0_2_1, v0_2_2, v0_2_3, v0_2_4), (4, 22, 11, 15)); assert_eq!((v0_2_1, v0_2_2, v0_2_3, v0_2_4), (4, 22, 11, 15));
assert_eq!(historical_current, 0); assert_eq!(historical_current, 0);
} }
#[test]
fn release_v0_2_7_pre_012_websocket_surface_accounts_for_all_nine_standard_pairs() {
let kinds = [
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
];
let expected_names = ["account", "block", "logs", "program", "root", "signature", "slot", "slots_updates", "vote"];
let actual_names = kinds.map(ksp_onchain_transport_lib::WsSubscriptionKind::as_str);
assert_eq!(actual_names, expected_names);
assert_eq!(kinds.len() * 2, 18);
let _account = ksp_onchain_transport_lib::WsSession::account_subscribe;
let _block = ksp_onchain_transport_lib::WsSession::block_subscribe;
let _logs = ksp_onchain_transport_lib::WsSession::logs_subscribe;
let _program = ksp_onchain_transport_lib::WsSession::program_subscribe;
let _root = ksp_onchain_transport_lib::WsSession::root_subscribe;
let _signature = ksp_onchain_transport_lib::WsSession::signature_subscribe;
let _slot = ksp_onchain_transport_lib::WsSession::slot_subscribe;
let _slots_updates = ksp_onchain_transport_lib::WsSession::slots_updates_subscribe;
let _vote = ksp_onchain_transport_lib::WsSession::vote_subscribe;
let _unsubscribe = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::unsubscribe;
}
#[test]
fn release_v0_2_7_pre_012_http_inventory_remains_52_current_plus_14_historical() {
let current = ksp_onchain_transport_lib::current_http_rpc_methods();
let historical = ksp_onchain_transport_lib::historical_http_rpc_methods();
assert_eq!(current.len(), 52);
assert_eq!(historical.len(), 14);
assert!(current.iter().all(|descriptor| return descriptor.runtime_status() == ksp_onchain_transport_lib::RpcRuntimeStatus::Supported));
assert!(historical.iter().all(|descriptor| return descriptor.runtime_status() == ksp_onchain_transport_lib::RpcRuntimeStatus::Removed));
}
#[test]
fn release_v0_2_8_pre_002_protocol_facades_preserve_the_standard_partition() {
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
let standard_kinds = [
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
];
assert_eq!(standard_kinds.len(), 9);
assert_eq!(
std::any::type_name::<ksp_onchain_transport_lib::SolanaStandardWsSession>().rsplit("::").next(),
std::option::Option::Some("SolanaStandardWsSession")
);
assert_eq!(
std::any::type_name::<ksp_onchain_transport_lib::HeliusLaserStreamWsSession>().rsplit("::").next(),
std::option::Option::Some("HeliusLaserStreamWsSession")
);
}
#[test]
fn release_v0_2_8_pre_003_original_six_standard_families_remain_available_after_provider_evolution() {
let _account = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::account_subscribe;
let _program = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::program_subscribe;
let _logs = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::logs_subscribe;
let _signature = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::signature_subscribe;
let _slot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slot_subscribe;
let _root = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::root_subscribe;
let source = include_str!("../src/ws_protocol_session.rs");
assert!(source.contains("unsupported_block"));
assert!(source.contains("unsupported_vote"));
assert!(!source.contains("pub async fn transaction_subscribe"));
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
}
#[test]
fn release_v0_2_8_pre_005_helius_transaction_request_surface_is_typed_before_actor_integration() {
let filter = ksp_onchain_transport_lib::HeliusTransactionSubscribeFilter::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::All),
);
let options = ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed),
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::Base64),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures),
std::option::Option::Some(true),
std::option::Option::None,
);
let request = ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
assert!(request.validate().is_ok());
let facade_source = include_str!("../src/ws_protocol_session.rs");
assert!(!facade_source.contains("pub async fn transaction_subscribe"));
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
}
#[test]
fn release_v0_2_8_pre_006_helius_transaction_lifecycle_is_actor_integrated_without_advancing_heartbeat() {
let _transaction = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe;
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction");
let source = include_str!("../src/ws_helius_transactions.rs");
assert!(source.contains("transactionNotification"));
assert!(source.contains("WsSubscriptionKind::HeliusTransaction"));
assert!(!source.contains("tokio_tungstenite::connect_async"));
let protocol_source = include_str!("../src/ws_protocol_session.rs");
assert!(protocol_source.contains("unsupported_block"));
assert!(protocol_source.contains("unsupported_vote"));
}
#[test]
fn release_v0_2_8_pre_007_helius_heartbeat_is_provider_owned_by_shared_actor_only() {
let actor_source = include_str!("../src/ws_session.rs");
assert!(actor_source.contains("HELIUS_WS_HEARTBEAT_INTERVAL"));
assert!(actor_source.contains("std::time::Duration::from_secs(60)"));
assert!(actor_source.contains("WsProtocolKind::HeliusLaserStream"));
assert!(actor_source.contains("tungstenite::Message::Ping"));
assert!(actor_source.contains("send_helius_heartbeat"));
let settings_source = include_str!("../src/ws_settings.rs");
assert!(!settings_source.contains("heartbeat_interval"));
assert!(!settings_source.contains("heartbeat_enabled"));
let protocol_source = include_str!("../src/ws_protocol_session.rs");
assert!(!protocol_source.contains("heartbeat_interval"));
}
#[test]
fn release_v0_2_8_pre_008_adversarial_guards_preserve_provider_isolation_and_safe_diagnostics() {
let protocol_source = include_str!("../src/ws_protocol_session.rs");
assert!(protocol_source.contains("unsupported_helius_transaction"));
assert!(protocol_source.contains("unsupported_block"));
assert!(protocol_source.contains("unsupported_vote"));
let helius_source = include_str!("../src/ws_helius_transactions.rs");
assert!(helius_source.contains("impl std::fmt::Debug for HeliusFullTransactionNotification"));
assert!(helius_source.contains("impl std::fmt::Debug for HeliusTransactionSignatureNotification"));
assert!(helius_source.contains("impl std::fmt::Debug for HeliusTransactionNotification"));
assert!(helius_source.contains("Self::Unknown(_)"));
assert!(helius_source.contains("<omitted>"));
let actor_source = include_str!("../src/ws_session.rs");
assert!(actor_source.contains("max_message_size_bytes"));
assert!(actor_source.contains("max_frame_size_bytes"));
assert!(actor_source.contains("WsNotificationDispatchOutcome::QueueFull"));
assert!(actor_source.contains("ERROR_CODE_WS_BACKPRESSURE_OVERFLOW"));
assert!(actor_source.contains("remote_to_local.remove"));
}
#[test]
fn release_v0_2_8_pre_009_http_and_standard_websocket_inventories_remain_exact() {
assert_eq!(ksp_onchain_transport_lib::current_http_rpc_methods().len(), 52);
assert_eq!(ksp_onchain_transport_lib::historical_http_rpc_methods().len(), 14);
let standard = [
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
];
assert_eq!(standard.len(), 9);
let lifecycle_source = include_str!("../src/ws_lifecycle.rs");
for method in [
"accountSubscribe",
"accountUnsubscribe",
"blockSubscribe",
"blockUnsubscribe",
"logsSubscribe",
"logsUnsubscribe",
"programSubscribe",
"programUnsubscribe",
"rootSubscribe",
"rootUnsubscribe",
"signatureSubscribe",
"signatureUnsubscribe",
"slotSubscribe",
"slotUnsubscribe",
"slotsUpdatesSubscribe",
"slotsUpdatesUnsubscribe",
"voteSubscribe",
"voteUnsubscribe",
] {
assert!(lifecycle_source.contains(method), "missing standard WebSocket method mapping: {method}");
}
let _account = ksp_onchain_transport_lib::SolanaStandardWsSession::account_subscribe;
let _block = ksp_onchain_transport_lib::SolanaStandardWsSession::block_subscribe;
let _logs = ksp_onchain_transport_lib::SolanaStandardWsSession::logs_subscribe;
let _program = ksp_onchain_transport_lib::SolanaStandardWsSession::program_subscribe;
let _root = ksp_onchain_transport_lib::SolanaStandardWsSession::root_subscribe;
let _signature = ksp_onchain_transport_lib::SolanaStandardWsSession::signature_subscribe;
let _slot = ksp_onchain_transport_lib::SolanaStandardWsSession::slot_subscribe;
let _slots_updates = ksp_onchain_transport_lib::SolanaStandardWsSession::slots_updates_subscribe;
let _vote = ksp_onchain_transport_lib::SolanaStandardWsSession::vote_subscribe;
}
#[test]
fn release_v0_2_8_pre_009_helius_surface_is_seven_standard_families_plus_transaction() {
let _account = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::account_subscribe;
let _program = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::program_subscribe;
let _logs = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::logs_subscribe;
let _signature = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::signature_subscribe;
let _slot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slot_subscribe;
let _root = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::root_subscribe;
let _slots_updates = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slots_updates_subscribe;
let _transaction = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe;
let protocol_source = include_str!("../src/ws_protocol_session.rs");
assert!(protocol_source.contains("unsupported_block"));
assert!(protocol_source.contains("unsupported_vote"));
assert!(!protocol_source.contains("unsupported_slots_updates"));
let cluster_source = include_str!("../src/ws_cluster.rs");
assert!(cluster_source.contains("impl crate::HeliusLaserStreamWsSession"));
assert!(cluster_source.contains("pub async fn slots_updates_subscribe"));
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates.as_str(), "slots_updates");
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction");
}
#[test]
fn release_v0_2_8_pre_009_config_secret_and_dependency_boundaries_remain_wired() {
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport integration test must resolve the workspace root");
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] {
assert!(!transport_manifest.contains(forbidden), "forbidden direct Transport dependency detected: {forbidden}");
}
let config_manifest = std::fs::read_to_string(workspace.join("crates/ksp-config-lib/Cargo.toml")).expect("Config manifest must be readable");
assert!(config_manifest.contains("ksp-onchain-transport-lib"));
let config_transport =
std::fs::read_to_string(workspace.join("crates/ksp-config-lib/src/transport.rs")).expect("Config Transport adapter source must be readable");
assert!(config_transport.contains("WsProtocolKind::HeliusLaserStream"));
let transport_example = std::fs::read_to_string(workspace.join("config/examples/std.transport.example.json")).expect("Transport example must be readable");
assert!(transport_example.contains("\"kind\": \"helius_laserstream\""));
assert!(transport_example.contains("${KSP_SECRET_HELIUS_API_KEY"));
let env_example = std::fs::read_to_string(workspace.join(".env.example")).expect(".env.example must be readable");
assert!(env_example.contains("KSP_SECRET_HELIUS_API_KEY"));
}
#[test]
fn release_v0_2_8_pre_010_live_smoke_policy_preserves_secret_and_dependency_ownership() {
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport integration test must resolve the workspace root");
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
assert!(!transport_manifest.contains("ksp-config-lib"));
let transport_ws_smoke =
std::fs::read_to_string(manifest_directory.join("tests/websocket_devnet_smoke.rs")).expect("Transport WebSocket smoke must be readable");
assert!(!transport_ws_smoke.contains("KSP_SECRET_HELIUS_API_KEY"));
assert!(!transport_ws_smoke.contains("ConfigEnvironment"));
assert!(!workspace.join("crates/ksp-config-lib/tests/helius_websocket_smoke.rs").exists());
let readme = std::fs::read_to_string(manifest_directory.join("README.md")).expect("Transport README must be readable");
assert!(readme.contains("Aucun smoke Helius live supplémentaire nest committé en `0.2.8-pre.010`"));
assert!(readme.contains("KSP_SECRET_HELIUS_API_KEY"));
let usage = std::fs::read_to_string(manifest_directory.join("USAGE.md")).expect("Transport USAGE must be readable");
assert!(usage.contains("### Smoke Helius live"));
assert!(usage.contains("surface KSP dintégration/orchestration dédiée"));
assert!(usage.contains("cargo tree -p ksp-onchain-transport-lib"));
let env_example = std::fs::read_to_string(workspace.join(".env.example")).expect(".env.example must be readable");
assert!(env_example.contains("KSP_SECRET_HELIUS_API_KEY"));
}

View File

@@ -0,0 +1,57 @@
// file: crates/ksp-onchain-transport-lib/tests/websocket_devnet_smoke.rs
// version: 1
//! Opt-in live Devnet smoke for one stable standard Solana WebSocket subscription.
fn devnet_websocket_endpoint() -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsEndpointSettings> {
let url_result = ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com");
let url = match url_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let session = ksp_onchain_transport_lib::WsSessionSettings::new(
std::time::Duration::from_secs(10),
std::time::Duration::from_secs(5),
ksp_onchain_transport_lib::WsReconnectSettings::new(0, std::time::Duration::from_millis(250), std::time::Duration::from_secs(2)),
ksp_onchain_transport_lib::WsResubscribePolicy::ActiveSubscriptions,
16,
16,
8,
8,
4 * 1024 * 1024,
4 * 1024 * 1024,
256 * 1024,
);
return std::result::Result::Ok(ksp_onchain_transport_lib::WsEndpointSettings::new(
"solana_devnet_public_ws",
true,
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
url,
session,
));
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "opt-in live Solana Devnet WebSocket smoke; performs an external network connection"]
async fn programmatic_devnet_websocket_reaches_slot_notification_then_unsubscribes_and_closes() {
let endpoint = devnet_websocket_endpoint().expect("programmatic Devnet WebSocket settings must construct an endpoint");
let session = ksp_onchain_transport_lib::WsSession::connect(endpoint).await.expect("Devnet WebSocket handshake must succeed");
assert_eq!(session.snapshot().state(), ksp_onchain_transport_lib::WsSessionState::Active);
let mut subscription = session.slot_subscribe().await.expect("Devnet slotSubscribe must succeed");
assert_eq!(subscription.kind(), ksp_onchain_transport_lib::WsSubscriptionKind::Slot);
assert_eq!(subscription.state(), ksp_onchain_transport_lib::WsSubscriptionState::Active);
let notification_wait = tokio::time::timeout(std::time::Duration::from_secs(20), subscription.recv()).await;
let notification = match notification_wait {
std::result::Result::Ok(std::option::Option::Some(std::result::Result::Ok(value))) => value,
std::result::Result::Ok(std::option::Option::Some(std::result::Result::Err(error))) => panic!("Devnet slot notification decoding failed: {error}"),
std::result::Result::Ok(std::option::Option::None) => panic!("Devnet slot subscription closed before one notification arrived"),
std::result::Result::Err(_) => panic!("Devnet slot notification did not arrive within the smoke timeout"),
};
assert!(notification.slot() > 0);
let unsubscribed = subscription.unsubscribe().await.expect("Devnet slotUnsubscribe must succeed");
assert!(unsubscribed);
session.close().await.expect("Devnet WebSocket session must close cleanly");
assert_eq!(session.snapshot().state(), ksp_onchain_transport_lib::WsSessionState::Closed);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs // file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
// version: 9 // version: 10
#[test] #[test]
fn transaction_encoding_strings_match_current_and_legacy_wire_labels() { fn transaction_encoding_strings_match_current_and_legacy_wire_labels() {
@@ -325,15 +325,36 @@ fn serve_transaction_once(body: &'static str) -> (std::string::String, std::thre
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind"); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
let address = listener.local_addr().expect("fixture listener address must resolve"); let address = listener.local_addr().expect("fixture listener address must resolve");
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("fixture server must accept one request"); loop {
let request = read_transaction_request(&mut stream); let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body,); let request = read_transaction_request(&mut stream);
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write"); if !transaction_request_complete(request.as_bytes()) {
return request; continue;
}
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body,);
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
return request;
}
}); });
return (format!("http://{address}"), handle); return (format!("http://{address}"), handle);
} }
#[tokio::test(flavor = "current_thread")]
async fn transaction_fixture_ignores_abandoned_connection_before_complete_request() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.null.json"));
let address = url.strip_prefix("http://").expect("fixture URL must use HTTP");
let abandoned = std::net::TcpStream::connect(address).expect("abandoned fixture connection must connect");
drop(abandoned);
let pool = transaction_pool_for_url(url.as_str());
let result = pool
.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::None)
.await
.expect("fixture must remain available after abandoned pre-request connection");
assert!(result.is_none());
let request = handle.join().expect("fixture server must join");
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("getTransaction"));
}
fn serve_transaction_status_and_count(status_line: &'static str) -> (std::string::String, std::thread::JoinHandle<(usize, std::string::String)>) { fn serve_transaction_status_and_count(status_line: &'static str) -> (std::string::String, std::thread::JoinHandle<(usize, std::string::String)>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind"); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
let address = listener.local_addr().expect("fixture listener address must resolve"); let address = listener.local_addr().expect("fixture listener address must resolve");

View File

@@ -0,0 +1,202 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
// version: 1
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_ws_accounts",
true,
crate::WsProviderName::new("local-fixture"),
crate::WsClusterName::new("local"),
crate::WsProtocolKind::SolanaStandard,
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
crate::WsSessionSettings::default(),
);
}
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
let address = listener.local_addr().expect("local listener must expose address");
return (listener, format!("ws://{address}"));
}
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
let text = message.to_text().expect("request must be text");
return serde_json::from_str(text).expect("request must contain JSON");
}
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
}
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, method: &str, remote_id: u64, result: serde_json::Value) {
let notification = serde_json::json!({"jsonrpc":"2.0","method":method,"params":{"result":result,"subscription":remote_id}});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
}
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
std::option::Option::Some(std::result::Result::Ok(_)) => {},
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
}
}
}
fn account_wire(lamports: u64) -> serde_json::Value {
return serde_json::json!({
"lamports": lamports,
"data": ["AQID", "base64"],
"owner": "11111111111111111111111111111111",
"executable": false,
"rentEpoch": 7,
"space": 3
});
}
#[test]
fn account_subscribe_config_preserves_effective_websocket_options_without_min_context_slot() {
let config = crate::SolanaAccountSubscribeConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64Zstd),
std::option::Option::Some(crate::SolanaDataSliceConfig::new(4, 16)),
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
);
assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaAccountEncoding::Base64Zstd));
assert_eq!(config.data_slice(), std::option::Option::Some(crate::SolanaDataSliceConfig::new(4, 16)));
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
assert_eq!(config.to_json_value(), serde_json::json!({"encoding":"base64+zstd","dataSlice":{"offset":4,"length":16},"commitment":"confirmed"}));
assert!(config.to_json_value().get("minContextSlot").is_none());
}
#[test]
fn program_subscribe_config_preserves_filters_with_context_and_deterministic_bounds() {
let config = crate::SolanaProgramSubscribeConfig::new(
crate::SolanaAccountSubscribeConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
std::option::Option::None,
std::option::Option::Some(crate::SolanaCommitment::Finalized),
),
std::vec![
crate::SolanaProgramAccountFilter::DataSize(80),
crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(4, crate::SolanaMemcmpBytes::Bytes(std::vec![1, 2, 3]))),
crate::SolanaProgramAccountFilter::TokenAccountState,
],
std::option::Option::Some(true),
);
assert_eq!(config.with_context(), std::option::Option::Some(true));
assert_eq!(config.filters().len(), 3);
assert_eq!(
config.to_json_value(),
serde_json::json!({
"encoding":"base64",
"commitment":"finalized",
"filters":[{"dataSize":80},{"memcmp":{"offset":4,"bytes":[1,2,3],"encoding":"bytes"}},"tokenAccountState"],
"withContext":true
})
);
let too_many = std::vec![
crate::SolanaProgramAccountFilter::DataSize(1),
crate::SolanaProgramAccountFilter::DataSize(2),
crate::SolanaProgramAccountFilter::DataSize(3),
crate::SolanaProgramAccountFilter::DataSize(4),
crate::SolanaProgramAccountFilter::DataSize(5),
];
let error = super::validate_program_subscribe_filters(too_many.as_slice()).expect_err("five programSubscribe filters must reject locally");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
let oversized =
std::vec![crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Bytes(std::vec![0; 129]),))];
let error = super::validate_program_subscribe_filters(oversized.as_slice()).expect_err("oversized raw memcmp bytes must reject locally");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
}
#[test]
fn program_notification_decoder_accepts_contextual_and_non_contextual_wire_forms() {
let keyed = serde_json::json!({"pubkey":"11111111111111111111111111111111","account":account_wire(42)});
let bare = super::decode_program_notification("programSubscribe", keyed.clone()).expect("bare program notification must decode");
assert!(bare.context().is_none());
assert_eq!(bare.account().account().lamports(), 42);
let contextual = super::decode_program_notification("programSubscribe", serde_json::json!({"context":{"slot":99,"apiVersion":"4.2.1"},"value":keyed}))
.expect("contextual program notification must decode");
assert_eq!(contextual.context().expect("context must be retained").slot(), 99);
assert_eq!(contextual.account().account().lamports(), 42);
}
#[tokio::test(flavor = "current_thread")]
async fn stable_account_and_program_wrappers_use_exact_methods_decode_notifications_and_unsubscribe_by_handle() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let account_request = read_request(&mut websocket).await;
assert_eq!(account_request["method"], serde_json::json!("accountSubscribe"));
assert_eq!(
account_request["params"],
serde_json::json!(["11111111111111111111111111111111", {"encoding":"base64","dataSlice":{"offset":1,"length":2},"commitment":"confirmed"}])
);
send_result(&mut websocket, &account_request, serde_json::json!(51)).await;
send_notification(&mut websocket, "accountNotification", 51, serde_json::json!({"context":{"slot":700},"value":account_wire(123)})).await;
let account_unsubscribe = read_request(&mut websocket).await;
assert_eq!(account_unsubscribe["method"], serde_json::json!("accountUnsubscribe"));
assert_eq!(account_unsubscribe["params"], serde_json::json!([51]));
send_result(&mut websocket, &account_unsubscribe, serde_json::json!(true)).await;
let program_request = read_request(&mut websocket).await;
assert_eq!(program_request["method"], serde_json::json!("programSubscribe"));
assert_eq!(
program_request["params"],
serde_json::json!(["11111111111111111111111111111111", {"encoding":"jsonParsed","filters":[{"dataSize":80}],"withContext":true}])
);
send_result(&mut websocket, &program_request, serde_json::json!(73)).await;
send_notification(
&mut websocket,
"programNotification",
73,
serde_json::json!({
"context":{"slot":701},
"value":{"pubkey":"11111111111111111111111111111111","account":account_wire(456)}
}),
)
.await;
let program_unsubscribe = read_request(&mut websocket).await;
assert_eq!(program_unsubscribe["method"], serde_json::json!("programUnsubscribe"));
assert_eq!(program_unsubscribe["params"], serde_json::json!([73]));
send_result(&mut websocket, &program_unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let account_pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("account pubkey fixture must parse");
let account_config = crate::SolanaAccountSubscribeConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
std::option::Option::Some(crate::SolanaDataSliceConfig::new(1, 2)),
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
);
let mut account = session.account_subscribe(&account_pubkey, std::option::Option::Some(&account_config)).await.expect("accountSubscribe must register");
assert_eq!(account.kind(), crate::WsSubscriptionKind::Account);
let notification = account.recv().await.expect("account notification must arrive").expect("account notification must decode");
assert_eq!(notification.context().slot(), 700);
assert_eq!(notification.value().lamports(), 123);
assert!(account.unsubscribe().await.expect("account unsubscribe must complete"));
let program_config = crate::SolanaProgramSubscribeConfig::new(
crate::SolanaAccountSubscribeConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
std::option::Option::None,
std::option::Option::None,
),
std::vec![crate::SolanaProgramAccountFilter::DataSize(80)],
std::option::Option::Some(true),
);
let mut program = session.program_subscribe(&account_pubkey, std::option::Option::Some(&program_config)).await.expect("programSubscribe must register");
assert_eq!(program.kind(), crate::WsSubscriptionKind::Program);
let notification = program.recv().await.expect("program notification must arrive").expect("program notification must decode");
assert_eq!(notification.context().expect("program context must be retained").slot(), 701);
assert_eq!(notification.account().account().lamports(), 456);
assert!(program.unsubscribe().await.expect("program unsubscribe must complete"));
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}

View File

@@ -0,0 +1,222 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs
// version: 1
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
fn fixture_pubkey() -> ksp_core_lib::Pubkey {
return "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
}
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_ws_blocks",
true,
crate::WsProviderName::new("local-fixture"),
crate::WsClusterName::new("local"),
crate::WsProtocolKind::SolanaStandard,
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
crate::WsSessionSettings::default(),
);
}
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
let address = listener.local_addr().expect("local listener must expose address");
return (listener, format!("ws://{address}"));
}
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
let text = message.to_text().expect("request must be text");
return serde_json::from_str(text).expect("request must contain JSON");
}
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
}
async fn send_error(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, code: i64, message: &str) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local error response must send");
}
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, remote_id: u64, result: serde_json::Value) {
let notification = serde_json::json!({"jsonrpc":"2.0","method":"blockNotification","params":{"result":result,"subscription":remote_id}});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
}
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
std::option::Option::Some(std::result::Result::Ok(_)) => {},
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
}
}
}
#[test]
fn block_subscribe_config_preserves_all_unstable_options_and_rejects_processed_commitment() {
let config = crate::SolanaBlockSubscribeConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed),
std::option::Option::Some(crate::SolanaTransactionDetails::Accounts),
std::option::Option::Some(7),
std::option::Option::Some(true),
);
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed));
assert_eq!(config.transaction_details(), std::option::Option::Some(crate::SolanaTransactionDetails::Accounts));
assert_eq!(config.max_supported_transaction_version(), std::option::Option::Some(7));
assert_eq!(config.show_rewards(), std::option::Option::Some(true));
assert_eq!(
config.to_json_value(),
serde_json::json!({
"commitment": "confirmed",
"encoding": "jsonParsed",
"transactionDetails": "accounts",
"maxSupportedTransactionVersion": 7,
"showRewards": true
})
);
let invalid = crate::SolanaBlockSubscribeConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Processed),
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
);
assert_eq!(invalid.validate().expect_err("processed commitment must be rejected").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
}
#[test]
fn block_notification_decoder_preserves_nulls_and_shared_confirmed_block_shape() {
let nulls = super::decode_block_notification(
"blockSubscribe",
serde_json::json!({"context":{"slot":51},"value":{"slot":51,"block":null,"err":{"reason":"missing"}}}),
)
.expect("nullable block notification must decode");
assert_eq!(nulls.context().slot(), 51);
assert_eq!(nulls.value().slot(), 51);
assert!(nulls.value().block().is_none());
assert_eq!(nulls.value().err(), std::option::Option::Some(&serde_json::json!({"reason":"missing"})));
let block = super::decode_block_notification(
"blockSubscribe",
serde_json::json!({
"context":{"slot":52},
"value":{
"slot":52,
"block":{
"previousBlockhash":"prev",
"blockhash":"current",
"parentSlot":51,
"signatures":["sig-a"],
"rewards":null,
"numRewardPartitions":4,
"blockTime":123,
"blockHeight":9
},
"err":null
}
}),
)
.expect("shared confirmed block shape must decode");
assert_eq!(block.value().block().expect("block must exist").blockhash(), "current");
assert!(block.value().err().is_none());
let large_transaction = "A".repeat(2_048);
let large_wire = serde_json::json!({
"context":{"slot":53},
"value":{
"slot":53,
"block":{
"previousBlockhash":"prev",
"blockhash":"large-current",
"parentSlot":52,
"transactions":[{"transaction":[large_transaction,"base64"],"meta":{"err":null,"fee":5000},"version":"legacy"}],
"rewards":[],
"blockTime":123,
"blockHeight":10
},
"err":null
}
});
assert!(large_wire.to_string().len() > 1_232);
assert!(super::decode_block_notification("blockSubscribe", large_wire).is_ok());
}
#[tokio::test(flavor = "current_thread")]
async fn unstable_block_wrapper_uses_exact_request_notification_and_handle_unsubscribe() {
let (listener, url) = bind_local_listener().await;
let pubkey = fixture_pubkey();
let server_pubkey = pubkey;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let subscribe = read_request(&mut websocket).await;
assert_eq!(subscribe["method"], serde_json::json!("blockSubscribe"));
assert_eq!(
subscribe["params"],
serde_json::json!([
{"mentionsAccountOrProgram":server_pubkey.to_string()},
{"commitment":"confirmed","encoding":"base64","transactionDetails":"signatures","maxSupportedTransactionVersion":3,"showRewards":false}
])
);
send_result(&mut websocket, &subscribe, serde_json::json!(301)).await;
send_notification(&mut websocket, 301, serde_json::json!({"context":{"slot":77},"value":{"slot":77,"block":null,"err":null}})).await;
let unsubscribe = read_request(&mut websocket).await;
assert_eq!(unsubscribe["method"], serde_json::json!("blockUnsubscribe"));
assert_eq!(unsubscribe["params"], serde_json::json!([301]));
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let config = crate::SolanaBlockSubscribeConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(crate::SolanaTransactionEncoding::Base64),
std::option::Option::Some(crate::SolanaTransactionDetails::Signatures),
std::option::Option::Some(3),
std::option::Option::Some(false),
);
let mut subscription = session
.block_subscribe(&crate::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(pubkey), std::option::Option::Some(&config))
.await
.expect("blockSubscribe must register");
let notification = subscription.recv().await.expect("block notification must arrive").expect("block notification must decode");
assert_eq!(notification.value().slot(), 77);
assert!(notification.value().block().is_none());
assert!(subscription.unsubscribe().await.expect("block unsubscribe must complete"));
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn unstable_block_validator_capability_rpc_error_does_not_fail_physical_session() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let block_subscribe = read_request(&mut websocket).await;
assert_eq!(block_subscribe["method"], serde_json::json!("blockSubscribe"));
send_error(&mut websocket, &block_subscribe, -32601, "block subscription disabled").await;
let root_subscribe = read_request(&mut websocket).await;
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
send_result(&mut websocket, &root_subscribe, serde_json::json!(302)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let error = session
.block_subscribe(&crate::SolanaBlockSubscribeFilter::All, std::option::Option::None)
.await
.expect_err("validator capability application error must surface to the caller");
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
assert_eq!(session.state(), crate::WsSessionState::Active);
let root = session.root_subscribe().await.expect("session must remain usable after block application error");
assert_eq!(root.state(), crate::WsSubscriptionState::Active);
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}

View File

@@ -0,0 +1,233 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
// version: 2
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_ws_cluster",
true,
crate::WsProviderName::new("local-fixture"),
crate::WsClusterName::new("local"),
crate::WsProtocolKind::SolanaStandard,
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
crate::WsSessionSettings::default(),
);
}
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
let address = listener.local_addr().expect("local listener must expose address");
return (listener, format!("ws://{address}"));
}
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
let text = message.to_text().expect("request must be text");
return serde_json::from_str(text).expect("request must contain JSON");
}
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
}
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, method: &str, remote_id: u64, result: serde_json::Value) {
let notification = serde_json::json!({"jsonrpc":"2.0","method":method,"params":{"result":result,"subscription":remote_id}});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
}
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
std::option::Option::Some(std::result::Result::Ok(_)) => {},
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
}
}
}
#[test]
fn slot_notification_decoder_preserves_slot_parent_and_root() {
let notification =
super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75,"root":44})).expect("slot notification must decode");
assert_eq!(notification.slot(), 76);
assert_eq!(notification.parent(), 75);
assert_eq!(notification.root(), 44);
assert!(super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75})).is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn stable_slot_and_root_wrappers_use_no_params_decode_exact_notifications_and_unsubscribe_by_handle() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let slot_subscribe = read_request(&mut websocket).await;
assert_eq!(slot_subscribe["method"], serde_json::json!("slotSubscribe"));
assert_eq!(slot_subscribe["params"], serde_json::json!([]));
send_result(&mut websocket, &slot_subscribe, serde_json::json!(201)).await;
send_notification(&mut websocket, "slotNotification", 201, serde_json::json!({"slot":76,"parent":75,"root":44})).await;
let root_subscribe = read_request(&mut websocket).await;
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
assert_eq!(root_subscribe["params"], serde_json::json!([]));
send_result(&mut websocket, &root_subscribe, serde_json::json!(202)).await;
send_notification(&mut websocket, "rootNotification", 202, serde_json::json!(42)).await;
let slot_unsubscribe = read_request(&mut websocket).await;
assert_eq!(slot_unsubscribe["method"], serde_json::json!("slotUnsubscribe"));
assert_eq!(slot_unsubscribe["params"], serde_json::json!([201]));
send_result(&mut websocket, &slot_unsubscribe, serde_json::json!(true)).await;
let root_unsubscribe = read_request(&mut websocket).await;
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
assert_eq!(root_unsubscribe["params"], serde_json::json!([202]));
send_result(&mut websocket, &root_unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let mut slot_subscription = session.slot_subscribe().await.expect("slotSubscribe must register");
let slot = slot_subscription.recv().await.expect("slot notification must arrive").expect("slot notification must decode");
assert_eq!((slot.slot(), slot.parent(), slot.root()), (76, 75, 44));
let mut root_subscription = session.root_subscribe().await.expect("rootSubscribe must register");
let root = root_subscription.recv().await.expect("root notification must arrive").expect("root notification must decode");
assert_eq!(root, 42);
assert!(slot_subscription.unsubscribe().await.expect("slot unsubscribe must complete"));
assert!(root_subscription.unsubscribe().await.expect("root unsubscribe must complete"));
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}
#[test]
fn slots_update_decoder_preserves_all_known_variants_and_unknown_bounded_fallback() {
let cases = [
(serde_json::json!({"slot":1,"timestamp":10,"type":"firstShredReceived"}), "firstShredReceived"),
(serde_json::json!({"slot":2,"timestamp":20,"type":"completed"}), "completed"),
(serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank","parent":2}), "createdBank"),
(
serde_json::json!({
"slot":4,
"timestamp":40,
"type":"frozen",
"stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3}
}),
"frozen",
),
(serde_json::json!({"slot":5,"timestamp":50,"type":"dead","err":"fixture dead"}), "dead"),
(serde_json::json!({"slot":6,"timestamp":60,"type":"optimisticConfirmation"}), "optimisticConfirmation"),
(serde_json::json!({"slot":7,"timestamp":70,"type":"root"}), "root"),
];
for (wire, expected_type) in cases {
let update = super::decode_slots_update_notification("slotsUpdatesSubscribe", wire).expect("known slot update must decode");
assert_eq!(update.update_type(), expected_type);
assert!(update.slot().is_some());
assert!(update.timestamp().is_some());
assert!(update.unknown_raw().is_none());
}
let frozen = super::decode_slots_update_notification(
"slotsUpdatesSubscribe",
serde_json::json!({
"slot":4,
"timestamp":40,
"type":"frozen",
"stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3}
}),
)
.expect("frozen update must decode");
match frozen {
crate::SolanaSlotUpdate::Frozen { stats, .. } => {
assert_eq!(stats.max_transactions_per_entry(), 64);
assert_eq!(stats.num_failed_transactions(), 1);
assert_eq!(stats.num_successful_transactions(), 9);
assert_eq!(stats.num_transaction_entries(), 3);
},
_ => panic!("fixture must decode as frozen"),
}
let unknown_wire = serde_json::json!({"slot":8,"timestamp":80,"type":"futureBankState","futureField":{"x":1}});
let unknown = super::decode_slots_update_notification("slotsUpdatesSubscribe", unknown_wire.clone()).expect("unknown update must remain consumable");
assert_eq!(unknown.update_type(), "futureBankState");
assert_eq!(unknown.slot(), std::option::Option::Some(8));
assert_eq!(unknown.timestamp(), std::option::Option::Some(80));
assert_eq!(unknown.unknown_raw(), std::option::Option::Some(&unknown_wire));
}
#[test]
fn slots_update_known_variants_require_their_variant_specific_fields() {
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank"})).is_err());
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":4,"timestamp":40,"type":"frozen"})).is_err());
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":5,"timestamp":50,"type":"dead"})).is_err());
}
#[test]
fn vote_notification_decoder_preserves_timestamp_omitted_null_and_value() {
let pubkey = "11111111111111111111111111111111";
for (wire, expected_timestamp) in [
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-a","signature":"sig-a"}), std::option::Option::None),
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-b","timestamp":null,"signature":"sig-b"}), std::option::Option::None),
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-c","timestamp":123,"signature":"sig-c"}), std::option::Option::Some(123)),
] {
let vote = super::decode_vote_notification("voteSubscribe", wire).expect("vote notification must decode");
assert_eq!(vote.vote_pubkey().to_string(), pubkey);
assert_eq!(vote.slots(), &[1, 2]);
assert_eq!(vote.timestamp(), expected_timestamp);
}
}
#[tokio::test(flavor = "current_thread")]
async fn unstable_slots_updates_and_vote_wrappers_use_no_params_and_handle_unsubscribe() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let slots_subscribe = read_request(&mut websocket).await;
assert_eq!(slots_subscribe["method"], serde_json::json!("slotsUpdatesSubscribe"));
assert_eq!(slots_subscribe["params"], serde_json::json!([]));
send_result(&mut websocket, &slots_subscribe, serde_json::json!(401)).await;
send_notification(
&mut websocket,
"slotsUpdatesNotification",
401,
serde_json::json!({"slot":76,"timestamp":1625081266243_i64,"type":"optimisticConfirmation"}),
)
.await;
let vote_subscribe = read_request(&mut websocket).await;
assert_eq!(vote_subscribe["method"], serde_json::json!("voteSubscribe"));
assert_eq!(vote_subscribe["params"], serde_json::json!([]));
send_result(&mut websocket, &vote_subscribe, serde_json::json!(402)).await;
send_notification(
&mut websocket,
"voteNotification",
402,
serde_json::json!({
"votePubkey":"11111111111111111111111111111111",
"slots":[75,76],
"hash":"fixture-hash",
"timestamp":null,
"signature":"fixture-signature"
}),
)
.await;
let slots_unsubscribe = read_request(&mut websocket).await;
assert_eq!(slots_unsubscribe["method"], serde_json::json!("slotsUpdatesUnsubscribe"));
assert_eq!(slots_unsubscribe["params"], serde_json::json!([401]));
send_result(&mut websocket, &slots_unsubscribe, serde_json::json!(true)).await;
let vote_unsubscribe = read_request(&mut websocket).await;
assert_eq!(vote_unsubscribe["method"], serde_json::json!("voteUnsubscribe"));
assert_eq!(vote_unsubscribe["params"], serde_json::json!([402]));
send_result(&mut websocket, &vote_unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let mut slots_subscription = session.slots_updates_subscribe().await.expect("slotsUpdatesSubscribe must register");
let update = slots_subscription.recv().await.expect("slots update must arrive").expect("slots update must decode");
assert_eq!(update.update_type(), "optimisticConfirmation");
let mut vote_subscription = session.vote_subscribe().await.expect("voteSubscribe must register");
let vote = vote_subscription.recv().await.expect("vote notification must arrive").expect("vote notification must decode");
assert_eq!(vote.slots(), &[75, 76]);
assert!(vote.timestamp().is_none());
assert!(slots_subscription.unsubscribe().await.expect("slots update unsubscribe must complete"));
assert!(vote_subscription.unsubscribe().await.expect("vote unsubscribe must complete"));
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}

View File

@@ -0,0 +1,142 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_helius_standard.rs
// version: 2
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
fn helius_endpoint(url: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_helius_standard_fixture",
true,
crate::WsProviderName::new("helius"),
crate::WsClusterName::new("local"),
crate::WsProtocolKind::HeliusLaserStream,
crate::WsEndpointUrl::parse(url).expect("local Helius WebSocket URL must parse"),
crate::WsSessionSettings::default(),
);
}
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
let address = listener.local_addr().expect("local listener must expose address");
return (listener, format!("ws://{address}"));
}
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
let text = message.to_text().expect("request must be text");
return serde_json::from_str(text).expect("request must contain JSON");
}
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
}
async fn expect_pair(
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
subscribe_method: &str,
expected_params: serde_json::Value,
unsubscribe_method: &str,
remote_id: u64,
) {
let subscribe = read_request(websocket).await;
assert_eq!(subscribe["method"], serde_json::Value::String(subscribe_method.to_owned()));
assert_eq!(subscribe["params"], expected_params);
send_result(websocket, &subscribe, serde_json::json!(remote_id)).await;
let unsubscribe = read_request(websocket).await;
assert_eq!(unsubscribe["method"], serde_json::Value::String(unsubscribe_method.to_owned()));
assert_eq!(unsubscribe["params"], serde_json::json!([remote_id]));
send_result(websocket, &unsubscribe, serde_json::json!(true)).await;
}
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
std::option::Option::Some(std::result::Result::Ok(_)) => {},
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn helius_facade_reuses_exact_standard_wire_for_all_seven_currently_supported_families() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
expect_pair(
&mut websocket,
"accountSubscribe",
serde_json::json!(["11111111111111111111111111111111", {"encoding":"base64","commitment":"confirmed"}]),
"accountUnsubscribe",
101,
)
.await;
expect_pair(
&mut websocket,
"programSubscribe",
serde_json::json!(["11111111111111111111111111111111", {"encoding":"jsonParsed","filters":[{"dataSize":80}],"withContext":true}]),
"programUnsubscribe",
102,
)
.await;
expect_pair(&mut websocket, "logsSubscribe", serde_json::json!(["all", {"commitment":"finalized"}]), "logsUnsubscribe", 103).await;
expect_pair(
&mut websocket,
"signatureSubscribe",
serde_json::json!(["fixture-signature", {"commitment":"confirmed","enableReceivedNotification":true}]),
"signatureUnsubscribe",
104,
)
.await;
expect_pair(&mut websocket, "slotSubscribe", serde_json::json!([]), "slotUnsubscribe", 105).await;
expect_pair(&mut websocket, "rootSubscribe", serde_json::json!([]), "rootUnsubscribe", 106).await;
expect_pair(&mut websocket, "slotsUpdatesSubscribe", serde_json::json!([]), "slotsUpdatesUnsubscribe", 107).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect");
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let account_config = crate::SolanaAccountSubscribeConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
std::option::Option::None,
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
);
let mut account = session.account_subscribe(&pubkey, std::option::Option::Some(&account_config)).await.expect("Helius accountSubscribe must register");
assert!(account.unsubscribe().await.expect("Helius accountUnsubscribe must complete"));
let program_config = crate::SolanaProgramSubscribeConfig::new(
crate::SolanaAccountSubscribeConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
std::option::Option::None,
std::option::Option::None,
),
std::vec![crate::SolanaProgramAccountFilter::DataSize(80)],
std::option::Option::Some(true),
);
let mut program = session.program_subscribe(&pubkey, std::option::Option::Some(&program_config)).await.expect("Helius programSubscribe must register");
assert!(program.unsubscribe().await.expect("Helius programUnsubscribe must complete"));
let logs_config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
let mut logs = session
.logs_subscribe(&crate::SolanaLogsSubscribeFilter::All, std::option::Option::Some(&logs_config))
.await
.expect("Helius logsSubscribe must register");
assert!(logs.unsubscribe().await.expect("Helius logsUnsubscribe must complete"));
let signature_config =
crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(true));
let mut signature = session
.signature_subscribe("fixture-signature", std::option::Option::Some(&signature_config))
.await
.expect("Helius signatureSubscribe must register");
assert!(signature.unsubscribe().await.expect("Helius signatureUnsubscribe must complete"));
let mut slot = session.slot_subscribe().await.expect("Helius slotSubscribe must register");
assert!(slot.unsubscribe().await.expect("Helius slotUnsubscribe must complete"));
let mut root = session.root_subscribe().await.expect("Helius rootSubscribe must register");
assert!(root.unsubscribe().await.expect("Helius rootUnsubscribe must complete"));
let mut slots_updates = session.slots_updates_subscribe().await.expect("Helius slotsUpdatesSubscribe must register");
assert!(slots_updates.unsubscribe().await.expect("Helius slotsUpdatesUnsubscribe must complete"));
session.close().await.expect("Helius facade close must complete");
server.await.expect("local Helius peer task must complete");
}

View File

@@ -0,0 +1,732 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs
// version: 5
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
fn pubkey(value: &str) -> ksp_core_lib::Pubkey {
return value.parse::<ksp_core_lib::Pubkey>().expect("fixture public key must parse");
}
fn base_filter() -> crate::HeliusTransactionSubscribeFilter {
return crate::HeliusTransactionSubscribeFilter::new(
std::option::Option::Some(false),
std::option::Option::Some(false),
std::option::Option::Some("fixture-signature-secret-canary".to_owned()),
std::option::Option::Some(std::vec![pubkey("11111111111111111111111111111111")]),
std::option::Option::Some(std::vec![pubkey("SysvarC1ock11111111111111111111111111111111")]),
std::option::Option::Some(std::vec![pubkey("Vote111111111111111111111111111111111111111")]),
std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged),
);
}
fn assert_oversized_filter_rejected(filter: crate::HeliusTransactionSubscribeFilter) {
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::None);
let error = request.validate().expect_err("50,001 Helius account filters must fail before I/O");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert!(error.to_string().contains("invalid_rpc_parameters"));
assert!(!error.to_string().contains("11111111111111111111111111111111"));
}
fn helius_endpoint(url: &str) -> crate::WsEndpointSettings {
return helius_endpoint_with_session(url, crate::WsSessionSettings::default());
}
fn helius_endpoint_with_session(url: &str, session: crate::WsSessionSettings) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_helius_transaction_fixture",
true,
crate::WsProviderName::new("helius"),
crate::WsClusterName::new("local"),
crate::WsProtocolKind::HeliusLaserStream,
crate::WsEndpointUrl::parse(url).expect("local Helius WebSocket URL must parse"),
session,
);
}
fn reconnect_session_settings(backoff: std::time::Duration) -> crate::WsSessionSettings {
let defaults = crate::WsSessionSettings::default();
return crate::WsSessionSettings::new(
std::time::Duration::from_millis(250),
std::time::Duration::from_millis(200),
crate::WsReconnectSettings::new(2, backoff, backoff),
crate::WsResubscribePolicy::ActiveSubscriptions,
defaults.command_queue_capacity(),
defaults.notification_queue_capacity(),
defaults.max_active_subscriptions(),
defaults.max_pending_requests(),
defaults.max_message_size_bytes(),
defaults.max_frame_size_bytes(),
defaults.max_write_buffer_size_bytes(),
);
}
fn backpressure_session_settings() -> crate::WsSessionSettings {
let defaults = crate::WsSessionSettings::default();
return crate::WsSessionSettings::new(
std::time::Duration::from_millis(250),
std::time::Duration::from_millis(200),
crate::WsReconnectSettings::new(0, std::time::Duration::from_millis(10), std::time::Duration::from_millis(10)),
crate::WsResubscribePolicy::ActiveSubscriptions,
defaults.command_queue_capacity(),
1,
2,
defaults.max_pending_requests(),
defaults.max_message_size_bytes(),
defaults.max_frame_size_bytes(),
defaults.max_write_buffer_size_bytes(),
);
}
fn adversarial_payload_session_settings() -> crate::WsSessionSettings {
let defaults = crate::WsSessionSettings::default();
return crate::WsSessionSettings::new(
std::time::Duration::from_millis(250),
std::time::Duration::from_millis(200),
crate::WsReconnectSettings::new(2, std::time::Duration::from_millis(20), std::time::Duration::from_millis(20)),
crate::WsResubscribePolicy::ActiveSubscriptions,
defaults.command_queue_capacity(),
defaults.notification_queue_capacity(),
defaults.max_active_subscriptions(),
defaults.max_pending_requests(),
256,
128,
defaults.max_write_buffer_size_bytes(),
);
}
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
let address = listener.local_addr().expect("local listener must expose address");
return (listener, format!("ws://{address}"));
}
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
let text = message.to_text().expect("request must be text");
return serde_json::from_str(text).expect("request must contain JSON");
}
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
return;
}
async fn send_error(
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
request: &serde_json::Value,
code: i64,
message: &str,
data: serde_json::Value,
) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message,"data":data}});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local error response must send");
return;
}
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, subscription: u64, result: serde_json::Value) {
let notification = serde_json::json!({"jsonrpc":"2.0","method":"transactionNotification","params":{"subscription":subscription,"result":result}});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
return;
}
async fn send_root_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, subscription: u64, root: u64) {
let notification = serde_json::json!({"jsonrpc":"2.0","method":"rootNotification","params":{"subscription":subscription,"result":root}});
websocket
.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into()))
.await
.expect("local root notification must send");
return;
}
async fn wait_for_gap_count(session: &crate::HeliusLaserStreamWsSession, expected: u64) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if session.snapshot().continuity_gap_count() >= expected && session.state() == crate::WsSessionState::Active {
return;
}
assert!(tokio::time::Instant::now() < deadline, "Helius session continuity gap count must advance before timeout");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
async fn wait_for_overflow_count(session: &crate::HeliusLaserStreamWsSession, expected: u64) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if session.snapshot().overflow_count() >= expected {
return;
}
assert!(tokio::time::Instant::now() < deadline, "Helius session overflow count must advance before timeout");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
async fn wait_for_session_subscription_count(session: &crate::HeliusLaserStreamWsSession, expected: usize) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if session.snapshot().subscription_count() == expected {
return;
}
assert!(tokio::time::Instant::now() < deadline, "Helius session subscription count must settle before timeout");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
async fn wait_for_subscription_state<T>(subscription: &crate::WsSubscription<T>, expected: crate::WsSubscriptionState) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if subscription.state() == expected {
return;
}
assert!(tokio::time::Instant::now() < deadline, "Helius logical subscription state must advance before timeout");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
std::option::Option::Some(std::result::Result::Ok(_)) => {},
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
}
}
}
#[test]
fn helius_transaction_filter_and_option_enums_match_documented_wire_labels() {
assert_eq!(crate::HeliusTokenAccountsFilter::None.as_str(), "none");
assert_eq!(crate::HeliusTokenAccountsFilter::BalanceChanged.as_str(), "balanceChanged");
assert_eq!(crate::HeliusTokenAccountsFilter::All.as_str(), "all");
assert_eq!(crate::HeliusTransactionSubscribeEncoding::Base58.as_str(), "base58");
assert_eq!(crate::HeliusTransactionSubscribeEncoding::Base64.as_str(), "base64");
assert_eq!(crate::HeliusTransactionSubscribeEncoding::JsonParsed.as_str(), "jsonParsed");
}
#[test]
fn helius_transaction_subscribe_request_serializes_complete_documented_filter_and_options() {
let filter = base_filter();
let options = crate::HeliusTransactionSubscribeOptions::new(
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed),
std::option::Option::Some(crate::SolanaTransactionDetails::Accounts),
std::option::Option::Some(true),
std::option::Option::Some(0),
);
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
let params = super::helius_transaction_subscribe_params(&request).expect("complete documented Helius request must validate");
assert_eq!(
params,
std::vec![
serde_json::json!({
"vote": false,
"failed": false,
"signature": "fixture-signature-secret-canary",
"accountInclude": ["11111111111111111111111111111111"],
"accountExclude": ["SysvarC1ock11111111111111111111111111111111"],
"accountRequired": ["Vote111111111111111111111111111111111111111"],
"tokenAccounts": "balanceChanged"
}),
serde_json::json!({
"commitment": "confirmed",
"encoding": "jsonParsed",
"transactionDetails": "accounts",
"showRewards": true,
"maxSupportedTransactionVersion": 0
})
]
);
assert_eq!(request.filter().vote(), std::option::Option::Some(false));
assert_eq!(request.filter().failed(), std::option::Option::Some(false));
assert_eq!(request.filter().signature(), std::option::Option::Some("fixture-signature-secret-canary"));
assert_eq!(request.filter().account_include().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1));
assert_eq!(request.filter().account_exclude().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1));
assert_eq!(request.filter().account_required().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1));
assert_eq!(request.filter().token_accounts(), std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged));
let options = request.options().expect("options must remain available");
assert_eq!(options.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
assert_eq!(options.encoding(), std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed));
assert_eq!(options.transaction_details(), std::option::Option::Some(crate::SolanaTransactionDetails::Accounts));
assert_eq!(options.show_rewards(), std::option::Option::Some(true));
assert_eq!(options.max_supported_transaction_version(), std::option::Option::Some(0));
}
#[test]
fn helius_transaction_request_preserves_omitted_explicit_empty_and_explicit_none_states() {
let omitted = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
assert_eq!(
super::helius_transaction_subscribe_params(&omitted).expect("fully omitted optional request must validate"),
std::vec![serde_json::json!({})]
);
let explicit = crate::HeliusTransactionSubscribeRequest::new(
crate::HeliusTransactionSubscribeFilter::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(std::vec::Vec::new()),
std::option::Option::Some(std::vec::Vec::new()),
std::option::Option::Some(std::vec::Vec::new()),
std::option::Option::Some(crate::HeliusTokenAccountsFilter::None),
),
std::option::Option::Some(crate::HeliusTransactionSubscribeOptions::default()),
);
assert_eq!(
super::helius_transaction_subscribe_params(&explicit).expect("explicit empty Helius request states must validate"),
std::vec![serde_json::json!({"accountInclude":[],"accountExclude":[],"accountRequired":[],"tokenAccounts":"none"}), serde_json::json!({})]
);
}
#[test]
fn helius_transaction_filter_enforces_each_documented_fifty_thousand_account_bound() {
let key = pubkey("11111111111111111111111111111111");
let maximum = std::vec![key; 50_000];
let accepted = crate::HeliusTransactionSubscribeRequest::new(
crate::HeliusTransactionSubscribeFilter::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(maximum),
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
),
std::option::Option::None,
);
assert!(accepted.validate().is_ok());
assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(std::vec![key; 50_001]),
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
));
assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(std::vec![key; 50_001]),
std::option::Option::None,
std::option::Option::None,
));
assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(std::vec![key; 50_001]),
std::option::Option::None,
));
}
#[test]
fn helius_transaction_details_require_max_supported_version_only_for_accounts_and_full() {
for details in [crate::SolanaTransactionDetails::Full, crate::SolanaTransactionDetails::Accounts] {
let options = crate::HeliusTransactionSubscribeOptions::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(details),
std::option::Option::None,
std::option::Option::None,
);
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(options));
let error = request.validate().expect_err("full/accounts details must require maxSupportedTransactionVersion");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
}
for details in [crate::SolanaTransactionDetails::Signatures, crate::SolanaTransactionDetails::None] {
let options = crate::HeliusTransactionSubscribeOptions::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(details),
std::option::Option::None,
std::option::Option::None,
);
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(options));
assert!(request.validate().is_ok());
}
let full_with_version = crate::HeliusTransactionSubscribeOptions::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(crate::SolanaTransactionDetails::Full),
std::option::Option::None,
std::option::Option::Some(0),
);
let request =
crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(full_with_version));
assert!(request.validate().is_ok());
}
#[test]
fn helius_transaction_notification_decoder_preserves_full_signature_and_unknown_shapes() {
let full_value = serde_json::json!({
"transaction":{"transaction":["AAAA","base64"],"meta":{"err":null}},
"signature":"full-signature",
"slot":224341380,
"transactionIndex":42
});
let full = super::decode_helius_transaction_notification(full_value.clone()).expect("full Helius notification must decode");
match full {
crate::HeliusTransactionNotification::Full(notification) => {
assert_eq!(notification.transaction(), &full_value["transaction"]);
assert_eq!(notification.signature(), "full-signature");
assert_eq!(notification.slot(), 224341380);
assert_eq!(notification.transaction_index(), 42);
},
_ => panic!("transaction member must select the full Helius notification variant"),
}
let signature_value = serde_json::json!({
"signature":"signature-only",
"slot":224341381,
"transactionIndex":43,
"err":null,
"memo":"memo-canary",
"blockTime":1720000000,
"confirmationStatus":"confirmed"
});
let signature = super::decode_helius_transaction_notification(signature_value).expect("signature Helius notification must decode");
match signature {
crate::HeliusTransactionNotification::Signature(notification) => {
assert_eq!(notification.signature(), "signature-only");
assert_eq!(notification.slot(), 224341381);
assert_eq!(notification.transaction_index(), 43);
assert!(matches!(notification.err(), crate::SolanaWireField::Null));
assert!(matches!(notification.memo(), crate::SolanaWireField::Value(value) if value == "memo-canary"));
assert!(matches!(notification.block_time(), crate::SolanaWireField::Value(1720000000)));
assert!(matches!(notification.confirmation_status(), crate::SolanaWireField::Value(value) if value == "confirmed"));
},
_ => panic!("signature envelope must select the lightweight Helius notification variant"),
}
let unknown_value = serde_json::json!({"futureProviderShape":{"value":7}});
let unknown = super::decode_helius_transaction_notification(unknown_value.clone()).expect("unknown Helius notification must remain forward-compatible");
assert!(matches!(unknown, crate::HeliusTransactionNotification::Unknown(value) if value == unknown_value));
}
#[test]
fn helius_transaction_filter_debug_omits_signature_and_account_values() {
let filter = base_filter();
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::None);
let debug = format!("{request:?}");
assert!(debug.contains("signature_present"));
assert!(debug.contains("account_include_count"));
assert!(!debug.contains("fixture-signature-secret-canary"));
assert!(!debug.contains("11111111111111111111111111111111"));
assert!(!debug.contains("SysvarC1ock11111111111111111111111111111111"));
assert!(!debug.contains("Vote111111111111111111111111111111111111111"));
}
#[test]
fn helius_transaction_notification_debug_omits_raw_provider_payloads() {
let full_value = serde_json::json!({
"transaction":{"raw":"MASSIVE-RAW-PAYLOAD-CANARY"},
"signature":"FULL-SIGNATURE-CANARY",
"slot":77,
"transactionIndex":3
});
let full = super::decode_helius_transaction_notification(full_value).expect("full Helius notification must decode");
let signature_value = serde_json::json!({
"signature":"SIGNATURE-MODE-CANARY",
"slot":78,
"transactionIndex":4,
"err":{"secret":"ERROR-DATA-CANARY"},
"memo":"MEMO-CANARY",
"blockTime":123,
"confirmationStatus":"CONFIRMATION-CANARY"
});
let signature = super::decode_helius_transaction_notification(signature_value).expect("signature Helius notification must decode");
let unknown = super::decode_helius_transaction_notification(serde_json::json!({"provider":"UNKNOWN-PAYLOAD-CANARY"}))
.expect("unknown Helius notification must remain forward-compatible");
let rendered = format!("{full:?} {signature:?} {unknown:?}");
assert!(rendered.contains("transaction: \"<omitted>\""));
assert!(rendered.contains("signature: \"<omitted>\""));
assert!(rendered.contains("err: \"value\""));
assert!(rendered.contains("memo: \"value\""));
assert!(rendered.contains("Unknown(\"<omitted>\")"));
for forbidden in [
"MASSIVE-RAW-PAYLOAD-CANARY",
"FULL-SIGNATURE-CANARY",
"SIGNATURE-MODE-CANARY",
"ERROR-DATA-CANARY",
"MEMO-CANARY",
"CONFIRMATION-CANARY",
"UNKNOWN-PAYLOAD-CANARY",
] {
assert!(!rendered.contains(forbidden));
}
}
#[tokio::test(flavor = "current_thread")]
async fn helius_provider_rpc_application_error_is_safe_and_does_not_fail_session() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept Helius client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
let transaction_subscribe = read_request(&mut websocket).await;
assert_eq!(transaction_subscribe["method"], serde_json::json!("transactionSubscribe"));
send_error(
&mut websocket,
&transaction_subscribe,
-32602,
"PROVIDER-MESSAGE-SECRET-CANARY",
serde_json::json!({"apiKey":"PROVIDER-ERROR-SECRET-CANARY","payload":"X".repeat(4096)}),
)
.await;
let root_subscribe = read_request(&mut websocket).await;
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
send_result(&mut websocket, &root_subscribe, serde_json::json!(72)).await;
send_root_notification(&mut websocket, 72, 88).await;
let root_unsubscribe = read_request(&mut websocket).await;
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
assert_eq!(root_unsubscribe["params"], serde_json::json!([72]));
send_result(&mut websocket, &root_unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let endpoint_url = format!("{url}/?api-key=HELIUS-ENDPOINT-SECRET-CANARY");
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(endpoint_url.as_str())).await.expect("Helius facade must connect");
let request = crate::HeliusTransactionSubscribeRequest::new(base_filter(), std::option::Option::None);
let error = session.transaction_subscribe(&request).await.expect_err("provider application error must reject only the logical subscribe request");
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
assert!(error.context().iter().any(|entry| return entry.key() == "rpc_code" && entry.value() == "-32602"));
assert!(error.context().iter().any(|entry| return entry.key() == "method" && entry.value() == "transactionSubscribe"));
let rendered = format!("{error:?} {error} {session:?} {:?}", session.snapshot());
for forbidden in ["PROVIDER-MESSAGE-SECRET-CANARY", "PROVIDER-ERROR-SECRET-CANARY", "HELIUS-ENDPOINT-SECRET-CANARY", "fixture-signature-secret-canary"] {
assert!(!rendered.contains(forbidden));
}
assert_eq!(session.state(), crate::WsSessionState::Active);
wait_for_session_subscription_count(&session, 0).await;
let mut root = session.root_subscribe().await.expect("session must accept a healthy subscription after provider application error");
assert_eq!(root.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 88);
assert!(root.unsubscribe().await.expect("healthy root unsubscribe must complete"));
session.close().await.expect("Helius fixture session must close");
server.await.expect("provider error fixture server must finish");
}
#[tokio::test(flavor = "current_thread")]
async fn helius_notification_method_mismatch_fails_only_transaction_subscription() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept Helius client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
let transaction_subscribe = read_request(&mut websocket).await;
send_result(&mut websocket, &transaction_subscribe, serde_json::json!(41)).await;
let root_subscribe = read_request(&mut websocket).await;
send_result(&mut websocket, &root_subscribe, serde_json::json!(42)).await;
send_root_notification(&mut websocket, 41, 5).await;
let cleanup = read_request(&mut websocket).await;
assert_eq!(cleanup["method"], serde_json::json!("transactionUnsubscribe"));
assert_eq!(cleanup["params"], serde_json::json!([41]));
send_result(&mut websocket, &cleanup, serde_json::json!(true)).await;
send_root_notification(&mut websocket, 42, 99).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect");
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
let mut transaction = session.transaction_subscribe(&request).await.expect("transaction subscription must register");
let mut root = session.root_subscribe().await.expect("root subscription must register");
wait_for_subscription_state(&transaction, crate::WsSubscriptionState::Failed).await;
assert_eq!(transaction.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_PROTOCOL_ERROR));
assert!(transaction.recv().await.is_none());
assert_eq!(session.state(), crate::WsSessionState::Active);
wait_for_session_subscription_count(&session, 1).await;
assert_eq!(root.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 99);
assert_eq!(root.state(), crate::WsSubscriptionState::Active);
session.close().await.expect("Helius fixture session must close");
server.await.expect("notification mismatch fixture server must finish");
}
#[tokio::test(flavor = "current_thread")]
async fn helius_oversized_inbound_payload_reconnects_before_provider_json_decode() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.expect("initial Helius client must connect");
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial Helius handshake must succeed");
first
.send(tokio_tungstenite::tungstenite::Message::Text("PROVIDER-PAYLOAD-CANARY".repeat(32).into()))
.await
.expect("oversized provider fixture payload must send");
let (replacement_stream, _) = listener.accept().await.expect("replacement Helius client must connect");
let mut replacement = tokio_tungstenite::accept_async(replacement_stream).await.expect("replacement Helius handshake must succeed");
let root_subscribe = read_request(&mut replacement).await;
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
send_result(&mut replacement, &root_subscribe, serde_json::json!(91)).await;
let root_unsubscribe = read_request(&mut replacement).await;
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
send_result(&mut replacement, &root_unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut replacement).await;
});
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), adversarial_payload_session_settings()))
.await
.expect("Helius facade must connect before adversarial payload");
wait_for_gap_count(&session, 1).await;
assert_eq!(session.state(), crate::WsSessionState::Active);
assert_eq!(session.snapshot().continuity_gap_count(), 1);
let mut root = session.root_subscribe().await.expect("recovered Helius session must remain usable");
assert!(root.unsubscribe().await.expect("recovered root subscription must unsubscribe"));
session.close().await.expect("recovered Helius session must close");
server.await.expect("oversized provider payload fixture server must finish");
}
#[tokio::test(flavor = "current_thread")]
async fn helius_transaction_live_handle_decodes_notification_and_unsubscribes_through_shared_actor() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let subscribe = read_request(&mut websocket).await;
assert_eq!(subscribe["method"], serde_json::json!("transactionSubscribe"));
assert_eq!(
subscribe["params"],
serde_json::json!([
{"failed":false,"accountInclude":["11111111111111111111111111111111"],"tokenAccounts":"balanceChanged"},
{"commitment":"confirmed","encoding":"jsonParsed","transactionDetails":"full","showRewards":false,"maxSupportedTransactionVersion":0}
])
);
send_result(&mut websocket, &subscribe, serde_json::json!(4242)).await;
send_notification(
&mut websocket,
4242,
serde_json::json!({
"transaction":{"transaction":["AAAA","base64"],"meta":{"err":null}},
"signature":"live-signature",
"slot":99,
"transactionIndex":7
}),
)
.await;
let unsubscribe = read_request(&mut websocket).await;
assert_eq!(unsubscribe["method"], serde_json::json!("transactionUnsubscribe"));
assert_eq!(unsubscribe["params"], serde_json::json!([4242]));
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect");
let filter = crate::HeliusTransactionSubscribeFilter::new(
std::option::Option::None,
std::option::Option::Some(false),
std::option::Option::None,
std::option::Option::Some(std::vec![pubkey("11111111111111111111111111111111")]),
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged),
);
let options = crate::HeliusTransactionSubscribeOptions::new(
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed),
std::option::Option::Some(crate::SolanaTransactionDetails::Full),
std::option::Option::Some(false),
std::option::Option::Some(0),
);
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
let mut subscription = session.transaction_subscribe(&request).await.expect("public Helius transaction subscription must register");
assert_eq!(subscription.kind(), crate::WsSubscriptionKind::HeliusTransaction);
let notification = subscription.recv().await.expect("Helius transaction notification must arrive").expect("Helius notification must decode");
match notification {
crate::HeliusTransactionNotification::Full(notification) => {
assert_eq!(notification.signature(), "live-signature");
assert_eq!(notification.slot(), 99);
assert_eq!(notification.transaction_index(), 7);
},
_ => panic!("full live payload must decode as HeliusTransactionNotification::Full"),
}
assert!(subscription.unsubscribe().await.expect("transactionUnsubscribe must complete"));
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
session.close().await.expect("Helius fixture session must close");
server.await.expect("local Helius transaction server must finish");
}
#[tokio::test(flavor = "current_thread")]
async fn helius_transaction_reconnect_remaps_remote_id_and_ignores_late_notification_after_unsubscribe() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.expect("initial Helius client must connect");
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial Helius handshake must succeed");
let first_subscribe = read_request(&mut first).await;
assert_eq!(first_subscribe["method"], serde_json::json!("transactionSubscribe"));
send_result(&mut first, &first_subscribe, serde_json::json!(41)).await;
send_notification(&mut first, 41, serde_json::json!({"signature":"generation-one","slot":1,"transactionIndex":0})).await;
drop(first);
let (second_stream, _) = listener.accept().await.expect("replacement Helius client must connect");
let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("replacement Helius handshake must succeed");
let second_subscribe = read_request(&mut second).await;
assert_eq!(second_subscribe["method"], serde_json::json!("transactionSubscribe"));
assert_eq!(second_subscribe["params"], first_subscribe["params"]);
send_result(&mut second, &second_subscribe, serde_json::json!(99)).await;
send_notification(&mut second, 99, serde_json::json!({"signature":"generation-two","slot":2,"transactionIndex":1})).await;
let unsubscribe = read_request(&mut second).await;
assert_eq!(unsubscribe["method"], serde_json::json!("transactionUnsubscribe"));
assert_eq!(unsubscribe["params"], serde_json::json!([99]));
send_notification(&mut second, 99, serde_json::json!({"signature":"late-after-cancel","slot":3,"transactionIndex":2})).await;
send_result(&mut second, &unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut second).await;
});
let settings = reconnect_session_settings(std::time::Duration::from_millis(20));
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), settings)).await.expect("Helius facade must connect");
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
let mut subscription = session.transaction_subscribe(&request).await.expect("initial Helius transaction subscription must register");
let stable_id = subscription.id();
let first = subscription.recv().await.expect("first generation notification must arrive").expect("first generation notification must decode");
assert!(matches!(first, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "generation-one"));
let second = tokio::time::timeout(std::time::Duration::from_secs(2), subscription.recv())
.await
.expect("resubscribed Helius notification must remain bounded")
.expect("resubscribed Helius channel must remain open")
.expect("resubscribed Helius notification must decode");
assert!(matches!(second, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "generation-two"));
assert_eq!(subscription.id(), stable_id);
assert_eq!(subscription.state(), crate::WsSubscriptionState::Active);
wait_for_gap_count(&session, 1).await;
assert_eq!(session.snapshot().continuity_gap_count(), 1);
assert!(subscription.unsubscribe().await.expect("Helius transaction cancellation must complete"));
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
assert!(tokio::time::timeout(std::time::Duration::from_millis(100), subscription.recv()).await.expect("closed Helius channel must settle").is_none());
session.close().await.expect("Helius fixture session must close");
server.await.expect("local reconnect Helius server must finish");
}
#[tokio::test(flavor = "current_thread")]
async fn helius_transaction_backpressure_fails_only_slow_subscription_and_uses_transaction_unsubscribe_cleanup() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept Helius client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
let transaction_subscribe = read_request(&mut websocket).await;
assert_eq!(transaction_subscribe["method"], serde_json::json!("transactionSubscribe"));
send_result(&mut websocket, &transaction_subscribe, serde_json::json!(41)).await;
let root_subscribe = read_request(&mut websocket).await;
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
send_result(&mut websocket, &root_subscribe, serde_json::json!(42)).await;
send_notification(&mut websocket, 41, serde_json::json!({"signature":"queued","slot":1,"transactionIndex":0})).await;
send_notification(&mut websocket, 41, serde_json::json!({"signature":"overflow","slot":2,"transactionIndex":1})).await;
let cleanup = read_request(&mut websocket).await;
assert_eq!(cleanup["method"], serde_json::json!("transactionUnsubscribe"));
assert_eq!(cleanup["params"], serde_json::json!([41]));
send_result(&mut websocket, &cleanup, serde_json::json!(true)).await;
send_root_notification(&mut websocket, 42, 99).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), backpressure_session_settings()))
.await
.expect("Helius facade must connect");
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
let mut slow = session.transaction_subscribe(&request).await.expect("slow Helius transaction subscription must register");
let mut healthy = session.root_subscribe().await.expect("healthy Helius root subscription must register");
wait_for_subscription_state(&slow, crate::WsSubscriptionState::Failed).await;
wait_for_overflow_count(&session, 1).await;
assert_eq!(slow.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW));
assert_eq!(session.state(), crate::WsSessionState::Active);
assert_eq!(session.snapshot().subscription_count(), 1);
let queued = slow.recv().await.expect("first Helius notification must remain queued").expect("queued Helius notification must decode");
assert!(matches!(queued, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "queued"));
assert!(slow.recv().await.is_none());
assert_eq!(healthy.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 99);
assert_eq!(healthy.state(), crate::WsSubscriptionState::Active);
assert_eq!(healthy.terminal_error_code(), std::option::Option::None);
session.close().await.expect("Helius fixture session must close");
server.await.expect("local Helius backpressure server must finish");
}

View File

@@ -0,0 +1,135 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
// version: 5
fn non_zero(value: u64) -> std::num::NonZeroU64 {
return std::num::NonZeroU64::new(value).expect("test ID must be non-zero");
}
#[test]
fn websocket_local_ids_preserve_ordering_and_numeric_identity() {
let first_session = crate::WsSessionId::new(non_zero(1));
let second_session = crate::WsSessionId::new(non_zero(2));
let subscription = crate::WsSubscriptionId::new(non_zero(7));
assert!(first_session < second_session);
assert_eq!(subscription.get(), 7);
}
#[test]
fn websocket_state_models_expose_concurrent_lifecycle_states() {
assert_eq!(crate::WsSessionState::Reconnecting { attempt: 3 }, crate::WsSessionState::Reconnecting { attempt: 3 });
assert_eq!(crate::WsSubscriptionState::Resubscribing, crate::WsSubscriptionState::Resubscribing);
assert_ne!(crate::WsSubscriptionState::Cancelling, crate::WsSubscriptionState::Closed);
}
#[test]
fn websocket_subscription_kinds_cover_all_nine_standard_families() {
let kinds = [
crate::WsSubscriptionKind::Account,
crate::WsSubscriptionKind::Block,
crate::WsSubscriptionKind::Logs,
crate::WsSubscriptionKind::Program,
crate::WsSubscriptionKind::Root,
crate::WsSubscriptionKind::Signature,
crate::WsSubscriptionKind::Slot,
crate::WsSubscriptionKind::SlotsUpdates,
crate::WsSubscriptionKind::Vote,
];
assert_eq!(kinds.len(), 9);
assert_eq!(kinds[7].as_str(), "slots_updates");
}
#[test]
fn websocket_snapshots_expose_safe_metadata_without_remote_ids_or_urls() {
let subscription = crate::WsSubscriptionSnapshot::new(
crate::WsSubscriptionId::new(non_zero(9)),
crate::WsSubscriptionKind::Slot,
crate::WsSubscriptionState::Active,
true,
std::option::Option::None,
);
let snapshot = crate::WsSessionSnapshot::new(
crate::WsSessionId::new(non_zero(3)),
"devnet_public",
crate::WsProviderName::new("solana-public"),
crate::WsClusterName::new("devnet"),
crate::WsProtocolKind::SolanaStandard,
crate::WsSessionState::Active,
2,
1,
0,
std::vec![subscription],
);
assert_eq!(snapshot.id().get(), 3);
assert_eq!(snapshot.endpoint_name(), "devnet_public");
assert_eq!(snapshot.pending_request_count(), 2);
assert_eq!(snapshot.continuity_gap_count(), 1);
assert_eq!(snapshot.subscription_count(), 1);
assert!(snapshot.subscriptions()[0].remote_bound());
assert_eq!(snapshot.subscriptions()[0].terminal_error_code(), std::option::Option::None);
assert_eq!(snapshot.overflow_count(), 0);
let rendered = format!("{snapshot:?}");
assert!(!rendered.contains("wss://"));
assert!(!rendered.contains("remote_subscription_id"));
}
#[test]
fn websocket_subscription_snapshot_preserves_only_safe_terminal_error_code() {
let snapshot = crate::WsSubscriptionSnapshot::new(
crate::WsSubscriptionId::new(non_zero(10)),
crate::WsSubscriptionKind::Logs,
crate::WsSubscriptionState::Failed,
false,
std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW),
);
assert_eq!(snapshot.state(), crate::WsSubscriptionState::Failed);
assert_eq!(snapshot.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW));
assert!(!format!("{snapshot:?}").contains("remote_subscription_id"));
}
#[test]
fn websocket_subscription_kinds_map_exact_standard_method_triplets() {
let cases = [
(crate::WsSubscriptionKind::Account, "accountSubscribe", "accountUnsubscribe", "accountNotification"),
(crate::WsSubscriptionKind::Block, "blockSubscribe", "blockUnsubscribe", "blockNotification"),
(crate::WsSubscriptionKind::Logs, "logsSubscribe", "logsUnsubscribe", "logsNotification"),
(crate::WsSubscriptionKind::Program, "programSubscribe", "programUnsubscribe", "programNotification"),
(crate::WsSubscriptionKind::Root, "rootSubscribe", "rootUnsubscribe", "rootNotification"),
(crate::WsSubscriptionKind::Signature, "signatureSubscribe", "signatureUnsubscribe", "signatureNotification"),
(crate::WsSubscriptionKind::Slot, "slotSubscribe", "slotUnsubscribe", "slotNotification"),
(crate::WsSubscriptionKind::SlotsUpdates, "slotsUpdatesSubscribe", "slotsUpdatesUnsubscribe", "slotsUpdatesNotification"),
(crate::WsSubscriptionKind::Vote, "voteSubscribe", "voteUnsubscribe", "voteNotification"),
];
for (kind, subscribe, unsubscribe, notification) in cases {
assert_eq!(kind.subscribe_method(), subscribe);
assert_eq!(kind.unsubscribe_method(), unsubscribe);
assert_eq!(kind.notification_method(), notification);
}
}
#[test]
fn websocket_unstable_subscription_partition_is_exact() {
let cases = [
(crate::WsSubscriptionKind::Account, false),
(crate::WsSubscriptionKind::Block, true),
(crate::WsSubscriptionKind::Logs, false),
(crate::WsSubscriptionKind::Program, false),
(crate::WsSubscriptionKind::Root, false),
(crate::WsSubscriptionKind::Signature, false),
(crate::WsSubscriptionKind::Slot, false),
(crate::WsSubscriptionKind::SlotsUpdates, true),
(crate::WsSubscriptionKind::Vote, true),
];
for (kind, unstable) in cases {
assert_eq!(kind.is_unstable(), unstable);
}
}
#[test]
fn helius_transaction_subscription_kind_maps_exact_provider_method_triplet_without_expanding_standard_partition() {
let kind = crate::WsSubscriptionKind::HeliusTransaction;
assert_eq!(kind.as_str(), "helius_transaction");
assert_eq!(kind.subscribe_method(), "transactionSubscribe");
assert_eq!(kind.unsubscribe_method(), "transactionUnsubscribe");
assert_eq!(kind.notification_method(), "transactionNotification");
assert!(!kind.is_unstable());
}

View File

@@ -0,0 +1,104 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_protocol_session.rs
// version: 3
use futures_util::StreamExt; // rust-rules: trait-import
fn endpoint(url: &str, protocol: crate::WsProtocolKind) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_protocol_fixture",
true,
crate::WsProviderName::new("local-fixture"),
crate::WsClusterName::new("local"),
protocol,
crate::WsEndpointUrl::parse(url).expect("local WebSocket URL must parse"),
crate::WsSessionSettings::default(),
);
}
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
let address = listener.local_addr().expect("local listener must expose address");
return (listener, format!("ws://{address}"));
}
async fn accept_until_close(listener: tokio::net::TcpListener) {
let (stream, _) = listener.accept().await.expect("local peer must accept connection");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
while let std::option::Option::Some(message) = websocket.next().await {
let message = message.expect("local peer message must decode");
if message.is_close() {
return;
}
}
return;
}
#[tokio::test]
async fn protocol_facades_share_the_existing_physical_session_path() {
let (standard_listener, standard_url) = bind_local_listener().await;
let standard_server = tokio::spawn(accept_until_close(standard_listener));
let standard = crate::SolanaStandardWsSession::connect(endpoint(standard_url.as_str(), crate::WsProtocolKind::SolanaStandard))
.await
.expect("standard facade must connect");
assert_eq!(standard.snapshot().protocol(), crate::WsProtocolKind::SolanaStandard);
standard.close().await.expect("standard facade must close");
standard_server.await.expect("standard peer task must finish");
let (helius_listener, helius_url) = bind_local_listener().await;
let helius_server = tokio::spawn(accept_until_close(helius_listener));
let helius_url = format!("{helius_url}/?api-key=SECRET-CANARY");
let helius = crate::HeliusLaserStreamWsSession::connect(endpoint(helius_url.as_str(), crate::WsProtocolKind::HeliusLaserStream))
.await
.expect("Helius facade must connect");
assert_eq!(helius.snapshot().protocol(), crate::WsProtocolKind::HeliusLaserStream);
let rendered = format!("{helius:?}");
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains(helius_url.as_str()));
helius.close().await.expect("Helius facade must close");
helius_server.await.expect("Helius peer task must finish");
}
#[tokio::test]
async fn historical_generic_constructor_remains_standard_only_before_network_io() {
let endpoint = endpoint("ws://127.0.0.1:9", crate::WsProtocolKind::HeliusLaserStream);
let error = crate::WsSession::connect(endpoint).await.expect_err("generic historical constructor must reject Helius protocol");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
assert_eq!(
error.context().iter().find(|entry| return entry.key() == "expected_protocol").map(|entry| return entry.value()),
std::option::Option::Some("solana_standard")
);
assert_eq!(
error.context().iter().find(|entry| return entry.key() == "actual_protocol").map(|entry| return entry.value()),
std::option::Option::Some("helius_laserstream")
);
}
#[tokio::test]
async fn typed_facades_reject_protocol_mismatch_before_network_io() {
let helius_error = crate::HeliusLaserStreamWsSession::connect(endpoint("ws://127.0.0.1:9", crate::WsProtocolKind::SolanaStandard))
.await
.expect_err("Helius facade must reject standard endpoint");
assert_eq!(helius_error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
let standard_error = crate::SolanaStandardWsSession::connect(endpoint("ws://127.0.0.1:9", crate::WsProtocolKind::HeliusLaserStream))
.await
.expect_err("standard facade must reject Helius endpoint");
assert_eq!(standard_error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn protocol_facades_define_no_second_actor_socket_or_public_inner_escape_hatch() {
let source = include_str!("../src/ws_protocol_session.rs");
assert!(!source.contains("tokio::spawn"));
assert!(!source.contains("tokio_tungstenite"));
assert!(!source.contains("WsSessionCommand"));
assert!(!source.contains("pub fn inner("));
assert!(!source.contains("pub fn into_inner("));
assert!(!source.contains("pub async fn account_subscribe"));
assert!(!source.contains("pub async fn block_subscribe"));
assert!(!source.contains("pub async fn logs_subscribe"));
assert!(!source.contains("pub async fn program_subscribe"));
assert!(!source.contains("pub async fn root_subscribe"));
assert!(!source.contains("pub async fn signature_subscribe"));
assert!(!source.contains("pub async fn slot_subscribe"));
assert!(!source.contains("pub async fn slots_updates_subscribe"));
assert!(!source.contains("pub async fn vote_subscribe"));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
// version: 2
fn valid_endpoint(name: &str, url_text: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
name,
true,
crate::WsProviderName::new("solana-public"),
crate::WsClusterName::new("devnet"),
crate::WsProtocolKind::SolanaStandard,
crate::WsEndpointUrl::parse(url_text).expect("test WebSocket URL must parse"),
crate::WsSessionSettings::default(),
);
}
#[test]
fn websocket_endpoint_url_accepts_ws_and_wss() {
assert!(crate::WsEndpointUrl::parse("wss://api.devnet.solana.com").is_ok());
assert!(crate::WsEndpointUrl::parse("ws://127.0.0.1:8900").is_ok());
}
#[test]
fn websocket_endpoint_url_rejects_http_schemes() {
let result = crate::WsEndpointUrl::parse("https://api.devnet.solana.com");
let error = result.expect_err("HTTP URL must not be accepted by WebSocket settings");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn websocket_endpoint_url_debug_redacts_secret_material() {
let url = crate::WsEndpointUrl::parse("wss://user:password@provider.invalid/path?api-key=SECRET-CANARY").expect("test URL must parse");
let rendered = format!("{url:?}");
assert_eq!(rendered, "WsEndpointUrl(<redacted>)");
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
assert!(!rendered.contains("password"));
}
#[test]
fn websocket_endpoint_url_errors_do_not_echo_sensitive_url() {
let result = crate::WsEndpointUrl::parse("http://user:password@provider.invalid/path?api-key=SECRET-CANARY");
let error = result.expect_err("unsupported scheme must fail");
let rendered = format!("{error:?}");
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
assert!(!rendered.contains("password"));
}
#[test]
fn websocket_protocol_kind_distinguishes_standard_and_helius_laserstream_websocket() {
assert_eq!(crate::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
assert_eq!(crate::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
assert_ne!(crate::WsProtocolKind::SolanaStandard, crate::WsProtocolKind::HeliusLaserStream);
}
#[test]
fn websocket_session_defaults_are_bounded_and_validate() {
let settings = crate::WsSessionSettings::default();
assert!(settings.validate().is_ok());
assert!(settings.command_queue_capacity() > 0);
assert!(settings.notification_queue_capacity() > 0);
assert!(settings.max_active_subscriptions() > 0);
assert!(settings.max_pending_requests() > 0);
assert!(settings.max_message_size_bytes() > 0);
assert!(settings.max_frame_size_bytes() > 0);
assert!(settings.max_write_buffer_size_bytes() > 0);
assert_eq!(settings.resubscribe(), crate::WsResubscribePolicy::ActiveSubscriptions);
}
#[test]
fn websocket_session_settings_reject_zero_runtime_bounds() {
let defaults = crate::WsSessionSettings::default();
let settings = crate::WsSessionSettings::new(
defaults.command_timeout(),
defaults.close_timeout(),
defaults.reconnect().clone(),
defaults.resubscribe(),
0,
defaults.notification_queue_capacity(),
defaults.max_active_subscriptions(),
defaults.max_pending_requests(),
defaults.max_message_size_bytes(),
defaults.max_frame_size_bytes(),
defaults.max_write_buffer_size_bytes(),
);
let error = settings.validate().expect_err("zero command queue capacity must fail");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn websocket_session_settings_reject_reversed_reconnect_backoff() {
let defaults = crate::WsSessionSettings::default();
let settings = crate::WsSessionSettings::new(
defaults.command_timeout(),
defaults.close_timeout(),
crate::WsReconnectSettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
defaults.resubscribe(),
defaults.command_queue_capacity(),
defaults.notification_queue_capacity(),
defaults.max_active_subscriptions(),
defaults.max_pending_requests(),
defaults.max_message_size_bytes(),
defaults.max_frame_size_bytes(),
defaults.max_write_buffer_size_bytes(),
);
assert!(settings.validate().is_err());
}
#[test]
fn websocket_transport_settings_validate_unique_enabled_endpoints() {
let settings = crate::WsTransportSettings::new(std::vec![
valid_endpoint("devnet_primary", "wss://api.devnet.solana.com"),
valid_endpoint("devnet_secondary", "wss://example.invalid/ws"),
]);
assert!(settings.validate().is_ok());
}
#[test]
fn websocket_transport_settings_reject_duplicate_endpoint_names() {
let settings =
crate::WsTransportSettings::new(std::vec![valid_endpoint("duplicate", "wss://one.invalid/ws"), valid_endpoint("duplicate", "wss://two.invalid/ws"),]);
let error = settings.validate().expect_err("duplicate endpoint names must fail");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn websocket_transport_settings_require_one_enabled_endpoint() {
let endpoint = crate::WsEndpointSettings::new(
"disabled",
false,
crate::WsProviderName::new("provider"),
crate::WsClusterName::new("devnet"),
crate::WsProtocolKind::SolanaStandard,
crate::WsEndpointUrl::parse("wss://provider.invalid/ws").expect("test URL must parse"),
crate::WsSessionSettings::default(),
);
let settings = crate::WsTransportSettings::new(std::vec![endpoint]);
assert!(settings.validate().is_err());
}
#[test]
fn websocket_transport_settings_debug_never_exposes_endpoint_url() {
let settings =
crate::WsTransportSettings::new(std::vec![valid_endpoint("secret_endpoint", "wss://user:password@provider.invalid/path?api-key=SECRET-CANARY",)]);
let rendered = format!("{settings:?}");
assert!(rendered.contains("WsEndpointUrl(<redacted>)"));
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
assert!(!rendered.contains("password"));
}

View File

@@ -0,0 +1,246 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
// version: 2
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
return crate::WsEndpointSettings::new(
"local_ws_transactions",
true,
crate::WsProviderName::new("local-fixture"),
crate::WsClusterName::new("local"),
crate::WsProtocolKind::SolanaStandard,
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
crate::WsSessionSettings::default(),
);
}
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
let address = listener.local_addr().expect("local listener must expose address");
return (listener, format!("ws://{address}"));
}
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
let text = message.to_text().expect("request must be text");
return serde_json::from_str(text).expect("request must contain JSON");
}
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
}
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
loop {
let message = websocket.next().await;
match message {
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
std::option::Option::Some(std::result::Result::Ok(_)) => {},
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
}
}
}
#[test]
fn logs_subscribe_filters_preserve_all_all_with_votes_and_exactly_one_mention() {
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("mention fixture must parse");
assert_eq!(crate::SolanaLogsSubscribeFilter::All.to_json_value(), serde_json::json!("all"));
assert_eq!(crate::SolanaLogsSubscribeFilter::AllWithVotes.to_json_value(), serde_json::json!("allWithVotes"));
assert_eq!(crate::SolanaLogsSubscribeFilter::Mentions(pubkey).to_json_value(), serde_json::json!({"mentions":["11111111111111111111111111111111"]}));
}
#[test]
fn logs_notification_decoder_preserves_context_signature_nullable_error_and_ordered_logs() {
let success = super::decode_logs_notification(
"logsSubscribe",
serde_json::json!({
"context":{"slot":81,"apiVersion":"4.2.1"},
"value":{"signature":"fixture-signature","err":null,"logs":["first","second"]}
}),
)
.expect("successful logs notification must decode");
assert_eq!(success.context().slot(), 81);
assert_eq!(success.value().signature(), "fixture-signature");
assert!(success.value().err().is_none());
assert_eq!(success.value().logs(), &["first".to_owned(), "second".to_owned()]);
let failed = super::decode_logs_notification(
"logsSubscribe",
serde_json::json!({"context":{"slot":82},"value":{"signature":"fixture-signature-2","err":{"InstructionError":[0,"Custom"]},"logs":[]}}),
)
.expect("failed logs notification must preserve transaction error wire value");
assert_eq!(failed.context().slot(), 82);
assert!(failed.value().err().is_some());
let missing_err =
super::decode_logs_notification("logsSubscribe", serde_json::json!({"context":{"slot":83},"value":{"signature":"fixture-signature-3","logs":[]}}));
assert!(missing_err.is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn stable_logs_wrapper_uses_exact_filter_config_notification_and_handle_unsubscribe() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let subscribe = read_request(&mut websocket).await;
assert_eq!(subscribe["method"], serde_json::json!("logsSubscribe"));
assert_eq!(subscribe["params"], serde_json::json!([{"mentions":["11111111111111111111111111111111"]},{"commitment":"finalized"}]));
send_result(&mut websocket, &subscribe, serde_json::json!(88)).await;
let id = subscribe.get("id").and_then(serde_json::Value::as_u64).expect("subscribe request id must exist");
assert!(id > 0);
let notification = serde_json::json!({
"jsonrpc":"2.0",
"method":"logsNotification",
"params":{
"result":{"context":{"slot":900},"value":{"signature":"fixture-signature","err":null,"logs":["Program fixture success"]}},
"subscription":88
}
});
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("logs notification must send");
let unsubscribe = read_request(&mut websocket).await;
assert_eq!(unsubscribe["method"], serde_json::json!("logsUnsubscribe"));
assert_eq!(unsubscribe["params"], serde_json::json!([88]));
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let mention = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("mention fixture must parse");
let filter = crate::SolanaLogsSubscribeFilter::Mentions(mention);
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
let mut subscription = session.logs_subscribe(&filter, std::option::Option::Some(&config)).await.expect("logsSubscribe must register");
assert_eq!(subscription.kind(), crate::WsSubscriptionKind::Logs);
let notification = subscription.recv().await.expect("logs notification must arrive").expect("logs notification must decode");
assert_eq!(notification.context().slot(), 900);
assert_eq!(notification.value().signature(), "fixture-signature");
assert_eq!(notification.value().logs(), &["Program fixture success".to_owned()]);
assert!(subscription.unsubscribe().await.expect("logs unsubscribe must complete"));
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}
#[test]
fn signature_subscribe_config_and_decoder_preserve_all_documented_wire_variants() {
let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(false));
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
assert_eq!(config.enable_received_notification(), std::option::Option::Some(false));
assert_eq!(config.to_json_value(), serde_json::json!({"commitment":"confirmed","enableReceivedNotification":false}));
let received = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":90},"value":"receivedSignature"}))
.expect("receivedSignature notification must decode");
assert_eq!(received.context().slot(), 90);
assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature);
assert!(!received.value().is_terminal());
let success = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":91},"value":{"err":null}}))
.expect("terminal successful signature notification must decode");
assert!(success.value().is_terminal());
assert!(success.value().err().is_none());
let failure = super::decode_signature_notification(
"signatureSubscribe",
serde_json::json!({"context":{"slot":92},"value":{"err":{"InstructionError":[0,"Custom"]}}}),
)
.expect("terminal failed signature notification must decode");
assert!(failure.value().is_terminal());
assert!(failure.value().err().is_some());
assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":93},"value":"futureVariant"})).is_err());
assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":94},"value":{}})).is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn signature_unsubscribe_before_terminal_notification_uses_current_remote_id() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
let subscribe = read_request(&mut websocket).await;
assert_eq!(subscribe["method"], serde_json::json!("signatureSubscribe"));
assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature"]));
send_result(&mut websocket, &subscribe, serde_json::json!(301)).await;
let unsubscribe = read_request(&mut websocket).await;
assert_eq!(unsubscribe["method"], serde_json::json!("signatureUnsubscribe"));
assert_eq!(unsubscribe["params"], serde_json::json!([301]));
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
wait_for_close_frame(&mut websocket).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let empty_config = crate::SolanaSignatureSubscribeConfig::default();
let mut subscription =
session.signature_subscribe("fixture-signature", std::option::Option::Some(&empty_config)).await.expect("signatureSubscribe must register");
assert!(subscription.unsubscribe().await.expect("signature unsubscribe must complete"));
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn signature_terminal_notification_closes_handle_and_is_not_resubscribed_after_reconnect() {
let (listener, url) = bind_local_listener().await;
let (send_terminal_tx, send_terminal_rx) = tokio::sync::oneshot::channel();
let (replacement_ready_tx, replacement_ready_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept initial client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("initial WebSocket handshake must succeed");
let subscribe = read_request(&mut websocket).await;
assert_eq!(subscribe["method"], serde_json::json!("signatureSubscribe"));
assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature",{"commitment":"finalized","enableReceivedNotification":true}]));
send_result(&mut websocket, &subscribe, serde_json::json!(401)).await;
let received = serde_json::json!({
"jsonrpc":"2.0",
"method":"signatureNotification",
"params":{"result":{"context":{"slot":100},"value":"receivedSignature"},"subscription":401}
});
websocket
.send(tokio_tungstenite::tungstenite::Message::Text(received.to_string().into()))
.await
.expect("receivedSignature notification must send");
send_terminal_rx.await.expect("client must observe early signature notification before terminal send");
let terminal = serde_json::json!({
"jsonrpc":"2.0",
"method":"signatureNotification",
"params":{"result":{"context":{"slot":101},"value":{"err":null}},"subscription":401}
});
websocket
.send(tokio_tungstenite::tungstenite::Message::Text(terminal.to_string().into()))
.await
.expect("terminal signature notification must send");
let unexpected_cleanup = tokio::time::timeout(std::time::Duration::from_millis(100), websocket.next()).await;
assert!(unexpected_cleanup.is_err(), "server-terminal signature notification must not trigger signatureUnsubscribe");
drop(websocket);
let (replacement_stream, _) = listener.accept().await.expect("local server must accept replacement client");
let mut replacement = tokio_tungstenite::accept_async(replacement_stream).await.expect("replacement WebSocket handshake must succeed");
let unexpected = tokio::time::timeout(std::time::Duration::from_millis(100), replacement.next()).await;
assert!(unexpected.is_err(), "terminal signature subscription must not be replayed after reconnect");
replacement_ready_tx.send(()).expect("replacement-ready signal must send");
wait_for_close_frame(&mut replacement).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(true));
let mut subscription =
session.signature_subscribe("fixture-signature", std::option::Option::Some(&config)).await.expect("signatureSubscribe must register");
let received = subscription.recv().await.expect("receivedSignature must arrive").expect("receivedSignature must decode");
assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature);
assert_eq!(subscription.state(), crate::WsSubscriptionState::Active);
send_terminal_tx.send(()).expect("terminal-send signal must reach fixture");
let terminal = subscription.recv().await.expect("terminal signature notification must arrive").expect("terminal signature notification must decode");
assert!(terminal.value().is_terminal());
assert!(terminal.value().err().is_none());
let closed = tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
if subscription.state() == crate::WsSubscriptionState::Closed {
return;
}
tokio::task::yield_now().await;
}
})
.await;
assert!(closed.is_ok());
assert!(subscription.recv().await.is_none());
assert!(subscription.terminal_error_code().is_none());
assert!(!subscription.unsubscribe().await.expect("already terminal signature unsubscribe must be local-only"));
replacement_ready_rx.await.expect("replacement connection must be observed without signature replay");
assert_eq!(session.snapshot().subscription_count(), 0);
assert!(session.snapshot().continuity_gap_count() >= 1);
session.close().await.expect("session close must complete");
server.await.expect("local server task must complete");
}

View File

@@ -1,11 +1,11 @@
<!-- file: crates/ksp-wallet-lib/README.md --> <!-- file: crates/ksp-wallet-lib/README.md -->
<!-- version: 5 --> <!-- version: 6 -->
# `ksp-wallet-lib` # `ksp-wallet-lib`
Statut : **stable depuis KSP `0.2.5` ; surface V2/multi-version stable depuis `0.2.6`**. Statut : **stable ; surface V1/V2 et façade multi-version validées**.
`ksp-wallet-lib` est la bibliothèque KSP propriétaire du Wallet Solana natif. Elle possède le format autonome `.kspwallet` V1 et le format binaire V2 canonique, les capacités indépendantes VIEW/OWNER, la protection du secret Solana, la signature, l'administration des metadata, les rotations de credentials, la persistence native et les adapters d'import/export explicitement supportés. Depuis KSP `0.2.6`, les APIs non versionnées créent/importent en V2 par default explicite et lisent V1/V2 par détection bornée. La migration V1 -> V2 est explicite et OWNER-authentifiée, sans migration à l'ouverture. `ksp-wallet-lib` est la bibliothèque KSP propriétaire du Wallet Solana natif. Elle possède le format autonome `.kspwallet` V1 et le format binaire V2 canonique, les capacités indépendantes VIEW/OWNER, la protection du secret Solana, la signature, l'administration des metadata, les rotations de credentials, la persistence native et les adapters d'import/export explicitement supportés. Les APIs non versionnées créent/importent en V2 par default explicite et lisent V1/V2 par détection bornée. La migration V1 -> V2 est explicite et OWNER-authentifiée, sans migration à l'ouverture.
La crate est volontairement indépendante de Config, du réseau et de Tauri. Un consumer fournit les chemins, passwords et metadata ; Wallet ouvre, protège, signe et persiste sans décider d'une policy de dépense ni contacter un RPC. La crate est volontairement indépendante de Config, du réseau et de Tauri. Un consumer fournit les chemins, passwords et metadata ; Wallet ouvre, protège, signe et persiste sans décider d'une policy de dépense ni contacter un RPC.
@@ -191,13 +191,13 @@ Elles couvrent le wire, Argon2id/XChaCha20-Poly1305, l'ouverture VIEW/OWNER, la
- [`USAGE.md`](USAGE.md) — exemples des principales surfaces publiques ; - [`USAGE.md`](USAGE.md) — exemples des principales surfaces publiques ;
- [`../../docs/formats/KSPWALLET_V1.md`](../../docs/formats/KSPWALLET_V1.md) — spécification normative indépendante de Rust ; - [`../../docs/formats/KSPWALLET_V1.md`](../../docs/formats/KSPWALLET_V1.md) — spécification normative indépendante de Rust ;
- [`../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan historique et threat model de `0.2.5` ; - [`../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan historique et threat model de la fondation Wallet ;
- [`../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) — matrice de sécurité/interoperabilité/compliance ; - [`../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) — matrice de sécurité/interoperabilité/compliance ;
- [`../../prompts/011-V0_2_6_START_PROMPT.md`](../../prompts/011-V0_2_6_START_PROMPT.md) — reprise vers Wallet Desk après publication stable de `0.2.5`. - [`../../prompts/011-V0_2_6_START_PROMPT.md`](../../prompts/011-V0_2_6_START_PROMPT.md) — prompt historique de reprise vers Wallet Desk.
## V2 stable depuis `0.2.6` ## Wire/runtime V2 et façade multi-version
`pre.015` a figé le wire structurel V2, son codec et ses transcripts/AAD. `pre.016` matérialise le runtime V2 complet et la façade multi-version : Le wire structurel V2, son codec et ses transcripts/AAD sont figés ; le runtime V2 complet et la façade multi-version exposent :
```text ```text
DEFAULT_WALLET_FORMAT = V2 DEFAULT_WALLET_FORMAT = V2
@@ -211,7 +211,7 @@ open/inspect génériques -> détection V1/V2
open/inspect _v1/_v2 -> format forcé strict open/inspect _v1/_v2 -> format forcé strict
``` ```
`WalletOwner` et `WalletView` conservent le format natif qu'ils ont ouvert : metadata, rotations OWNER/VIEW, disable/recreate VIEW, self-rotation VIEW, signature et export ne transcodent jamais implicitement le fichier. Le default est une décision explicite et ne suit pas automatiquement une future V3. `pre.017` matérialise la migration authentifiée V1 -> V2 comme opération séparée ; aucune lecture ou mutation ordinaire ne migre implicitement. `WalletOwner` et `WalletView` conservent le format natif qu'ils ont ouvert : metadata, rotations OWNER/VIEW, disable/recreate VIEW, self-rotation VIEW, signature et export ne transcodent jamais implicitement le fichier. Le default est une décision explicite et ne suit pas automatiquement une future V3. La migration authentifiée V1 -> V2 est une opération séparée ; aucune lecture ou mutation ordinaire ne migre implicitement.
### Migration explicite V1 -> V2 ### Migration explicite V1 -> V2
```text ```text

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-wallet-lib/USAGE.md --> <!-- file: crates/ksp-wallet-lib/USAGE.md -->
<!-- version: 5 --> <!-- version: 6 -->
# Utilisation de `ksp-wallet-lib` # Utilisation de `ksp-wallet-lib`
@@ -278,9 +278,9 @@ ksp-onchain-transport-lib
-> utilise cette Pubkey pour getBalance et autres lectures réseau -> utilise cette Pubkey pour getBalance et autres lectures réseau
``` ```
Cette composition est le rôle de `0.2.6 — ksp-app-wallet-desk`, pas de `ksp-wallet-lib`. Cette composition appartient à `ksp-app-wallet-desk`, pas à `ksp-wallet-lib`.
## Wire/runtime V2 stable (`0.2.6`) ## Wire/runtime V2
Le codec structurel V2 reste disponible directement pour les outils qui travaillent explicitement au niveau wire : Le codec structurel V2 reste disponible directement pour les outils qui travaillent explicitement au niveau wire :

View File

@@ -0,0 +1,147 @@
<!-- file: deltas/0.2.7/pre.001-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.001-fix.001` — correction documentaire du gate WebSocket
## Base requise
Livraison immédiatement précédente :
```text
0.2.7-pre.001
workspace.package.version = 0.2.7-pre.1
```
Ce fix est **documentaire uniquement**. Conformément à `VERSION_WORKFLOW.md` et `FILE_CONTRACTS.md`, il ne modifie pas `Cargo.toml` et conserve :
```text
workspace.package.version = 0.2.7-pre.1
commit = v0.2.7-pre.001-fix.001
aucun tag prerelease
```
## Objet
Corriger et renforcer le gate `pre.001` avant toute implémentation WebSocket :
- partir des versions du plan `014` et de la compliance `010` réalignées par l'opérateur ;
- interdire les séparateurs `|` à l'intérieur des cellules de tableaux Markdown, en utilisant `ou`, du texte ou une autre forme non ambiguë ;
- remplacer le cross-check Agave `v3.1.8` par la baseline Git actuelle **Agave `v4.2.1`** ;
- appliquer au WebSocket la même discipline que la compliance HTTP : documentation Solana, tag Git Agave courant et audit SIMD ;
- ajouter un audit SIMD ciblé des changements pouvant affecter le wire, la sémantique ou les bornes de ressources WebSocket ;
- préparer `std.transport` V2 à plusieurs familles WebSocket via un discriminateur explicite, sans ajouter de paramètres Helius dans `0.2.7`.
## Baseline Agave corrigée
Le tag Git audité est :
```text
anza-xyz/agave v4.2.1
```
Le cross-check `v4.2.1` confirme :
```text
9 subscribe + 9 unsubscribe = 18 opérations standard actuelles
aucune famille PubSub standard supplémentaire détectée
accountSubscribe.minContextSlot toujours ignoré côté handler PubSub
programSubscribe.withContext conservé
a vote.timestamp optionnel côté RpcVote
7 variantes SlotUpdate actuelles inchangées
```
La documentation Solana reste l'autorité pour la **surface publique annoncée**. Le tag Agave courant sert de contrôle d'implémentation, même si une page documentaire contient encore un lien vers une révision source plus ancienne.
## Audit SIMD ajouté
Le plan et la compliance suivent désormais explicitement les SIMDs pertinents :
| SIMD | Statut | Décision KSP principale |
|-----------------------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------|
| `0118` Partitioned Epoch Rewards Distribution | Activated | réutiliser les DTOs bloc/rewards lossless déjà acquis par HTTP |
| `0291` Commission Rate in Basis Points | Review | ne pas dériver localement une représentation de commission depuis une autre |
| `0296` Larger Transaction Size | Review | proposition jusqu'à 4096 octets ; aucune limite WS dérivée en dur de l'ancienne taille transaction de 1232 octets |
| `0298` Bank Hash in Block Footer | Idea | aucun `bankHash` spéculatif |
| `0301` parent bank hash | PR fermé | aucun `parentBankHash` spéculatif ; PR non mergée |
| `0307` Add Block Footer | Review | aucun `footer` spéculatif ; réaudit lorsque l'upstream l'expose |
| `0326` Alpenglow | Review | ne pas figer les sémantiques TowerBFT des flux unstable |
| `0337` Alpenglow Fast Leader Handover Markers | Review | surveiller l'impact futur sur shape/taille des blocs |
| `0384` Alpenglow migration | Review | ne pas supposer une séquence exhaustive de notifications commitment/optimistic confirmation |
| `0385` Transaction V1 | Review | conserver versions transaction et `maxSupportedTransactionVersion` génériques |
Aucun de ces SIMDs n'ajoute, dans la baseline Agave `v4.2.1`, une dixième famille WebSocket standard.
## Préparation multi-familles WebSocket
Le shape Config V2 planifié devient explicitement extensible :
```text
profiles[].ws_endpoints[].kind
```
Pour `0.2.7` :
```text
kind = solana_standard supporté
autre kind rejet explicite
```
Le type Transport correspondant est planifié `#[non_exhaustive]`. Une release ultérieure pourra ainsi ajouter notamment une famille **Helius Enhanced WebSocket** sans refondre `profiles[].ws_endpoints[]` et sans contaminer `WsSessionSettings` ou les wrappers Solana standard avec des options provider-specific.
Ce fix ne décide pas encore si Helius Enhanced WebSocket et LaserStream WebSocket partageront exactement la même famille de settings ou le même moteur ; ce point reste soumis à l'audit provider-specific prévu après `0.2.7`.
## Markdown
Correction appliquée dans la matrice lifecycle :
```text
avant : Never PIPE ActiveSubscriptions
après : Never ou ActiveSubscriptions
```
Les caractères `|` restent uniquement les délimiteurs structurels nécessaires aux tableaux Markdown ; ils ne sont pas utilisés comme séparateurs sémantiques dans une cellule.
## Fichiers modifiés
```text
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
```
## Fichier ajouté
```text
deltas/0.2.7/pre.001-fix.001.md
```
## Fichiers volontairement inchangés
```text
Cargo.toml
deltas/0.2.7/pre.001.md
docs/000-README.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/validation/000-README.md
CHANGELOG.md
ROADMAP.md
config/**
crates/**
```
Les index ne changent pas : ils référencent déjà les documents `014` et `010`.
## Validation documentaire du fix
À vérifier avant commit :
```text
aucun pipe sémantique dans une cellule de tableau Markdown
nombre de colonnes cohérent pour chaque table modifiée
18 opérations WebSocket toujours présentes dans la compliance
Agave v4.2.1 utilisé comme baseline Git
SIMDs ciblés documentés avec leur statut courant
Cargo.toml absent du delta fix
```
Ce fix ne change aucun code, build, runtime, configuration effective ou migration ; les gates Cargo ne sont donc pas redéclarés comme exécutés par ce delta documentaire.

245
deltas/0.2.7/pre.001.md Normal file
View File

@@ -0,0 +1,245 @@
<!-- file: deltas/0.2.7/pre.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.001` — audit WebSocket Solana, threat model, dependencies et sizing
## Base requise
Release stable attendue et auditée :
```text
v0.2.6
workspace.package.version = 0.2.6 avant ouverture
```
L'archive Gitea fournie contient `deltas/0.2.6/rel.001.md` et annonce `0.2.7 — WebSocket Solana standard` comme prochaine release. Elle est utilisée comme autorité primaire.
## Type de livraison
```text
ksp-general-0.2.7-pre.001.zip
```
L'archive d'échange est un delta applicable depuis la racine de `v0.2.6` et contient uniquement les fichiers ajoutés/modifiés par cette tranche.
## Objet
`pre.001` reste volontairement un gate de lecture/audit/conception. Aucun client WebSocket, session runtime, wrapper subscribe ou dependency réseau nouvelle n'est encore ajouté.
Le gate :
- relit les règles, architecture, plans, validations et contrats réels requis ;
- vérifie la stabilité `v0.2.6` et l'héritage HTTP/Wallet Desk ;
- réaudite `ksp-onchain-transport-lib` et l'adapter Config actuel ;
- constate que `std.transport` V1 est strictement HTTP et décide un V2 explicite HTTP+WS avec backward V1 ;
- audite l'archive bot3 comme référence historique seulement ;
- réaudite la documentation Solana WebSocket officielle du 2026-08-22 et cross-checke Agave `v3.1.8` sur les ambiguïtés ;
- documente notamment `accountSubscribe.minContextSlot` comme option partagée mais ignorée en PubSub, et `vote.timestamp` comme `Option<i64>` ;
- compte exactement **18 méthodes = 9 subscribe + 9 unsubscribe** ;
- classe `block`, `slotsUpdates` et `vote` comme paires unstable ;
- crée la matrice compliance initiale `010` ;
- audite les crates candidates et retient `tokio-tungstenite 0.30.0` + `futures-util 0.3.34` pour une tranche ultérieure ;
- fixe le modèle actor/session, IDs locaux, state machines, reconnect/resubscribe, continuity gaps, backpressure et shutdown ;
- fixe les exigences de redaction URL/credentials et de bornes de ressources ;
- regranularise la release jusqu'à un forecast nominal `pre.014` sans imposer ce numéro comme deadline.
## Décisions principales
### Cardinalité
```text
endpoint -> N sessions physiques explicites -> N subscriptions par session
aucun pool/scheduler automatique en 0.2.7
```
### Identités
```text
WsSessionId stable local KSP
WsSubscriptionId stable local KSP
remote id éphémère et interne, remappé après reconnect
```
### Reconnect / resubscribe
```text
budget fini
backoff exponentiel borné
pas de jitter en 0.2.7
policy Never | ActiveSubscriptions
ordre de restore déterministe par ID local
continuity gap explicite après toute reconnexion
aucune garantie lossless / aucun backfill HTTP Transport
```
### Backpressure
```text
command queue bounded
notification queue bounded par subscription
overflow -> subscription Failed explicite + best-effort unsubscribe
aucun drop silencieux
les autres subscriptions restent actives
```
### Config
```text
std.transport V1 reste strict et lisible
std.transport V2 = HTTP existant + ws_defaults + profiles[].ws_endpoints
Config -> Transport uniquement
```
### Dependencies
```text
tokio-tungstenite ^0.30, default-features=false, connect + rustls-tls-webpki-roots
futures-util ^0.3, default-features=false, std + sink
```
Ces dependencies sont **planifiées seulement** ; le graphe n'est pas modifié dans `pre.001`.
## Prévision souple recalibrée
```text
pre.001 audit + matrice + threat model + dependencies + sizing
pre.002 settings/IDs/states/snapshots/redaction
pre.003 std.transport V2 + adapter Config
pre.004 actor session physique + deps WS + local server
pre.005 limits/control/cancellation/shutdown
pre.006 registry + generic subscribe/unsubscribe + channels typed
pre.007 reconnect/resubscribe/gap/races
pre.008 backpressure/limits/leaks adversarial
pre.009 account/program/logs
pre.010 signature/slot/root
pre.011 block/slotsUpdates/vote unstable
pre.012 compliance 18/18 + Config composition + HTTP regression
pre.013 smoke live + README/USAGE + cargo trees
pre.014 workspace final + docs/compliance + prompt 0.2.8
rel.001 publication stable
```
Le sizing reste positif : la release n'est pas scindée fonctionnellement, mais le forecast initial `pre.008` est volontairement décompressé.
## Fichiers ajoutés
```text
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
deltas/0.2.7/pre.001.md
```
## Fichiers modifiés
```text
Cargo.toml
docs/000-README.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/validation/000-README.md
```
## Fichiers volontairement inchangés
```text
CHANGELOG.md
ROADMAP.md
.env.example
config/**
crates/**
docs/architecture/**
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
```
`ROADMAP.md` reste global et possède déjà l'entrée `0.2.7`. README/USAGE Transport ne sont pas modifiés avant qu'une surface runtime WebSocket existe réellement.
## Version technique
Conformément au workflow non-fix :
```text
workspace.package.version = 0.2.7-pre.1
commit = v0.2.7-pre.001
aucun tag prerelease
```
## Audit officiel WebSocket
Index :
```text
https://solana.com/docs/rpc/websocket
```
Inventaire exact au 2026-08-22 :
```text
accountSubscribe/accountUnsubscribe
blockSubscribe/blockUnsubscribe unstable pair
logsSubscribe/logsUnsubscribe
programSubscribe/programUnsubscribe
rootSubscribe/rootUnsubscribe
signatureSubscribe/signatureUnsubscribe
slotSubscribe/slotUnsubscribe
slotsUpdatesSubscribe/slotsUpdatesUnsubscribe unstable pair
voteSubscribe/voteUnsubscribe unstable pair
```
Aucune méthode de cet index n'est marquée Deprecated.
## Audit bot3
Référence inspectée :
```text
ks-onchain-transport/src/standard_ws.rs
ks-onchain-transport/src/ws_client.rs
ks-onchain-transport/src/ws_pool.rs
ks-onchain-transport/src/ws_session.rs
```
Repris comme concepts : session multiplexée, ID local/remote séparé, reconnect/resubscribe borné. Rejetés : Transport -> Config, tracing direct, scheduler/pool, unsubscribe public par remote ID et broadcast data comme contrat principal.
## Validations exécutées avant modification
```text
archive stable v0.2.6 extraite/auditée OK
documents internes obligatoires relus OK
inventory Transport + Config OK
archive bot3 auditée OK
documentation Solana WebSocket actuelle auditée OK
dependencies Rust candidates auditée OK
python3 scripts/audit_rust_workspace_rules.py OK
```
Sortie audit Python :
```text
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
```
## Validations tentées mais impossibles dans le sandbox
Le binaire `cargo` n'est pas installé. Tentatives avant modification :
```text
cargo fmt --all code 127
cargo check --workspace code 127
cargo clippy --workspace --all-targets code 127
```
Aucune de ces commandes n'est déclarée réussie.
## Validation opérateur requise avant commit
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
```
Aucun test Transport, Config, workspace ou smoke live n'est déclaré vert dans ce sandbox tant qu'il n'a pas été effectivement exécuté par l'opérateur. Aucun build Tauri n'est requis pour `0.2.7-pre.001`.

View File

@@ -0,0 +1,156 @@
<!-- file: deltas/0.2.7/pre.002-fix.001.md -->
<!-- version: 2 -->
# Delta `0.2.7-pre.002-fix.001` — Clippy strict, canaris desktop et signal Cargo synchronisé
## 1. Base requise
```text
0.2.7-pre.002 appliquée
workspace.package.version = 0.2.7-pre.2
```
La validation opérateur de `0.2.7-pre.002` confirme :
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace OK avec 2 warnings dead_code WS
cargo test -p ksp-onchain-transport-lib 256 unit + 28 public API + 22 release completeness OK
```
`cargo clippy --workspace --all-targets` échoue sur les règles workspace strictes `clippy::implicit_return` et `clippy::question_mark_used`. `cargo test --workspace` atteint ensuite le canari Config Desk qui compare encore la version packagée stable `0.2.6` à la prerelease Cargo courante.
Cette version 2 du fichier delta accompagne l'archive d'échange **corrigée** de `pre.002-fix.001`. L'archive `pre.002-fix.001` précédemment transmise sans mise à jour du `Cargo.toml` racine est révoquée avant application/commit : elle ne doit pas être utilisée.
## 2. Signal technique
Le correctif touche des sources Rust et des tests Rust. Conformément à `VER-ID-007`, `VER-ID-010` et au contrat `Cargo.toml` de `FILE_CONTRACTS.md`, le signal Cargo est synchronisé :
```text
livraison = 0.2.7-pre.002-fix.001
workspace.package.version = 0.2.7-pre.2.fix.1
commit = v0.2.7-pre.002-fix.001
```
Aucun tag prerelease.
Toutes les crates qui utilisent `version.workspace = true` héritent automatiquement de `0.2.7-pre.2.fix.1`.
Le manifeste racine passe aussi :
```text
# version: 193 -> 194
```
Aucune dépendance, feature, membre workspace ou lint n'est modifié.
## 3. Corrections Transport
`crates/ksp-onchain-transport-lib/src/ws_settings.rs` et `ws_lifecycle.rs` sont alignés avec les lints workspace stricts :
- les helpers `as_str()` utilisent un `return match` explicite ;
- les propagations d'erreur n'utilisent plus l'opérateur `?`, interdit par `clippy::question_mark_used` ;
- les constructeurs crate-internal de snapshots, encore réservés aux tests dans `pre.002`, sont compilés uniquement sous `cfg(test)` afin de supprimer les warnings `dead_code` avant leur consommation runtime future.
Aucun contrat public, default WebSocket, logging target ou règle de redaction n'est modifié. Le tracing reste exclusivement émis via `ksp-logging-lib` avec le `TRACING_TARGET` de `ksp-onchain-transport-lib`; aucun `tracing` direct n'est introduit.
## 4. Canaris desktop packagés
Le test Config Desk :
```text
pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root
```
ne compare plus `tauri.conf.json` et `package.json` à chaque valeur de `CARGO_PKG_VERSION`. Cette égalité rendait le test faux dès l'ouverture d'une prerelease Cargo alors que les ressources desktop packagées restaient volontairement sur la baseline stable `0.2.6`.
Le canari Wallet Desk analogue est corrigé dans la même livraison afin d'éviter le même faux négatif lors du `cargo test --workspace` suivant.
Les garanties conservées sont :
```text
version tauri >= 0.2.6
version package >= 0.2.6
version tauri == version package
```
Le cœur SemVer est comparé sur `major.minor.patch`; un suffixe prerelease éventuel ne change pas ce floor. Wallet Desk conserve en plus la vérification que le HTML embarque la version packagée réellement déclarée.
Les versions npm/Tauri ne sont pas forcées à suivre chaque prerelease Cargo par ce correctif.
## 5. Fichiers ajoutés
```text
deltas/0.2.7/pre.002-fix.001.md
```
## 6. Fichiers modifiés
```text
Cargo.toml
crates/ksp-app-config-desk/tests/desktop_contract.rs
crates/ksp-app-wallet-desk/tests/desktop_contract.rs
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
crates/ksp-onchain-transport-lib/src/ws_settings.rs
```
## 7. Fichiers supprimés
```text
aucun
```
## 8. Validations exécutées pour la préparation de l'archive corrigée
Sur la reconstruction `0.2.7-pre.002 + pre.002-fix.001 corrigé` :
```text
python3 scripts/audit_rust_workspace_rules.py OK / clean
inspection workspace.package.version 0.2.7-pre.2.fix.1
inspection absence de tracing direct OK
inspection absence de ? dans ws_settings.rs OK
inspection contenu archive OK
```
## 9. Validations non exécutées dans le sandbox
Cargo n'est pas disponible dans l'environnement de préparation. Aucun gate Cargo n'est déclaré réussi pour cette archive corrigée.
## 10. Validation opérateur requise
Après application de **cette archive corrigée uniquement** :
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test -p ksp-app-config-desk --test desktop_contract
cargo test -p ksp-app-wallet-desk --test desktop_contract
cargo test --workspace
```
Si ce checkpoint est vert, le commit attendu est :
```text
v0.2.7-pre.002-fix.001
```
La tranche suivante reste `0.2.7-pre.003`.
## 11. Décisions prises
```text
le fix code porte bien un signal Cargo fix.1
les canaris desktop conservent un floor >= 0.2.6 au lieu d'une égalité à CARGO_PKG_VERSION
aucune modification fonctionnelle supplémentaire de la surface WebSocket
aucune synchronisation forcée des versions npm/Tauri avec les prereleases Cargo
```
## 12. Questions ouvertes
```text
aucune pour ce correctif
```

263
deltas/0.2.7/pre.002.md Normal file
View File

@@ -0,0 +1,263 @@
<!-- file: deltas/0.2.7/pre.002.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.002` — WebSocket settings + lifecycle contracts
## 1. Objet
Cette tranche matérialise la première surface Rust WebSocket de `ksp-onchain-transport-lib` sans ouvrir encore de socket physique et sans ajouter de dépendance WebSocket externe.
Version workspace :
```text
0.2.7-pre.2
```
Livraison :
```text
0.2.7-pre.002
```
Commit attendu :
```text
v0.2.7-pre.002
```
Aucun tag prerelease.
## 2. Surface Transport ajoutée
Settings publics :
```text
WsEndpointUrl
WsProviderName
WsClusterName
WsProtocolKind
WsReconnectSettings
WsResubscribePolicy
WsSessionSettings
WsEndpointSettings
WsTransportSettings
```
Lifecycle public :
```text
WsSessionId
WsSubscriptionId
WsSessionState
WsSubscriptionState
WsSubscriptionKind
WsSessionSnapshot
WsSubscriptionSnapshot
```
`WsProtocolKind` est `#[non_exhaustive]` et ne fournit pour `0.2.7` que `SolanaStandard`. Cette forme prépare l'ajout futur d'une famille provider-specific telle que Helius Enhanced WebSocket sans ajouter de paramètres Helius dans les settings Solana standard.
Les IDs locaux reposent sur `NonZeroU64`. Aucun ID serveur WebSocket n'entre dans le contrat public de contrôle.
## 3. URL et secrets
`WsEndpointUrl` :
- accepte uniquement `ws://` et `wss://` ;
- exige un host ;
- conserve la valeur sensible uniquement pour le futur code de connexion ;
- rend `WsEndpointUrl(<redacted>)` en `Debug` ;
- ne copie pas la valeur URL dans les erreurs de validation.
`WsEndpointSettings` et `WsTransportSettings` peuvent conserver `Debug` dérivé car le sous-type URL est lui-même redacted.
Les snapshots ne contiennent jamais :
```text
URL complète
credential/query token
request body
raw notification
remote subscription id
```
## 4. Settings session bornés
Defaults initiaux Transport, explicitement policies KSP locales :
```text
command timeout 10 s
close timeout 5 s
reconnect retries 5
reconnect initial backoff 250 ms
reconnect maximum backoff 5 s
command queue 128
notification queue per sub 256
active subscriptions 1024
pending JSON-RPC requests 128
maximum message 64 MiB
maximum frame 16 MiB
maximum write buffer 1 MiB
resubscribe default ActiveSubscriptions
```
Ces valeurs ne sont pas présentées comme des limites Solana. `pre.004`/`pre.005` devront les appliquer réellement à l'actor/socket et pourront les recalibrer si les fixtures adversariales le justifient.
Validation structurelle :
- timeouts non nuls ;
- reconnect backoff non nul et ordonné ;
- capacités/limites strictement positives ;
- au moins un endpoint WS configuré et enabled ;
- noms endpoint uniques ;
- name/provider/cluster non vides et sans whitespace de bord.
## 5. Lifecycle et snapshots
États session matérialisés :
```text
Disconnected
Connecting
Active
Reconnecting { attempt }
Closing
Closed
Failed
```
États subscription matérialisés :
```text
Requested
Active
Resubscribing
Cancelling
Closed
Failed
```
`WsSubscriptionKind` couvre les neuf familles standard auditées : account, block, logs, program, root, signature, slot, slotsUpdates et vote.
`WsSessionSnapshot` expose uniquement des metadata sûres : local session ID, endpoint logical name, provider, cluster, protocol, state, pending request count, continuity gap count, overflow count et projections de subscriptions.
`WsSubscriptionSnapshot` expose local subscription ID, kind, state et `remote_bound`; l'ID distant reste interne et remappable.
Les constructeurs de snapshots sont crate-internal : les consumers ne peuvent pas fabriquer de faux états runtime.
## 6. Logging et tracing
La constante existante reste l'autorité crate-wide :
```rust
TRACING_TARGET = "ksp-onchain-transport-lib"
```
Elle est définie dans `crates/ksp-onchain-transport-lib/src/constants.rs`.
Toute nouvelle émission passe par `ksp-logging-lib` :
- `trace` pour entrée/succès de validations et metadata endpoint sûres ;
- `debug` pour settings validés, compteurs et bornes ;
- `warn` pour rejets de settings/URL ;
- aucun `error` artificiel pour une erreur de validation caller.
Aucun appel direct à `tracing` n'est ajouté. Les logs n'incluent jamais la valeur de `WsEndpointUrl`.
## 7. Tests ajoutés
Tests unitaires settings :
```text
ws/wss acceptés
HTTP rejeté
Debug URL redacted
erreur de scheme sans secret
protocol kind standard
settings defaults bornés
zero bound rejeté
reconnect backoff inversé rejeté
transport endpoints valides
endpoint names dupliqués rejetés
au moins un endpoint enabled
Debug transport sans URL/credential
```
Tests unitaires lifecycle :
```text
IDs locaux non-zéro et ordonnables
états reconnect/resubscribe/cancelling distincts
9 familles standard couvertes
snapshot sans URL ni remote subscription id
```
Un canari `tests/public_api.rs` vérifie l'accès crate-root aux nouveaux contrats.
## 8. Fichiers principaux
Nouveaux :
```text
crates/ksp-onchain-transport-lib/src/ws_settings.rs
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
deltas/0.2.7/pre.002.md
```
Modifiés :
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/constants.rs
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
```
Aucune dependency externe n'est ajoutée dans cette tranche.
## 9. Validation disponible dans le sandbox
Exécuté après modifications :
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
```
Le sandbox ne fournit toujours pas `cargo`; les gates Rust compilés ne sont donc pas déclarés réussis ici.
Baseline opérateur reçue avant `pre.002` : `cargo fmt`, audit Python, `cargo check` et `cargo clippy` verts sur `0.2.7-pre.1`. `cargo test --workspace` n'échoue que sur le canari Config Desk qui compare encore la ressource packagée `0.2.6` à la version workspace `0.2.7-pre.1`; aucune régression WebSocket n'y est impliquée.
## 10. Gates opérateur avant commit
Après application du delta :
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Le test workspace complet peut encore reproduire le canari de version packagée Config Desk tant que cette ressource n'est volontairement resynchronisée.
## 11. Suite
`0.2.7-pre.003` doit matérialiser :
```text
std.transport V2 HTTP + WS
backward read V1 HTTP-only
schema/fixtures V2
Config -> WsTransportSettings
ws_endpoints[].kind = solana_standard
```
La direction reste strictement `Config -> Transport`; aucun reverse dependency n'est autorisé.

225
deltas/0.2.7/pre.003.md Normal file
View File

@@ -0,0 +1,225 @@
<!-- file: deltas/0.2.7/pre.003.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.003` — `std.transport` V2 HTTP + WebSocket
## 1. Base requise
```text
0.2.7-pre.002-fix.001 appliquée
workspace.package.version = 0.2.7-pre.2.fix.1
```
Le checkpoint opérateur reçu avant cette tranche est entièrement vert : `cargo fmt`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport ciblés et `cargo test --workspace`.
## 2. Signal technique
Cette prerelease non-fix modifie configuration exécutable, Rust et tests. Conformément à `VER-ID-009` :
```text
livraison = 0.2.7-pre.003
workspace.package.version = 0.2.7-pre.3
commit = v0.2.7-pre.003
```
Aucun tag prerelease.
## 3. `std.transport` V2
Le document standard livré devient :
```text
format_version = 2
retry
ws_defaults
default_profile
profiles[]
profile_id
endpoints[]
ws_endpoints[]
kind
session?
```
Le HTTP existant reste inchangé dans `endpoints[]`. `ws_defaults` contient les defaults génériques de `WsSessionSettings`; `ws_endpoints[].session` peut surcharger seulement les paramètres génériques nécessaires à un endpoint.
Le discriminateur est obligatoire :
```text
kind = solana_standard
```
Toute autre famille est rejetée en `0.2.7`. Aucun paramètre Helius, LaserStream ou autre provider-specific n'est pré-implémenté.
## 4. Backward V1 strict
Le schema enregistré passe à :
```text
urn:ksp:schema:std.transport:v2
```
Il conserve deux branches strictes discriminées par `format_version` :
```text
V1 -> HTTP-only historique
V2 -> HTTP + ws_defaults + ws_endpoints
```
Le V1 n'est pas rendu compatible par un relâchement de `additionalProperties`. Une fixture V1 dédiée prouve le chemin historique.
Dans l'adapter :
```text
V1 -> HttpTransportSettings + ws_settings = None
V2 -> HttpTransportSettings + Some(WsTransportSettings)
```
`WsTransportSettings` conserve donc son invariant `pre.002` : il n'existe jamais comme faux conteneur vide.
## 5. Adapter Config -> Transport
`ResolvedTransportConfig` conserve `settings()` pour compatibilité HTTP et ajoute :
```text
http_settings()
ws_settings() -> Option<&WsTransportSettings>
into_transport_settings() -> (HttpTransportSettings, Option<WsTransportSettings>)
```
Le mapper V2 construit :
```text
WsProviderName
WsClusterName
WsProtocolKind::SolanaStandard
WsEndpointUrl
WsReconnectSettings
WsResubscribePolicy
WsSessionSettings
WsEndpointSettings
WsTransportSettings
```
La direction reste strictement `Config -> Transport`. Aucun import de Config n'est ajouté à `ksp-onchain-transport-lib`.
## 6. Overrides session
Les valeurs de `ws_defaults` correspondent aux defaults KSP matérialisés en `pre.002`. Un endpoint peut surcharger indépendamment :
```text
command_timeout_ms
close_timeout_ms
reconnect.{max_retries, initial_backoff_ms, max_backoff_ms}
resubscribe
command_queue_capacity
notification_queue_capacity
max_active_subscriptions
max_pending_requests
max_message_size_bytes
max_frame_size_bytes
max_write_buffer_size_bytes
```
La session résultante est toujours validée par `WsSessionSettings::validate()`.
## 7. Secrets et logging
Les URLs HTTP et WebSocket peuvent provenir de `KSP_SECRET_*`; les valeurs réelles restent disponibles au runtime mais la projection Config safe les redacted. Les erreurs d'adaptation ne recopient pas l'URL.
Le mapping Config utilise exclusivement `ksp-logging-lib` avec le `TRACING_TARGET` existant de `ksp-config-lib` :
```text
trace -> début mapping et chemin backward V1
debug -> version, compteurs HTTP/WS après validation
```
Aucun `tracing` direct n'est ajouté.
## 8. Environment inventory
Ajouts `.env.example` :
```text
KSP_PUBLIC_SOLANA_DEVNET_WS_URL
KSP_PUBLIC_SOLANA_MAINNET_WS_URL
# KSP_SECRET_SOLANA_WS_URL
```
Les URLs provider privées restent des valeurs complètes gérées via `KSP_SECRET_*`.
## 9. Tests ajoutés/étendus
```text
V2 fixture HTTP + WS complète
ws_defaults + endpoint overrides
V1 strict toujours chargeable
V1 n'invente pas de WsTransportSettings vide
committed Devnet/Mainnet V2 mappe HTTP + WS
provenance ws_defaults globale et ws_endpoints profil
secret WebSocket URL disponible au runtime mais redacted en safe/Debug
public API canary pour les nouveaux accessors
```
## 10. Fichiers principaux modifiés
```text
Cargo.toml
.env.example
config/std.transport.json
config/examples/std.transport.example.json
config/schemas/std.transport.schema.json
crates/ksp-config-lib/src/transport.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/src/registry.rs
crates/ksp-config-lib/unit_tests/transport.rs
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
crates/ksp-config-lib/tests/public_api.rs
crates/ksp-config-lib/README.md
crates/ksp-config-lib/USAGE.md
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
```
Nouveau :
```text
crates/ksp-config-lib/unit_tests/fixtures_v1/std.transport.json
deltas/0.2.7/pre.003.md
```
Aucune dépendance externe n'est ajoutée.
## 11. Validation de préparation
Le sandbox de génération ne fournit pas Cargo. Sont exécutés ici :
```text
python3 scripts/audit_rust_workspace_rules.py
validation JSON des documents/schema
inspection absence de tracing direct
inspection version workspace
inspection archive delta
```
Les gates compilés restent opérateur-only.
## 12. Gates opérateur avant commit
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-config-lib
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Si le checkpoint est vert :
```text
commit = v0.2.7-pre.003
```
La tranche suivante est `0.2.7-pre.004` : dépendances WebSocket, actor physique, handshake/read/write, pending JSON-RPC et serveur local déterministe.

View File

@@ -0,0 +1,79 @@
<!-- file: deltas/0.2.7/pre.004-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.004-fix.001` — conformité Clippy du runtime WebSocket
## 1. Objet
Ce correctif ferme les écarts de compilation stricte détectés par la validation opérateur de `0.2.7-pre.004` sans modifier la surface fonctionnelle WebSocket, les dépendances ni l'architecture de session.
Comme le correctif modifie du code Rust, le signal technique Cargo est synchronisé avec l'identité de livraison conformément aux règles KSP :
```text
livraison = 0.2.7-pre.004-fix.001
workspace.package.version = 0.2.7-pre.4.fix.1
commit = v0.2.7-pre.004-fix.001
```
Aucun tag prerelease.
## 2. Écarts détectés sur `pre.004`
La validation opérateur a confirmé :
- `cargo fmt --all` : OK ;
- `scripts/audit_rust_workspace_rules.py` : clean ;
- `cargo check --workspace` : compilation réussie mais un warning `unreachable_code` dans `ws_session.rs` ;
- `cargo clippy --workspace --all-targets` : échec sur trois violations `clippy::implicit_return` plus le warning `unreachable_code` ;
- `cargo test --workspace` : tests fonctionnels verts, dont les cinq nouveaux canaris WebSocket de `pre.004`.
Le problème est donc limité à la conformité aux règles Rust strictes du workspace et non au comportement couvert par les tests.
## 3. Corrections `ws_session.rs`
Le runtime WebSocket est conservé fonctionnellement à l'identique.
Les corrections sont :
- `handle_session_command` n'utilise plus `return match ...` lorsque toutes les branches divergent déjà par des `return` explicites ; cela supprime l'expression inatteignable signalée par Rust ;
- la collecte des requêtes JSON-RPC expirées n'utilise plus une closure `filter_map` à retours implicites ; une boucle explicite construit désormais la liste des identifiants expirés ;
- la closure passée à `AtomicU64::fetch_update` retourne explicitement `current.checked_add(1)` afin de respecter `clippy::implicit_return` ;
- le header de version de `ws_session.rs` passe de `1` à `2`.
Aucun changement n'est apporté :
- à `WsSession` ou à sa surface publique ;
- aux états de lifecycle ;
- aux limites de message/frame/write buffer ;
- au pending map et aux timeouts ;
- aux dépendances `tokio-tungstenite` / `futures-util` ;
- au firewall de dépendances ;
- au tracing : toutes les émissions restent exclusivement via `ksp-logging-lib` et `TRACING_TARGET = "ksp-onchain-transport-lib"`.
## 4. Version Cargo
Le `Cargo.toml` racine passe à :
```toml
[workspace.package]
version = "0.2.7-pre.4.fix.1"
```
Aucune autre entrée Cargo n'est modifiée.
## 5. Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Le smoke Devnet reste opt-in et n'est pas requis pour ce correctif.
## 6. Suite
Si ce checkpoint est vert, `0.2.7-pre.004` est considéré clos via `pre.004-fix.001` et la série peut poursuivre avec `0.2.7-pre.005` : limites adversariales, control frames, cancellation, close et shutdown borné.

240
deltas/0.2.7/pre.004.md Normal file
View File

@@ -0,0 +1,240 @@
<!-- file: deltas/0.2.7/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.004` — runtime WebSocket physique + actor JSON-RPC
## 1. Base requise
```text
0.2.7-pre.003 appliquée
workspace.package.version = 0.2.7-pre.3
```
Le checkpoint opérateur reçu avant cette tranche est vert : `cargo fmt --all`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport, tests Config et `cargo test --workspace`.
## 2. Signal technique
Cette prerelease non-fix modifie dépendances, runtime Rust et tests. Conformément au workflow KSP :
```text
livraison = 0.2.7-pre.004
workspace.package.version = 0.2.7-pre.4
commit = v0.2.7-pre.004
```
Aucun tag prerelease.
## 3. Dépendances WebSocket matérialisées
Le réaudit du 22 août 2026 confirme les versions retenues depuis `pre.001` :
```text
tokio-tungstenite 0.30.0
futures-util 0.3.34
```
Le root déclare sans features consumer :
```toml
tokio-tungstenite = { version = "^0.30", default-features = false }
futures-util = { version = "^0.3", default-features = false }
```
Transport active seulement :
```text
tokio-tungstenite : connect + rustls-tls-webpki-roots
futures-util : sink + std
tokio : macros + rt + sync + time
```
Le fixture serveur local ajoute `tokio/net` côté dev.
Aucune dépendance Config, Store, Program, Wallet ou `tracing` direct n'est introduite.
## 4. `WsSession` physique
Nouvelle surface publique :
```text
WsSession::connect(WsEndpointSettings)
WsSession::id()
WsSession::state()
WsSession::snapshot()
```
Un appel de `connect` crée exactement une connexion physique. Deux appels avec le même endpoint créent deux sockets indépendants ; aucun singleton, pool ou scheduler automatique n'est ajouté.
Le caller ne reçoit jamais le socket brut.
## 5. Actor propriétaire du socket
Une tâche actor unique possède :
```text
WebSocketStream
compteur JSON-RPC request id
map pending requests
bounded command receiver
publication WsSessionSnapshot
```
Le handle communique avec l'actor par `tokio::sync::mpsc` borné selon `command_queue_capacity`.
Les snapshots sont publiés via `tokio::sync::watch` et conservent seulement les metadata sûres prévues en `pre.002`.
## 6. Handshake et `WebSocketConfig`
`WsSession::connect` attend le handshake sous `command_timeout` et configure explicitement :
```text
write_buffer_size = 0
max_write_buffer_size = WsSessionSettings.max_write_buffer_size_bytes
max_message_size = WsSessionSettings.max_message_size_bytes
max_frame_size = WsSessionSettings.max_frame_size_bytes
```
Le `write_buffer_size = 0` évite de rendre la validité de la configuration KSP dépendante du buffer par défaut interne de Tungstenite et garantit que le plafond configuré reste strictement supérieur au target buffer.
Les tests oversized et les recalibrages éventuels restent le gate `pre.005`.
## 7. Pending JSON-RPC
La primitive interne actor :
```text
execute_json_rpc(method, params)
```
reste **`pub(crate)`**. Elle n'est volontairement pas exposée comme API raw provider-extension publique.
Comportement :
- ID numérique KSP monotone par session ;
- sérialisation via `JsonRpcRequest` existant ;
- map `BTreeMap` bornée par `max_pending_requests` ;
- deadline par request issue de `command_timeout` ;
- dispatch des réponses par `id`, y compris si elles arrivent hors ordre ;
- erreurs JSON-RPC applicatives renvoyées au caller concerné sans teardown de la connexion ;
- ID réponse inconnu/stale ignoré avec diagnostic sûr ;
- JSON structurellement invalide classé erreur protocole session.
Cette primitive sera consommée par le moteur de subscriptions à partir de `pre.006`.
## 8. Lifecycle limité à la tranche
`pre.004` matérialise :
```text
Connecting -> Active
connection/read/write failure -> Failed
last handle dropped -> cleanup best-effort -> Closed
```
Le reconnect/resubscribe reste `pre.007`.
Le shutdown async public, les budgets de Close et les fixtures peer hostile restent `pre.005`.
Ping reçu est répondu par Pong afin de conserver l'interopérabilité du socket. Aucun heartbeat applicatif périodique n'est ajouté.
## 9. Erreurs
Nouveaux codes publics :
```text
ws_backpressure_overflow
ws_connection_failed
ws_protocol_error
ws_session_closed
```
Les erreurs de connexion WebSocket ne conservent volontairement pas la source Tungstenite brute : celle-ci pourrait contenir une request/URI ou d'autres détails provider. Les erreurs KSP exposent uniquement `session_id`, endpoint logique, provider et cluster.
## 10. Logging / tracing
Toutes les émissions passent exclusivement par `ksp-logging-lib` et réutilisent :
```text
crates/ksp-onchain-transport-lib/src/constants.rs
TRACING_TARGET = "ksp-onchain-transport-lib"
```
Répartition principale :
```text
trace -> ouverture socket, send/dispatch JSON-RPC, Ping/Pong, notification prématurée ignorée
debug -> actor start, handshake actif, réponse stale, timeout pending, remote close
warn -> handshake/read/write failure, malformed wire, capacité pending épuisée
```
Ne sont jamais loggés : URL complète, credentials, query token, payload JSON-RPC complet ou notification brute.
## 11. Serveur local déterministe
Nouveaux tests runtime sans Internet :
```text
handshake local + round-trip JSON-RPC
deux sessions physiques distinctes sur la même URL
deux requests concurrentes + réponses inversées
application error sans teardown de session
connection error sans fuite URL/credential
```
Le serveur utilise `tokio::net::TcpListener` + `tokio_tungstenite::accept_async`.
## 12. Canaris workspace/public API
Le canari workspace dependencies est synchronisé avec les nouvelles dépendances/features et continue de vérifier le firewall Transport.
Le canari public API vérifie la disponibilité de `WsSession` et les quatre nouveaux codes d'erreur.
## 13. Documentation synchronisée
Mis à jour :
```text
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
```
`ROADMAP.md` et `CHANGELOG.md` restent inchangés conformément à la politique de série prerelease.
## 14. Validation de préparation
Exécuté dans le sandbox :
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
inspection absence tracing direct OK
inspection TRACING_TARGET OK
inspection dépendances workspace/member OK
inspection Markdown tables OK
```
Cargo n'est pas disponible dans le sandbox de génération ; aucun résultat Cargo local n'est revendiqué.
## 15. Gates opérateur avant commit
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Si le checkpoint est vert :
```text
commit = v0.2.7-pre.004
```
La tranche suivante est `0.2.7-pre.005` : adversarial limits frame/message/request, control frames, cancellation, close/shutdown explicite et peer hostile.

172
deltas/0.2.7/pre.005.md Normal file
View File

@@ -0,0 +1,172 @@
<!-- file: deltas/0.2.7/pre.005.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.005` — limits adversariales + control frames + shutdown borné
## 1. Base requise
```text
0.2.7-pre.004-fix.001 appliqué
workspace.package.version = 0.2.7-pre.4.fix.1
```
Le checkpoint opérateur reçu avant cette tranche est vert : `cargo fmt --all`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test --workspace`. Les 261 tests unitaires Transport de `pre.004-fix.001` passent également.
## 2. Signal technique
Cette prerelease modifie le runtime Rust, les tests et la documentation. Conformément aux règles KSP :
```text
livraison = 0.2.7-pre.005
workspace.package.version = 0.2.7-pre.5
commit = v0.2.7-pre.005
```
Aucun tag prerelease.
## 3. Shutdown explicite `WsSession::close()`
Nouvelle surface publique :
```text
WsSession::close().await
```
Le signal de shutdown est transporté par un canal `watch` distinct de la command queue. Une command queue saturée ne peut donc pas empêcher la demande de fermeture.
Lifecycle :
```text
Active -> Closing -> Closed
remote Close propre -> Closed
I/O ou violation protocolaire -> Failed
```
`close()` :
1. publie la demande de shutdown avec une deadline issue de `close_timeout` ;
2. l'actor passe `Closing` ;
3. toutes les requests JSON-RPC pending reçoivent `ws_session_closed` ;
4. l'actor tente un Close WebSocket best-effort sous la deadline ;
5. l'actor publie `Closed` ;
6. le caller attend `Closed` sans pouvoir rester bloqué indéfiniment.
Le reconnect reste absent jusqu'à `pre.007`.
## 4. Control frames
Ping/Pong/Close sont maintenant testés explicitement.
Pour Ping, Tungstenite met automatiquement le Pong correspondant en file lors de la lecture ; KSP force le `flush` de cette réponse automatique. Aucun Pong duplicatif et aucun heartbeat applicatif périodique ne sont ajoutés.
Un Close distant propre est distingué d'une rupture I/O : il termine la session en `Closed`, alors qu'une rupture physique ou une violation de protocole termine en `Failed`.
## 5. Bornes frame/message/write
Les limites configurées depuis `pre.002` et raccordées à Tungstenite depuis `pre.004` sont désormais couvertes par des fixtures adversariales :
```text
max_message_size
max_frame_size
max_write_buffer_size
```
Un payload JSON-RPC outbound dépassant `max_message_size` ou `max_frame_size` est rejeté avant écriture avec `invalid_rpc_parameters`, sans teardown d'une session saine.
Un frame/message inbound surdimensionné est rejeté par Tungstenite avant que KSP tente le parse JSON ; l'anomalie est classée protocolaire et la session devient `Failed`.
Aucune limite legacy Solana de type 1232 bytes n'est introduite.
## 6. Pending requests et cancellation
Le runtime `pre.005` ajoute les preuves suivantes :
- `max_pending_requests` rejette uniquement la request excédentaire avec `ws_backpressure_overflow` ;
- une request pending silencieuse expire selon `command_timeout`, reçoit `timeout` et libère immédiatement sa capacité ;
- une fermeture explicite annule toutes les pending requests avec `ws_session_closed` ;
- un receiver caller abandonné est purgé du pending map au prochain cycle actor, et reste de toute façon borné par la deadline de request.
La cancellation de subscriptions et les races unsubscribe/reconnect restent dans `pre.006`/`pre.007`.
## 7. Socket I/O borné et shutdown prioritaire
Les writes JSON-RPC et le flush Pong surveillent le signal shutdown pendant leur attente. La fermeture peut donc préempter une opération socket qui serait autrement bloquante.
Un write JSON-RPC dépassant `command_timeout` provoque une erreur caller bornée et termine la session physique, car l'état de l'écriture devient impropre à une poursuite sûre.
## 8. Fixtures adversariales locales
Les tests Transport ajoutent notamment :
```text
pending capacity = 1 avec deuxième request rejetée isolément
oversized outbound request rejetée avant write
oversized inbound frame/message rejeté avant JSON
pending request timeout + purge
Ping -> Pong + session toujours Active
remote Close -> Closed
close explicite + pending cancellation + peer hostile
8 cycles connect/close bornés
```
Toutes les fixtures restent locales et déterministes ; aucun réseau Solana réel n'est requis.
## 9. Logging / sécurité
Toutes les émissions passent exclusivement par `ksp-logging-lib` et réutilisent :
```text
TRACING_TARGET = "ksp-onchain-transport-lib"
```
Les nouveaux diagnostics ne loggent que session ID, endpoint logique, méthode statique, compteurs et tailles numériques. Les URLs, credentials, query tokens et payloads JSON restent absents.
Les erreurs Tungstenite brutes ne sont pas projetées dans les erreurs KSP afin d'éviter une fuite de données provider.
## 10. Public API et documentation
Le canari public API vérifie désormais `WsSession::close` et les états `Closing`/`Closed`.
Synchronisés :
```text
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
```
`ROADMAP.md` et `CHANGELOG.md` restent inchangés pendant la série prerelease.
## 11. Validation de préparation
Le sandbox de génération doit au minimum vérifier :
```text
python3 scripts/audit_rust_workspace_rules.py
absence de tracing direct
présence du TRACING_TARGET existant
absence de question-mark operator dans le runtime modifié
overlay exact du delta
```
Cargo n'est pas disponible dans le sandbox de génération ; aucun résultat Cargo local n'est revendiqué.
## 12. Gates opérateur avant commit
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Si le checkpoint est vert :
```text
commit = v0.2.7-pre.005
```
La tranche suivante est `0.2.7-pre.006` : registry subscriptions, IDs locaux stables, moteur generic subscribe/unsubscribe et channels typed bornés.

View File

@@ -0,0 +1,131 @@
<!-- file: deltas/0.2.7/pre.006-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.006-fix.001` — synchronisation des canaris d'isolation WS + warning watch
## 1. Base requise
```text
0.2.7-pre.006 appliqué
workspace.package.version = 0.2.7-pre.6
```
Le checkpoint opérateur de `pre.006` a établi :
```text
cargo fmt --all exécuté
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace réussi avec 1 warning
cargo clippy --workspace --all-targets réussi avec le même warning
cargo test -p ksp-onchain-transport-lib 273 réussis, 2 échoués
```
Les deux échecs concernent les canaris :
```text
unknown remote subscription id -> session attendue Active
typed notification decode failure -> session attendue Active
```
## 2. Diagnostic
Les chemins runtime concernés dans `handle_subscription_notification` renvoient déjà `WsActorIoOutcome::Continue` :
- un remote subscription ID inconnu ou stale est ignoré sans modifier la session ;
- une erreur du decoder typed ferme uniquement la subscription locale en `Failed`.
Le `Failed` observé par les deux tests provenait ensuite du fixture serveur local : après l'envoi des notifications, la tâche serveur se terminait immédiatement et abandonnait le socket sans Close frame. Le contrat de session acquis classe correctement cette fin physique sans Close comme une défaillance de connexion.
Le test mélangeait donc deux événements distincts : l'anomalie logique à isoler et une déconnexion physique ultérieure.
## 3. Correctif des canaris
Les deux fixtures maintiennent désormais le peer local ouvert après l'anomalie et attendent une requête JSON-RPC témoin :
```text
afterUnknownRemote
afterDecodeFailure
```
Le caller vérifie successivement :
1. le résultat attendu de la notification ou de l'erreur typed ;
2. l'état logique attendu de la subscription ;
3. `WsSessionState::Active` ;
4. la réussite d'une nouvelle requête JSON-RPC sur la même session physique.
Le serveur peut seulement terminer ensuite.
Cette synchronisation supprime la course du fixture et prouve plus fortement la propriété recherchée : la session reste réellement utilisable après une anomalie locale de subscription.
## 4. Warning `state_rx`
Le receiver initial créé uniquement pour construire le `watch::Sender` n'est pas consommé par l'actor. Le binding inutile :
```text
state_rx
```
est supprimé. Le handle public continue à recevoir son propre `watch::Receiver` lors de l'ACK subscribe via `state_tx.subscribe()`.
Aucune lifecycle state, API ou politique de notification n'est modifiée.
## 5. Signal technique
Le fix modifie du Rust et des tests. Conformément aux règles de version KSP :
```text
livraison = 0.2.7-pre.006-fix.001
workspace.package.version = 0.2.7-pre.6.fix.1
commit = v0.2.7-pre.006-fix.001
```
Aucun tag prerelease.
## 6. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
```
## 7. Fichier ajouté
```text
deltas/0.2.7/pre.006-fix.001.md
```
## 8. Fichiers supprimés
Aucun.
## 9. Invariants préservés
Le fix ne change pas :
- `WsSession` ou `WsSubscription<T>` publics ;
- les IDs locaux ou remote ;
- le mapping remote vers local ;
- les neuf familles standard ;
- la capacité des channels ;
- les règles unsubscribe ;
- la classification d'une vraie perte physique comme `Failed` ;
- les frontières de dépendances ;
- la façade de logging KSP ;
- le scope reconnect/resubscribe réservé à `pre.007`.
## 10. Validation sandbox
Le sandbox ne permet pas de revendiquer les gates Cargo opérateur. Les validations statiques disponibles sont exécutées sur le workspace reconstruit avec ce fix.
## 11. Validation opérateur attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```

287
deltas/0.2.7/pre.006.md Normal file
View File

@@ -0,0 +1,287 @@
<!-- file: deltas/0.2.7/pre.006.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.006` — registry subscriptions + IDs locaux + moteur typed générique
## 1. Base requise
```text
0.2.7-pre.005 appliqué
workspace.package.version = 0.2.7-pre.5
```
Le checkpoint opérateur reçu avant cette tranche est vert : `cargo fmt --all`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib` et `cargo test --workspace`. Les 269 tests unitaires Transport de `pre.005` passent.
## 2. Objectif
Cette tranche ajoute le registry de subscriptions au même actor que le socket et la pending map JSON-RPC, sans avancer sur reconnect/resubscribe.
Objectifs matérialisés :
```text
1 session physique
-> 0..N subscriptions logiques
-> WsSubscriptionId local stable
-> remote subscription id interne/transient
-> channel de notifications typed et bounded
```
La création générique de subscription reste crate-private jusqu'aux wrappers standard publics des lots `pre.009+`.
## 3. Signal technique
Cette prerelease modifie le runtime Rust, les tests et la documentation. Conformément aux règles KSP :
```text
livraison = 0.2.7-pre.006
workspace.package.version = 0.2.7-pre.6
commit = v0.2.7-pre.006
```
Aucun tag prerelease.
## 4. Registry actor-owned
Le même actor possède maintenant :
```text
socket physique
request counter
pending JSON-RPC map
subscription local counter
subscription registry local
remote_subscription_id -> WsSubscriptionId
safe snapshots
```
Le registry local est ordonné par local ID. Les IDs locaux sont assignés par l'actor et ne dépendent jamais du remote ID retourné par Solana.
Le remote ID reste absent de l'API publique, de `Debug`, des snapshots et des logs. Seul `remote_bound: bool` reste projeté par `WsSubscriptionSnapshot`.
## 5. Binding subscribe atomique
Le subscribe n'est pas implémenté comme :
```text
execute_json_rpc
puis register local
```
car une notification pourrait alors arriver entre les deux opérations.
`pre.006` ajoute une command actor `Subscribe`. Le pending request conserve le local ID et le dispatcher typed. Lorsque la réponse `*Subscribe` est reçue :
1. la réponse JSON-RPC est validée ;
2. le remote ID numérique est validé ;
3. l'unicité du remote ID actif est vérifiée ;
4. le remote ID est lié au local ID ;
5. la subscription passe `Requested -> Active` ;
6. seulement ensuite le handle typed est rendu au caller.
Le prochain message socket ne peut donc pas être dispatché avant la mise à jour du mapping actor-owned.
## 6. `WsSubscription<T>`
Nouvelle surface publique commune :
```text
WsSubscription<T>
id() -> WsSubscriptionId
kind() -> WsSubscriptionKind
state() -> WsSubscriptionState
recv().await -> Option<Result<T>>
unsubscribe().await -> Result<bool>
```
Le receiver de notifications est borné par `notification_queue_capacity`.
La création `WsSession::subscribe_typed(...)` reste `pub(crate)` : elle sera consommée par les wrappers typed standard et ne crée pas de surface raw provider-extension publique.
## 7. Dispatch notifications
Pour une notification Solana standard :
```text
JSON-RPC notification
-> params.subscription remote u64
-> remote_to_local
-> WsSubscriptionRuntime
-> validation notification method
-> decoder typed
-> bounded typed channel
```
Le mapping exact famille/méthodes est centralisé sur `WsSubscriptionKind` pour les neuf familles :
```text
account
block
logs
program
root
signature
slot
slotsUpdates
vote
```
Chaque famille possède son triplet exact `*Subscribe`, `*Unsubscribe`, `*Notification`.
## 8. Anomalies isolées
Politique matérialisée dans cette tranche :
- remote ID inconnu/stale : safe drop + diagnostic sûr, session inchangée ;
- notification method incompatible avec la famille enregistrée : subscription `Failed`, session reste `Active` ;
- typed decoder failure : erreur livrée au channel lorsque possible, subscription `Failed`, session reste `Active` ;
- queue typed déjà pleine : aucune perte silencieuse considérée normale, subscription terminale ; le compteur/cleanup adversarial complet est finalisé en `pre.008` ;
- malformed JSON / structure JSON-RPC invalide : reste une anomalie de session selon le contrat acquis.
Aucun reconnect n'est déclenché par une erreur RPC applicative ou une erreur typed locale.
## 9. Unsubscribe
`WsSubscription<T>::unsubscribe()` ne reçoit jamais le remote ID du caller.
L'actor :
1. marque la subscription `Cancelling` ;
2. retire immédiatement le remote ID du mapping de dispatch local ;
3. construit le `*Unsubscribe` exact avec le remote ID interne ;
4. valide la réponse booléenne ;
5. publie `Closed` et ferme le channel local.
Le booléen standard Solana est préservé au caller afin de ne pas perdre une variante de réponse pertinente.
Les races unsubscribe/reconnect et le principe « local cancellation wins » sous reconnexion restent le scope explicite de `pre.007`.
## 10. Tests déterministes
Ajouts principaux :
```text
subscribe slot -> remote binding -> notification typed -> unsubscribe exact
2 familles -> 2 IDs locaux ordonnés + remote IDs indépendants
unknown remote ID -> notification suivante valide toujours dispatchée
notification method mismatch -> seule la subscription échoue
typed decode error -> erreur receiver + seule la subscription échoue
triplets exacts des 9 familles standard
public API canary WsSubscription<T>
```
Le serveur reste exclusivement local et déterministe. Aucun réseau Solana réel n'est requis.
## 11. Logging et sécurité
Toutes les émissions passent exclusivement par `ksp-logging-lib` avec :
```text
TRACING_TARGET = "ksp-onchain-transport-lib"
```
Les diagnostics n'exposent que :
```text
session_id
subscription_id local
subscription_kind
endpoint logique
counts/états sûrs
```
Aucun remote subscription ID n'est journalisé comme identité métier, et aucune URL, credential ou notification brute n'est loggée.
## 12. Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
deltas/0.2.7/pre.006.md
```
## 13. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
```
## 14. Fichiers supprimés
Aucun.
`ROADMAP.md` et `CHANGELOG.md` restent inchangés pendant la série prerelease.
## 15. Validation exécutée dans le sandbox
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
```
Contrôles statiques supplémentaires :
```text
workspace.package.version = 0.2.7-pre.6
tracing direct = absent
question-mark runtime = absent
remote ID public = absent
lignes Rust > 160 ajoutées = absentes
```
## 16. Validation non exécutée dans le sandbox
Cargo/rustfmt ne sont pas disponibles dans le sandbox de génération. Aucun résultat local n'est revendiqué pour :
```text
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
## 17. Gates opérateur avant commit
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Si le checkpoint est vert :
```text
commit = v0.2.7-pre.006
```
La tranche suivante est `0.2.7-pre.007` : reconnect borné, resubscribe déterministe, continuity gap et races unsubscribe/reconnect.
## 18. Décisions / questions ouvertes
Décisions :
- le registry et le mapping remote/local appartiennent exclusivement à l'actor ;
- le local ID est stable et le remote ID est transient/interne ;
- la création générique typed reste crate-private ;
- le handle typed public possède le receiver et l'unsubscribe ;
- une anomalie typed connue ne doit pas faire tomber la session physique ;
- aucune promesse lossless n'est introduite.
Questions laissées aux tranches suivantes :
- `pre.007` finalise la restauration déterministe après reconnexion et les races cancellation/ACK ;
- `pre.008` finalise overflow counters, cleanup best-effort et leak/lifecycle adversarial sous slow consumer.

View File

@@ -0,0 +1,139 @@
<!-- file: deltas/0.2.7/pre.007-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.007-fix.001` — représentation reconnect compacte + canari oversized aligné
## 1. Base requise
```text
0.2.7-pre.007 appliqué
workspace.package.version = 0.2.7-pre.7
```
Le checkpoint opérateur de `pre.007` a établi :
```text
cargo fmt --all exécuté
python3 scripts/audit_rust_workspace_rules.py clean
cargo check --workspace réussi
cargo clippy --workspace --all-targets réussi avec 2 warnings large_enum_variant
cargo test -p ksp-onchain-transport-lib 282 réussis, 1 échoué
cargo test --workspace échoue sur le même canari Transport
```
Le seul test en échec est :
```text
websocket_oversized_inbound_frame_fails_before_json_decode
```
## 2. Diagnostic du canari oversized
Le canari provenait de `pre.005`, où une erreur de frame WebSocket conduisait directement la session physique à l'état terminal `Failed`.
`pre.007` a volontairement changé ce contrat : une erreur physique, I/O, TLS, WebSocket ou protocolaire structurelle entre désormais dans le budget de reconnect. Une frame entrante dépassant `max_frame_size` est donc toujours rejetée par Tungstenite avant le décodage JSON, mais l'actor doit ensuite tenter la reconnexion au lieu de devenir immédiatement terminal.
L'ancienne assertion :
```text
oversized frame -> Failed
```
était devenue incompatible avec la politique runtime acquise par `pre.007`.
## 3. Correctif du canari oversized
Le fixture local accepte désormais deux connexions physiques :
1. la connexion initiale envoie une frame texte de 512 octets alors que `max_frame_size = 64` ;
2. l'actor détecte la rupture provoquée par la limite Tungstenite et entre en reconnect ;
3. le serveur accepte la connexion de remplacement ;
4. le caller attend `continuity_gap_count == 1` et le retour à `WsSessionState::Active` ;
5. la session récupérée est fermée explicitement avec `WsSession::close()`.
Le canari est renommé :
```text
websocket_oversized_inbound_frame_triggers_reconnect_before_json_decode
```
Il vérifie ainsi simultanément que la limite est appliquée avant le parse JSON et que le nouveau lifecycle reconnect de `pre.007` est respecté.
## 4. Warnings `large_enum_variant`
Clippy signalait deux enums internes dont la variante `Connected` embarquait directement le type lourd `WsPhysicalStream` :
```text
WsReconnectOutcome
WsConnectAttemptOutcome
```
Le socket est désormais stocké derrière `Box<WsPhysicalStream>` uniquement dans ces objets de résultat transitoires, puis immédiatement déboxé lorsque l'actor reprend la propriété de la connexion.
Ce changement :
- réduit fortement la taille des enums ;
- supprime les warnings `clippy::large_enum_variant` ;
- ne change ni la propriété du socket, ni la cardinalité des sessions, ni la sémantique reconnect ;
- n'ajoute aucune allocation persistante autour du socket une fois celui-ci réinstallé dans l'actor.
## 5. Signal technique
Le fix modifie du Rust et des tests. Conformément aux règles KSP :
```text
livraison = 0.2.7-pre.007-fix.001
workspace.package.version = 0.2.7-pre.7.fix.1
commit = v0.2.7-pre.007-fix.001
```
Aucun tag prerelease.
## 6. Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
```
## 7. Fichier ajouté
```text
deltas/0.2.7/pre.007-fix.001.md
```
## 8. Fichiers supprimés
Aucun.
## 9. Invariants préservés
Le fix ne change pas :
- les APIs publiques `WsSession` et `WsSubscription<T>` ;
- les IDs locaux et remote ;
- le mapping remote vers local ;
- la politique `ActiveSubscriptions` ou `Never` ;
- le budget, le calcul ou le reset du reconnect ;
- l'ordre déterministe des resubscriptions ;
- la priorité de l'unsubscribe local ;
- la sémantique des continuity gaps ;
- les frontières de dépendances ;
- la façade de logging KSP ;
- le scope de `pre.008` et des wrappers publics ultérieurs.
## 10. Validation sandbox
Le sandbox ne permet pas de revendiquer les gates Cargo opérateur. Les contrôles statiques disponibles sont exécutés sur le workspace reconstruit avec ce fix.
## 11. Validation opérateur attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```

152
deltas/0.2.7/pre.007.md Normal file
View File

@@ -0,0 +1,152 @@
<!-- file: deltas/0.2.7/pre.007.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.007` — reconnect borné et resubscribe déterministe
## Base
Base directe validée par l'opérateur :
```text
0.2.7-pre.006-fix.001
Cargo 0.2.7-pre.6.fix.1
```
Le checkpoint précédent est vert sur `cargo fmt --all`, audit Python KSP, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib` avec 275 tests réussis, puis `cargo test --workspace` avec uniquement les smokes/diagnostics déjà attendus en ignored.
## Objectif
Matérialiser la résilience de session WebSocket prévue par le plan sans avancer sur les wrappers typed publics ni sur le backpressure per-subscription :
- reconnect automatique uniquement après perte physique ou protocolaire structurelle ;
- budget fini `max_retries` ;
- backoff exponentiel borné sans jitter ;
- shutdown prioritaire pendant backoff et handshake ;
- invalidation immédiate des remote subscription IDs après rupture ;
- incrément du `continuity_gap_count` une fois par perte de continuité ;
- restauration déterministe des subscriptions encore désirées en ordre croissant de `WsSubscriptionId` ;
- conservation et replay exact des paramètres de subscribe ;
- remapping des nouveaux remote IDs sans changer les IDs locaux ;
- policy `Never` sans restauration automatique ;
- cancellation locale gagnante pendant reconnect/resubscribe ;
- nettoyage best-effort d'un ACK de resubscribe devenu stale ;
- reset du budget seulement après retour complet à `Active` ;
- aucun replay implicite des requests applicatives en vol ;
- aucun backfill HTTP et aucune promesse lossless.
## Signal de version
```text
livraison 0.2.7-pre.007
workspace.package.version 0.2.7-pre.7
commit attendu v0.2.7-pre.007
Git tag aucun
```
## Fichiers 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/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
deltas/0.2.7/pre.007.md
```
## Implémentation
### Session physique
Une perte détectée après activation de la session entre dans `Reconnecting { attempt }` au lieu de terminer immédiatement l'actor. Les triggers retenus restent les erreurs physiques/socket/TLS/WebSocket, EOF ou Close distant inattendu et les violations protocolaires structurelles. Les erreurs RPC applicatives continuent de ne pas déclencher de reconnect.
Chaque tentative attend un backoff exponentiel borné par les settings existants. Le signal de shutdown est surveillé séparément de la command queue pendant le backoff et le handshake de remplacement afin qu'un `WsSession::close()` puisse interrompre la reprise sans ouvrir une nouvelle connexion uniquement pour effectuer du nettoyage.
Le budget est consommé pour la perte courante et n'est considéré réinitialisé qu'après établissement d'un nouveau socket, restauration des subscriptions retenues et publication complète de `Active`.
### Continuity gaps et subscriptions
Lors d'une rupture, tous les remote IDs sont invalidés et le mapping remote vers local est vidé. `continuity_gap_count` est incrémenté une fois pour la perte logique. Ce compteur ne constitue pas une garantie de livraison et Transport n'effectue aucun backfill.
Avec `WsResubscribePolicy::ActiveSubscriptions`, les subscriptions `Active` passent en `Resubscribing`. L'actor conserve leurs paramètres de création et les rejoue séquentiellement dans l'ordre des IDs locaux. Chaque ACK réussi remappe un nouveau remote ID au même `WsSubscriptionId`.
Avec `WsResubscribePolicy::Never`, la reconnexion physique reste possible mais les anciennes subscriptions deviennent terminales et doivent être recréées par le consumer.
### Races de cancellation
Un unsubscribe reçu pendant le backoff retire immédiatement la subscription de la restauration et retourne sans reconnecter pour nettoyer l'ancien remote ID devenu invalide.
Si la cancellation arrive après l'émission d'un resubscribe mais avant son ACK, le handle local devient terminal et ne peut plus repasser à `Active`. L'actor conserve toutefois cette request interne jusqu'à son ACK ou son timeout borné avant de poursuivre la restauration séquentielle. Lorsque l'ACK tardif fournit un nouvel ID distant, l'actor envoie un `*Unsubscribe` best-effort pour cet ID sans le publier dans le registry local. Cette attente bornée évite qu'une request stale consomme `max_pending_requests` pendant la restauration de la subscription suivante.
### Dépendances
Le runtime Transport active désormais la feature Tokio `net` parce que la session de remplacement utilise le type concret `tokio::net::TcpStream` retourné par `tokio-tungstenite`. La dépendance reste déclarée au workspace root et la canary de frontière est mise à jour. Aucun nouveau package tiers n'est ajouté.
## Tests déterministes ajoutés ou recalibrés
Les fixtures locales couvrent notamment :
- remote Close avec budget insuffisant puis terminaison `Failed` bornée ;
- deux subscriptions restaurées en ordre local stable avec remote IDs remappés ;
- replay exact des paramètres ;
- unsubscribe pendant backoff empêchant tout resubscribe ;
- unsubscribe après émission du resubscribe et ACK tardif nettoyé sans réactivation ;
- policy `Never` ;
- deux pertes distinctes démontrant le reset du budget après retour complet à `Active` ;
- progression de `continuity_gap_count` de 1 à 2 ;
- backoff exponentiel borné ;
- shutdown interrompant un backoff long ;
- erreur RPC applicative sur un resubscribe ne faisant échouer que la subscription concernée.
Les tests HTTP et les canaries de registry existants ne sont pas modifiés fonctionnellement.
## Logging et sécurité
Les nouveaux diagnostics runtime passent exclusivement par `ksp-logging-lib` avec `TRACING_TARGET = "ksp-onchain-transport-lib"`.
Les champs observables restent limités aux métadonnées sûres : session ID local, subscription ID local, nom logique d'endpoint, tentative de reconnect, compteur de gap, kind et code d'erreur sûr. Aucune URL, credential, payload arbitraire ni remote subscription ID n'est projeté dans les logs publics/snapshots.
## Validation exécutée dans le sandbox
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
direct tracing scan runtime/tests clean
question-mark scan runtime modifié clean
Rust lines > 160 0
workspace.package.version 0.2.7-pre.7
```
Le sandbox ne fournit pas `cargo`, `rustc` ni `rustfmt`. Aucune validation Cargo de cette tranche n'est donc revendiquée ici.
## Validation opérateur 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 --workspace
```
## Décisions
- Le reconnect initial de `WsSession::connect` n'est pas transformé en boucle automatique dans cette tranche : la policy de reprise s'applique après acquisition réussie d'une session physique.
- Les requests applicatives en vol lors d'une rupture échouent et ne sont pas rejouées automatiquement.
- Une erreur RPC applicative pendant un resubscribe fait échouer la subscription concernée sans casser la nouvelle session physique.
- La restoration est séquentielle afin de préserver un ordre déterministe et de ne pas dépasser artificiellement `max_pending_requests`.
- Le backpressure/overflow par subscription et les canaries de leak associées restent réservés à `pre.008`.
- Les wrappers standards typed publics restent réservés aux lots `pre.009+`.
## Questions ouvertes
Aucune question bloquante pour ce checkpoint. Les décisions de backpressure per-subscription restent à matérialiser dans `pre.008` conformément au plan.

View File

@@ -0,0 +1,63 @@
<!-- file: deltas/0.2.7/pre.008-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.008-fix.001` — normalisation de la cause terminale avec resubscribe `Never`
## Base
Base directe publiée puis validée partiellement par l'opérateur :
```text
0.2.7-pre.008
Cargo 0.2.7-pre.8
```
Le checkpoint opérateur a confirmé `cargo fmt --all`, l'audit Python KSP, `cargo check --workspace` et `cargo clippy --workspace --all-targets`. Le test Transport a exécuté 287 tests avec 286 succès et un échec : `websocket_resubscribe_never_keeps_session_but_fails_logical_subscription`.
## Signal technique
```text
livraison = 0.2.7-pre.008-fix.001
workspace.package.version = 0.2.7-pre.8.fix.1
commit = v0.2.7-pre.008-fix.001
```
Aucun tag Git n'est requis pour ce fix de prerelease.
## Diagnostic
`pre.008` a rendu la cause terminale sûre observable sur `WsSubscription<T>`. Le contrat de validation retenu pour `WsResubscribePolicy::Never` est que toute subscription active invalidée par une rupture physique devient terminale avec `ws_connection_failed`, car la policy interdit sa restauration après perte de continuité.
Le runtime transmettait directement à `prepare_subscriptions_for_reconnect` le code causal de la rupture. Dans le fixture concerné, le serveur abandonne le WebSocket sans Close frame ; Tungstenite classe cette rupture dans le chemin protocolaire, donc `ws_protocol_error` était publié sur le handle alors que la policy `Never` exige `ws_connection_failed`.
## Correction
`prepare_subscriptions_for_reconnect` distingue désormais :
- `ActiveSubscriptions` : conservation du code causal reçu pour les subscriptions non restaurables dans leur état courant ;
- `Never` : normalisation de la cause terminale des subscriptions à `ERROR_CODE_WS_CONNECTION_FAILED`.
La classification de la rupture physique, le budget de reconnect, les logs de session, `continuity_gap_count`, les remote IDs et le comportement `ActiveSubscriptions` ne sont pas modifiés.
Le canari existant n'est pas affaibli : il conserve l'attente `ws_connection_failed` et vérifie donc directement la correction runtime.
## Fichiers
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_session.rs
deltas/0.2.7/pre.008-fix.001.md
```
## Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Les validations Cargo doivent être exécutées par l'opérateur ; elles ne sont pas revendiquées depuis l'environnement de préparation du delta.

163
deltas/0.2.7/pre.008.md Normal file
View File

@@ -0,0 +1,163 @@
<!-- file: deltas/0.2.7/pre.008.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.008` — backpressure par subscription, causes terminales et cleanup borné
## Base
Base directe validée par l'opérateur :
```text
0.2.7-pre.007-fix.001
Cargo 0.2.7-pre.7.fix.1
```
Le checkpoint précédent est entièrement vert sur `cargo fmt --all`, audit Python KSP, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib` avec 283 tests réussis, puis `cargo test --workspace` avec uniquement les smokes/diagnostics déjà attendus en ignored.
## Objectif
Matérialiser la policy de backpressure WebSocket définie par le plan sans avancer sur les wrappers standard publics :
- queue de notifications réellement bornée par subscription ;
- aucun drop silencieux quand un consumer ne draine plus assez vite ;
- overflow isolé à la seule subscription lente ;
- compteur `overflow_count` réellement incrémenté et observable sur la session ;
- cause terminale KSP sûre observable sur `WsSubscription<T>` ;
- unsubscribe distant best-effort après overflow ou terminaison locale détectée ;
- libération de la capacité `max_active_subscriptions` après terminaison ;
- cleanup d'un receiver abandonné à la notification suivante ;
- maintien de la session physique et des subscriptions saines ;
- conservation des garanties reconnect/resubscribe acquises en `pre.007` ;
- aucune promesse lossless et aucun backfill implicite.
## Signal de version
```text
livraison 0.2.7-pre.008
workspace.package.version 0.2.7-pre.8
commit attendu v0.2.7-pre.008
Git tag aucun
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
crates/ksp-onchain-transport-lib/src/ws_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/unit_tests/ws_lifecycle.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
```
## Fichier ajouté
```text
deltas/0.2.7/pre.008.md
```
## Implémentation
### Overflow par subscription
Le dispatcher typed réserve d'abord une place avec `try_reserve` sur la queue `mpsc` bornée. Une queue pleine est donc détectée avant décodage et ne bloque jamais l'actor socket :
1. `overflow_count` est incrémenté de façon saturante ;
2. la subscription concernée reçoit la cause terminale `ERROR_CODE_WS_BACKPRESSURE_OVERFLOW` ;
3. son état devient `Failed` ;
4. son entrée registry et son mapping remote vers local sont retirés ;
5. un `*Unsubscribe` distant est envoyé best-effort lorsque le binding existait ;
6. les autres subscriptions et la session restent actives.
Une notification déjà présente dans la queue avant l'overflow reste lisible. Après drainage de cette valeur, le receiver se ferme parce que le dispatcher actor de la subscription terminale a été libéré.
### Causes terminales sûres
`WsSubscription<T>` expose désormais :
```text
terminal_error_code() -> Option<ErrorCode>
```
La projection contient uniquement un code KSP stable et sûr. Elle ne transporte ni message RPC distant, ni payload de notification, ni URL, ni credential, ni remote subscription ID.
Les transitions runtime vers `Failed` publient leur cause avant de terminer le handle : overflow, mismatch protocolaire, decode typed invalide, timeout, erreur RPC applicative, échec de subscribe/resubscribe, policy `Never` après rupture et exhaustion terminale du reconnect. Une fermeture normale ou un unsubscribe réussi conserve `None`.
`WsSubscriptionSnapshot` peut également porter cette cause sûre lorsqu'une entrée terminale reste présente dans un snapshot de session, notamment lors d'une exhaustion globale. Les subscriptions terminales retirées du registry restent observables par leur handle local.
### Cleanup et capacité
`max_active_subscriptions` reste une limite d'admission distincte du compteur d'overflow de notifications. Un rejet de création au plafond utilise le domaine d'erreur de capacité existant mais n'incrémente pas `overflow_count`.
Une subscription qui est fermée, échoue ou dont le receiver est abandonné puis détecté à la notification suivante libère son entrée locale. Le binding distant est nettoyé best-effort sans attendre son ACK et sans bloquer la session. Une nouvelle subscription peut ensuite réutiliser la capacité locale disponible avec un nouvel ID local monotone.
Le cleanup distant best-effort est également réutilisé pour les mismatchs de méthode, les erreurs de décodage typed et les ACK de resubscribe tardifs déjà traités en `pre.007`.
### Reconnect
Une subscription devenue terminale à cause d'un overflow ou d'une autre erreur locale est retirée du registry et ne peut donc pas être sélectionnée lors d'un reconnect ultérieur. Les subscriptions saines conservent le comportement `ActiveSubscriptions` ou `Never` défini en `pre.007`.
`overflow_count` est conservé à travers les snapshots et les reconnects. Il reste distinct de `continuity_gap_count` : le premier décrit une saturation locale d'un consumer, le second une interruption de continuité physique.
## Tests déterministes ajoutés ou renforcés
Les fixtures locales couvrent notamment :
- queue capacité 1 avec deux notifications et consumer lent ;
- `overflow_count == 1` après saturation ;
- cause terminale `ws_backpressure_overflow` sur le seul handle lent ;
- première notification déjà queueée encore lisible puis fermeture du receiver ;
- subscription saine parallèle toujours `Active` et recevant sa notification ;
- session physique toujours `Active` après overflow isolé ;
- `*Unsubscribe` distant best-effort exact après overflow ;
- plafond `max_active_subscriptions` sans incrément du compteur d'overflow ;
- capacité réutilisable après unsubscribe terminal ;
- receiver abandonné détecté sur notification suivante, cleanup distant et capacité réutilisable ;
- mismatch de méthode publiant `ws_protocol_error` sur le handle ;
- decode typed invalide publiant `invalid_response` sur le handle ;
- policy `Never` publiant `ws_connection_failed` sur le handle terminal ;
- erreur RPC applicative de resubscribe publiant `rpc_application_error` sur le handle concerné.
Les tests HTTP et la surface 52+14 restent inchangés fonctionnellement.
## Logging et sécurité
Tous les diagnostics passent par `ksp-logging-lib` avec le target crate-owned existant.
Les logs d'overflow/cleanup contiennent uniquement des métadonnées sûres : session ID local, subscription ID local, kind, compteur et code KSP. Ils ne contiennent jamais l'URL, le remote subscription ID ni le payload de notification.
## Validation exécutée dans le sandbox
Le sandbox ne fournit pas `cargo`, `rustc` ni `rustfmt`. Aucune validation Cargo de cette tranche n'est revendiquée ici.
Les contrôles statiques KSP et l'overlay exact sont exécutés avant publication de l'archive.
## Validation opérateur 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 --workspace
```
## Décisions
- `overflow_count` compte uniquement les saturations des queues de notifications ; un rejet d'admission par `max_active_subscriptions` ne l'incrémente pas.
- Une queue pleine fait échouer la subscription au lieu de bloquer l'actor ou de dropper silencieusement des notifications.
- La cause terminale publique reste un `ErrorCode` KSP sans contenu distant arbitraire.
- Le cleanup distant est best-effort et non bloquant ; la terminaison locale reste prioritaire.
- Une subscription terminale libère sa capacité locale et n'est jamais resubscribe automatiquement.
- Les wrappers WebSocket Solana standard publics restent réservés à `pre.009+`.
## Questions ouvertes
Aucune question bloquante pour ce checkpoint. La tranche suivante peut ouvrir le premier lot de wrappers standard stables : account, program et logs.

View File

@@ -0,0 +1,81 @@
<!-- file: deltas/0.2.7/pre.009-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.009-fix.001` — conformité Clippy des closures de décodage WebSocket
## Base
Base directe publiée puis validée partiellement par l'opérateur :
```text
0.2.7-pre.009
Cargo 0.2.7-pre.9
```
Le checkpoint opérateur a confirmé `cargo fmt --all`, l'audit Python KSP et `cargo check --workspace`. `cargo clippy --workspace --all-targets` échoue uniquement sur trois violations de `clippy::implicit-return` dans les closures de décodage des nouveaux wrappers WebSocket. Les tests exécutés malgré cet échec sont verts : 294 tests unitaires Transport, 33 tests d'API publique, 22 tests de release completeness et le reste du workspace.
## Signal technique
```text
livraison = 0.2.7-pre.009-fix.001
workspace.package.version = 0.2.7-pre.9.fix.1
commit = v0.2.7-pre.009-fix.001
```
Aucun tag Git n'est requis pour ce fix de prerelease.
## Diagnostic
Les trois nouveaux wrappers passent leur décodeur à `WsSession::subscribe_typed` via une closure. Le workspace impose `-D clippy::implicit-return`, y compris dans les closures. Les corps suivants utilisaient encore un retour implicite :
```text
accountSubscribe -> decode_account_notification
programSubscribe -> decode_program_notification
logsSubscribe -> decode_logs_notification
```
Le problème est purement syntaxique. Les 294 tests Transport passent déjà sur la livraison `pre.009`, donc aucune correction fonctionnelle ou protocolaire n'est requise.
## Correction
Les trois closures utilisent maintenant un `return` explicite :
```rust
|value| return decode_...(method, value)
```
Aucun DTO, paramètre JSON-RPC, décodage, état de subscription, remote ID, reconnect, backpressure ou contrat public n'est modifié.
Les headers des deux fichiers Rust modifiés sont incrémentés conformément au contrat de fichiers. Le signal Cargo devient `0.2.7-pre.9.fix.1`.
## Fichiers
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_accounts.rs
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
deltas/0.2.7/pre.009-fix.001.md
```
`ROADMAP.md`, `CHANGELOG.md`, le plan et le document de validation restent inchangés.
## Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Comptages Transport attendus inchangés :
```text
unit tests = 294
public API tests = 33
release completeness = 22
```
Les validations Cargo doivent être exécutées par l'opérateur ; elles ne sont pas revendiquées depuis l'environnement de préparation du delta.

View File

@@ -0,0 +1,94 @@
<!-- file: deltas/0.2.7/pre.009-fix.002.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.009-fix.002` — robustesse du fixture HTTP one-shot Transactions
## Base
Base directe publiée :
```text
0.2.7-pre.009-fix.001
Cargo 0.2.7-pre.9.fix.1
```
La validation opérateur de `fix.001` confirme :
- `cargo fmt --all` : vert ;
- audit Python KSP : vert ;
- `cargo check --workspace` : vert ;
- `cargo clippy --workspace --all-targets` : vert ;
- premier `cargo test -p ksp-onchain-transport-lib` : 294 tests unitaires verts, 33 tests d'API publique verts, 22 tests de release completeness verts ;
- le passage ultérieur via `cargo test --workspace` reproduit une seule défaillance intermittente dans un canari HTTP historique `getTransaction`, avec `http_connection_failed / Connection refused` sur son endpoint local one-shot.
Le wrapper WebSocket `pre.009` et le correctif Clippy `fix.001` ne sont pas en cause : le même test `getTransaction` est passé quelques secondes auparavant dans le premier run Transport.
## Signal technique
```text
livraison = 0.2.7-pre.009-fix.002
workspace.package.version = 0.2.7-pre.9.fix.2
commit = v0.2.7-pre.009-fix.002
```
Aucun tag Git n'est requis pour ce fix de prerelease.
## Diagnostic
Le helper historique `serve_transaction_once` consommait sans distinction la première connexion TCP acceptée :
```text
accept
-> read_transaction_request
-> réponse fixture
-> fermeture du listener
```
`read_transaction_request` peut cependant revenir sur EOF avant qu'une requête HTTP complète ait été reçue. Une connexion locale ouverte puis abandonnée avant l'envoi complet de la requête pouvait donc consommer le serveur one-shot. Une tentative cliente suivante rencontrait alors un listener déjà fermé et pouvait échouer avec `Connection refused`.
Cette faiblesse appartient uniquement au fixture de test local. Aucun changement du runtime HTTP ou WebSocket n'est requis.
## Correction
`serve_transaction_once` ignore désormais toute connexion qui se termine avant que `transaction_request_complete(...)` soit vrai. Le body fixture n'est consommé et le listener n'est terminé qu'après réception d'une requête HTTP complète.
Un canari déterministe est ajouté :
```text
transaction_fixture_ignores_abandoned_connection_before_complete_request
```
Il ouvre volontairement une première connexion TCP locale puis la ferme sans envoyer de requête, avant d'exécuter un `getTransaction` normal. Le fixture doit rester disponible et servir correctement la requête complète suivante.
Le correctif est exclusivement test-only : aucun fichier `src/`, aucun DTO, aucune règle de retry, aucun wrapper RPC et aucune sémantique WebSocket ne changent.
## Fichiers
```text
Cargo.toml
crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
deltas/0.2.7/pre.009-fix.002.md
```
`ROADMAP.md`, `CHANGELOG.md`, le plan et le document de validation restent inchangés.
## Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Comptages Transport attendus :
```text
unit tests = 295
public API tests = 33
release completeness = 22
```
Les validations Cargo doivent être exécutées par l'opérateur ; elles ne sont pas revendiquées depuis l'environnement de préparation du delta.

197
deltas/0.2.7/pre.009.md Normal file
View File

@@ -0,0 +1,197 @@
<!-- file: deltas/0.2.7/pre.009.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.009` — wrappers WebSocket stables lot A
## Base
Base requise :
```text
0.2.7-pre.008-fix.001
workspace.package.version = 0.2.7-pre.8.fix.1
```
Le checkpoint opérateur de cette base est vert : `cargo fmt --all`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport et `cargo test --workspace`.
## Signal de version
```text
livraison = 0.2.7-pre.009
workspace.package.version = 0.2.7-pre.9
commit = v0.2.7-pre.009
tag = aucun
```
## Objectif
Ouvrir les trois premiers wrappers WebSocket Solana standard publics sans exposer le moteur générique provider-extension :
```text
accountSubscribe / accountUnsubscribe
programSubscribe / programUnsubscribe
logsSubscribe / logsUnsubscribe
```
L'unsubscribe reste porté par `WsSubscription<T>::unsubscribe()` et traduit l'identité locale stable vers l'ID serveur courant détenu par l'actor.
## `accountSubscribe`
Nouvelle configuration dédiée :
```text
SolanaAccountSubscribeConfig
encoding
data_slice
commitment
```
Les cinq encodings déjà acquis par Transport sont conservés : `binary`, `base58`, `base64`, `jsonParsed`, `base64+zstd`.
`minContextSlot` n'est pas exposé sur ce config WebSocket. Le type partagé upstream le possède, mais le handler PubSub Agave `v4.2.1` l'ignore explicitement ; KSP ne transforme donc pas ce champ en promesse effective.
Retour typed :
```text
WsSubscription<SolanaRpcResponse<SolanaAccount>>
```
Le DTO account et le contexte RPC sont réutilisés sans couche de décodage Program/SPL supplémentaire.
## `programSubscribe`
Nouvelle configuration dédiée :
```text
SolanaProgramSubscribeConfig
account: SolanaAccountSubscribeConfig
filters
with_context
```
Les filtres `dataSize`, `memcmp` et `tokenAccountState` sont conservés. Les bornes déterministes retenues par l'audit HTTP sont également appliquées à la surface WS : maximum quatre filtres et maximum 128 octets pour `SolanaMemcmpBytes::Bytes`.
`withContext` conserve les états omitted/false/true et son défaut upstream `false`. `sortResults` n'est pas exposé : cette option appartient à la surface HTTP `getProgramAccounts` et n'est pas consommée par le handler PubSub audité.
Le résultat est volontairement une union :
```text
SolanaProgramNotification::Account(SolanaKeyedAccount)
SolanaProgramNotification::Context(SolanaRpcResponse<SolanaKeyedAccount>)
```
Le décodeur accepte donc la forme non contextée documentée et la forme contextée observée sans figer une hypothèse plus stricte que l'upstream.
## `logsSubscribe`
Nouveau filtre public :
```text
SolanaLogsSubscribeFilter::All
SolanaLogsSubscribeFilter::AllWithVotes
SolanaLogsSubscribeFilter::Mentions(Pubkey)
```
La forme `Mentions(Pubkey)` encode par construction exactement une adresse, conformément à la contrainte upstream actuelle.
Le commitment réutilise `SolanaCommitmentConfig`. La notification typed est :
```text
SolanaRpcResponse<SolanaLogsNotification>
```
`SolanaLogsNotification` conserve :
```text
signature : String opaque
err : null ou valeur JSON TransactionError
logs : Vec<String> ordonné
```
Aucune interprétation locale des erreurs transactionnelles ou des messages de log n'est ajoutée à Transport.
## Moteur et lifecycle
Les trois wrappers utilisent exclusivement `WsSession::subscribe_typed`, qui reste `pub(crate)`. Les acquisitions précédentes restent communes :
```text
IDs locaux stables
remote IDs internes
ACK/register atomique
reconnect fini
resubscribe déterministe
continuity_gap_count
backpressure par subscription
overflow_count
terminal_error_code
cleanup distant best-effort
```
Les paramètres typed sérialisés sont conservés par l'actor et rejoués à l'identique après reconnect avec `ActiveSubscriptions`.
## Tests déterministes ajoutés
Sept tests unitaires supplémentaires couvrent :
```text
account config exact + absence minContextSlot
program config filters/withContext + bornes déterministes
program notification contextée et non contextée
account + program end-to-end sur serveur WS local + unsubscribe exact
logs filters all/allWithVotes/mentions
logs notification context/signature/err/logs + err requis
logs end-to-end sur serveur WS local + unsubscribe exact
```
Un canari d'API publique supplémentaire vérifie l'adresse des trois méthodes et des nouveaux DTOs depuis la racine de crate.
Comptages attendus après compilation :
```text
Transport unit tests = 294
Transport public API tests = 33
release completeness = 22
```
## Sécurité / observabilité
Aucun wrapper ne journalise les paramètres, pubkeys, logs de transaction ou payloads de notification. Les URL et remote subscription IDs restent absents des DTOs et snapshots publics.
`SolanaLogsNotification` rend le payload disponible au consumer par API typed, mais il n'est jamais utilisé comme metadata de tracing interne.
## Fichiers ajoutés ou modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/src/ws_accounts.rs
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
deltas/0.2.7/pre.009.md
```
`ROADMAP.md` et `CHANGELOG.md` restent inchangés pendant cette tranche.
## Validation opérateur 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 --workspace
```
## Tranche suivante
Si ce checkpoint est vert, `0.2.7-pre.010` ouvre le lot stable B : `signatureSubscribe`, `slotSubscribe` et `rootSubscribe`, avec terminaison one-shot de signature et compliance `KSP-TRANSPORT-007` associée.

View File

@@ -0,0 +1,98 @@
<!-- file: deltas/0.2.7/pre.010-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.010-fix.001` — suppression du helper WebSocket interne devenu mort
## Base
Base directe publiée :
```text
0.2.7-pre.010
Cargo 0.2.7-pre.10
```
La validation opérateur de `pre.010` confirme :
- `cargo fmt --all` : vert ;
- audit Python KSP : vert ;
- `cargo check --workspace` : vert avec deux warnings Transport ;
- `cargo clippy --workspace --all-targets` : vert avec les mêmes deux warnings ;
- `cargo test -p ksp-onchain-transport-lib` : 300 tests unitaires, 34 tests d'API publique et 22 tests de release completeness verts ;
- `cargo test --workspace` : vert, avec les mêmes warnings répétés lors de la compilation de Transport.
Les deux warnings concernent exclusivement `typed_notification_channel`, helper crate-private désormais remplacé par `typed_notification_channel_with_completion` depuis l'introduction de la terminaison one-shot de `signatureSubscribe`.
## Signal technique
```text
livraison = 0.2.7-pre.010-fix.001
workspace.package.version = 0.2.7-pre.10.fix.1
commit = v0.2.7-pre.010-fix.001
```
Aucun tag Git n'est requis pour ce fix de prerelease.
## Diagnostic
`pre.010` a généralisé le constructeur de canal typed afin que le dispatcher puisse distinguer une notification normale d'une notification terminale :
```text
typed_notification_channel_with_completion(...)
```
Le helper historique :
```text
typed_notification_channel(...)
```
n'a alors plus aucun callsite. Son re-export crate-private dans `lib.rs` est lui aussi devenu inutilisé. Rust signale donc :
```text
unused import: self::ws_subscription::typed_notification_channel
function typed_notification_channel is never used
```
## Correction
Le fix supprime uniquement :
- le re-export crate-private `typed_notification_channel` depuis `lib.rs` ;
- la fonction wrapper crate-private `typed_notification_channel` depuis `ws_subscription.rs`.
`typed_notification_channel_with_completion` reste l'unique constructeur interne. Pour les subscriptions continues, `WsSession::subscribe_typed` lui fournit déjà une classification terminale constamment fausse ; pour `signatureSubscribe`, le wrapper fournit sa classification one-shot dédiée.
Aucune API publique, aucun DTO, aucun wire format, aucun comportement de reconnect/resubscribe/backpressure et aucune sémantique HTTP ou WebSocket ne changent.
## Fichiers
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
deltas/0.2.7/pre.010-fix.001.md
```
`ROADMAP.md`, `CHANGELOG.md`, le plan et le document de validation restent inchangés.
## Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Comptages Transport attendus :
```text
unit tests = 300
public API tests = 34
release completeness = 22
```
Les validations Cargo doivent être exécutées par l'opérateur ; elles ne sont pas revendiquées depuis l'environnement de préparation du delta.

189
deltas/0.2.7/pre.010.md Normal file
View File

@@ -0,0 +1,189 @@
<!-- file: deltas/0.2.7/pre.010.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.010` — wrappers WebSocket stables lot B
## Base
Base requise :
```text
0.2.7-pre.009-fix.002
workspace.package.version = 0.2.7-pre.9.fix.2
```
Le checkpoint opérateur de cette base est entièrement vert : `cargo fmt --all`, audit Python KSP, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, 295 tests unitaires Transport, 33 tests d'API publique, 22 tests de release completeness puis `cargo test --workspace`.
Le canari ajouté par `pre.009-fix.002` pour une première connexion HTTP locale abandonnée passe dans le run Transport isolé comme dans le run workspace.
## Signal de version
```text
livraison = 0.2.7-pre.010
workspace.package.version = 0.2.7-pre.10
commit = v0.2.7-pre.010
tag = aucun
```
## Objectif
Compléter les six familles WebSocket standard non instables en ajoutant le lot B :
```text
signatureSubscribe / signatureUnsubscribe
slotSubscribe / slotUnsubscribe
rootSubscribe / rootUnsubscribe
```
Les trois familles unstable `block`, `slotsUpdates` et `vote` restent explicitement différées à `pre.011`.
## `signatureSubscribe`
Nouvelle configuration publique :
```text
SolanaSignatureSubscribeConfig
commitment
enable_received_notification
```
Les deux options sont indépendamment optionnelles. `enableReceivedNotification = false` explicite est préservé comme distinct de l'option omise ; un config explicitement vide est canonisé en absence du second paramètre.
Le résultat typed conserve les deux variantes wire actuelles :
```text
SolanaRpcResponse<SolanaSignatureNotification>
SolanaSignatureNotification::ReceivedSignature
SolanaSignatureNotification::Processed { err }
```
`ReceivedSignature` correspond au littéral wire `receivedSignature` et reste non terminal. `Processed { err }` est terminal ; `err = None` représente un succès et `err = Some(Value)` conserve sans interprétation locale le `TransactionError` wire.
Un littéral string inconnu ou un objet terminal sans champ `err` est rejeté comme `invalid_response` pour la subscription concernée, sans faire tomber une session physique autrement saine.
## Terminaison one-shot signature
Le serveur Solana annule automatiquement `signatureSubscribe` après la notification terminale. Le runtime KSP doit donc fermer le handle au même instant logique, sans envoyer d'unsubscribe redondant et surtout sans restaurer cette subscription après une reconnexion ultérieure.
Le moteur typed acquiert pour cela une classification interne :
```text
Delivered
DeliveredTerminal
ReceiverClosed
QueueFull
DecodeFailed
```
La notification terminale est d'abord insérée dans la queue typed, puis l'actor retire la subscription du registry et du mapping remote/local et publie `WsSubscriptionState::Closed` avec `terminal_error_code = None`.
Cette séquence garantit :
```text
consumer reçoit la valeur terminale
-> handle Closed
-> canal se ferme après la valeur déjà queueée
-> aucune signatureUnsubscribe automatique
-> aucune présence dans une sélection de resubscribe future
```
Une cancellation explicite avant la notification terminale conserve le chemin générique `WsSubscription::unsubscribe()` et émet `signatureUnsubscribe` avec le remote ID détenu uniquement par l'actor. Après la terminaison observée, `unsubscribe()` retourne `false` localement.
## `slotSubscribe`
Nouveau DTO public :
```text
SolanaSlotNotification
slot
parent
root
```
`WsSession::slot_subscribe()` n'accepte aucun paramètre et retourne :
```text
WsSubscription<SolanaSlotNotification>
```
La subscription est continue et utilise normalement reconnect, resubscribe, backpressure et cancellation.
## `rootSubscribe`
`WsSession::root_subscribe()` n'accepte aucun paramètre et retourne directement :
```text
WsSubscription<u64>
```
Le `u64` conserve le dernier root slot rapporté par `rootNotification`. La subscription est continue et son unsubscribe passe par le handle générique.
## Tests déterministes ajoutés
Cinq tests unitaires supplémentaires couvrent :
```text
signature config commitment + enableReceivedNotification omitted/false/true
signature decoder receivedSignature + succès terminal + erreur transactionnelle terminale
signature variants invalides -> invalid_response typed
signatureUnsubscribe exact avant terminaison
signature terminale -> valeur livrée puis Closed sans terminal_error_code
signature terminale -> aucun signatureUnsubscribe redondant
perte physique après signature terminale -> session reconnectée, aucune resubscription signature
slotNotification -> slot/parent/root exacts
slotSubscribe/rootSubscribe -> params vides et notifications typed exactes
slotUnsubscribe/rootUnsubscribe -> remote IDs internes via handles
```
Un canari d'API publique supplémentaire vérifie les trois nouvelles méthodes et les DTOs depuis la racine de crate.
Comptages attendus après compilation :
```text
Transport unit tests = 300
Transport public API tests = 34
release completeness = 22
```
## Sécurité / observabilité
Aucun remote subscription ID n'est ajouté à l'API publique. La signature fournie au wrapper n'est pas ajoutée aux logs de lifecycle. Le log de terminaison one-shot contient uniquement `session_id`, `subscription_id` local et `subscription_kind`.
La valeur `err` terminale reste accessible au consumer dans le DTO typed mais n'est jamais projetée dans `terminal_error_code`, car une transaction échouée reste une notification métier valide et non une erreur Transport.
## Fichiers ajoutés ou modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
deltas/0.2.7/pre.010.md
```
`ROADMAP.md` et `CHANGELOG.md` restent inchangés pendant cette tranche.
## Validation opérateur 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 --workspace
```
## Tranche suivante
Si ce checkpoint est vert, `0.2.7-pre.011` ouvre les trois familles unstable : `blockSubscribe`, `slotsUpdatesSubscribe` et `voteSubscribe`, avec warnings centralisés, variantes wire évolutives et compliance `KSP-TRANSPORT-007`.

View File

@@ -0,0 +1,99 @@
<!-- file: deltas/0.2.7/pre.011-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.011-fix.001` — rustdoc `SolanaSlotUpdate` + idée metering différée
## Base
Base directe publiée :
```text
0.2.7-pre.011
Cargo 0.2.7-pre.11
```
La validation opérateur de `pre.011` confirme :
- `cargo fmt --all` : vert ;
- audit Python KSP : vert ;
- `cargo check --workspace` : vert avec 19 warnings `missing_docs` dans `ws_cluster.rs` ;
- `cargo clippy --workspace --all-targets` : vert avec les mêmes 19 warnings ;
- `cargo test -p ksp-onchain-transport-lib` : 309 tests unitaires, 35 tests d'API publique et 22 tests de release completeness verts ;
- `cargo test --workspace` : vert, avec les mêmes warnings répétés lors de la compilation de Transport.
Les warnings concernent exclusivement les champs publics des variantes de `SolanaSlotUpdate` introduites par le lot unstable `slotsUpdatesSubscribe`.
## Signal technique
```text
livraison = 0.2.7-pre.011-fix.001
workspace.package.version = 0.2.7-pre.11.fix.1
commit = v0.2.7-pre.011-fix.001
```
Aucun tag Git n'est requis pour ce fix de prerelease.
## Correction Rust
Chaque champ public des variantes suivantes reçoit une rustdoc adjacente et spécifique :
```text
FirstShredReceived
Completed
CreatedBank
Frozen
Dead
OptimisticConfirmation
Root
Unknown
```
Les 19 warnings `missing_docs` doivent ainsi disparaître sans `allow`, sans changement de visibilité et sans modification du wire ou du comportement runtime.
Aucune API publique n'est retirée ou ajoutée. Les wrappers `block`, `slotsUpdates` et `vote`, leurs DTOs, reconnect/resubscribe, backpressure et les warnings unstable restent sémantiquement identiques à `pre.011`.
## Idée différée : mesure du trafic / metering
`docs/IDEAS.md` enregistre une piste explicitement différée ; elle ne devient pas une tranche supplémentaire de `0.2.7`.
Direction à étudier ultérieurement :
- `ksp-onchain-transport-lib` reste le meilleur point de vérité pour les faits réellement observés à la frontière réseau, notamment volumes de payload, requêtes/réponses, notifications WebSocket, retries et reconnects ;
- ces mesures restent neutres et ne portent aucun modèle de prix, crédit ou facturation fournisseur ;
- le futur Store conserve les raw et pourra servir aux statistiques historiques sur les types de réponses, tailles, fréquences et distributions, mais ne reconstruit pas autoritairement les octets réellement transportés ;
- une future crate dédiée, nom provisoire `ksp-metering-lib` ou `ksp-metering-observer-lib`, pourra observer et agréger ces données sans devenir un filtre obligatoire dans le data path ;
- le nom, l'API, la granularité des événements et la relation exacte avec Store restent à déterminer avec les besoins réels.
Aucune métrique nouvelle n'est introduite dans le runtime par ce fix.
## Fichiers
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
docs/IDEAS.md
deltas/0.2.7/pre.011-fix.001.md
```
`ROADMAP.md`, `CHANGELOG.md`, le plan `0.2.7` et le document de validation restent inchangés.
## Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Comptages Transport attendus :
```text
unit tests = 309
public API tests = 35
release completeness = 22
```
Les validations Cargo doivent être exécutées par l'opérateur ; elles ne sont pas revendiquées depuis l'environnement de préparation du delta.

214
deltas/0.2.7/pre.011.md Normal file
View File

@@ -0,0 +1,214 @@
<!-- file: deltas/0.2.7/pre.011.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.011` — familles WebSocket unstable : block + slotsUpdates + vote
## Base
Base requise :
```text
0.2.7-pre.010-fix.001
workspace.package.version = 0.2.7-pre.10.fix.1
```
Le checkpoint opérateur reçu sur cette base est vert et permet de poursuivre la série. Le correctif `pre.010-fix.001` a supprimé le helper typed devenu mort sans modifier le lifecycle WebSocket.
## Signal technique
```text
livraison = 0.2.7-pre.011
workspace.package.version = 0.2.7-pre.11
commit = v0.2.7-pre.011
tag = aucun
```
## Objectif
Compléter les neuf familles standard Solana avec les trois paires officiellement instables :
```text
blockSubscribe / blockUnsubscribe
slotsUpdatesSubscribe / slotsUpdatesUnsubscribe
voteSubscribe / voteUnsubscribe
```
La surface promise par `0.2.7` atteint ainsi :
```text
9/9 subscribe wrappers publics typed
9/9 unsubscribe via WsSubscription::unsubscribe()
18/18 opérations standard représentées
```
La compliance finale et les canaris transverses 18/18 restent le scope de `pre.012`.
## `blockSubscribe`
Nouveaux contrats publics :
```text
SolanaBlockSubscribeFilter
All
MentionsAccountOrProgram(Pubkey)
SolanaBlockSubscribeConfig
commitment
encoding
transaction_details
max_supported_transaction_version
show_rewards
SolanaBlockNotification
slot
block : Option<SolanaConfirmedBlock>
err : Option<serde_json::Value>
```
Le config sérialise les noms wire exacts : `commitment`, `encoding`, `transactionDetails`, `maxSupportedTransactionVersion`, `showRewards`.
Un commitment `processed` explicitement fourni est rejeté avant I/O. `confirmed` et `finalized` sont acceptés. Les cinq encodings documentés et les quatre niveaux de transaction details réutilisent les enums HTTP déjà acquis.
`maxSupportedTransactionVersion` reste un `u8` numérique générique : KSP ne durcit pas la valeur à `0` et reste compatible avec de futures versions numériques supportées par le runtime ciblé.
La notification réutilise `SolanaConfirmedBlock::decode_wire`, ce qui conserve les formes `full`, `accounts`, `signatures`, `none`, les encodings modern/legacy déjà acquis, les rewards et les extensions SIMD déjà couvertes côté HTTP. `block` et `err` restent indépendamment nullables.
Un fixture couvre un payload de notification supérieur à 1232 octets tout en restant sous la limite WebSocket KSP, conformément à la décision de ne jamais dériver la taille maximale WS de l'ancienne limite transaction legacy.
Une erreur RPC applicative simulant un validator sans capability block est renvoyée au caller sans teardown ni reconnect de la session physique.
## `slotsUpdatesSubscribe`
Nouveaux contrats publics :
```text
SolanaSlotUpdateStats
SolanaSlotUpdate
FirstShredReceived
Completed
CreatedBank
Frozen
Dead
OptimisticConfirmation
Root
Unknown
```
Les sept variantes courantes conservent leurs champs spécifiques. `createdBank` exige `parent`, `frozen` exige `stats`, `dead` exige `err`.
Une nouvelle valeur upstream du champ `type` devient :
```text
Unknown { update_type, raw }
```
au lieu de faire échouer la subscription. Le `raw` est déjà borné par `WsSessionSettings.max_message_size_bytes` avant le parse JSON. Le fallback ne masque pas les violations d'une variante déjà connue : une forme connue mais structurellement invalide reste `invalid_response` pour la subscription concernée.
## `voteSubscribe`
Nouveau DTO public :
```text
SolanaVoteNotification
vote_pubkey : Pubkey
slots : Vec<u64>
hash : String
timestamp : Option<i64>
signature : String
```
`timestamp` conserve de manière tolérante les trois formes wire retenues par l'audit : omitted, null et valeur `i64`. Omission et null deviennent `None`; une valeur devient `Some(i64)`.
Les votes restent des observations gossip pre-consensus. Transport ne leur attribue aucune sémantique de confirmation ledger.
## Warning unstable centralisé
`WsSubscriptionKind` connaît désormais exactement la partition unstable :
```text
Block
SlotsUpdates
Vote
```
Le point commun `WsSession::subscribe_typed_with_completion` appelle `WsSubscriptionKind::warn_if_unstable()` avant la création logique. Le warning passe exclusivement par `ksp-logging-lib` et contient seulement :
```text
rpc_method
subscription_kind
documentation_status = unstable
```
Il n'inclut jamais filtre, pubkey, signature, payload, remote subscription ID, URL ou credential. Le warning n'est pas répété à chaque notification.
## Tests
Neuf tests unitaires supplémentaires couvrent :
```text
block config complet + processed rejeté
block notification null/block + shared SolanaConfirmedBlock
block payload > 1232 octets sous borne WS
block request exact + notification + blockUnsubscribe
block RPC capability error sans échec session
7 variantes slotsUpdates + Unknown raw
champs obligatoires createdBank/frozen/dead
vote timestamp omitted/null/value
slotsUpdates + vote end-to-end + unsubscribe
partition unstable exacte
```
Le nombre de fonctions `#[test]` supplémentaires est neuf parce que le test block notification agrège aussi la preuve de payload >1232 et le test lifecycle agrège la partition warning. Un canari d'API publique supplémentaire vérifie les nouveaux wrappers et DTOs depuis la racine de crate.
Comptages attendus après compilation :
```text
Transport unit tests = 309
Transport public API tests = 35
release completeness = 22
```
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/src/ws_blocks.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs
deltas/0.2.7/pre.011.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
```
`ROADMAP.md` et `CHANGELOG.md` restent inchangés.
## Validation de préparation
Le sandbox de préparation ne dispose pas de Cargo/rustc. Les contrôles statiques KSP sont exécutés avant packaging ; les gates compilées restent opérateur.
## Validation opérateur 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 --workspace
```
Si ce checkpoint est vert, `pre.012` réalise la compliance WebSocket **18/18**, les canaris de composition Config et les régressions HTTP finales prévues par le plan.

View File

@@ -0,0 +1,71 @@
<!-- file: deltas/0.2.7/pre.012-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.012-fix.001` — conformité Clippy des canaris HTTP
## Base
Base directe publiée :
```text
0.2.7-pre.012
Cargo 0.2.7-pre.12
```
La validation opérateur de `pre.012` confirme :
- `cargo fmt --all` : vert ;
- audit Python KSP : vert ;
- `cargo check --workspace` : vert ;
- `cargo clippy --workspace --all-targets` : échec sur exactement deux closures du nouveau canari de régression HTTP ;
- `cargo test -p ksp-onchain-transport-lib` : 309 tests unitaires, 36 tests d'API publique et 24 tests de release completeness verts ;
- `cargo test --workspace` : vert, avec 109 tests unitaires Config verts.
Les deux erreurs sont `clippy::implicit-return` dans `tests/release_completeness.rs` et n'affectent aucun comportement runtime.
## Signal technique
```text
livraison = 0.2.7-pre.012-fix.001
workspace.package.version = 0.2.7-pre.12.fix.1
commit = v0.2.7-pre.012-fix.001
tag = aucun
```
## Correction
Les deux prédicats `Iterator::all` du canari `release_v0_2_7_pre_012_http_inventory_remains_52_current_plus_14_historical` utilisent désormais un `return` explicite conformément à la politique workspace `-D clippy::implicit-return`.
Aucun contrat, wrapper, DTO, comportement WebSocket/HTTP, configuration, reconnect, resubscribe ou backpressure n'est modifié.
## Fichiers
```text
Cargo.toml
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
deltas/0.2.7/pre.012-fix.001.md
```
`ROADMAP.md`, `CHANGELOG.md`, le plan `0.2.7`, la matrice de validation et les fichiers runtime restent inchangés.
## Validation attendue
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
Comptages attendus :
```text
Transport unit tests = 309
Transport public API tests = 36
Transport release completeness = 24
Config unit tests = 109
```
Les validations Cargo doivent être exécutées par l'opérateur ; elles ne sont pas revendiquées depuis l'environnement de préparation du delta.

77
deltas/0.2.7/pre.012.md Normal file
View File

@@ -0,0 +1,77 @@
<!-- file: deltas/0.2.7/pre.012.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.012` — compliance WebSocket 18/18
## Signal technique
```text
delivery = 0.2.7-pre.012
workspace.package.version = 0.2.7-pre.12
commit = v0.2.7-pre.012
tag = aucun
```
## Objet
Cette tranche ne crée aucune nouvelle famille WebSocket et ne modifie pas le runtime actor. Elle consolide les acquis `pre.002` à `pre.011` en gates de release explicites avant le smoke live et l'audit de dépendances final de `pre.013`.
## Compliance WebSocket
Le gate de release couvre désormais explicitement :
```text
9 familles standard WsSubscriptionKind
9 wrappers subscribe publics
9 opérations unsubscribe via WsSubscription<T>::unsubscribe
18 opérations subscribe/unsubscribe standard comptabilisées
triplets JSON-RPC exacts déjà verrouillés par ws_lifecycle
3 familles unstable exactes : block, slotsUpdates, vote
```
Aucune primitive publique `subscribe(method, raw_params)` n'est ajoutée.
## Composition Config -> Transport
Un canari déterministe Config charge le profil V2 committed `devnet_public`, récupère les settings HTTP + WebSocket, valide `WsTransportSettings`, clone le `WsEndpointSettings` produit par Config puis construit le future `WsSession::connect(endpoint)`.
Le future reste volontairement non pollé : le test vérifie la compatibilité réelle des contrats Config -> Transport sans effectuer de connexion réseau. Le smoke live reste réservé à `pre.013`.
## Régression HTTP
Un gate de release supplémentaire confirme :
```text
52 méthodes HTTP courantes -> Supported
14 méthodes HTTP historiques -> Removed
```
Le canari exhaustif `0.2.4-pre.009` reste également présent et continue de verrouiller les noms exacts, statuts, partitions de release et classes de retry HTTP.
## Documentation
`docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md` enregistre le checkpoint `pre.012` et les comptages attendus.
`docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md` marque `pre.012` comme réalisé dans le forecast. `ROADMAP.md` et `CHANGELOG.md` restent volontairement inchangés pendant cette prerelease.
## Comptages attendus
```text
Transport unit tests = 309
Transport public API tests = 36
Transport release completeness = 24
Config public API tests = 16
```
## Validation 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 sandbox de préparation ne possède pas Cargo/rustc ; les gates Cargo restent donc à exécuter côté opérateur.

99
deltas/0.2.7/pre.013.md Normal file
View File

@@ -0,0 +1,99 @@
<!-- file: deltas/0.2.7/pre.013.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.013` — smoke WebSocket live et audit final des dépendances
## Base
Base directe validée par l'opérateur :
```text
0.2.7-pre.012-fix.001
Cargo 0.2.7-pre.12.fix.1
```
Les gates `fmt`, audit Python, `check`, `clippy`, les 309 tests unitaires Transport, 36 tests d'API publique, 24 tests de release completeness, 109 tests unitaires Config et le workspace complet sont verts.
## Signal technique
```text
livraison = 0.2.7-pre.013
workspace.package.version = 0.2.7-pre.13
commit = v0.2.7-pre.013
tag = aucun
```
## Smoke WebSocket Devnet opt-in
Un nouveau smoke Transport pur `websocket_devnet_smoke.rs` couvre le chemin live stable minimal :
```text
settings programmatiques
-> wss://api.devnet.solana.com
-> WsSession::connect
-> slotSubscribe
-> une slotNotification sous timeout 20 s
-> slotUnsubscribe via WsSubscription
-> WsSession::close
```
Le test est `#[ignore]` par défaut. Il ne lit ni Config ni environnement et n'utilise aucune famille unstable. Les fixtures WebSocket locales restent les gates déterministes du lifecycle, des DTOs et des familles `block`, `slotsUpdates` et `vote`.
Un échec lié au réseau, au rate-limit ou à l'indisponibilité Devnet ne constitue pas automatiquement une régression KSP.
## Audit de dépendances
Le canari workspace de `ksp-core-lib` verrouille désormais les noms directs de dépendances de Transport :
```text
runtime : futures-util, ksp-core-lib, ksp-logging-lib, reqwest, serde, serde_json, tokio, tokio-tungstenite
dev : tokio
```
Il complète le firewall existant contre Config, Store, Program et `tracing` direct.
L'inspection du graphe résolu doit être faite côté opérateur :
```bash
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
cargo tree --duplicates
```
Les doublons transitifs sont à analyser, pas à supprimer mécaniquement lorsqu'ils résultent de contraintes upstream différentes.
## Documentation
`README.md` et `USAGE.md` distinguent désormais les smokes HTTP Transport, WebSocket Transport et le smoke historique Config -> Transport. Le plan et la matrice enregistrent le checkpoint `pre.013`.
`ROADMAP.md` et `CHANGELOG.md` restent inchangés pendant cette prerelease.
## Comptages attendus
```text
Transport unit tests = 309
Transport public API tests = 36
Transport release completeness = 24
Transport WebSocket live smoke = 1 ignored par défaut
Config unit tests = 109
Core workspace dependency tests = 3
```
## Validation 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
cargo tree -p ksp-onchain-transport-lib
cargo tree -p ksp-onchain-transport-lib --duplicates
cargo tree --duplicates
cargo test -p ksp-onchain-transport-lib --test websocket_devnet_smoke -- --ignored --nocapture
```
Le smoke live peut être qualifié séparément si Devnet est indisponible ; les autres gates restent déterministes.
Le sandbox de préparation ne possède pas Cargo/rustc ; les gates Cargo et le smoke live restent à exécuter côté opérateur.

View File

@@ -0,0 +1,279 @@
<!-- file: deltas/0.2.7/pre.014-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.014-fix.001` — complétude du prompt de démarrage `0.2.8`
## 1. Base requise
Base directe attendue :
```text
0.2.7-pre.014
workspace.package.version = 0.2.7-pre.14
```
Ce correctif intervient **avant `0.2.7-rel.001`** et corrige uniquement le prompt de reprise de la release suivante.
La livraison est :
```text
0.2.7-pre.014-fix.001
commit = v0.2.7-pre.014-fix.001
tag = aucun
```
## 2. Nature du fix et signal Cargo
Le fix est **strictement documentaire** :
```text
aucun fichier Rust
aucun Cargo.toml
aucune configuration runtime
aucun schema
aucune dépendance
aucun comportement HTTP/WebSocket
```
Conformément à `VERSION_WORKFLOW.md`, la version Cargo de la base directe est conservée :
```text
workspace.package.version = 0.2.7-pre.14
```
Le prompt modifié incrémente son header :
```text
prompts/013-V0_2_8_START_PROMPT.md
version: 1 -> 2
```
## 3. Diagnostic
Le prompt produit par `pre.014` contenait déjà une section :
```text
## 12. Prévision souple initiale des prereleases
```
avec un forecast nominal `pre.001 -> pre.007`.
Le défaut n'est donc pas l'absence littérale du forecast, mais sa **granularité et son niveau de prescription insuffisants** par rapport :
```text
docs/rules/PROMPT_STRUCTURE.md
aux prompts de démarrage récents
au mode de travail réellement appliqué pendant 0.2.7
```
Le `0.2.7-pre.001` avait notamment recalibré son forecast jusqu'à `pre.014` et rendu explicites :
```text
le sizing avant implémentation
le budget nominal d'environ 1520 minutes par tranche
la possibilité de scinder toute tranche trop large
l'insertion libre de fixes
le fait que le dernier numéro prévu n'est jamais une deadline
```
Le prompt `0.2.8` regroupait encore trop de responsabilités dans `pre.002` à `pre.007`, ce qui pouvait faire perdre cette discipline au démarrage de la nouvelle session.
## 4. Renforcement de la structure du prompt
Le prompt `0.2.8` version 2 renforce la reprise stable :
- interdit explicitement l'ouverture depuis une prerelease `0.2.7-pre.*` ou un ZIP intermédiaire ;
- conserve l'archive stable `v0.2.7` fournie par l'opérateur comme première autorité ;
- exige la vérification du signal Cargo stable et du delta `rel.001`.
L'ordre de lecture est complété avec :
```text
docs/architecture/010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md
docs/validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md
docs/IDEAS.md
.env.example
```
Ces ajouts servent respectivement à préserver :
```text
les frontières app/service/smoke
la règle KSP-TRANSPORT-007
les travaux explicitement différés, dont le metering
la discipline d'inventaire Config/env pour les futurs credentials Helius
```
## 5. Baseline `pre.001` rendue explicite
Le gate d'ouverture doit désormais enregistrer avant modification, lorsque l'environnement le permet :
```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
```
Une commande indisponible ou non exécutée ne peut jamais être déclarée verte.
Le résultat de cette baseline doit vivre dans le plan/delta `pre.001`, comme cela a été pratiqué pendant `0.2.7`.
## 6. Sizing et forecast corrigés
`pre.001` doit désormais produire pour chaque tranche prévue :
```text
objectif précis
principaux contrats/fichiers attendus
preuves/tests/gates attendus
budget nominal <= environ 1520 minutes
critères de split
```
Le forecast initial passe de sept prereleases très agrégées à onze tranches nominales :
```text
pre.001 audit + matrice + architecture + threat model + dependencies + sizing
pre.002 provider/protocol settings + capability model + secrets/redaction
pre.003 Config V2 provider + mapping Config -> Transport
pre.004 transactionSubscribe/unsubscribe params/filters/options
pre.005 transaction notifications + reconnect/resubscribe/races
pre.006 account/program extensions Helius retenues
pre.007 heartbeat/idle + lifecycle/shutdown
pre.008 capability failures + limits/backpressure/adversarial
pre.009 compliance provider + non-régressions 18/18 et 52/14
pre.010 smoke live sûr + dependency audit + README/USAGE
pre.011 workspace final + docs/matrice/indexes + prompt 0.2.9
rel.001 publication stable
```
Chaque ligne du prompt contient maintenant également la preuve/gate principal attendu.
Ce forecast reste **souple** :
```text
pre.001 doit le recalibrer
une tranche > 1520 min doit normalement être scindée
des tranches peuvent être fusionnées si l'audit réduit réellement la surface
des fixes peuvent être insérés librement
pre.011 n'est pas une deadline
aucun numéro de prerelease ne vaut critère de clôture
```
## 7. Versionnement et archives d'échange
Le prompt précise maintenant les noms usuels :
```text
ksp-general-0.2.8-pre.001.zip
ksp-general-0.2.8-pre.NNN-fix.MMM.zip
ksp-general-0.2.8-rel.001.zip
```
L'overlay reste minimal et conserve les chemins depuis la racine du workspace.
## 8. Critères de clôture renforcés
La clôture `0.2.8` reste pilotée par les gates fonctionnels et de compliance, jamais par le forecast.
Le prompt précise désormais :
```text
si les gates ne sont pas verts à pre.011 -> continuer avec pre.012+ ou fixes
forecast réalisé ou explicitement recalibré sans dette silencieuse
rel.001 seulement après fermeture réelle de la matrice et du workspace
```
## 9. Fichiers
Modifié :
```text
prompts/013-V0_2_8_START_PROMPT.md
```
Ajouté :
```text
deltas/0.2.7/pre.014-fix.001.md
```
Volontairement inchangés :
```text
Cargo.toml
ROADMAP.md
CHANGELOG.md
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
prompts/000-README.md
crates/**
config/**
```
Le delta `pre.014.md` publié n'est jamais réécrit.
## 10. Runtime / API
Aucun contrat runtime ne change.
Les acquis restent exactement ceux de `pre.014` :
```text
HTTP 52 current + 14 historical
WebSocket Solana standard 18/18
WsSession/WsSubscription lifecycle inchangé
Config V2 inchangé
dependency graph inchangé
comptages de tests inchangés
```
## 11. Validation opérateur
Le fix n'introduit aucun Rust ni aucune configuration exécutable.
Contrôle documentaire minimal :
```bash
python3 scripts/audit_rust_workspace_rules.py
```
Comme cette livraison devient la base directe de `rel.001`, il est recommandé de rejouer le gate final complet avant clôture :
```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 attendus inchangés par rapport à `pre.014` :
```text
Transport unit tests = 309
Transport public API tests = 36
Transport release completeness = 24
Transport WebSocket live smoke = 1 ignored par défaut
Config unit tests = 109
Core workspace dependency tests = 3
```
Aucun smoke live ni `cargo tree` supplémentaire n'est requis par ce fix documentaire si la base appliquée est exactement `pre.014` et que le graphe n'a pas changé.
## 12. Suite
Si `0.2.7-pre.014-fix.001` est appliqué et le gate final reste vert, la suite est :
```text
0.2.7-rel.001
Cargo = 0.2.7
commit = v0.2.7-rel.001
tag stable = v0.2.7
```
`rel.001` devra publier la synthèse stable, fermer le statut du plan/matrice et conserver `prompts/013-V0_2_8_START_PROMPT.md` version 2 comme prompt autoritaire de la session suivante.

131
deltas/0.2.7/pre.014.md Normal file
View File

@@ -0,0 +1,131 @@
<!-- file: deltas/0.2.7/pre.014.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.014` — clôture candidate, audit documentaire et prompt `0.2.8`
## Base
Base autoritaire fournie par lopérateur : copie complète `0.2.7-pre.013`, déjà validée par `fmt`, audit structurel KSP, `check`, `clippy`, tests Transport/workspace, audit `cargo tree` et smoke WebSocket Devnet opt-in.
La livraison non-fix synchronise `workspace.package.version` vers `0.2.7-pre.14` conformément à `VER-ID-009`.
## Preuve opérateur héritée de `pre.013`
```text
cargo fmt --all OK
python3 scripts/audit_rust_workspace_rules.py OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-onchain-transport-lib OK — 309 unit / 36 public API / 24 release completeness
cargo test --workspace OK
Config unit tests OK — 109
Core workspace dependency tests OK — 3
WebSocket Devnet smoke opt-in OK
```
Le smoke live valide `slotSubscribe -> slotNotification -> unsubscribe -> close` contre `wss://api.devnet.solana.com`.
Le graphe Transport résout notamment `reqwest 0.13.4`, `tokio 1.53.1`, `tokio-tungstenite 0.30.0` et `futures-util 0.3.34`. Les doublons locaux `syn` 2/3 et `webpki-roots` 0.26/1.0 sont transitifs/upstream et sont conservés ; aucune unification artificielle nest introduite.
## Audit documentaire
Audit effectué sur la copie complète du workspace, pas sur un overlay partiel.
Constats structurels :
- 83 documents Markdown durables contrôlés après correction ;
- aucun lien relatif Markdown réellement cassé dans la documentation durable auditée ;
- headers `file`/`version` cohérents sur les documents durables contrôlés ;
- séquence `0.2.7 -> 0.2.8 -> 0.2.9` cohérente entre ROADMAP, plan fonctionnel et architecture ;
- `CHANGELOG.md` reste réservé à la publication stable et nest pas modifié ici ;
- le statut stable de `0.2.7` reste réservé à `rel.001`.
Corrections/synchronisations :
- `ROADMAP.md` passe `0.2.7` en candidate/en cours `[/]`, sans le déclarer stable ;
- plan et matrice `0.2.7` enregistrent les preuves opérateur `pre.013`, le smoke live et lanalyse des doublons Cargo ;
- les index `docs/`, `plans/`, `validation/` et `prompts/` sont synchronisés ;
- la séquence fonctionnelle `0.2.7 -> 0.2.8 -> 0.2.9` est réalignée sur le statut candidate et la preuve de clôture ;
- `ksp-core-lib`, bibliothèque déjà complétée mais dépourvue de documentation locale, reçoit les `README.md` et `USAGE.md` durables exigés par `FILE_CONTRACTS.md`, fondés sur son API publique réelle ;
- `ksp-onchain-transport-lib/README.md` décrit la surface HTTP + WebSocket réellement acquise sans organiser le contrat WebSocket par numéros de prerelease ;
- `ksp-onchain-transport-lib/USAGE.md` devient un guide durable sans notes de version, conformément à `FILE_CONTRACTS.md` ;
- les narrations de prerelease/version présentes dans les README/USAGE historiques de Config Desk, Wallet Desk, Config, Logging et Wallet sont réécrites en contrats présents sans déplacer lhistorique hors des plans/deltas ;
- le scan final des `crates/*/README.md` et `crates/*/USAGE.md` ne contient plus de `pre.NNN`, `rel.NNN` ni de version KSP littérale utilisée comme note de release ;
- aucun refactoring documentaire historique non nécessaire nest entrepris hors de ces écarts.
## Prompt `0.2.8`
Ajout de `prompts/013-V0_2_8_START_PROMPT.md`, rédigé selon `docs/rules/PROMPT_STRUCTURE.md`.
Le prompt :
- exige la base stable exacte `v0.2.7` ;
- impose lordre de lecture des sources internes ;
- impose un réaudit Helius officiel actuel avant implémentation et référence les points dentrée documentaires actuels `/docs/rpc/websocket`, `/docs/api-reference/rpc/websocket*`, `/docs/faqs/websockets` et `docs/llms.txt` ;
- conserve les frontières HTTP/WebSocket/session/reconnect/backpressure/secrets acquises ;
- interdit de dupliquer le client WebSocket standard ;
- distingue LaserStream WebSocket de LaserStream gRPC et Yellowstone gRPC ;
- laisse ouvertes les décisions `transactionSubscribe/unsubscribe`, `notifyOn`, `tokenAccounts`, capability mapping, heartbeat/idle et shape Config jusquau gate ;
- rend `0.2.8-pre.001` strictement audit/brainstorming/sizing ;
- fournit une prévision souple visible et des critères de clôture ;
- prépare `0.2.9 — Yellowstone gRPC standard/provider-neutral` sans lanticiper.
## Runtime / API
Aucun fichier `src/` nest modifié. Aucun contrat HTTP/WebSocket, DTO, lifecycle, reconnect, backpressure, Config runtime ou dépendance directe Transport nest changé.
## Fichiers
```text
Cargo.toml
ROADMAP.md
docs/000-README.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/000-README.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
prompts/000-README.md
prompts/013-V0_2_8_START_PROMPT.md
crates/ksp-app-config-desk/README.md
crates/ksp-app-config-desk/USAGE.md
crates/ksp-app-wallet-desk/README.md
crates/ksp-config-lib/README.md
crates/ksp-config-lib/USAGE.md
crates/ksp-core-lib/README.md
crates/ksp-core-lib/USAGE.md
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-logging-lib/USAGE.md
crates/ksp-wallet-lib/README.md
crates/ksp-wallet-lib/USAGE.md
deltas/0.2.7/pre.014.md
```
## Validation opérateur 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 --workspace
```
Les graphes Cargo et le smoke live nont pas besoin dêtre rejoués sur cette tranche documentaire si la base appliquée est exactement `pre.013`; ils redeviennent obligatoires si le graphe ou le chemin WebSocket est modifié.
Comptages attendus inchangés :
```text
Transport unit tests = 309
Transport public API tests = 36
Transport release completeness = 24
Transport WebSocket live smoke = 1 ignored par défaut
Config unit tests = 109
Core workspace dependency tests = 3
```
## Suite
Si `pre.014` est vert, la suite est `0.2.7-rel.001` : publication stable, Cargo `0.2.7`, `ROADMAP.md` `[X]`, synthèse `CHANGELOG.md`, statut final plan/matrice, delta `rel.001`, commit `v0.2.7-rel.001` et tag stable `v0.2.7`.

254
deltas/0.2.7/rel.001.md Normal file
View File

@@ -0,0 +1,254 @@
<!-- file: deltas/0.2.7/rel.001.md -->
<!-- version: 1 -->
# Delta `0.2.7-rel.001` — publication stable WebSocket Solana standard
## 1. Base requise
Base directe attendue :
```text
0.2.7-pre.014-fix.001
workspace.package.version = 0.2.7-pre.14
```
Le fix `pre.014-fix.001` est strictement documentaire et a uniquement renforcé `prompts/013-V0_2_8_START_PROMPT.md`; il n'a modifié ni runtime, ni Config exécutable, ni dépendance.
Commit attendu pour cette livraison :
```text
v0.2.7-rel.001
```
Tag stable attendu après validation :
```text
v0.2.7
```
## 2. Objectif
Publier `0.2.7 — WebSocket Solana standard` sans ajouter de capacité fonctionnelle après la candidate.
`rel.001` :
- passe `workspace.package.version` de `0.2.7-pre.14` à `0.2.7` ;
- clôt `ROADMAP.md`, le plan `014` et la matrice `validation/010` ;
- ajoute l'entrée stable `0.2.7` au `CHANGELOG.md` ;
- passe `Standard WS` à `Stable` dans l'inventaire composants ;
- synchronise les index/documentation de séquence vers le statut publié ;
- conserve `prompts/013-V0_2_8_START_PROMPT.md` version 2 comme contrat actif pour `0.2.8`.
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.7-pre.014-fix.001` est 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 309 passed
public API 36 passed
release completeness 24 passed
Transport HTTP Devnet smoke 1 ignored par défaut
Transport WebSocket Devnet smoke 1 ignored par défaut
cargo test --workspace OK
```
Les suites workspace Config/Core/Logging/Wallet/Desks passent également. Les tests live, benchmark ou diagnostic explicitement operator-only restent ignorés par défaut conformément à leur contrat.
Le smoke WebSocket Devnet avait été exécuté explicitement avec succès à `pre.013` :
```text
WsSession::connect
-> slotSubscribe
-> slotNotification
-> unsubscribe par handle
-> close explicite
```
`pre.014` et `pre.014-fix.001` n'ont modifié ni le runtime WebSocket ni le graphe de dépendances ; cette preuve live et l'audit `cargo tree` de `pre.013` restent donc valides.
## 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
Les neuf familles publiées sont :
```text
account
block
logs
program
root
signature
slot
slotsUpdates
vote
```
soit :
```text
9 subscribe + 9 unsubscribe = 18/18 opérations standard
```
Partition issue de l'audit normatif courant :
```text
stable : account, logs, program, root, signature, slot
unstable : block, slotsUpdates, vote
```
Contrats stabilisés :
```text
1 endpoint -> 0..N sessions physiques explicites
1 session -> 0..N subscriptions logiques typées
WsSessionId / WsSubscriptionId locaux et stables
remote subscription id interne et remappable
actor unique propriétaire du socket
pending requests / queues / active subscriptions bornés
reconnect fini + backoff borné
resubscribe déterministe par local id
continuity_gap_count observable, sans promesse lossless
backpressure isolé par subscription
late ACK/notifications nettoyés sans réactivation
signatureSubscribe one-shot après notification terminale
WsSession::close().await borné
control frames Ping/Pong/Close gérés
aucun heartbeat applicatif périodique imposé
```
### Config et frontières
```text
std.transport V2 = HTTP + WebSocket
lecture V1 = HTTP-only backward compatible
WsProtocolKind extensible ; seul solana_standard est publié en 0.2.7
Config -> Transport autorisé
Transport -X-> Config/Wallet/Store/Program/tracing direct
```
Les URLs WebSocket et credentials restent redacted dans Debug/logs/snapshots ; les remote subscription IDs et payloads arbitraires ne sont pas projetés comme metadata sûre.
## 5. Fichiers ajoutés
```text
deltas/0.2.7/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/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/000-README.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
prompts/000-README.md
```
Volontairement inchangés :
```text
prompts/013-V0_2_8_START_PROMPT.md # version 2 déjà livrée par pre.014-fix.001
crates/ksp-onchain-transport-lib/src/**
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
config/**
crates/ksp-app-*/package.json
crates/ksp-app-*/tauri.conf.json
```
Les versions applicatives npm/Tauri des Desks restent `0.2.6` : `0.2.7` ne modifie pas ces applications et leur version propre n'est pas utilisée comme signal de la release Transport.
## 7. Fichiers supprimés
Aucun.
## 8. Décisions de clôture
Aucune nouvelle décision de protocole n'est introduite par `rel.001`. La publication confirme :
1. le moteur WebSocket standard appartient à `ksp-onchain-transport-lib`, aux côtés du moteur HTTP ;
2. les neuf familles ciblées et leurs unsubscribe constituent la surface standard publiée `0.2.7` ;
3. les trois familles upstream unstable restent exposées avec leur statut, sans les présenter comme garanties provider ;
4. reconnect/resubscribe n'implique aucune garantie de continuité historique ou de backfill ;
5. plusieurs sessions physiques explicites sont autorisées, sans scheduler/pool automatique imposé ;
6. Config V2 adapte vers les settings WebSocket publics sans dépendance inverse ;
7. les extensions provider-specific restent hors `0.2.7` ;
8. `0.2.8` ouvre Helius LaserStream WebSocket en réutilisant ce moteur standard, conformément au prompt version 2.
## 9. 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 et le smoke live n'ont pas besoin d'être rejoués si l'overlay appliqué est exactement `0.2.7-rel.001`, car cette livraison ne change ni dépendance, ni source Rust, ni Config runtime. En cas de modification additionnelle avant publication, réévaluer les gates concernés.
Comptages Transport attendus :
```text
unit = 309
public API = 36
release completeness = 24
HTTP Devnet smoke = 1 ignored par défaut
WebSocket Devnet smoke = 1 ignored par défaut
```
## 10. Commit et tag stable
Après succès du gate :
```text
commit : v0.2.7-rel.001
tag : v0.2.7
```
Aucun tag intermédiaire de prerelease/fix/rel n'est requis.
## 11. Suite
Après création du tag stable `v0.2.7`, ouvrir :
```text
0.2.8-pre.001
```
avec :
```text
prompts/013-V0_2_8_START_PROMPT.md
```
La première tranche est un gate strict de relecture/audit Helius actuel, architecture, threat model, dépendances et sizing. Le forecast `pre.001 -> pre.011` du prompt est une prévision souple que `pre.001` doit recalibrer avant toute implémentation lourde.

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

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