Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d70c3a1672 | |||
| 7cdf5e80c9 | |||
| d8bfd7cd2e | |||
| 9c0d4fc197 | |||
| 7c12ec886b | |||
| 7eb6dec809 | |||
| 68f4384c5b | |||
| 9cc140fb84 | |||
| 3b64d1e0ec | |||
| 56b9ce6abc | |||
| 1c8d69778b | |||
| 8e739b9e55 | |||
| 53dbb5bccd | |||
| 92224e5ac6 | |||
| cbb4e7b0de | |||
| 38fd62c256 | |||
| f2a3ec62aa | |||
| b3363073c4 | |||
| c94a54f3e3 | |||
| 0871b85df9 | |||
| 315e7e67e5 | |||
| df95f2f558 | |||
| f0865d5137 | |||
| 4d77b607ea | |||
| 307711f873 | |||
| 5aa7b45840 | |||
| 3c5786f273 | |||
| 3b4d355537 | |||
| 628b4f12f2 | |||
| 66deaf8245 | |||
| 0e256a8ecf | |||
| 9eb0e19d81 | |||
| 74686892e9 | |||
| 1391858972 | |||
| 98bf88e431 | |||
| f0f444bc86 | |||
| d17161234a | |||
| 93199d1856 | |||
| 6fefc64e75 | |||
| 4c540d67a7 | |||
| b67fa89f44 | |||
| 435126f67a | |||
| 8721e54b18 | |||
| 6e3a0fa034 | |||
| 34637848eb | |||
| 778ea58ee1 | |||
| b0461f15ec | |||
| d5df0fe9af | |||
| ab29dc51bb | |||
| b6908cb573 | |||
| b64a799c85 | |||
| cf4b28df2b | |||
| 9e8fd53291 |
14
.env.example
14
.env.example
@@ -1,5 +1,5 @@
|
||||
# file: .env.example
|
||||
# version: 4
|
||||
# version: 6
|
||||
|
||||
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
||||
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
||||
@@ -22,10 +22,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.
|
||||
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
|
||||
|
||||
# Optional public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
|
||||
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
|
||||
|
||||
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
|
||||
# The committed Transport document falls back to wss://api.mainnet-beta.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
|
||||
|
||||
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
||||
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
||||
# KSP_SECRET_SOLANA_HTTP_URL=https://provider.example/?api-key=replace-me
|
||||
|
||||
# Helius API key used by the LaserStream WebSocket endpoint in config/examples/std.transport.example.json.
|
||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||
# KSP_SECRET_HELIUS_API_KEY=replace-me
|
||||
|
||||
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
||||
KSP_DESK_SPLASH_FADE_IN_MS=300
|
||||
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,10 +1,22 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 10 -->
|
||||
<!-- version: 12 -->
|
||||
|
||||
# Changelog KSP
|
||||
|
||||
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
|
||||
|
||||
## 0.2.8 — Helius LaserStream WebSocket — 2026-08-23
|
||||
|
||||
`0.2.8` étend `ksp-onchain-transport-lib` avec une façade `HeliusLaserStreamWsSession` dédiée qui réutilise le même `WsSession` physique/actor que le WebSocket Solana standard, sans second client, socket, registry ou scheduler. La surface stable Helius réutilise les sept familles standard actuellement retenues (`account`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates`) et ajoute l’extension typée `transactionSubscribe` / `transactionUnsubscribe`; `block` et `vote` restent absents de la façade Helius et `slotsUpdates` conserve son statut unstable. Le heartbeat provider est possédé par l’actor partagé et émet un WebSocket Ping control frame toutes les 60 secondes uniquement pour `WsProtocolKind::HeliusLaserStream`.
|
||||
|
||||
La release ajoute le mapping Config V2 `helius_laserstream`, les profils Helius mainnet/devnet et le secret `KSP_SECRET_HELIUS_API_KEY` avec provenance/redaction segmentaire, sans dépendance inverse Transport -> Config ni lecture directe de l’environnement par Transport. Les canaris couvrent erreurs RPC provider, payload oversized, mismatch de notification, reconnect/remap/unsubscribe races, backpressure isolé et diagnostics sans payload brut. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, **9 familles / 18 opérations WebSocket Solana standard**, et la surface Helius `7 standard + transaction`. Le smoke Helius live cross-crates est volontairement reporté vers une future surface d’intégration/orchestration afin de préserver l’ownership Config du secret. Les graphes Cargo finaux n’introduisent aucun SDK Helius/gRPC ni nouvelle duplication bloquante. `prompts/014-V0_2_9_START_PROMPT.md` ouvre ensuite `0.2.9 — Yellowstone gRPC standard/provider-neutral` uniquement depuis le tag stable `v0.2.8`, avec audit service/proto/crates/licences/MSRV/features et sizing strict en `pre.001` avant toute implémentation lourde.
|
||||
|
||||
## 0.2.7 — WebSocket Solana standard — 2026-08-23
|
||||
|
||||
`0.2.7` stabilise dans `ksp-onchain-transport-lib` le moteur WebSocket Solana standard en complément de la surface HTTP déjà complète. La release couvre exactement les **9 familles subscribe + 9 unsubscribe** de l’inventaire officiel ciblé : `account`, `block`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates` et `vote`. Les wrappers sont typés, les IDs KSP de session/subscription restent locaux et stables, les IDs serveur restent internes/remappables, et plusieurs sessions physiques peuvent coexister explicitement sur la même URL sans introduire de pool/scheduler automatique. Les familles `block`, `slotsUpdates` et `vote` restent identifiées comme unstable selon l’audit normatif courant et utilisent le warning KSP centralisé.
|
||||
|
||||
Le lifecycle WebSocket est borné : actor unique propriétaire du socket, pending JSON-RPC et queues de notifications bornés, `WsSession::close().await`, control frames Ping/Pong/Close, reconnect fini avec backoff borné, resubscribe déterministe par ID local, `continuity_gap_count`, isolation des erreurs applicatives et du backpressure par subscription, nettoyage des late ACK/notifications et terminaison one-shot de `signatureSubscribe`. Config passe à `std.transport` V2 pour composer HTTP + WebSocket tout en gardant la lecture V1 HTTP-only ; la direction reste `Config -> Transport`. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, les canaries Transport (`309` unit, `36` public API, `24` release completeness), le smoke WebSocket Devnet opt-in `slotSubscribe -> notification -> unsubscribe -> close`, les frontières de dépendances et la redaction des URLs/credentials. `prompts/013-V0_2_8_START_PROMPT.md`, renforcé par `0.2.7-pre.014-fix.001`, ouvre ensuite `0.2.8 — Helius LaserStream WebSocket` uniquement depuis le tag stable `v0.2.7`.
|
||||
|
||||
## 0.2.6 — Wallet Desk + `.kspwallet` V2 — 2026-08-22
|
||||
|
||||
`0.2.6` stabilise `ksp-app-wallet-desk` comme seconde application Tauri KSP spécialisée et étend `ksp-wallet-lib` avec le wire binaire `.kspwallet` V2. Wallet Desk compose Config, Wallet, Transport HTTP et Logging sans déplacer leurs responsabilités : inventory root-scoped et symlink-safe, création/import V2 par défaut, ouverture VIEW/OWNER V1/V2, candidats secrets résolus exclusivement par Config, `getBalance` Devnet via Transport, administration alias/notes, rotations OWNER/VIEW, disable/recreate VIEW fort et export Solana CLI JSON/Base58. Les secrets, keypairs, handles, chemins complets import/export et credentials Config restent côté Rust ; le frontend ne reçoit que des projections sûres et utilise des modals Bootstrap pour les opérations privilégiées.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 191
|
||||
# version: 236
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.6"
|
||||
version = "0.2.8"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
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 }
|
||||
base64 = { version = "^0.23" }
|
||||
fs2 = { version = "^0.4" }
|
||||
futures-util = { version = "^0.3", default-features = false }
|
||||
serde = { 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 }
|
||||
solana-keypair = { version = "^3.1", 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-appender = { version = "^0.2", default-features = false }
|
||||
tokio = { version = "^1.53", default-features = false }
|
||||
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||
tempfile = { version = "^3.27" }
|
||||
chrono = { version = "^0.4", default-features = false }
|
||||
tauri = { version = "^2.11" }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 80 -->
|
||||
<!-- version: 83 -->
|
||||
|
||||
# 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.5` — Wallet foundation stable : `.kspwallet` V1, VIEW/OWNER indépendants, Argon2id/XChaCha20-Poly1305, autorité Ed25519 OWNER, persistence no-clobber, signature, administration/rotations/révocation VIEW forte, import/export Solana CLI JSON + Base58, canaris adversariaux, interop externe et documentation durable publiés. La clôture `pre.010-fix.001`–`fix.003` ajoute `ed25519-dalek 3.0.0` direct, normalise le Rust workspace et installe l’audit structurel Python complémentaire à rustfmt/Clippy. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet.
|
||||
- [X] `0.2.6` — `ksp-app-wallet-desk` + `.kspwallet` V2 stables : composition Config/Wallet/HTTP/Logging, lifecycle VIEW/OWNER, balance, administration/import/export, wire binaire V2, APIs multi-version, migration V1 -> V2 explicite et runtime Tauri packagé user-writable validés ; bundles Linux `.deb`/`.rpm`/`.AppImage` produits avant publication. Plan clôturé : `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`.
|
||||
- [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe.
|
||||
- [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.
|
||||
- [X] `0.2.7` — WebSocket Solana standard stable : 9 familles subscribe/unsubscribe typées (18/18 opérations), sessions physiques multiples explicites, subscriptions logiques typées, lifecycle/reconnect/resubscribe/backpressure/shutdown bornés, Config V2, non-régression HTTP 52+14, compliance finale, smoke WebSocket Devnet et audit de dépendances validés ; publication `rel.001` et prompt `0.2.8` prêts.
|
||||
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur l’actor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
|
||||
- [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
|
||||
- [ ] `0.2.10` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
||||
- [ ] `0.2.11` — Introduire une petite application desk de visualisation/validation des prix offchain, puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation possédée par le composant spécialisé.
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"format_version": 2,
|
||||
"retry": {
|
||||
"max_retries": 3,
|
||||
"initial_backoff_ms": 150,
|
||||
"max_backoff_ms": 3000
|
||||
},
|
||||
"ws_defaults": {
|
||||
"command_timeout_ms": 10000,
|
||||
"close_timeout_ms": 5000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"resubscribe": "active_subscriptions",
|
||||
"command_queue_capacity": 128,
|
||||
"notification_queue_capacity": 256,
|
||||
"max_active_subscriptions": 1024,
|
||||
"max_pending_requests": 128,
|
||||
"max_message_size_bytes": 67108864,
|
||||
"max_frame_size_bytes": 16777216,
|
||||
"max_write_buffer_size_bytes": 1048576
|
||||
},
|
||||
"default_profile": "mainnet_mixed",
|
||||
"profiles": [
|
||||
{
|
||||
@@ -23,7 +40,9 @@
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 200,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
@@ -47,7 +66,9 @@
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 20,
|
||||
@@ -58,6 +79,69 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
},
|
||||
{
|
||||
"name": "mainnet_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
||||
"session": {
|
||||
"notification_queue_capacity": 512,
|
||||
"max_active_subscriptions": 2048
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "devnet_helius",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "devnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "devnet_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "devnet",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:ksp:schema:std.transport:v1",
|
||||
"title": "KSP standard HTTP Transport configuration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["format_version", "retry", "default_profile", "profiles"],
|
||||
"properties": {
|
||||
"format_version": {"const": 1},
|
||||
"retry": {"$ref": "#/$defs/retry"},
|
||||
"default_profile": {"$ref": "#/$defs/profileId"},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/profile"}
|
||||
"$id": "urn:ksp:schema:std.transport:v2",
|
||||
"title": "KSP standard HTTP + WebSocket Transport configuration",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/documentV1"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/documentV2"
|
||||
}
|
||||
},
|
||||
],
|
||||
"$defs": {
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
@@ -35,50 +30,99 @@
|
||||
"minimum": 1,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"positiveUsize": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"retry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["max_retries", "initial_backoff_ms", "max_backoff_ms"],
|
||||
"required": [
|
||||
"max_retries",
|
||||
"initial_backoff_ms",
|
||||
"max_backoff_ms"
|
||||
],
|
||||
"properties": {
|
||||
"max_retries": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||
"initial_backoff_ms": {"$ref": "#/$defs/positiveMs"},
|
||||
"max_backoff_ms": {"$ref": "#/$defs/positiveMs"}
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"requests_per_second": {"$ref": "#/$defs/positiveU32"},
|
||||
"burst_capacity": {"$ref": "#/$defs/positiveU32"},
|
||||
"max_concurrent_requests": {"$ref": "#/$defs/positiveU32"},
|
||||
"pause_after_rate_limit_ms": {"$ref": "#/$defs/positiveMs"}
|
||||
"requests_per_second": {
|
||||
"$ref": "#/$defs/positiveU32"
|
||||
},
|
||||
"burst_capacity": {
|
||||
"$ref": "#/$defs/positiveU32"
|
||||
},
|
||||
"max_concurrent_requests": {
|
||||
"$ref": "#/$defs/positiveU32"
|
||||
},
|
||||
"pause_after_rate_limit_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["role", "enabled", "request_kinds", "priority", "limits"],
|
||||
"required": [
|
||||
"role",
|
||||
"enabled",
|
||||
"request_kinds",
|
||||
"priority",
|
||||
"limits"
|
||||
],
|
||||
"properties": {
|
||||
"role": {"$ref": "#/$defs/descriptor"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"role": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"request_kinds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {"$ref": "#/$defs/descriptor"},
|
||||
"items": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"contains": {"const": "*"}},
|
||||
"then": {"maxItems": 1}
|
||||
"if": {
|
||||
"contains": {
|
||||
"const": "*"
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"maxItems": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": {"type": "integer", "minimum": 0, "maximum": 4294967295},
|
||||
"limits": {"$ref": "#/$defs/limits"}
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"limits": {
|
||||
"$ref": "#/$defs/limits"
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"httpEndpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
@@ -92,31 +136,323 @@
|
||||
"roles"
|
||||
],
|
||||
"properties": {
|
||||
"name": {"$ref": "#/$defs/descriptor"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"provider": {"$ref": "#/$defs/descriptor"},
|
||||
"cluster": {"$ref": "#/$defs/descriptor"},
|
||||
"url": {"type": "string", "minLength": 1},
|
||||
"connect_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
||||
"request_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
||||
"max_idle_connections_per_host": {"type": "integer", "minimum": 1},
|
||||
"name": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"cluster": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"connect_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"request_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"max_idle_connections_per_host": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/role"}
|
||||
"items": {
|
||||
"$ref": "#/$defs/role"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"wsReconnect": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["profile_id", "endpoints"],
|
||||
"required": [
|
||||
"max_retries",
|
||||
"initial_backoff_ms",
|
||||
"max_backoff_ms"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {"$ref": "#/$defs/profileId"},
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsReconnectOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsSession": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"command_timeout_ms",
|
||||
"close_timeout_ms",
|
||||
"reconnect",
|
||||
"resubscribe",
|
||||
"command_queue_capacity",
|
||||
"notification_queue_capacity",
|
||||
"max_active_subscriptions",
|
||||
"max_pending_requests",
|
||||
"max_message_size_bytes",
|
||||
"max_frame_size_bytes",
|
||||
"max_write_buffer_size_bytes"
|
||||
],
|
||||
"properties": {
|
||||
"command_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/wsReconnect"
|
||||
},
|
||||
"resubscribe": {
|
||||
"enum": [
|
||||
"never",
|
||||
"active_subscriptions"
|
||||
]
|
||||
},
|
||||
"command_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"notification_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_active_subscriptions": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_pending_requests": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_message_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_frame_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_write_buffer_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsSessionOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"command_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/wsReconnectOverride"
|
||||
},
|
||||
"resubscribe": {
|
||||
"enum": [
|
||||
"never",
|
||||
"active_subscriptions"
|
||||
]
|
||||
},
|
||||
"command_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"notification_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_active_subscriptions": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_pending_requests": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_message_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_frame_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_write_buffer_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsEndpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"enabled",
|
||||
"provider",
|
||||
"cluster",
|
||||
"kind",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"cluster": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"solana_standard",
|
||||
"helius_laserstream"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/$defs/wsSessionOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"endpoints"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/endpoint"}
|
||||
"items": {
|
||||
"$ref": "#/$defs/httpEndpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"endpoints",
|
||||
"ws_endpoints"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/httpEndpoint"
|
||||
}
|
||||
},
|
||||
"ws_endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/wsEndpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"retry",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 1
|
||||
},
|
||||
"retry": {
|
||||
"$ref": "#/$defs/retry"
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"retry",
|
||||
"ws_defaults",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 2
|
||||
},
|
||||
"retry": {
|
||||
"$ref": "#/$defs/retry"
|
||||
},
|
||||
"ws_defaults": {
|
||||
"$ref": "#/$defs/wsSession"
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"format_version": 2,
|
||||
"retry": {
|
||||
"max_retries": 2,
|
||||
"initial_backoff_ms": 100,
|
||||
"max_backoff_ms": 2000
|
||||
},
|
||||
"ws_defaults": {
|
||||
"command_timeout_ms": 10000,
|
||||
"close_timeout_ms": 5000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"resubscribe": "active_subscriptions",
|
||||
"command_queue_capacity": 128,
|
||||
"notification_queue_capacity": 256,
|
||||
"max_active_subscriptions": 1024,
|
||||
"max_pending_requests": 128,
|
||||
"max_message_size_bytes": 67108864,
|
||||
"max_frame_size_bytes": 16777216,
|
||||
"max_write_buffer_size_bytes": 1048576
|
||||
},
|
||||
"default_profile": "devnet_public",
|
||||
"profiles": [
|
||||
{
|
||||
@@ -23,7 +40,9 @@
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"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",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-config-desk/README.md -->
|
||||
<!-- version: 27 -->
|
||||
<!-- version: 28 -->
|
||||
|
||||
# `ksp-app-config-desk`
|
||||
|
||||
@@ -78,7 +78,7 @@ Les DTO Rust applicatifs restent la source de vérité et les bindings généré
|
||||
|
||||
## Robustesse desktop et extensibilité
|
||||
|
||||
`pre.018` rend le fallback de bootstrap Logging testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink.
|
||||
Le fallback de bootstrap Logging est testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink.
|
||||
|
||||
Le frontend possède désormais `shell_registry.ts`. Le registre décrit les vues du shell et les adapters d'éditeurs spécialisés par `file_id`. Le panneau Documents reste générique ; lorsqu'un document possède un adapter enregistré, **Ouvrir l'éditeur spécialisé** déclenche la navigation par événement de registre. `cfg.std.logging -> Logging` est le premier adapter. Ajouter un futur éditeur ne demande donc pas de réécrire le moteur Documents ni la logique générale d'activation des panneaux.
|
||||
|
||||
@@ -123,11 +123,11 @@ main -> ksp-app-config-desk.frontend.main
|
||||
splash -> ksp-app-config-desk.frontend.splash
|
||||
```
|
||||
|
||||
Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit est volontairement hors périmètre de `0.1.4` : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes.
|
||||
Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit reste volontairement hors de la surface actuelle : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes.
|
||||
|
||||
## Panneau Test Logging
|
||||
|
||||
`pre.017` ajoute une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime.
|
||||
Le panneau fournit une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime.
|
||||
|
||||
**Log via bridge frontend** réutilise le bridge déjà installé. Son contrat reste volontairement fixe : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. Cela permet de comparer dans le même panneau le routing backend et le chemin WebView -> Rust -> `ksp-logging-lib`, avant et après modification/hot reload des filtres.
|
||||
|
||||
@@ -171,13 +171,13 @@ La vue **Logging** charge le document standard exclusivement avec `ConfigManagem
|
||||
|
||||
Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. Le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder et appliquer**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Après persistence, Config Desk recharge un `ConfigEnvironment` frais, résout le `default_profile`, puis appelle `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` actif. Le hot reload est immédiat et `logging_generation` avance uniquement après succès. Si l'application runtime échoue, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** ne modifie que le brouillon/source persistée.
|
||||
|
||||
`pre.016` distingue en plus le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés.
|
||||
Le panneau distingue le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés.
|
||||
|
||||
Chaque lancement de Config Desk crée aussi une `LoggingRuntimeIdentity` stable : `application_id` + timestamp UTC de démarrage + PID. `ksp-logging-lib` utilise cette identité pour préfixer les noms des fichiers actifs et la conserve pendant tous les hot reloads du même processus. Deux lancements distincts ne partagent donc plus le même fichier persistant, même avec une rotation `daily`. Avec la configuration de release `info/ksp-info.log`, un prefix effectif peut être `ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log`. Le path Config reste inchangé ; l'identité appartient au runtime, pas au document source.
|
||||
|
||||
## Baseline Logging de release
|
||||
|
||||
La configuration canonique livrée avec `0.1.4` revient à une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors d’un développement ou diagnostic, puis redescendus avant la release suivante.
|
||||
La configuration canonique de release utilise une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors d’un développement ou diagnostic, puis redescendus avant la release suivante.
|
||||
|
||||
## Traçabilité frontend
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
||||
<!-- version: 27 -->
|
||||
<!-- version: 28 -->
|
||||
|
||||
# Utilisation de `ksp-app-config-desk`
|
||||
|
||||
@@ -85,7 +85,7 @@ splash
|
||||
|
||||
Ils sont convertis côté Rust vers des targets KSP statiques `ksp-app-config-desk.frontend*` et émis uniquement via `ksp-logging-lib`. Un niveau différent de `trace`, `debug`, `info`, `warn` ou `error`, ou un `targetId` non whitelisté, est rejeté avec un `CommandErrorDto` sûr.
|
||||
|
||||
`main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit est reporté hors `0.1.4`; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge.
|
||||
`main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit reste hors de la surface actuelle ; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge.
|
||||
|
||||
## Bindings TS-RS
|
||||
|
||||
@@ -287,7 +287,7 @@ Si la résolution ou la préparation du profil explicite échoue, `ksp_logging_l
|
||||
|
||||
## Baseline Logging de release
|
||||
|
||||
Après les tests `debug`/`trace`, la configuration canonique `0.1.4` revient à :
|
||||
Après les tests `debug`/`trace`, la configuration canonique revient à :
|
||||
|
||||
```text
|
||||
default_filter = warn
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
//! Desktop build/shell contract audits for Config Desk.
|
||||
|
||||
@@ -26,6 +26,43 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
||||
let core = match value.split_once('-') {
|
||||
std::option::Option::Some((core, _)) => core,
|
||||
std::option::Option::None => value,
|
||||
};
|
||||
let mut parts = core.split('.');
|
||||
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some((major, minor, patch));
|
||||
}
|
||||
|
||||
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
||||
assert!(value.is_some(), "{field} packaged version must exist");
|
||||
let value = match value {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let parsed = parse_semver_core(value);
|
||||
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
||||
if let std::option::Option::Some(parsed) = parsed {
|
||||
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_and_frontend_build_contracts_remain_explicit() {
|
||||
let root = app_root();
|
||||
@@ -103,11 +140,13 @@ fn pre_014_template_uses_sidebar_navigation_and_kbot_style_splash_contract() {
|
||||
#[test]
|
||||
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
|
||||
let root = app_root();
|
||||
let expected_version = env!("CARGO_PKG_VERSION");
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
assert_eq!(tauri.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
||||
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
assert_eq!(package.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
||||
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(package_version, "package.json");
|
||||
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||
assert!(resources.is_some(), "packaged Config resources map must exist");
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- file: crates/ksp-app-wallet-desk/README.md -->
|
||||
<!-- version: 2 -->
|
||||
<!-- version: 3 -->
|
||||
|
||||
# `ksp-app-wallet-desk`
|
||||
|
||||
`ksp-app-wallet-desk` est l'application desktop spécialisée stable d'administration et de validation des wallets KSP depuis KSP `0.2.6`.
|
||||
`ksp-app-wallet-desk` est l'application desktop spécialisée stable d'administration et de validation des wallets KSP.
|
||||
|
||||
La crate est un package Tauri mixte :
|
||||
|
||||
@@ -29,7 +29,7 @@ Le frontend ne reçoit jamais les keypairs, ciphertexts, passwords Config, paths
|
||||
|
||||
## `.kspwallet` V1/V2
|
||||
|
||||
La release `0.2.6` conserve V1 et ajoute V2 :
|
||||
La surface courante conserve V1 et utilise V2 comme format natif par défaut :
|
||||
|
||||
```text
|
||||
V1 : JSON UTF-8 historique, lecture explicite toujours supportée
|
||||
@@ -45,9 +45,9 @@ Wallet Desk consomme uniquement les APIs non versionnées de `ksp-wallet-lib` :
|
||||
|
||||
`ksp-wallet-lib` conserve parallèlement les APIs explicites `_v1` / `_v2` pour les consumers qui doivent imposer un format. `DEFAULT_WALLET_FORMAT` et `LATEST_SUPPORTED_WALLET_FORMAT` sont des politiques distinctes ; l'apparition future d'un V3 n'impose donc pas de modifier automatiquement le format créé par les APIs génériques.
|
||||
|
||||
La migration V1 -> V2 est une opération explicite OWNER-authentifiée possédée par `ksp-wallet-lib`. Wallet Desk `0.2.6` ne migre jamais silencieusement un wallet lors de sa sélection, inspection ou ouverture.
|
||||
La migration V1 -> V2 est une opération explicite OWNER-authentifiée possédée par `ksp-wallet-lib`. Wallet Desk ne migre jamais silencieusement un wallet lors de sa sélection, inspection ou ouverture.
|
||||
|
||||
## Capacités fonctionnelles `0.2.6`
|
||||
## Capacités fonctionnelles
|
||||
|
||||
La surface validée comprend :
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
||||
// version: 21
|
||||
// version: 22
|
||||
|
||||
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
||||
|
||||
@@ -31,6 +31,43 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
||||
let core = match value.split_once('-') {
|
||||
std::option::Option::Some((core, _)) => core,
|
||||
std::option::Option::None => value,
|
||||
};
|
||||
let mut parts = core.split('.');
|
||||
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some((major, minor, patch));
|
||||
}
|
||||
|
||||
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
||||
assert!(value.is_some(), "{field} packaged version must exist");
|
||||
let value = match value {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let parsed = parse_semver_core(value);
|
||||
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
||||
if let std::option::Option::Some(parsed) = parsed {
|
||||
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_shell_uses_reserved_wallet_desk_ports_and_template_windows() {
|
||||
let root = app_root();
|
||||
@@ -419,13 +456,19 @@ fn pre_017_wallet_desk_open_paths_remain_non_migrating() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_current() {
|
||||
fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_version_coherent() {
|
||||
let root = app_root();
|
||||
let expected_version = env!("CARGO_PKG_VERSION");
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
assert_eq!(tauri.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
||||
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
assert_eq!(package.pointer("/version").and_then(serde_json::Value::as_str), std::option::Option::Some(expected_version));
|
||||
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(package_version, "package.json");
|
||||
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
||||
let packaged_version = match package_version {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
@@ -440,7 +483,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
|
||||
);
|
||||
}
|
||||
let main = read_text(root.join("frontend/main.html").as_path());
|
||||
assert!(main.contains(expected_version));
|
||||
assert!(main.contains(packaged_version));
|
||||
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
|
||||
assert!(tauri_source.contains("tauri::utils::platform::resource_dir"));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/README.md -->
|
||||
<!-- version: 6 -->
|
||||
<!-- version: 8 -->
|
||||
|
||||
# ksp-config-lib
|
||||
|
||||
@@ -23,7 +23,7 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
||||
- la classification `Public`, `Internal`, `Secret` ;
|
||||
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
|
||||
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
||||
- l'adapter du document HTTP Transport effectif vers `ksp_onchain_transport_lib::HttpTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
||||
- l'adapter du document Transport V1/V2 vers `HttpTransportSettings` et, en V2, `WsTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
||||
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
|
||||
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
|
||||
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
|
||||
@@ -81,15 +81,15 @@ Un secret reste accessible au runtime ou au management lorsqu'un consumer autori
|
||||
|
||||
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
|
||||
|
||||
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport les accepte pour les URLs endpoint : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
|
||||
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport 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
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
|
||||
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ;
|
||||
- [`TODO.md`](TODO.md) — points explicitement différés ;
|
||||
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
|
||||
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard HTTP Transport ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport 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/composite.ksp-app-wallet-desk.json`](../../config/composite.ksp-app-wallet-desk.json) — composition Logging/Transport/Wallet de Wallet Desk ;
|
||||
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||
<!-- version: 6 -->
|
||||
<!-- version: 11 -->
|
||||
|
||||
# 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.
|
||||
|
||||
### 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` :
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/lib.rs
|
||||
// version: 16
|
||||
// version: 17
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -154,9 +154,9 @@ pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
|
||||
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
|
||||
/// Default physical filename for the standard Logging JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
||||
/// Default physical filename for the standard HTTP Transport configuration document.
|
||||
/// Default physical filename for the standard HTTP + WebSocket Transport configuration document.
|
||||
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
||||
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
||||
/// Default physical filename for the standard HTTP + WebSocket Transport JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
|
||||
/// Default physical filename for the standard Wallet configuration document.
|
||||
pub use self::registry::DEFAULT_STD_WALLET_FILENAME;
|
||||
@@ -168,13 +168,13 @@ pub use self::registry::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
|
||||
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
||||
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
|
||||
/// Logical file identifier for the standard HTTP + WebSocket Transport JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_WALLET;
|
||||
/// Logical file identifier for the standard Logging configuration document.
|
||||
pub use self::registry::FILE_ID_STD_LOGGING;
|
||||
/// Logical file identifier for the standard HTTP Transport configuration document.
|
||||
/// Logical file identifier for the standard HTTP + WebSocket Transport configuration document.
|
||||
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
||||
/// Logical file identifier for the standard Wallet configuration document.
|
||||
pub use self::registry::FILE_ID_STD_WALLET;
|
||||
@@ -188,7 +188,7 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
|
||||
pub use self::sensitivity::ResolvedConfigJson;
|
||||
/// One resolved Config string preserving real/safe representations and provenance.
|
||||
pub use self::sensitivity::ResolvedConfigText;
|
||||
/// Effective standard HTTP Transport configuration mapped to `ksp_onchain_transport_lib::HttpTransportSettings`.
|
||||
/// Effective standard Transport configuration mapped to HTTP and optional WebSocket runtime settings.
|
||||
pub use self::transport::ResolvedTransportConfig;
|
||||
/// Effective standard Wallet configuration resolved to validated filesystem roots.
|
||||
pub use self::wallet::ResolvedWalletConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/registry.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||
pub const ARG_FILE_MAP: &str = "--filemap";
|
||||
@@ -11,9 +11,9 @@ pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
||||
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
||||
/// Default physical filename for the standard Logging JSON Schema document.
|
||||
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
||||
/// Default physical filename for the standard HTTP Transport configuration document.
|
||||
/// Default physical filename for the standard HTTP + WebSocket Transport configuration document.
|
||||
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
||||
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
||||
/// Default physical filename for the standard HTTP + WebSocket Transport JSON Schema document.
|
||||
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
|
||||
/// Default physical filename for the standard Wallet configuration document.
|
||||
pub const DEFAULT_STD_WALLET_FILENAME: &str = "std.wallet.json";
|
||||
@@ -25,13 +25,13 @@ pub const FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK: &str = "cfg.composite.ksp-app-w
|
||||
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
||||
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
|
||||
/// Logical file identifier for the standard HTTP + WebSocket Transport JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet";
|
||||
/// Logical file identifier for the standard Logging configuration document.
|
||||
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
||||
/// Logical file identifier for the standard HTTP Transport configuration document.
|
||||
/// Logical file identifier for the standard HTTP + WebSocket Transport configuration document.
|
||||
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
||||
/// Logical file identifier for the standard Wallet configuration document.
|
||||
pub const FILE_ID_STD_WALLET: &str = "cfg.std.wallet";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-config-lib/src/transport.rs
|
||||
// version: 2
|
||||
// version: 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)]
|
||||
pub struct ResolvedTransportConfig {
|
||||
file_id: crate::ConfigFileId,
|
||||
@@ -10,6 +10,7 @@ pub struct ResolvedTransportConfig {
|
||||
selection_source: crate::ConfigProfileSelectionSource,
|
||||
effective: crate::ResolvedConfigJson,
|
||||
settings: ksp_onchain_transport_lib::HttpTransportSettings,
|
||||
ws_settings: std::option::Option<ksp_onchain_transport_lib::WsTransportSettings>,
|
||||
}
|
||||
|
||||
impl ResolvedTransportConfig {
|
||||
@@ -47,16 +48,40 @@ impl ResolvedTransportConfig {
|
||||
}
|
||||
|
||||
/// Returns the validated runtime HTTP Transport settings.
|
||||
///
|
||||
/// This compatibility accessor keeps the HTTP contract introduced before Transport V2.
|
||||
#[must_use]
|
||||
pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Returns the validated runtime HTTP Transport settings.
|
||||
#[must_use]
|
||||
pub const fn http_settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Returns validated WebSocket Transport settings when the selected document uses format V2.
|
||||
///
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return self.settings;
|
||||
}
|
||||
|
||||
/// Consumes this resolved Config and returns both HTTP and optional WebSocket runtime settings.
|
||||
#[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 {
|
||||
@@ -68,16 +93,16 @@ impl std::fmt::Debug for ResolvedTransportConfig {
|
||||
.field("profile_id", &self.profile_id)
|
||||
.field("selection_source", &self.selection_source)
|
||||
.field("effective", &self.effective)
|
||||
.field("has_ws_settings", &self.ws_settings.is_some())
|
||||
.finish_non_exhaustive();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ConfigDocumentEngine {
|
||||
/// Loads the standard HTTP Transport document, selects a profile, resolves environment placeholders and maps it to Transport runtime settings.
|
||||
/// Loads the standard Transport document, selects a profile, resolves environment placeholders and maps HTTP plus optional WebSocket runtime settings.
|
||||
///
|
||||
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed
|
||||
/// because the Transport URL wrapper owns runtime redaction. Invalid environment-resolved values are reported as
|
||||
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] without copying endpoint URL values into ordinary error context.
|
||||
/// because Transport URL wrappers own runtime redaction. V1 documents remain HTTP-only; V2 documents require WebSocket defaults and endpoints.
|
||||
pub fn load_resolved_transport_config(
|
||||
&self,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
@@ -96,7 +121,7 @@ impl crate::ConfigDocumentEngine {
|
||||
return resolve_transport_profile(&profile, environment);
|
||||
}
|
||||
|
||||
/// Maps an already resolved standard Transport profile to the runtime HTTP Transport adapter while preserving its selection provenance.
|
||||
/// Maps an already resolved standard Transport profile to HTTP plus optional WebSocket runtime adapters while preserving selection provenance.
|
||||
///
|
||||
/// This entry point is intended for profiles selected by a composite. The profile must reference `cfg.std.transport`.
|
||||
pub fn resolve_transport_config_profile(
|
||||
@@ -121,7 +146,11 @@ struct EffectiveTransportSource {
|
||||
format_version: u32,
|
||||
profile_id: String,
|
||||
retry: EffectiveRetrySource,
|
||||
#[serde(default)]
|
||||
ws_defaults: std::option::Option<EffectiveWsSessionSource>,
|
||||
endpoints: std::vec::Vec<EffectiveEndpointSource>,
|
||||
#[serde(default)]
|
||||
ws_endpoints: std::option::Option<std::vec::Vec<EffectiveWsEndpointSource>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -165,7 +194,69 @@ struct EffectiveLimitsSource {
|
||||
pause_after_rate_limit_ms: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsSessionSource {
|
||||
command_timeout_ms: u64,
|
||||
close_timeout_ms: u64,
|
||||
reconnect: EffectiveWsReconnectSource,
|
||||
resubscribe: String,
|
||||
command_queue_capacity: usize,
|
||||
notification_queue_capacity: usize,
|
||||
max_active_subscriptions: usize,
|
||||
max_pending_requests: usize,
|
||||
max_message_size_bytes: usize,
|
||||
max_frame_size_bytes: usize,
|
||||
max_write_buffer_size_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsReconnectSource {
|
||||
max_retries: u32,
|
||||
initial_backoff_ms: u64,
|
||||
max_backoff_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsEndpointSource {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
provider: String,
|
||||
cluster: String,
|
||||
kind: String,
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
session: std::option::Option<EffectiveWsSessionOverrideSource>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsSessionOverrideSource {
|
||||
command_timeout_ms: std::option::Option<u64>,
|
||||
close_timeout_ms: std::option::Option<u64>,
|
||||
reconnect: std::option::Option<EffectiveWsReconnectOverrideSource>,
|
||||
resubscribe: std::option::Option<String>,
|
||||
command_queue_capacity: std::option::Option<usize>,
|
||||
notification_queue_capacity: std::option::Option<usize>,
|
||||
max_active_subscriptions: std::option::Option<usize>,
|
||||
max_pending_requests: std::option::Option<usize>,
|
||||
max_message_size_bytes: std::option::Option<usize>,
|
||||
max_frame_size_bytes: std::option::Option<usize>,
|
||||
max_write_buffer_size_bytes: std::option::Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveWsReconnectOverrideSource {
|
||||
max_retries: std::option::Option<u32>,
|
||||
initial_backoff_ms: std::option::Option<u64>,
|
||||
max_backoff_ms: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapping standard Transport Config profile");
|
||||
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||
let effective = match effective {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -180,12 +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() {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Transport profile_id does not match the selected profile"));
|
||||
}
|
||||
let format_version = source.format_version;
|
||||
let retry = ksp_onchain_transport_lib::HttpRetrySettings::new(
|
||||
source.retry.max_retries,
|
||||
std::time::Duration::from_millis(source.retry.initial_backoff_ms),
|
||||
@@ -197,14 +286,24 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(endpoints, retry);
|
||||
let validation = settings.validate();
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(transport_contract_error(profile, "effective Transport settings fail the Transport runtime contract", &error));
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(transport_contract_error(profile, "effective HTTP Transport settings fail the Transport runtime contract", &error));
|
||||
}
|
||||
let ws_settings = map_optional_ws_settings(format_version, source.ws_defaults, source.ws_endpoints, profile);
|
||||
let ws_settings = match ws_settings {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let 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!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
endpoint_count = settings.endpoints().len(),
|
||||
format_version,
|
||||
http_endpoint_count = settings.endpoints().len(),
|
||||
ws_endpoint_count,
|
||||
"mapped standard Transport Config to runtime settings"
|
||||
);
|
||||
return std::result::Result::Ok(ResolvedTransportConfig {
|
||||
@@ -214,9 +313,53 @@ fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment
|
||||
selection_source: profile.selection_source(),
|
||||
effective,
|
||||
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(
|
||||
sources: std::vec::Vec<EffectiveEndpointSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
@@ -229,7 +372,7 @@ fn map_endpoints(
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||
transport_contract_error(profile, "effective HTTP endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -253,6 +396,172 @@ fn map_endpoints(
|
||||
return std::result::Result::Ok(endpoints);
|
||||
}
|
||||
|
||||
fn map_ws_endpoints(
|
||||
sources: std::vec::Vec<EffectiveWsEndpointSource>,
|
||||
defaults: &EffectiveWsSessionSource,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::WsEndpointSettings>> {
|
||||
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::WsEndpointSettings>::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
let endpoint_name = source.name.clone();
|
||||
let protocol = map_ws_protocol_kind(source.kind.as_str(), profile, endpoint_name.as_str());
|
||||
let protocol = match protocol {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let url = ksp_onchain_transport_lib::WsEndpointUrl::parse(source.url);
|
||||
let url = match url {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective WebSocket endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
let session = map_ws_session_settings(defaults, source.session.as_ref(), profile, endpoint_name.as_str());
|
||||
let session = match session {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
endpoints.push(ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||
source.name,
|
||||
source.enabled,
|
||||
ksp_onchain_transport_lib::WsProviderName::new(source.provider),
|
||||
ksp_onchain_transport_lib::WsClusterName::new(source.cluster),
|
||||
protocol,
|
||||
url,
|
||||
session,
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(endpoints);
|
||||
}
|
||||
|
||||
fn map_ws_protocol_kind(
|
||||
value: &str,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsProtocolKind> {
|
||||
return match value {
|
||||
"solana_standard" => std::result::Result::Ok(ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard),
|
||||
"helius_laserstream" => std::result::Result::Ok(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream),
|
||||
_ => std::result::Result::Err(
|
||||
effective_error(profile, "effective WebSocket protocol kind is unsupported")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("ws_kind", value),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_ws_session_settings(
|
||||
defaults: &EffectiveWsSessionSource,
|
||||
overrides: std::option::Option<&EffectiveWsSessionOverrideSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsSessionSettings> {
|
||||
let mut command_timeout_ms = defaults.command_timeout_ms;
|
||||
let mut close_timeout_ms = defaults.close_timeout_ms;
|
||||
let mut reconnect_max_retries = defaults.reconnect.max_retries;
|
||||
let mut reconnect_initial_backoff_ms = defaults.reconnect.initial_backoff_ms;
|
||||
let mut reconnect_max_backoff_ms = defaults.reconnect.max_backoff_ms;
|
||||
let mut resubscribe_text = defaults.resubscribe.clone();
|
||||
let mut command_queue_capacity = defaults.command_queue_capacity;
|
||||
let mut notification_queue_capacity = defaults.notification_queue_capacity;
|
||||
let mut max_active_subscriptions = defaults.max_active_subscriptions;
|
||||
let mut max_pending_requests = defaults.max_pending_requests;
|
||||
let mut max_message_size_bytes = defaults.max_message_size_bytes;
|
||||
let mut max_frame_size_bytes = defaults.max_frame_size_bytes;
|
||||
let mut max_write_buffer_size_bytes = defaults.max_write_buffer_size_bytes;
|
||||
if let std::option::Option::Some(overrides) = overrides {
|
||||
if let std::option::Option::Some(value) = overrides.command_timeout_ms {
|
||||
command_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.close_timeout_ms {
|
||||
close_timeout_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(reconnect) = overrides.reconnect.as_ref() {
|
||||
if let std::option::Option::Some(value) = reconnect.max_retries {
|
||||
reconnect_max_retries = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = reconnect.initial_backoff_ms {
|
||||
reconnect_initial_backoff_ms = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = reconnect.max_backoff_ms {
|
||||
reconnect_max_backoff_ms = value;
|
||||
}
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.resubscribe.as_ref() {
|
||||
resubscribe_text = value.clone();
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.command_queue_capacity {
|
||||
command_queue_capacity = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.notification_queue_capacity {
|
||||
notification_queue_capacity = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_active_subscriptions {
|
||||
max_active_subscriptions = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_pending_requests {
|
||||
max_pending_requests = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_message_size_bytes {
|
||||
max_message_size_bytes = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_frame_size_bytes {
|
||||
max_frame_size_bytes = value;
|
||||
}
|
||||
if let std::option::Option::Some(value) = overrides.max_write_buffer_size_bytes {
|
||||
max_write_buffer_size_bytes = value;
|
||||
}
|
||||
}
|
||||
let reconnect = ksp_onchain_transport_lib::WsReconnectSettings::new(
|
||||
reconnect_max_retries,
|
||||
std::time::Duration::from_millis(reconnect_initial_backoff_ms),
|
||||
std::time::Duration::from_millis(reconnect_max_backoff_ms),
|
||||
);
|
||||
let resubscribe = map_ws_resubscribe_policy(resubscribe_text.as_str(), profile, endpoint_name);
|
||||
let resubscribe = match resubscribe {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let settings = ksp_onchain_transport_lib::WsSessionSettings::new(
|
||||
std::time::Duration::from_millis(command_timeout_ms),
|
||||
std::time::Duration::from_millis(close_timeout_ms),
|
||||
reconnect,
|
||||
resubscribe,
|
||||
command_queue_capacity,
|
||||
notification_queue_capacity,
|
||||
max_active_subscriptions,
|
||||
max_pending_requests,
|
||||
max_message_size_bytes,
|
||||
max_frame_size_bytes,
|
||||
max_write_buffer_size_bytes,
|
||||
);
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective WebSocket session settings fail the Transport runtime contract", &error)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(settings);
|
||||
}
|
||||
|
||||
fn map_ws_resubscribe_policy(
|
||||
value: &str,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::WsResubscribePolicy> {
|
||||
return match value {
|
||||
"never" => std::result::Result::Ok(ksp_onchain_transport_lib::WsResubscribePolicy::Never),
|
||||
"active_subscriptions" => std::result::Result::Ok(ksp_onchain_transport_lib::WsResubscribePolicy::ActiveSubscriptions),
|
||||
_ => std::result::Result::Err(
|
||||
effective_error(profile, "effective WebSocket resubscribe policy is unsupported")
|
||||
.with_context("endpoint_name", endpoint_name)
|
||||
.with_context("resubscribe", value),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn map_roles(
|
||||
sources: std::vec::Vec<EffectiveRoleSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||
// version: 21
|
||||
// version: 22
|
||||
|
||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
|
||||
//! Logging/Transport adapters and management contracts.
|
||||
@@ -252,6 +252,9 @@ fn transport_adapter_contract_is_available_from_crate_root() {
|
||||
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_transport_config;
|
||||
let _composite_loader = ksp_config_lib::ConfigDocumentEngine::resolve_transport_config_profile;
|
||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedTransportConfig>() > 0);
|
||||
let _http_settings = ksp_config_lib::ResolvedTransportConfig::http_settings;
|
||||
let _ws_settings = ksp_config_lib::ResolvedTransportConfig::ws_settings;
|
||||
let _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_SCHEMA_STD_TRANSPORT, "schema.std.transport");
|
||||
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json");
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"format_version": 2,
|
||||
"retry": {
|
||||
"max_retries": 4,
|
||||
"initial_backoff_ms": 125,
|
||||
"max_backoff_ms": 2500
|
||||
},
|
||||
"ws_defaults": {
|
||||
"command_timeout_ms": 8000,
|
||||
"close_timeout_ms": 4000,
|
||||
"reconnect": {
|
||||
"max_retries": 6,
|
||||
"initial_backoff_ms": 200,
|
||||
"max_backoff_ms": 4000
|
||||
},
|
||||
"resubscribe": "active_subscriptions",
|
||||
"command_queue_capacity": 64,
|
||||
"notification_queue_capacity": 96,
|
||||
"max_active_subscriptions": 256,
|
||||
"max_pending_requests": 48,
|
||||
"max_message_size_bytes": 33554432,
|
||||
"max_frame_size_bytes": 8388608,
|
||||
"max_write_buffer_size_bytes": 524288
|
||||
},
|
||||
"default_profile": "secret_test",
|
||||
"profiles": [
|
||||
{
|
||||
@@ -23,7 +40,9 @@
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 7,
|
||||
"limits": {
|
||||
"requests_per_second": 9,
|
||||
@@ -34,6 +53,74 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "fixture_private_ws",
|
||||
"enabled": true,
|
||||
"provider": "fixture-provider",
|
||||
"cluster": "fixture-cluster",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_SECRET_TRANSPORT_TEST_WS_URL:-wss://fallback.invalid}",
|
||||
"session": {
|
||||
"command_timeout_ms": 4500,
|
||||
"reconnect": {
|
||||
"max_retries": 7
|
||||
},
|
||||
"resubscribe": "never",
|
||||
"notification_queue_capacity": 48,
|
||||
"max_pending_requests": 24
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fixture_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "helius_devnet",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "fixture_devnet_http",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"url": "https://api.devnet.solana.com",
|
||||
"connect_timeout_ms": 750,
|
||||
"request_timeout_ms": 2500,
|
||||
"max_idle_connections_per_host": 3,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 7,
|
||||
"limits": {
|
||||
"requests_per_second": 9,
|
||||
"burst_capacity": 12,
|
||||
"max_concurrent_requests": 4,
|
||||
"pause_after_rate_limit_ms": 650
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "fixture_helius_devnet_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "devnet",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-fixture-helius-key}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"retry": {
|
||||
"max_retries": 1,
|
||||
"initial_backoff_ms": 90,
|
||||
"max_backoff_ms": 900
|
||||
},
|
||||
"default_profile": "legacy_http",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "legacy_http",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "legacy_http",
|
||||
"enabled": true,
|
||||
"provider": "legacy-provider",
|
||||
"cluster": "devnet",
|
||||
"url": "https://legacy.invalid",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 2200,
|
||||
"max_idle_connections_per_host": 2,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": [
|
||||
"*"
|
||||
],
|
||||
"priority": 10,
|
||||
"limits": {
|
||||
"requests_per_second": 2,
|
||||
"burst_capacity": 3,
|
||||
"max_concurrent_requests": 2,
|
||||
"pause_after_rate_limit_ms": 250
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/unit_tests/transport.rs
|
||||
// version: 2
|
||||
// version: 6
|
||||
|
||||
#[test]
|
||||
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().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)));
|
||||
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]
|
||||
@@ -59,12 +109,47 @@ fn committed_transport_document_maps_default_and_explicit_profiles() {
|
||||
assert_eq!(default.profile_id(), "devnet_public");
|
||||
assert_eq!(default.settings().endpoints()[0].cluster().as_str(), "devnet");
|
||||
assert_eq!(default.settings().endpoints()[0].url().as_str(), "https://api.devnet.solana.com");
|
||||
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 {
|
||||
assert_eq!(mainnet.profile_id(), "mainnet_public");
|
||||
assert_eq!(mainnet.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||
assert_eq!(mainnet.settings().endpoints()[0].cluster().as_str(), "mainnet-beta");
|
||||
assert_eq!(mainnet.settings().endpoints()[0].url().as_str(), "https://api.mainnet-beta.solana.com");
|
||||
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:?}");
|
||||
if let std::result::Result::Ok(profile) = profile {
|
||||
assert_eq!(profile.origin("retry"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||
assert_eq!(profile.origin("ws_defaults"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||
assert_eq!(profile.origin("endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
|
||||
assert_eq!(profile.origin("ws_endpoints"), std::option::Option::Some(crate::ConfigValueOrigin::Profile));
|
||||
assert_eq!(profile.origin("format_version"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||
}
|
||||
}
|
||||
@@ -149,6 +236,108 @@ fn secret_transport_url_is_runtime_available_but_safe_projection_is_redacted() {
|
||||
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_websocket_url_is_runtime_available_but_safe_projection_is_redacted() {
|
||||
let engine = fixture_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let canary = "wss://user:pass@secret-provider.invalid/path?api-key=transport-ws-secret-canary";
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_TRANSPORT_TEST_WS_URL".to_owned(), canary.to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_ok(), "secret WebSocket endpoint should map without being exposed: {resolved:?}");
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let ws = resolved.ws_settings();
|
||||
assert!(ws.is_some(), "V2 fixture should expose WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = ws {
|
||||
assert_eq!(ws.endpoints()[0].url().as_str(), canary);
|
||||
}
|
||||
let safe_url = resolved.effective().safe_value().pointer("/ws_endpoints/0/url").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(safe_url, std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||
let debug = format!("{resolved:?}");
|
||||
assert!(!debug.contains("transport-ws-secret-canary"));
|
||||
assert!(!debug.contains("user:pass"));
|
||||
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_laserstream_mainnet_and_devnet_api_key_map_to_protocol_and_safe_redaction() {
|
||||
let engine = fixture_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let canary = "helius-api-key-canary";
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_HELIUS_API_KEY".to_owned(), canary.to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
let mainnet = engine.load_resolved_transport_config(std::option::Option::None, &environment);
|
||||
assert!(mainnet.is_ok(), "Helius mainnet WebSocket endpoint should map without exposing its API key: {mainnet:?}");
|
||||
let mainnet = match mainnet {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mainnet_ws = mainnet.ws_settings();
|
||||
assert!(mainnet_ws.is_some(), "V2 fixture should expose mainnet WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = mainnet_ws {
|
||||
assert_eq!(ws.endpoints().len(), 2);
|
||||
let endpoint = &ws.endpoints()[1];
|
||||
assert_eq!(endpoint.provider().as_str(), "helius");
|
||||
assert_eq!(endpoint.cluster().as_str(), "mainnet-beta");
|
||||
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||
assert_eq!(endpoint.url().as_str(), "wss://mainnet.helius-rpc.com/?api-key=helius-api-key-canary");
|
||||
}
|
||||
let mainnet_safe_url = mainnet.effective().safe_value().pointer("/ws_endpoints/1/url").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(mainnet_safe_url, std::option::Option::Some("wss://mainnet.helius-rpc.com/?api-key=********"));
|
||||
let mainnet_provenance = mainnet.effective().provenance_at("/ws_endpoints/1/url");
|
||||
assert!(mainnet_provenance.is_some(), "Helius mainnet endpoint URL should retain secret environment provenance");
|
||||
if let std::option::Option::Some(provenance) = mainnet_provenance {
|
||||
assert_eq!(provenance.len(), 2);
|
||||
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||
assert_eq!(provenance[1].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_HELIUS_API_KEY"));
|
||||
}
|
||||
let devnet = engine.load_resolved_transport_config(std::option::Option::Some("helius_devnet"), &environment);
|
||||
assert!(devnet.is_ok(), "Helius devnet WebSocket endpoint should map without exposing its API key: {devnet:?}");
|
||||
let devnet = match devnet {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let devnet_ws = devnet.ws_settings();
|
||||
assert!(devnet_ws.is_some(), "V2 fixture should expose devnet WebSocket settings");
|
||||
if let std::option::Option::Some(ws) = devnet_ws {
|
||||
assert_eq!(ws.endpoints().len(), 1);
|
||||
let endpoint = &ws.endpoints()[0];
|
||||
assert_eq!(endpoint.provider().as_str(), "helius");
|
||||
assert_eq!(endpoint.cluster().as_str(), "devnet");
|
||||
assert_eq!(endpoint.protocol(), ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream);
|
||||
assert_eq!(endpoint.url().as_str(), "wss://devnet.helius-rpc.com/?api-key=helius-api-key-canary");
|
||||
let _connect_future = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(endpoint.clone());
|
||||
}
|
||||
let devnet_safe_url = devnet.effective().safe_value().pointer("/ws_endpoints/0/url").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(devnet_safe_url, std::option::Option::Some("wss://devnet.helius-rpc.com/?api-key=********"));
|
||||
let devnet_provenance = devnet.effective().provenance_at("/ws_endpoints/0/url");
|
||||
assert!(devnet_provenance.is_some(), "Helius devnet endpoint URL should retain secret environment provenance");
|
||||
if let std::option::Option::Some(provenance) = devnet_provenance {
|
||||
assert_eq!(provenance.len(), 2);
|
||||
assert_eq!(provenance[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||
assert_eq!(provenance[1].environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||
assert_eq!(provenance[1].variable_name(), std::option::Option::Some("KSP_SECRET_HELIUS_API_KEY"));
|
||||
}
|
||||
let mainnet_debug = format!("{mainnet:?}");
|
||||
let devnet_debug = format!("{devnet:?}");
|
||||
assert!(!mainnet_debug.contains(canary));
|
||||
assert!(!devnet_debug.contains(canary));
|
||||
assert!(mainnet_debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
assert!(devnet_debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_secret_url_provenance_uses_process_and_process_beats_dotenv() {
|
||||
let engine = fixture_engine();
|
||||
@@ -216,6 +405,22 @@ fn fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||
}
|
||||
|
||||
fn v1_fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||
let workspace = workspace_root();
|
||||
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures_v1");
|
||||
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = crate::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||
}
|
||||
|
||||
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||
let workspace = workspace_root();
|
||||
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||
|
||||
135
crates/ksp-core-lib/README.md
Normal file
135
crates/ksp-core-lib/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<!-- file: crates/ksp-core-lib/README.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# `ksp-core-lib`
|
||||
|
||||
`ksp-core-lib` porte les contrats fondamentaux partagés par les couches KSP sans dépendre des domaines de plus haut niveau.
|
||||
|
||||
La crate possède actuellement trois responsabilités :
|
||||
|
||||
- le type d'erreur commun KSP ;
|
||||
- le type Solana `Pubkey` réexporté comme primitive d'adresse canonique ;
|
||||
- le registre KSP des Program IDs Solana fondamentaux et leur taxonomie.
|
||||
|
||||
## Frontière architecturale
|
||||
|
||||
Core reste une couche basse. Elle ne possède ni configuration, ni logging runtime, ni transport réseau, ni wallet, ni stockage, ni logique de décodage/exécution.
|
||||
|
||||
Les crates de niveau supérieur peuvent dépendre de Core et réutiliser ses contrats ; Core ne doit pas introduire de dépendance inverse vers ces couches.
|
||||
|
||||
La seule dépendance runtime externe directe actuelle est `solana-pubkey`, utilisée pour la primitive `Pubkey`.
|
||||
|
||||
## Erreur commune KSP
|
||||
|
||||
Le contrat d'erreur public repose sur :
|
||||
|
||||
```text
|
||||
ErrorCode
|
||||
ErrorContext
|
||||
Error
|
||||
Result<T>
|
||||
```
|
||||
|
||||
`ErrorCode` sépare un `domain` stable d'un `code` stable. `Error` ajoute :
|
||||
|
||||
- un message humain ;
|
||||
- des champs de contexte ordonnés ;
|
||||
- une source d'erreur standard optionnelle compatible `Send + Sync`.
|
||||
|
||||
L'affichage d'une erreur reste compact :
|
||||
|
||||
```text
|
||||
<domain>.<code>: <message>
|
||||
```
|
||||
|
||||
Les consommateurs ajoutent uniquement des contextes sûrs. Les secrets, credentials, key material, URLs sensibles ou payloads massifs ne doivent pas être copiés dans le message ou le contexte d'une erreur.
|
||||
|
||||
## `Pubkey`
|
||||
|
||||
La crate réexporte :
|
||||
|
||||
```rust
|
||||
ksp_core_lib::Pubkey
|
||||
```
|
||||
|
||||
Les autres crates KSP utilisent cette primitive lorsqu'un contrat public a besoin d'une adresse Solana générique. Elles évitent ainsi de multiplier les propriétaires de type pour la même notion.
|
||||
|
||||
## Program IDs fondamentaux
|
||||
|
||||
Core possède 18 Program IDs Solana fondamentaux sous deux formes cohérentes :
|
||||
|
||||
```text
|
||||
PRGID_* -> Base58 &str
|
||||
PRGIDPK_* -> Pubkey typé
|
||||
```
|
||||
|
||||
Les deux formes sont déclarées depuis une même valeur grâce à `declare_program_id!`.
|
||||
|
||||
Le registre couvre notamment :
|
||||
|
||||
- System, Vote, Stake, Config et Feature ;
|
||||
- Address Lookup Table et Compute Budget ;
|
||||
- les loaders natif, BPF v1, BPF v2, BPF upgradeable et Loader v4 ;
|
||||
- les precompiles Ed25519, Secp256k1 et Secp256r1 ;
|
||||
- Slashing ;
|
||||
- ZK ElGamal Proof et ZK Token Proof.
|
||||
|
||||
Le registre n'est pas un catalogue général de tous les programmes Solana. Les protocoles, applications et Program IDs de domaines futurs sont ajoutés dans les couches propriétaires appropriées lorsqu'un besoin réel existe.
|
||||
|
||||
## Registre et taxonomie
|
||||
|
||||
Chaque entrée est exposée sous `ProgramIdEntry` avec :
|
||||
|
||||
```text
|
||||
code
|
||||
name
|
||||
program_id
|
||||
pubkey
|
||||
domain
|
||||
family
|
||||
protocol
|
||||
subfamily
|
||||
program_version
|
||||
kind
|
||||
```
|
||||
|
||||
`ProgramIdKind` distingue actuellement :
|
||||
|
||||
```text
|
||||
Program
|
||||
Loader
|
||||
Precompile
|
||||
EnshrinedProgram
|
||||
```
|
||||
|
||||
La lecture du registre s'effectue par :
|
||||
|
||||
```text
|
||||
entries()
|
||||
native_program_ids()
|
||||
program_ids(filter)
|
||||
program_ids_by_domain(...)
|
||||
program_ids_by_family(...)
|
||||
program_ids_by_protocol(...)
|
||||
find_program_id(...)
|
||||
find_program_pubkey(...)
|
||||
```
|
||||
|
||||
`ProgramIdFilter` permet de combiner les axes `domain`, `family`, `protocol`, `subfamily`, `program_version` et `kind`.
|
||||
|
||||
## Garanties validées
|
||||
|
||||
Les tests publics et unitaires verrouillent notamment :
|
||||
|
||||
- la correspondance Base58 / `Pubkey` des constantes ;
|
||||
- l'unicité des codes, Program IDs et pubkeys du registre ;
|
||||
- les 18 entrées fondamentales ;
|
||||
- les recherches textuelles et typées ;
|
||||
- les vues et filtres taxonomiques ;
|
||||
- l'absence des comptes well-known qui ne sont pas des programmes ;
|
||||
- le contrat `Error`, son ordre de contexte et sa chaîne `source()` ;
|
||||
- `Error: Send + Sync`.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — exemples d'utilisation de l'erreur commune, de `Pubkey`, des Program IDs et du registre.
|
||||
265
crates/ksp-core-lib/USAGE.md
Normal file
265
crates/ksp-core-lib/USAGE.md
Normal file
@@ -0,0 +1,265 @@
|
||||
<!-- file: crates/ksp-core-lib/USAGE.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Utilisation de `ksp-core-lib`
|
||||
|
||||
## Utiliser le type d'erreur commun
|
||||
|
||||
Une crate KSP définit des codes stables puis retourne `ksp_core_lib::Result<T>` :
|
||||
|
||||
```rust
|
||||
const ERROR_CODE_LOAD_FAILED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("store", "load_failed");
|
||||
|
||||
fn load_value() -> ksp_core_lib::Result<u64> {
|
||||
let error = ksp_core_lib::Error::new(
|
||||
ERROR_CODE_LOAD_FAILED,
|
||||
"unable to load value",
|
||||
)
|
||||
.with_context("operation", "load_value");
|
||||
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
Le domaine et le code sont accessibles séparément :
|
||||
|
||||
```rust
|
||||
let code = ERROR_CODE_LOAD_FAILED;
|
||||
assert_eq!(code.domain(), "store");
|
||||
assert_eq!(code.code(), "load_failed");
|
||||
```
|
||||
|
||||
Le message et les champs de contexte restent accessibles sans parser `Display` :
|
||||
|
||||
```rust
|
||||
let error = ksp_core_lib::Error::new(
|
||||
ERROR_CODE_LOAD_FAILED,
|
||||
"unable to load value",
|
||||
)
|
||||
.with_context("component", "postgres")
|
||||
.with_context("operation", "load_value");
|
||||
|
||||
assert_eq!(error.code(), ERROR_CODE_LOAD_FAILED);
|
||||
assert_eq!(error.message(), "unable to load value");
|
||||
assert_eq!(error.context()[0].key(), "component");
|
||||
assert_eq!(error.context()[0].value(), "postgres");
|
||||
```
|
||||
|
||||
Un `ErrorContext` peut aussi être construit indépendamment lorsqu’un caller prépare explicitement son contexte :
|
||||
|
||||
```rust
|
||||
let context = ksp_core_lib::ErrorContext::new(
|
||||
"operation",
|
||||
"load_value",
|
||||
);
|
||||
|
||||
assert_eq!(context.key(), "operation");
|
||||
assert_eq!(context.value(), "load_value");
|
||||
```
|
||||
|
||||
Les valeurs de contexte doivent être sûres à exposer. Ne pas y placer de secret, credential, seed, key material, URL contenant une clé API ou payload brut volumineux.
|
||||
|
||||
## Conserver une erreur source
|
||||
|
||||
Une erreur externe compatible `Send + Sync + 'static` peut rester dans la chaîne standard :
|
||||
|
||||
```rust
|
||||
#[derive(Debug)]
|
||||
struct SourceError;
|
||||
|
||||
impl std::fmt::Display for SourceError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("source failure");
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SourceError {}
|
||||
|
||||
let error = ksp_core_lib::Error::new(
|
||||
ERROR_CODE_LOAD_FAILED,
|
||||
"unable to load value",
|
||||
)
|
||||
.with_source(SourceError);
|
||||
|
||||
let source = std::error::Error::source(&error);
|
||||
assert!(source.is_some());
|
||||
```
|
||||
|
||||
La source sert au chaînage d'erreurs ; son contenu ne doit pas être recopié sans contrôle dans un contexte ou un log public.
|
||||
|
||||
## Utiliser `Pubkey`
|
||||
|
||||
Core réexporte la primitive Solana utilisée par les contrats KSP :
|
||||
|
||||
```rust
|
||||
let system = ksp_core_lib::Pubkey::from_str_const(
|
||||
ksp_core_lib::PRGID_SOLANA_SYSTEM,
|
||||
);
|
||||
|
||||
assert_eq!(system, ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
|
||||
```
|
||||
|
||||
Une crate consommatrice peut donc utiliser `ksp_core_lib::Pubkey` dans sa propre API sans dépendre directement de `solana-pubkey` lorsque Core est déjà le propriétaire architectural de cette primitive.
|
||||
|
||||
## Utiliser les constantes Program ID
|
||||
|
||||
Chaque Program ID Core possède une forme texte et une forme typée :
|
||||
|
||||
```rust
|
||||
let system_text: &str = ksp_core_lib::PRGID_SOLANA_SYSTEM;
|
||||
let system_pubkey: ksp_core_lib::Pubkey =
|
||||
ksp_core_lib::PRGIDPK_SOLANA_SYSTEM;
|
||||
|
||||
assert_eq!(
|
||||
system_pubkey,
|
||||
ksp_core_lib::Pubkey::from_str_const(system_text),
|
||||
);
|
||||
```
|
||||
|
||||
Les constantes `PRGID_*` sont utiles pour les wires, diagnostics sûrs ou comparaisons texte. Les constantes `PRGIDPK_*` sont préférées dès qu'un contrat manipule une adresse Solana typée.
|
||||
|
||||
## Déclarer une paire texte / `Pubkey`
|
||||
|
||||
`declare_program_id!` permet à une couche KSP propriétaire d'un Program ID de déclarer les deux représentations depuis une seule valeur Base58 :
|
||||
|
||||
```rust
|
||||
ksp_core_lib::declare_program_id!(
|
||||
PRGID_EXAMPLE,
|
||||
PRGIDPK_EXAMPLE,
|
||||
"11111111111111111111111111111111"
|
||||
);
|
||||
|
||||
assert_eq!(PRGID_EXAMPLE, ksp_core_lib::PRGID_SOLANA_SYSTEM);
|
||||
assert_eq!(PRGIDPK_EXAMPLE, ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
|
||||
```
|
||||
|
||||
La macro ne signifie pas que toute nouvelle constante doit être ajoutée au registre Core. La couche propriétaire du domaine décide où vit le nouveau Program ID.
|
||||
|
||||
## Parcourir le registre canonique
|
||||
|
||||
`entries()` retourne le registre complet en ordre déterministe :
|
||||
|
||||
```rust
|
||||
for entry in ksp_core_lib::entries() {
|
||||
let code = entry.code();
|
||||
let name = entry.name();
|
||||
let text = entry.program_id();
|
||||
let pubkey = entry.pubkey();
|
||||
let domain = entry.domain();
|
||||
let family = entry.family();
|
||||
let protocol = entry.protocol();
|
||||
let subfamily = entry.subfamily();
|
||||
let program_version = entry.program_version();
|
||||
let kind = entry.kind();
|
||||
|
||||
let _ = (
|
||||
code,
|
||||
name,
|
||||
text,
|
||||
pubkey,
|
||||
domain,
|
||||
family,
|
||||
protocol,
|
||||
subfamily,
|
||||
program_version,
|
||||
kind,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`native_program_ids()` fournit la vue des Program IDs Solana fondamentaux actuellement enregistrés :
|
||||
|
||||
```rust
|
||||
let count = ksp_core_lib::native_program_ids().count();
|
||||
assert_eq!(count, 18);
|
||||
```
|
||||
|
||||
## Rechercher une entrée
|
||||
|
||||
Recherche par représentation Base58 :
|
||||
|
||||
```rust
|
||||
let entry = ksp_core_lib::find_program_id(
|
||||
ksp_core_lib::PRGID_SOLANA_SYSTEM,
|
||||
);
|
||||
|
||||
let entry = match entry {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
|
||||
assert_eq!(entry.code(), "solana.system");
|
||||
```
|
||||
|
||||
Recherche par `Pubkey` :
|
||||
|
||||
```rust
|
||||
let entry = ksp_core_lib::find_program_pubkey(
|
||||
&ksp_core_lib::PRGIDPK_SOLANA_VOTE,
|
||||
);
|
||||
|
||||
assert!(entry.is_some());
|
||||
```
|
||||
|
||||
Une recherche inconnue retourne `None`; le registre n'invente pas une entrée générique.
|
||||
|
||||
## Filtrer par axe simple
|
||||
|
||||
Les helpers spécialisés conviennent aux filtres simples :
|
||||
|
||||
```rust
|
||||
let loaders = ksp_core_lib::program_ids_by_family("loader");
|
||||
for loader in loaders {
|
||||
assert_eq!(loader.family(), "loader");
|
||||
}
|
||||
```
|
||||
|
||||
Les vues disponibles peuvent être consommées directement :
|
||||
|
||||
```rust
|
||||
let solana_entries = ksp_core_lib::program_ids_by_domain("solana").count();
|
||||
let loaders = ksp_core_lib::program_ids_by_family("loader").count();
|
||||
let solana_protocol = ksp_core_lib::program_ids_by_protocol("solana").count();
|
||||
|
||||
assert_eq!(solana_entries, 18);
|
||||
assert_eq!(loaders, 5);
|
||||
assert_eq!(solana_protocol, 18);
|
||||
```
|
||||
|
||||
## Combiner plusieurs axes
|
||||
|
||||
`ProgramIdFilter` compose les contraintes sans allocation de collection intermédiaire :
|
||||
|
||||
```rust
|
||||
let filter = ksp_core_lib::ProgramIdFilter::new()
|
||||
.with_domain("solana")
|
||||
.with_family("loader")
|
||||
.with_protocol("solana")
|
||||
.with_subfamily("bpf")
|
||||
.with_program_version("v2")
|
||||
.with_kind(ksp_core_lib::ProgramIdKind::Loader);
|
||||
|
||||
for entry in ksp_core_lib::program_ids(filter) {
|
||||
assert_eq!(entry.domain(), "solana");
|
||||
assert_eq!(entry.family(), "loader");
|
||||
assert_eq!(entry.protocol(), "solana");
|
||||
assert_eq!(entry.subfamily(), std::option::Option::Some("bpf"));
|
||||
assert_eq!(entry.program_version(), std::option::Option::Some("v2"));
|
||||
assert_eq!(entry.kind(), ksp_core_lib::ProgramIdKind::Loader);
|
||||
}
|
||||
```
|
||||
|
||||
Un `ProgramIdFilter::new()` vide correspond à toutes les entrées du registre.
|
||||
|
||||
## Choisir entre texte, `Pubkey` et descriptor
|
||||
|
||||
Utiliser :
|
||||
|
||||
```text
|
||||
PRGID_* / &str pour une représentation Base58 canonique
|
||||
PRGIDPK_* / Pubkey pour un contrat Solana typé
|
||||
ProgramIdEntry lorsqu'il faut aussi la taxonomie KSP
|
||||
```
|
||||
|
||||
Ne pas reparcourir les constantes ou reconstruire une taxonomie parallèle dans une crate consommatrice lorsque le registre Core fournit déjà l'information requise.
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||
// version: 1
|
||||
// version: 5
|
||||
|
||||
//! 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-logging-lib"));
|
||||
assert!(manifest.contains("futures-util = { workspace = true, features = [\"sink\", \"std\"] }"));
|
||||
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("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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-logging-lib/USAGE.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 8 -->
|
||||
|
||||
# Utilisation de ksp-logging-lib
|
||||
|
||||
@@ -48,7 +48,7 @@ Les fichiers persistants interdisent `ansi = true`.
|
||||
|
||||
## Runtime multi-output et routing `domain`
|
||||
|
||||
`0.1.3-pre.005` active le multi-sink, les formats et le routing niveau/target ; `0.1.3-pre.006` complète le routing structuré `domain`. Le runtime supporte donc réellement :
|
||||
Le runtime supporte le multi-sink, les formats, le routing niveau/target et le routing structuré `domain` :
|
||||
|
||||
- plusieurs fichiers simultanés ;
|
||||
- les formats `Human`, `Compact`, `Pretty` et `Json` ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-onchain-transport-lib/Cargo.toml
|
||||
# version: 3
|
||||
# version: 6
|
||||
|
||||
[package]
|
||||
name = "ksp-onchain-transport-lib"
|
||||
@@ -10,13 +10,15 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
futures-util = { workspace = true, features = ["sink", "std"] }
|
||||
reqwest = { workspace = true, features = ["rustls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "sync", "time"] }
|
||||
tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] }
|
||||
tokio-tungstenite = { workspace = true, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt"] }
|
||||
tokio = { workspace = true, features = ["io-util", "net", "rt", "test-util"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 20 -->
|
||||
|
||||
# `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 lorsqu’une release les cible.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
@@ -19,7 +19,8 @@ La crate possède :
|
||||
- les enveloppes JSON-RPC 2.0 et leur validation ;
|
||||
- le registre audité des méthodes Solana HTTP ;
|
||||
- l'exécution générique des méthodes standard supportées ;
|
||||
- les wrappers typés explicitement livrés par KSP ;
|
||||
- les wrappers typés HTTP et WebSocket explicitement livrés par KSP ;
|
||||
- les sessions physiques WebSocket, subscriptions logiques, reconnect/resubscribe et backpressure bornés ;
|
||||
- les snapshots runtime sûrs ;
|
||||
- l'observabilité Transport via `ksp-logging-lib`.
|
||||
|
||||
@@ -35,6 +36,7 @@ ksp-config-lib
|
||||
-> ksp-core-lib
|
||||
-> ksp-logging-lib
|
||||
-> reqwest / tokio / serde
|
||||
-> tokio-tungstenite / futures-util
|
||||
```
|
||||
|
||||
La direction inverse est interdite :
|
||||
@@ -69,22 +71,152 @@ Le registre porte notamment :
|
||||
- remplacement historique éventuel ;
|
||||
- release de couverture typée KSP.
|
||||
|
||||
La release stable `0.2.4` complète la surface typée des **52 méthodes courantes** :
|
||||
La surface HTTP typée couvre les **52 méthodes courantes** :
|
||||
|
||||
```text
|
||||
0.2.1 foundation : 4
|
||||
0.2.2 Accounts/Tokens/Cluster : 22
|
||||
0.2.3 Transactions : 11
|
||||
0.2.4 Blocks/Economics : 15
|
||||
total : 52
|
||||
foundation : 4
|
||||
Accounts/Tokens/Cluster : 22
|
||||
Transactions : 11
|
||||
Blocks/Economics : 15
|
||||
total : 52
|
||||
```
|
||||
|
||||
`0.2.4` ajoute les dix wrappers Blocks et les cinq wrappers Economics. Les points sensibles restent notamment `getBlock` moderne + bare encoding legacy deprecated, les quatre `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions`, `commissionBps`, les overloads de `getBlocks`, les ranges de production, les valeurs d'inflation/minimum de délégation fournies par le runtime et les `null` positionnels de `getInflationReward`.
|
||||
Les dix wrappers Blocks et les cinq wrappers Economics couvrent notamment `getBlock` moderne + bare encoding legacy deprecated, les quatre `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions`, `commissionBps`, les overloads de `getBlocks`, les ranges de production, les valeurs d'inflation/minimum de délégation fournies par le runtime et les `null` positionnels de `getInflationReward`.
|
||||
|
||||
`KSP-TRANSPORT-007` impose qu'un wrapper typé couvre toutes les possibilités RPC supportées retenues par l'audit : paramètres/options, overloads et formes legacy encore supportées, contraintes déterministes utiles et variantes de réponse pertinentes sans perte. Le réaudit final `0.2.4` agrège le réaudit 37/37 de `0.2.3` avec les 15 nouveaux wrappers et porte la preuve stable à **52/52**.
|
||||
`KSP-TRANSPORT-007` impose qu'un wrapper typé couvre toutes les possibilités RPC supportées retenues par l'audit : paramètres/options, overloads et formes legacy encore supportées, contraintes déterministes utiles et variantes de réponse pertinentes sans perte. Les canaris de release portent la preuve globale à **52/52** méthodes courantes typées.
|
||||
|
||||
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
|
||||
|
||||
## Moteur WebSocket standard
|
||||
|
||||
La première session physique WebSocket est matérialisée sans introduire de pool/scheduler automatique ni de registry de subscriptions anticipé.
|
||||
|
||||
`WsSession::connect(WsEndpointSettings)` :
|
||||
|
||||
- ouvre exactement une connexion physique pour un appel ;
|
||||
- confie le socket à une tâche actor unique ;
|
||||
- sérialise les commandes internes par un canal `mpsc` borné ;
|
||||
- maintient une map bornée de requests JSON-RPC en attente ;
|
||||
- publie `WsSessionSnapshot` via un état compact `watch` ;
|
||||
- applique aux sockets les plafonds KSP de message, frame et write buffer ;
|
||||
- ne projette jamais l'URL dans `Debug`, snapshot, erreurs KSP ou logs ;
|
||||
- répond aux `Ping` reçus et tolère les `Pong`; le lifecycle `Close`/shutdown est borné et explicite.
|
||||
|
||||
Le chemin JSON-RPC générique reste `pub(crate)`. Il sert de primitive au moteur typed de subscriptions et **ne constitue pas une API publique raw provider-extension**. Le registry de subscriptions et le mapping remote/local, le reconnect/resubscribe et le backpressure borné par subscription font partie du moteur standard.
|
||||
|
||||
Les tests déterministes utilisent un serveur WebSocket local et prouvent le handshake, le round-trip JSON-RPC, le dispatch de réponses hors ordre, l'isolation des erreurs RPC applicatives, deux sessions physiques distinctes sur la même URL et la redaction des erreurs de connexion.
|
||||
|
||||
### Limites, control frames et shutdown
|
||||
|
||||
La session physique dispose désormais de `WsSession::close().await`. Le signal de shutdown est distinct de la command queue, passe l'état en `Closing`, annule les requests JSON-RPC en attente, envoie un Close WebSocket best-effort sous `close_timeout`, puis publie `Closed`. Un peer qui ne répond pas au Close ne peut donc pas bloquer indéfiniment le shutdown.
|
||||
|
||||
Les limites `max_message_size`, `max_frame_size`, `max_write_buffer_size` et `max_pending_requests` sont couvertes par des fixtures adversariales locales. Les requests outbound qui dépassent les bornes message/frame sont rejetées avant écriture ; les frames/messages inbound surdimensionnés sont rejetés par Tungstenite avant parse JSON. Les timeouts pending libèrent leur capacité sans faire tomber une session encore saine.
|
||||
|
||||
Ping/Pong/Close sont traités comme control frames : le Pong automatique Tungstenite est flushé et aucun heartbeat applicatif périodique n'est ajouté. Un Close distant inattendu, EOF, erreur I/O/TLS/WebSocket ou violation protocolaire structurelle entre dans le reconnect borné ; `Closed` reste réservé au shutdown local explicite ou à la disparition des handles.
|
||||
|
||||
### Registry de subscriptions
|
||||
|
||||
Le même actor possède maintenant le registre des subscriptions logiques, sans exposer les IDs numériques distants. Chaque subscription reçoit un `WsSubscriptionId` local stable, et le mapping `remote_subscription_id -> WsSubscriptionId` reste strictement runtime/interne.
|
||||
|
||||
La création générique typed reste `pub(crate)` et n'est jamais exposée comme API raw provider-extension. Les wrappers standard publics l’utilisent derrière leurs DTOs et paramètres typés. Le handle public `WsSubscription<T>` expose uniquement :
|
||||
|
||||
- `id()` et `kind()` ;
|
||||
- `state()` ;
|
||||
- `recv()` sur un canal typed borné ;
|
||||
- `unsubscribe()` qui conserve le booléen retourné par l'unsubscribe Solana standard.
|
||||
|
||||
L'ACK de subscribe est traité atomiquement dans l'actor : le remote ID est lié au local ID avant que la notification suivante puisse être dispatchée. Les notifications inconnues/stale sont ignorées avec un diagnostic sûr. Un mismatch de méthode de notification ou un échec de décodage typed termine uniquement la subscription concernée ; la session physique reste `Active`.
|
||||
|
||||
### Reconnect et resubscribe
|
||||
|
||||
Une perte de connexion physique invalide immédiatement les remote subscription IDs et incrémente `continuity_gap_count`. Le runtime utilise `WsReconnectSettings` pour appliquer un nombre fini de tentatives avec backoff exponentiel borné et sans jitter. Le shutdown surveille les phases de backoff et de handshake et interrompt la reprise sans reconnecter uniquement pour nettoyer des subscriptions.
|
||||
|
||||
Avec `WsResubscribePolicy::ActiveSubscriptions`, les subscriptions encore désirées passent en `Resubscribing` et sont restaurées dans l'ordre croissant de leur `WsSubscriptionId`. Les paramètres de subscribe conservés par l'actor sont rejoués, puis chaque nouvel ACK remappe un remote ID sans changer l'identité locale. Le retour à `Active` ne se produit qu'après la fin de cette restauration, ce qui réinitialise alors le budget de reconnect.
|
||||
|
||||
Avec `WsResubscribePolicy::Never`, la session physique peut se reconnecter mais les subscriptions précédentes deviennent terminales. Une cancellation locale reçue pendant reconnect gagne toujours : elle retire la subscription de la restauration ; si un ACK distant arrive après cette cancellation, l'actor envoie un unsubscribe best-effort du nouvel ID sans réactiver le handle local.
|
||||
|
||||
Le compteur de continuity gaps est un signal d'observabilité, pas une garantie de livraison. Transport n'ajoute aucun backfill HTTP et ne promet aucune continuité lossless pendant l'intervalle de déconnexion.
|
||||
|
||||
### Backpressure et libération de capacité
|
||||
|
||||
Chaque subscription dispose de sa propre queue typed bornée par `notification_queue_capacity`. Le runtime ne droppe jamais silencieusement une notification lorsque cette queue est pleine : il incrémente `WsSessionSnapshot::overflow_count()`, fait passer uniquement le handle lent à `Failed`, publie `ERROR_CODE_WS_BACKPRESSURE_OVERFLOW` via `WsSubscription::terminal_error_code()` et programme un `*Unsubscribe` distant best-effort. Les autres subscriptions et la session physique restent utilisables.
|
||||
|
||||
Les autres terminaisons en échec publient également un code KSP sûr sur le handle : erreur protocolaire, timeout, erreur RPC applicative ou perte physique terminale. Les fermetures normales et les unsubscriptions réussis conservent `terminal_error_code() == None`. Aucun payload distant, remote subscription ID ou endpoint URL n'est projeté dans cette cause.
|
||||
|
||||
`max_active_subscriptions` reste une limite d'admission distincte du compteur d'overflow de notifications : un rejet de création ne l'incrémente pas. Lorsqu'une subscription est fermée, échoue ou que son receiver est abandonné puis détecté sur la notification suivante, son entrée runtime et son binding distant sont nettoyés et la capacité locale redevient réutilisable.
|
||||
|
||||
Les fixtures adversariales prouvent l'isolation d'un consumer lent, la survie d'une subscription saine, le cleanup distant best-effort, la réutilisation de capacité après unsubscribe ou abandon du receiver et la conservation du compteur d'overflow à travers les snapshots. Aucune promesse de livraison lossless n'est ajoutée.
|
||||
|
||||
### Wrappers stables : account, program et logs
|
||||
|
||||
Les trois premiers wrappers WebSocket standards sont publics sur `WsSession` :
|
||||
|
||||
```text
|
||||
account_subscribe -> WsSubscription<SolanaRpcResponse<SolanaAccount>>
|
||||
program_subscribe -> WsSubscription<SolanaProgramNotification>
|
||||
logs_subscribe -> WsSubscription<SolanaRpcResponse<SolanaLogsNotification>>
|
||||
```
|
||||
|
||||
`SolanaAccountSubscribeConfig` expose uniquement les options réellement effectives du PubSub audité : `encoding`, `dataSlice` et `commitment`. `minContextSlot` reste volontairement absent car le handler Agave ciblé l'ignore pour `accountSubscribe`; KSP ne transforme donc pas un champ partagé mais inopérant en promesse WebSocket.
|
||||
|
||||
`SolanaProgramSubscribeConfig` réutilise les encodings, slices et commitments account, ajoute les filtres programme et conserve `withContext`. Le décodeur `SolanaProgramNotification` accepte aussi bien le keyed account non contexté que la forme `RpcResponse` contextée afin de préserver les deux formes retenues par l'audit sans perte. `sortResults`, présent sur la surface HTTP `getProgramAccounts`, n'est pas exposé ici car le handler PubSub audité ne le consomme pas.
|
||||
|
||||
`SolanaLogsSubscribeFilter` rend les trois filtres upstream explicites : `All`, `AllWithVotes` et `Mentions(Pubkey)`. La variante `Mentions` encode par construction exactement une adresse. `SolanaLogsNotification` conserve la signature opaque, le `err` nullable et l'ordre des messages `logs`, enveloppés dans `SolanaRpcResponse`.
|
||||
|
||||
L'unsubscribe de ces trois familles passe toujours par `WsSubscription::unsubscribe()`: le caller ne voit ni ne fournit l'ID serveur. Les paramètres initiaux restent conservés par l’actor pour le resubscribe déterministe, et toutes les règles de backpressure/terminal error s’appliquent sans branche spéciale aux DTOs publics.
|
||||
|
||||
### Wrappers stables : signature, slot et root
|
||||
|
||||
Le second lot stable complète les subscriptions standard non instables :
|
||||
|
||||
```text
|
||||
signature_subscribe -> WsSubscription<SolanaRpcResponse<SolanaSignatureNotification>>
|
||||
slot_subscribe -> WsSubscription<SolanaSlotNotification>
|
||||
root_subscribe -> WsSubscription<u64>
|
||||
```
|
||||
|
||||
`SolanaSignatureSubscribeConfig` conserve séparément `commitment` et `enableReceivedNotification`, y compris la différence entre option omise et booléen explicitement faux. `SolanaSignatureNotification` représente les deux formes wire : `ReceivedSignature` pour l'événement précoce optionnel et `Processed { err }` pour la notification terminale. Après livraison de `Processed`, l'actor ferme localement la subscription avec `terminal_error_code() == None`, retire son binding et ne la remet jamais dans le set de resubscribe, conformément au caractère one-shot du serveur Solana.
|
||||
|
||||
Une cancellation effectuée avant cette terminaison continue d'utiliser `WsSubscription::unsubscribe()` et émet `signatureUnsubscribe` avec le remote ID détenu uniquement par l'actor. Après la notification terminale, `unsubscribe()` devient local-only et retourne `false`, puisque la subscription est déjà fermée côté serveur et côté KSP.
|
||||
|
||||
`slot_subscribe()` et `root_subscribe()` n'acceptent aucun paramètre. `SolanaSlotNotification` conserve exactement `slot`, `parent` et `root`; `root_subscribe()` délivre directement le root `u64`. Ces deux subscriptions restent continues et utilisent donc le reconnect/resubscribe standard.
|
||||
|
||||
### Wrappers unstable : block, slotsUpdates et vote
|
||||
|
||||
Les trois familles standard restantes complètent désormais l'inventaire **9/9 subscribe + 9/9 unsubscribe via handles** :
|
||||
|
||||
```text
|
||||
block_subscribe -> WsSubscription<SolanaRpcResponse<SolanaBlockNotification>>
|
||||
slots_updates_subscribe -> WsSubscription<SolanaSlotUpdate>
|
||||
vote_subscribe -> WsSubscription<SolanaVoteNotification>
|
||||
```
|
||||
|
||||
Ces familles restent explicitement **unstable**. Le moteur commun `subscribe_typed_with_completion` émet un warning KSP centralisé pour `Block`, `SlotsUpdates` et `Vote` avant l'ouverture logique, sans recopier filtres, pubkeys, payloads ou URL dans les logs.
|
||||
|
||||
`SolanaBlockSubscribeConfig` conserve `commitment`, `encoding`, `transactionDetails`, `maxSupportedTransactionVersion` et `showRewards`. Le filtre représente `All` ou `MentionsAccountOrProgram(Pubkey)`. Un commitment `processed` explicitement fourni est rejeté avant I/O ; la notification réutilise `SolanaConfirmedBlock` pour le block nullable et conserve l'erreur publication nullable sans interprétation métier. Le numéro de version transaction supporté reste un `u8` générique et n'est pas durci à `0`.
|
||||
|
||||
`SolanaSlotUpdate` représente les sept variantes courantes `firstShredReceived`, `completed`, `createdBank`, `frozen`, `dead`, `optimisticConfirmation` et `root`. Une variante upstream inconnue devient `Unknown { update_type, raw }` au lieu de faire tomber la session. Le `raw` reste borné par `max_message_size_bytes` avant le parse JSON.
|
||||
|
||||
`SolanaVoteNotification` conserve `votePubkey`, `slots`, `hash`, `timestamp` et `signature`. Le timestamp reste optionnel : omission et `null` deviennent `None`, tandis qu'une valeur `i64` est préservée. Transport ne transforme pas ces votes gossip pre-consensus en vérité ledger.
|
||||
|
||||
## Helius LaserStream WebSocket
|
||||
|
||||
La façade `HeliusLaserStreamWsSession` utilise le même actor physique `WsSession` mais expose uniquement la surface provider actuellement retenue par l’audit Helius :
|
||||
|
||||
```text
|
||||
standard réutilisé : account / logs / program / root / signature / slot / slotsUpdates
|
||||
extension Helius : transactionSubscribe / transactionUnsubscribe
|
||||
absent Helius : block / vote
|
||||
```
|
||||
|
||||
`slotsUpdates` reste **unstable** et conserve le warning centralisé du moteur standard. `block` et `vote` restent absents de la façade Helius même s’ils existent sur la façade Solana standard. `transactionSubscribe` reste provider-specific et n’est jamais ajouté à `SolanaStandardWsSession`.
|
||||
|
||||
Les endpoints Helius mainnet/devnet utilisent un `api-key` dans l’URL. KSP recommande de les construire via `ksp-config-lib` et `KSP_SECRET_HELIUS_API_KEY`; la valeur réelle atteint Transport mais les projections sûres, `Debug`, snapshots, erreurs et diagnostics n’exposent pas le credential. Transport ne lit jamais l’environnement et ne dépend jamais de Config.
|
||||
|
||||
Pour Helius, l’actor envoie automatiquement un control frame WebSocket `Ping` toutes les 60 secondes sur une session active. Cette policy est provider-owned, non configurable et ne s’applique pas aux sessions `SolanaStandard`. Une perte physique suit le reconnect/resubscribe borné déjà décrit; aucun replay/lossless n’est promis par la couche WebSocket.
|
||||
|
||||
LaserStream **gRPC** reste un backend distinct, hors de cette façade, de `WsProtocolKind` et de la Config WebSocket `helius_laserstream`.
|
||||
|
||||
## Résilience
|
||||
|
||||
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.
|
||||
|
||||
Deux smokes Devnet opt-in sont séparés par responsabilité :
|
||||
Trois smokes Devnet opt-in sont séparés par responsabilité :
|
||||
|
||||
```text
|
||||
Transport pur : settings programmatiques -> HttpTransportPool
|
||||
-> Accounts/Tokens/Cluster représentatifs
|
||||
-> trois reads Transactions
|
||||
-> getBlockHeight
|
||||
-> getInflationRate/getStakeMinimumDelegation
|
||||
Transport HTTP pur : settings programmatiques -> HttpTransportPool
|
||||
-> Accounts/Tokens/Cluster représentatifs
|
||||
-> trois reads Transactions
|
||||
-> getBlockHeight
|
||||
-> getInflationRate/getStakeMinimumDelegation
|
||||
|
||||
Transport WebSocket pur : settings programmatiques -> WsSession
|
||||
-> slotSubscribe
|
||||
-> une slotNotification sous timeout
|
||||
-> slotUnsubscribe
|
||||
-> close
|
||||
|
||||
Composition historique : Config -> std.transport/devnet_public -> HttpTransportPool
|
||||
-> getHealth/getGenesisHash/getVersion/getBalance
|
||||
```
|
||||
|
||||
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 n’est committé en `0.2.8-pre.010`. Un tel test devrait à la fois obtenir `KSP_SECRET_HELIUS_API_KEY` via Config et exercer Transport ; l’ajouter dans Transport violerait l’ownership environnement/secret, tandis que l’ajouter dans Config étendrait l’exception cross-crates que le projet veut au contraire résorber. La première surface KSP d’intégration/orchestration dédiée devra héberger ce smoke. Le scénario live recommandé est alors `helius_devnet -> HeliusLaserStreamWsSession -> slotSubscribe -> notification -> unsubscribe -> close`; `transactionSubscribe` reste un smoke optionnel dépendant des droits provider et ne devient pas un gate stable de release.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — consommation directe, Config -> Transport, API typed/raw, smokes et inspection runtime ;
|
||||
- [`../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](../../docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md) — foundation HTTP stable ;
|
||||
- [`../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](../../docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — extension typed Accounts/Tokens/Cluster ;
|
||||
- [`../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée `0.2.2` ;
|
||||
- [`../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé de `0.2.3` ;
|
||||
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée `0.2.3` ;
|
||||
- [`../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](../../docs/validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) — matrice finale validée Accounts/Tokens/Cluster ;
|
||||
- [`../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](../../docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé Transactions ;
|
||||
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée Transactions ;
|
||||
- [`../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) — plan Blocks/Economics et compliance HTTP finale ;
|
||||
- [`../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée `52/52 + 14/14` et audit `KSP-TRANSPORT-007` global ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP.
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP + WebSocket V2, avec lecture backward V1 HTTP-only.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 20 -->
|
||||
|
||||
# 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.
|
||||
|
||||
## 3. Appels typés
|
||||
## 3. Session physique WebSocket
|
||||
|
||||
Un consumer peut créer explicitement une session physique WebSocket :
|
||||
|
||||
```rust
|
||||
let ws_url = match ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com") {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let endpoint = ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||
"devnet_public",
|
||||
true,
|
||||
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
|
||||
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
|
||||
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
|
||||
ws_url,
|
||||
ksp_onchain_transport_lib::WsSessionSettings::default(),
|
||||
);
|
||||
let session = match ksp_onchain_transport_lib::WsSession::connect(endpoint).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let snapshot = session.snapshot();
|
||||
```
|
||||
|
||||
Deux appels `WsSession::connect` avec le même endpoint créent volontairement deux connexions physiques distinctes. Il n'existe encore aucun pool de sessions automatique.
|
||||
|
||||
Le socket brut et la primitive JSON-RPC générique ne sont pas publics. Le moteur générique de subscription typed reste `pub(crate)` ; il ne constitue donc pas une escape hatch provider-specific.
|
||||
|
||||
`WsSubscription<T>` est le handle public commun retourné par les wrappers standards. Il porte un `WsSubscriptionId` local stable, jamais le remote ID numérique du serveur. Les notifications arrivent via un receiver typed borné et `unsubscribe().await` exécute le `*Unsubscribe` correspondant en préservant son résultat booléen.
|
||||
|
||||
Le snapshot de session expose les subscriptions actuellement enregistrées via `WsSubscriptionSnapshot`, avec `remote_bound: bool` seulement. Le remote ID réel n'est jamais projeté.
|
||||
|
||||
### Premiers wrappers standards publics
|
||||
|
||||
Trois familles stables peuvent être créées directement sur la session :
|
||||
|
||||
```rust
|
||||
let account = match "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>() {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let account_config = ksp_onchain_transport_lib::SolanaAccountSubscribeConfig::new(
|
||||
Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64),
|
||||
None,
|
||||
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
);
|
||||
let mut account_subscription = match session.account_subscribe(&account, Some(&account_config)).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let next = account_subscription.recv().await;
|
||||
let removed = account_subscription.unsubscribe().await;
|
||||
```
|
||||
|
||||
La même session expose `program_subscribe()` avec `SolanaProgramSubscribeConfig` et `logs_subscribe()` avec `SolanaLogsSubscribeFilter` plus `SolanaCommitmentConfig`. Pour `logsSubscribe`, `Mentions(pubkey)` représente exactement une adresse, conformément à la contrainte upstream retenue par l'audit.
|
||||
|
||||
`accountSubscribe` ne propose pas `minContextSlot`: le champ existe dans un config partagé upstream mais est ignoré par le handler PubSub audité. `programSubscribe` conserve en revanche `withContext`; `SolanaProgramNotification` permet au consumer de traiter explicitement une notification contextée ou non contextée.
|
||||
|
||||
Les trois wrappers retournent le même handle `WsSubscription<T>` : reconnect, resubscribe, overflow, cause terminale et unsubscribe restent donc uniformes. Aucun wrapper public n'accepte un nom de méthode JSON-RPC arbitraire ni un remote subscription ID.
|
||||
|
||||
### Lot stable B : signature, slot et root
|
||||
|
||||
La session expose également les familles stables suivantes :
|
||||
|
||||
```rust
|
||||
let signature_config = ksp_onchain_transport_lib::SolanaSignatureSubscribeConfig::new(
|
||||
Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
|
||||
Some(true),
|
||||
);
|
||||
let mut signature_subscription = match session.signature_subscribe("<base58-signature>", Some(&signature_config)).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
while let Some(notification) = signature_subscription.recv().await {
|
||||
let notification = match notification {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if notification.value().is_terminal() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Avec `enableReceivedNotification = true`, `ReceivedSignature` peut arriver avant la variante terminale `Processed { err }`. La variante terminale ferme automatiquement le handle KSP, sans `signatureUnsubscribe` supplémentaire et sans resubscribe lors d'une reconnexion ultérieure. Une cancellation explicite avant cette notification terminale reste possible via `unsubscribe().await`.
|
||||
|
||||
`slot_subscribe().await` retourne un `WsSubscription<SolanaSlotNotification>` dont les getters exposent `slot`, `parent` et `root`. `root_subscribe().await` retourne un `WsSubscription<u64>`. Ces deux méthodes n'acceptent aucune configuration ni aucun paramètre RPC.
|
||||
|
||||
### Familles unstable : block, slotsUpdates et vote
|
||||
|
||||
Les trois familles unstable standard sont également typées. Leur utilisation déclenche un warning KSP centralisé :
|
||||
|
||||
```rust
|
||||
let block_config = ksp_onchain_transport_lib::SolanaBlockSubscribeConfig::new(
|
||||
Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64),
|
||||
Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures),
|
||||
Some(0),
|
||||
Some(false),
|
||||
);
|
||||
let mut blocks = match session
|
||||
.block_subscribe(&ksp_onchain_transport_lib::SolanaBlockSubscribeFilter::All, Some(&block_config))
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
`blockSubscribe` requiert un validator qui active la capability upstream correspondante. Une erreur applicative RPC liée à cette capability est renvoyée au caller sans reconnect de la session. `processed` est refusé localement ; `confirmed` et `finalized` sont admis. `maxSupportedTransactionVersion` n'est pas limité artificiellement à `0`.
|
||||
|
||||
`slots_updates_subscribe().await` délivre `SolanaSlotUpdate`. Les sept variantes courantes sont structurées ; une variante inconnue reste consommable via `Unknown` et `unknown_raw()`, sous la borne de taille WebSocket déjà appliquée avant décodage.
|
||||
|
||||
`vote_subscribe().await` délivre `SolanaVoteNotification`. `timestamp()` retourne `Option<i64>` pour conserver omission/null/value. Ce flux reste gossip et pre-consensus : le consumer ne doit pas l'assimiler à une confirmation ledger.
|
||||
|
||||
Les trois familles utilisent le même `WsSubscription::unsubscribe().await`; aucun remote subscription ID n'entre dans l'API publique.
|
||||
|
||||
### Façade Helius LaserStream WebSocket
|
||||
|
||||
Pour un endpoint Config `kind = "helius_laserstream"`, le consumer doit sélectionner l’endpoint WebSocket résolu puis ouvrir la façade Helius, sans reconstruire ni journaliser l’URL contenant l’API key :
|
||||
|
||||
```rust
|
||||
let resolved = match engine.load_resolved_transport_config(Some("helius_devnet"), &environment) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let ws_settings = match resolved.ws_settings() {
|
||||
Some(value) => value,
|
||||
None => return Err(ksp_core_lib::Error::new(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS, "Helius profile requires WebSocket settings")),
|
||||
};
|
||||
let endpoint = match ws_settings
|
||||
.endpoints()
|
||||
.iter()
|
||||
.find(|candidate| candidate.enabled() && candidate.protocol() == ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream)
|
||||
{
|
||||
Some(value) => value.clone(),
|
||||
None => return Err(ksp_core_lib::Error::new(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS, "Helius WebSocket endpoint is unavailable")),
|
||||
};
|
||||
let session = match ksp_onchain_transport_lib::HeliusLaserStreamWsSession::connect(endpoint).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut slots = match session.slot_subscribe().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let notification = slots.recv().await;
|
||||
let removed = slots.unsubscribe().await;
|
||||
let closed = session.close().await;
|
||||
```
|
||||
|
||||
La façade Helius réutilise `account`, `logs`, `program`, `root`, `signature`, `slot` et `slotsUpdates`. `slotsUpdates` reste unstable. `block` et `vote` ne sont pas exposés. `transaction_subscribe()` prend `HeliusTransactionSubscribeRequest` et retourne `WsSubscription<HeliusTransactionNotification>` ; son unsubscribe reste porté par le handle et produit `transactionUnsubscribe` sans exposer l’ID distant.
|
||||
|
||||
Le heartbeat Helius est automatique : `WsSession` envoie un control frame `Ping` toutes les 60 secondes tant que la session Helius est active. Le consumer ne configure pas un second timer et ne lance pas un task heartbeat parallèle. Cette règle ne vaut pas pour `SolanaStandardWsSession`.
|
||||
|
||||
`KSP_SECRET_HELIUS_API_KEY` appartient à Config. Ne pas lire l’environnement dans Transport, ne pas recopier l’URL résolue dans un log et ne pas ajouter un dev-dependency inverse `Transport -> Config`. LaserStream gRPC reste un backend différent et ne doit pas réutiliser `WsProtocolKind::HeliusLaserStream`.
|
||||
|
||||
### Reconnect automatique borné
|
||||
|
||||
Les settings de session contrôlent le reconnect physique. Une perte de socket publie `Reconnecting { attempt }`, invalide les remote IDs et incrémente `continuity_gap_count`. Avec la policy par défaut `ActiveSubscriptions`, les handles logiques gardent leur `WsSubscriptionId` et passent temporairement en `Resubscribing`; l'actor recrée leurs subscriptions dans l'ordre local avant de republier `Active`.
|
||||
|
||||
`WsResubscribePolicy::Never` reconnecte uniquement la session physique : les subscriptions existantes deviennent terminales et doivent être recréées explicitement par le consumer. Dans les deux modes, les requests applicatives qui étaient en vol lors de la coupure échouent et ne sont pas rejouées implicitement.
|
||||
|
||||
`unsubscribe().await` peut être appelé pendant `Reconnecting` ou `Resubscribing`. La cancellation locale gagne et le handle ne redevient jamais `Active`. Un ACK distant tardif est nettoyé best-effort par l'actor. Aucun backfill HTTP n'est déclenché automatiquement ; le consumer doit traiter `continuity_gap_count` comme un signal de réconciliation éventuelle.
|
||||
|
||||
### Backpressure par subscription
|
||||
|
||||
`WsSessionSettings::notification_queue_capacity()` borne la queue de chaque `WsSubscription<T>`. Le consumer doit donc drainer `recv()` selon son débit métier. Une queue pleine ne bloque pas l'actor et n'affecte pas les autres subscriptions : la subscription lente devient terminale avec `state() == Failed` et `terminal_error_code() == Some(ERROR_CODE_WS_BACKPRESSURE_OVERFLOW)`, tandis que `WsSessionSnapshot::overflow_count()` est incrémenté.
|
||||
|
||||
Un échec terminal non lié à l'overflow expose lui aussi un `ErrorCode` KSP sûr via `terminal_error_code()`. Une fermeture normale conserve `None`. Cette projection ne contient ni payload de notification, ni remote subscription ID, ni URL d'endpoint.
|
||||
|
||||
`max_active_subscriptions` borne séparément le nombre d'entrées logiques enregistrées. Son rejet utilise le même domaine d'erreur de capacité mais n'incrémente pas `overflow_count`, réservé aux queues de notifications saturées. Une subscription fermée ou nettoyée après abandon de son receiver libère sa capacité locale ; l'actor tente aussi de supprimer son binding distant sans rendre ce cleanup bloquant.
|
||||
|
||||
Le consumer doit traiter `overflow_count` et `continuity_gap_count` comme deux signaux distincts : le premier indique une perte locale par saturation d'un consumer, le second une interruption de continuité liée à une reconnexion. Aucun des deux n'implique un replay ou un backfill automatique.
|
||||
|
||||
### Fermeture explicite
|
||||
|
||||
Fermer explicitement la session est la voie normale de shutdown :
|
||||
|
||||
```rust
|
||||
let session = ksp_onchain_transport_lib::WsSession::connect(endpoint).await?;
|
||||
// ... wrappers standard puis recv()/unsubscribe() ...
|
||||
session.close().await?;
|
||||
```
|
||||
|
||||
`close()` agit sur toute la session physique, y compris les clones du handle. Il annule les requests en attente, publie `Closing`, tente le Close WebSocket dans le budget configuré, puis publie `Closed`. Une session `Closed` refuse les nouvelles requests internes.
|
||||
|
||||
Les limites de taille et de capacité sont des policies KSP configurables par `WsSessionSettings`; elles ne doivent pas être interprétées comme des limites protocolaires Solana officielles.
|
||||
|
||||
Le snapshot expose seulement l'identité locale, les metadata logiques de l'endpoint, l'état, les compteurs sûrs et les projections locales de subscriptions. L'URL et les remote subscription IDs ne sont jamais projetés. La disparition de tous les handles de session déclenche le cleanup actor best-effort ; `close().await` reste la voie normale de shutdown.
|
||||
|
||||
## 4. Appels typés
|
||||
|
||||
Les wrappers typés se trouvent directement sur `HttpTransportPool`.
|
||||
|
||||
@@ -86,7 +278,7 @@ let balance = pool
|
||||
.await;
|
||||
```
|
||||
|
||||
Les quatre canaris `0.2.1` restent disponibles. `0.2.2` ajoute les wrappers typés Accounts, Tokens et Cluster. Exemples représentatifs :
|
||||
Les wrappers foundation, Accounts, Tokens et Cluster sont disponibles directement sur le pool. Exemples représentatifs :
|
||||
|
||||
```rust
|
||||
let account = pool
|
||||
@@ -96,7 +288,7 @@ let epoch = pool.get_epoch_info(&role, None).await;
|
||||
let vote_accounts = pool.get_vote_accounts(&role, None).await;
|
||||
```
|
||||
|
||||
La release stable `0.2.4` contient les **52 wrappers typés courants** : 4 foundation + 22 Accounts/Tokens/Cluster + 11 Transactions + 10 Blocks + 5 Economics. Les DTOs Transport conservent les `null`, options, overloads et formes wire sans décodage Program/SPL métier.
|
||||
La surface HTTP contient **52 wrappers typés courants** : 4 foundation + 22 Accounts/Tokens/Cluster + 11 Transactions + 10 Blocks + 5 Economics. Les DTOs Transport conservent les `null`, options, overloads et formes wire sans décodage Program/SPL métier.
|
||||
|
||||
Exemples Transaction représentatifs :
|
||||
|
||||
@@ -125,7 +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.
|
||||
|
||||
## 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 :
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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 :
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
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()`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 8. Logging
|
||||
## 9. Logging
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
Les endpoints publics Solana sont rate-limités et non destinés à la production. Un échec réseau externe n'est pas assimilé automatiquement à une régression locale ; les fixtures HTTP locales restent les gates reproductibles.
|
||||
### Smoke Helius live
|
||||
|
||||
Aucun nouveau test Helius live n’est committé en `0.2.8-pre.010`. La raison est architecturale : Transport ne peut pas lire `KSP_SECRET_HELIUS_API_KEY` ni dépendre de Config, et Config ne doit pas devenir la destination générale des futurs smokes `Config + autre crate`. Créer un quatrième smoke dans l’une de ces deux crates contournerait donc une frontière déjà documentée.
|
||||
|
||||
Lorsque la surface KSP d’intégration/orchestration dédiée existera, le smoke live minimal recommandé sera :
|
||||
|
||||
```text
|
||||
Config helius_devnet
|
||||
-> endpoint helius_laserstream résolu avec KSP_SECRET_HELIUS_API_KEY
|
||||
-> HeliusLaserStreamWsSession::connect
|
||||
-> slotSubscribe
|
||||
-> une slotNotification sous timeout
|
||||
-> slotUnsubscribe
|
||||
-> close
|
||||
```
|
||||
|
||||
Ce scénario utilise une méthode standard stable sur l’endpoint Helius et teste donc auth + façade provider + actor + unsubscribe sans dépendre d’une entitlement particulière de `transactionSubscribe`. Un smoke `transactionSubscribe` pourra être ajouté séparément comme opt-in provider-specific si l’environnement opérateur possède les droits nécessaires ; il ne doit pas devenir un gate réseau obligatoire de la release.
|
||||
|
||||
Les endpoints publics/provider sont des dépendances externes. Un rate-limit, refus d’auth, entitlement absente ou incident réseau n’est pas assimilé automatiquement à une régression locale ; les fixtures HTTP/WebSocket locales et les gates déterministes restent autoritaires.
|
||||
|
||||
Pour auditer les dépendances, inspecter également le graphe effectif après résolution Cargo :
|
||||
|
||||
```bash
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||
cargo tree --duplicates
|
||||
```
|
||||
|
||||
Le premier graphe doit conserver la frontière `Transport -> Core + Logging + crates techniques`; il ne doit introduire aucune dépendance Config, Wallet, Store, Program ou `tracing` directe. Les sorties `--duplicates` sont un diagnostic de résolution transitive : une duplication n'est pas supprimée aveuglément si elle est imposée par des dépendances upstream incompatibles.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/constants.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
//! Transport-owned tracing constants.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// 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");
|
||||
@@ -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");
|
||||
/// Error code used when a transport deadline expires.
|
||||
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
||||
/// Error code used when a bounded WebSocket runtime queue or pending-request capacity is exhausted.
|
||||
pub const ERROR_CODE_WS_BACKPRESSURE_OVERFLOW: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_backpressure_overflow");
|
||||
/// Error code used when a physical WebSocket connection or handshake fails.
|
||||
pub const ERROR_CODE_WS_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_connection_failed");
|
||||
/// Error code used when WebSocket wire data violates the KSP protocol contract.
|
||||
pub const ERROR_CODE_WS_PROTOCOL_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_protocol_error");
|
||||
/// Error code used when a WebSocket session is no longer available to a caller.
|
||||
pub const ERROR_CODE_WS_SESSION_CLOSED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_session_closed");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
// version: 21
|
||||
// version: 34
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![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 `getBlock`, positional inflation rewards, runtime-provided economics values and the final `KSP-TRANSPORT-007` compliance target.
|
||||
//! The candidate surface therefore exposes typed wrappers for all 52 current audited Solana HTTP methods while retaining 14 removed historical descriptors.
|
||||
//! `0.2.7-pre.002` adds the provider-neutral WebSocket settings foundation, redacted endpoint URLs, explicit protocol-family discrimination, local session and
|
||||
//! subscription identities, observable lifecycle states and safe snapshots. `0.2.7-pre.004` adds the first physical WebSocket runtime with one
|
||||
//! actor-owned socket,
|
||||
//! bounded handshake, command/pending JSON-RPC flow and deterministic local-server fixtures. `0.2.7-pre.005` adds explicit bounded shutdown, adversarial
|
||||
//! request/frame/message limits and control-frame handling. `0.2.7-pre.006` adds the typed subscription registry with stable local IDs and internal remote-ID
|
||||
//! routing. `0.2.7-pre.007` adds finite reconnect, deterministic resubscribe and continuity-gap tracking. `0.2.7-pre.008` makes per-subscription notification
|
||||
//! backpressure terminal and observable, preserves safe terminal error codes, performs best-effort remote cleanup and proves bounded capacity reuse.
|
||||
//! `0.2.7-pre.009` opens the first stable typed WebSocket wrappers for account, program-account and transaction-log subscriptions without exposing a raw
|
||||
//! provider-extension subscription API. `0.2.8-pre.002` adds a Helius LaserStream WebSocket protocol discriminator and two typed protocol facades while
|
||||
//! keeping the `WsSession` actor/socket implementation unique and the historical generic constructor standard-only.
|
||||
//! `0.2.8-pre.003` initially exposed the six standard families unambiguously supported by the audited Helius pages; `0.2.8-pre.009` reconciles the current
|
||||
//! Helius documentation and adds the now-documented unstable `slotsUpdatesSubscribe` pair while keeping explicitly unsupported block/vote pairs absent.
|
||||
//! `0.2.8-pre.005` adds the typed Helius `transactionSubscribe` request contract and provider filter/options validation. `0.2.8-pre.006` integrates the live
|
||||
//! transaction handle and typed `transactionNotification` union into the same actor-owned registry, remote-ID remap, unsubscribe-race handling and
|
||||
//! per-subscription backpressure path.
|
||||
|
||||
mod client;
|
||||
mod constants;
|
||||
@@ -34,6 +49,16 @@ mod rpc_method;
|
||||
mod rpc_tokens;
|
||||
mod rpc_transactions;
|
||||
mod settings;
|
||||
mod ws_accounts;
|
||||
mod ws_blocks;
|
||||
mod ws_cluster;
|
||||
mod ws_helius_transactions;
|
||||
mod ws_lifecycle;
|
||||
mod ws_protocol_session;
|
||||
mod ws_session;
|
||||
mod ws_settings;
|
||||
mod ws_subscription;
|
||||
mod ws_transactions;
|
||||
|
||||
/// Passive runtime availability reported for one logical HTTP endpoint.
|
||||
pub use self::client::HttpEndpointAvailability;
|
||||
@@ -69,6 +94,14 @@ pub use self::error::ERROR_CODE_RATE_LIMITED;
|
||||
pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR;
|
||||
/// Error code used when a transport deadline expires.
|
||||
pub use self::error::ERROR_CODE_TIMEOUT;
|
||||
/// Error code used when bounded WebSocket runtime capacity is exhausted.
|
||||
pub use self::error::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW;
|
||||
/// Error code used when a physical WebSocket connection or handshake fails.
|
||||
pub use self::error::ERROR_CODE_WS_CONNECTION_FAILED;
|
||||
/// Error code used when WebSocket wire data violates protocol invariants.
|
||||
pub use self::error::ERROR_CODE_WS_PROTOCOL_ERROR;
|
||||
/// Error code used when a WebSocket session is no longer available.
|
||||
pub use self::error::ERROR_CODE_WS_SESSION_CLOSED;
|
||||
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
||||
pub use self::json_rpc::JsonRpcErrorObject;
|
||||
/// Validated JSON-RPC 2.0 error response.
|
||||
@@ -103,9 +136,9 @@ pub use self::resilience::evaluate_transport_retry;
|
||||
pub use self::rpc_accounts::SolanaAccount;
|
||||
/// Address and lamport balance returned by `getLargestAccounts`.
|
||||
pub use self::rpc_accounts::SolanaAccountBalance;
|
||||
/// Wire-preserving account data returned by Solana HTTP account methods.
|
||||
/// Wire-preserving account data returned by Solana account HTTP and WebSocket methods.
|
||||
pub use self::rpc_accounts::SolanaAccountData;
|
||||
/// Account-data encoding accepted by Solana HTTP account methods.
|
||||
/// Account-data encoding accepted by Solana account HTTP and WebSocket methods.
|
||||
pub use self::rpc_accounts::SolanaAccountEncoding;
|
||||
/// Shared account configuration used by account-info and token-account list methods.
|
||||
pub use self::rpc_accounts::SolanaAccountInfoConfig;
|
||||
@@ -183,15 +216,15 @@ pub use self::rpc_cluster::SolanaVoteAccountInfo;
|
||||
pub use self::rpc_cluster::SolanaVoteAccountStatus;
|
||||
/// Configuration accepted by `getVoteAccounts`.
|
||||
pub use self::rpc_cluster::SolanaVoteAccountsConfig;
|
||||
/// Commitment level accepted by typed Solana HTTP RPC adapters.
|
||||
/// Commitment level accepted by typed Solana HTTP and WebSocket adapters.
|
||||
pub use self::rpc_common::SolanaCommitment;
|
||||
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
|
||||
/// Optional commitment-only configuration shared by typed Solana RPC methods.
|
||||
pub use self::rpc_common::SolanaCommitmentConfig;
|
||||
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
|
||||
pub use self::rpc_common::SolanaContextConfig;
|
||||
/// Typed Solana RPC context shared by contextual HTTP responses.
|
||||
/// Typed Solana RPC context shared by contextual HTTP and WebSocket responses.
|
||||
pub use self::rpc_common::SolanaRpcContext;
|
||||
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
|
||||
/// Generic contextual result returned by typed Solana HTTP and WebSocket adapters.
|
||||
pub use self::rpc_common::SolanaRpcResponse;
|
||||
/// Inflation-governor values returned by `getInflationGovernor`.
|
||||
pub use self::rpc_economics::SolanaInflationGovernor;
|
||||
@@ -291,6 +324,90 @@ pub use self::settings::HttpRoleLimits;
|
||||
pub use self::settings::HttpRoleName;
|
||||
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
|
||||
pub use self::settings::HttpTransportSettings;
|
||||
/// Configuration accepted by the standard Solana `accountSubscribe` WebSocket method.
|
||||
pub use self::ws_accounts::SolanaAccountSubscribeConfig;
|
||||
/// One `programNotification` payload preserving contextual and non-contextual upstream forms.
|
||||
pub use self::ws_accounts::SolanaProgramNotification;
|
||||
/// Configuration accepted by the standard Solana `programSubscribe` WebSocket method.
|
||||
pub use self::ws_accounts::SolanaProgramSubscribeConfig;
|
||||
/// Typed value carried inside an unstable Solana `blockNotification` response.
|
||||
pub use self::ws_blocks::SolanaBlockNotification;
|
||||
/// Optional configuration accepted by unstable Solana `blockSubscribe`.
|
||||
pub use self::ws_blocks::SolanaBlockSubscribeConfig;
|
||||
/// Filter accepted by unstable Solana `blockSubscribe`.
|
||||
pub use self::ws_blocks::SolanaBlockSubscribeFilter;
|
||||
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
|
||||
pub use self::ws_cluster::SolanaSlotNotification;
|
||||
/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback.
|
||||
pub use self::ws_cluster::SolanaSlotUpdate;
|
||||
/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates.
|
||||
pub use self::ws_cluster::SolanaSlotUpdateStats;
|
||||
/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`.
|
||||
pub use self::ws_cluster::SolanaVoteNotification;
|
||||
/// Full/accounts-mode notification delivered by Helius `transactionSubscribe`.
|
||||
pub use self::ws_helius_transactions::HeliusFullTransactionNotification;
|
||||
/// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`.
|
||||
pub use self::ws_helius_transactions::HeliusTokenAccountsFilter;
|
||||
/// Typed Helius `transactionNotification` payload union.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionNotification;
|
||||
/// Signatures-mode notification delivered by Helius `transactionSubscribe`.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSignatureNotification;
|
||||
/// Transaction encoding accepted by Helius `transactionSubscribe`.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSubscribeEncoding;
|
||||
/// Helius-specific filter object accepted as the first `transactionSubscribe` parameter.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSubscribeFilter;
|
||||
/// Optional Helius `transactionSubscribe` result-shaping configuration.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSubscribeOptions;
|
||||
/// Complete typed request contract for Helius `transactionSubscribe` before actor registration.
|
||||
pub use self::ws_helius_transactions::HeliusTransactionSubscribeRequest;
|
||||
/// Stable local identity assigned to one physical WebSocket session.
|
||||
pub use self::ws_lifecycle::WsSessionId;
|
||||
/// Safe runtime snapshot for one physical WebSocket session.
|
||||
pub use self::ws_lifecycle::WsSessionSnapshot;
|
||||
/// Observable lifecycle state of one physical WebSocket session.
|
||||
pub use self::ws_lifecycle::WsSessionState;
|
||||
/// Stable local identity assigned to one logical WebSocket subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionId;
|
||||
/// WebSocket subscription family represented by one logical subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionKind;
|
||||
/// Safe lifecycle projection for one logical WebSocket subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionSnapshot;
|
||||
/// Observable lifecycle state of one logical WebSocket subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionState;
|
||||
/// Typed facade for one Helius LaserStream WebSocket physical session.
|
||||
pub use self::ws_protocol_session::HeliusLaserStreamWsSession;
|
||||
/// Typed facade for one standard Solana WebSocket physical session.
|
||||
pub use self::ws_protocol_session::SolanaStandardWsSession;
|
||||
/// Shareable compatibility handle for one explicitly created standard Solana physical WebSocket session.
|
||||
pub use self::ws_session::WsSession;
|
||||
/// Open cluster or network descriptor used by WebSocket endpoint settings.
|
||||
pub use self::ws_settings::WsClusterName;
|
||||
/// Runtime settings for one named WebSocket endpoint.
|
||||
pub use self::ws_settings::WsEndpointSettings;
|
||||
/// Runtime WebSocket endpoint URL with redacted diagnostics.
|
||||
pub use self::ws_settings::WsEndpointUrl;
|
||||
/// WebSocket protocol family understood by KSP Transport.
|
||||
pub use self::ws_settings::WsProtocolKind;
|
||||
/// Open provider descriptor used by WebSocket endpoint settings.
|
||||
pub use self::ws_settings::WsProviderName;
|
||||
/// Bounded reconnect settings owned by the WebSocket transport runtime.
|
||||
pub use self::ws_settings::WsReconnectSettings;
|
||||
/// Policy controlling logical resubscription after reconnect.
|
||||
pub use self::ws_settings::WsResubscribePolicy;
|
||||
/// Runtime limits and lifecycle settings for one physical WebSocket session.
|
||||
pub use self::ws_settings::WsSessionSettings;
|
||||
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
|
||||
pub use self::ws_settings::WsTransportSettings;
|
||||
/// Typed handle for one logical WebSocket subscription.
|
||||
pub use self::ws_subscription::WsSubscription;
|
||||
/// Typed value carried by a contextual Solana `logsNotification`.
|
||||
pub use self::ws_transactions::SolanaLogsNotification;
|
||||
/// Filter accepted by the standard Solana `logsSubscribe` WebSocket method.
|
||||
pub use self::ws_transactions::SolanaLogsSubscribeFilter;
|
||||
/// Typed value carried by standard Solana `signatureNotification` messages.
|
||||
pub use self::ws_transactions::SolanaSignatureNotification;
|
||||
/// Optional configuration accepted by standard Solana `signatureSubscribe`.
|
||||
pub use self::ws_transactions::SolanaSignatureSubscribeConfig;
|
||||
|
||||
/// Owning tracing target for events emitted by the on-chain transport crate.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
@@ -306,3 +423,15 @@ pub(crate) use self::rpc_common::decode_wire_json;
|
||||
pub(crate) use self::rpc_common::parse_wire_pubkey;
|
||||
/// Validates endpoint settings.
|
||||
pub(crate) use self::settings::validate_endpoint_settings;
|
||||
/// Crate-internal command surface shared by the physical session and typed subscription handle.
|
||||
pub(crate) use self::ws_session::WsSessionCommand;
|
||||
/// Crate-internal notification dispatch result.
|
||||
pub(crate) use self::ws_subscription::WsNotificationDispatchOutcome;
|
||||
/// Crate-internal type-erased notification dispatcher.
|
||||
pub(crate) use self::ws_subscription::WsNotificationDispatcher;
|
||||
/// Crate-internal actor registration returned after subscribe acknowledgement.
|
||||
pub(crate) use self::ws_subscription::WsSubscriptionRegistration;
|
||||
/// Crate-internal actor-owned logical subscription runtime entry.
|
||||
pub(crate) use self::ws_subscription::WsSubscriptionRuntime;
|
||||
/// Crate-internal constructor for bounded typed notification channels with terminal-value classification.
|
||||
pub(crate) use self::ws_subscription::typed_notification_channel_with_completion;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
const MAX_MEMCMP_BYTES: usize = 128;
|
||||
const MAX_MULTIPLE_ACCOUNTS: usize = 100;
|
||||
const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4;
|
||||
|
||||
/// Account-data encoding accepted by Solana HTTP account methods.
|
||||
/// Account-data encoding accepted by Solana account HTTP and WebSocket methods.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaAccountEncoding {
|
||||
/// Legacy binary/base58 request encoding.
|
||||
@@ -71,7 +71,8 @@ impl SolanaDataSliceConfig {
|
||||
return self.length;
|
||||
}
|
||||
|
||||
fn to_json_value(self) -> serde_json::Value {
|
||||
/// Serializes this data-slice configuration to the Solana JSON-RPC wire object.
|
||||
pub(crate) fn to_json_value(self) -> serde_json::Value {
|
||||
return serde_json::json!({"offset": self.offset, "length": self.length});
|
||||
}
|
||||
}
|
||||
@@ -277,7 +278,8 @@ pub enum SolanaProgramAccountFilter {
|
||||
}
|
||||
|
||||
impl SolanaProgramAccountFilter {
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
/// Serializes this program-account filter to the Solana JSON-RPC wire representation.
|
||||
pub(crate) fn to_json_value(&self) -> serde_json::Value {
|
||||
return match self {
|
||||
Self::DataSize(size) => serde_json::json!({"dataSize": size}),
|
||||
Self::Memcmp(filter) => serde_json::json!({"memcmp": filter.to_json_value()}),
|
||||
@@ -385,7 +387,7 @@ impl SolanaParsedAccountData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-preserving account data returned by Solana HTTP account methods.
|
||||
/// Wire-preserving account data returned by Solana account HTTP and WebSocket methods.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaAccountData {
|
||||
/// Legacy single-string binary form retained for backwards compatibility.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
|
||||
// version: 5
|
||||
// version: 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)]
|
||||
pub enum SolanaCommitment {
|
||||
/// Query the most recent processed bank.
|
||||
@@ -24,7 +24,7 @@ impl SolanaCommitment {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
|
||||
/// Optional commitment-only configuration shared by typed Solana RPC methods.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaCommitmentConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
@@ -94,7 +94,7 @@ impl SolanaContextConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed Solana RPC context shared by contextual HTTP responses.
|
||||
/// Typed Solana RPC context shared by contextual HTTP and WebSocket responses.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaRpcContext {
|
||||
slot: u64,
|
||||
@@ -128,7 +128,7 @@ impl SolanaRpcContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
|
||||
/// Generic contextual result returned by typed Solana HTTP and WebSocket adapters.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaRpcResponse<T> {
|
||||
context: crate::SolanaRpcContext,
|
||||
@@ -168,7 +168,7 @@ pub(crate) fn decode_wire_json<T: serde::de::DeserializeOwned>(method: &str, val
|
||||
return match decoded {
|
||||
std::result::Result::Ok(decoded) => std::result::Result::Ok(decoded),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response has an invalid wire shape")
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana RPC response has an invalid wire shape")
|
||||
.with_context("rpc_method", method)
|
||||
.with_source(error),
|
||||
),
|
||||
@@ -181,7 +181,7 @@ pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_c
|
||||
return match parsed {
|
||||
std::result::Result::Ok(pubkey) => std::result::Result::Ok(pubkey),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response contains an invalid public key")
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana RPC response contains an invalid public key")
|
||||
.with_context("rpc_method", method)
|
||||
.with_context("field", field),
|
||||
),
|
||||
|
||||
322
crates/ksp-onchain-transport-lib/src/ws_accounts.rs
Normal file
322
crates/ksp-onchain-transport-lib/src/ws_accounts.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_accounts.rs
|
||||
// version: 3
|
||||
|
||||
const MAX_PROGRAM_SUBSCRIBE_FILTERS: usize = 4;
|
||||
const MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES: usize = 128;
|
||||
|
||||
/// Configuration accepted by the standard Solana `accountSubscribe` WebSocket method.
|
||||
///
|
||||
/// `minContextSlot` is deliberately absent: Agave `v4.2.1` carries that field in the shared account config but the PubSub handler ignores it, so KSP does
|
||||
/// not expose it as an effective WebSocket option.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaAccountSubscribeConfig {
|
||||
encoding: std::option::Option<crate::SolanaAccountEncoding>,
|
||||
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
}
|
||||
|
||||
impl SolanaAccountSubscribeConfig {
|
||||
/// Creates an explicit `accountSubscribe` configuration.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
encoding: std::option::Option<crate::SolanaAccountEncoding>,
|
||||
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
) -> Self {
|
||||
return Self { encoding, data_slice, commitment };
|
||||
}
|
||||
|
||||
/// Returns the optional account-data encoding.
|
||||
#[must_use]
|
||||
pub const fn encoding(&self) -> std::option::Option<crate::SolanaAccountEncoding> {
|
||||
return self.encoding;
|
||||
}
|
||||
|
||||
/// Returns the optional account-data slice.
|
||||
#[must_use]
|
||||
pub const fn data_slice(&self) -> std::option::Option<crate::SolanaDataSliceConfig> {
|
||||
return self.data_slice;
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.encoding.is_none() && self.data_slice.is_none() && self.commitment.is_none();
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(encoding) = self.encoding {
|
||||
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(data_slice) = self.data_slice {
|
||||
object.insert("dataSlice".to_owned(), data_slice.to_json_value());
|
||||
}
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration accepted by the standard Solana `programSubscribe` WebSocket method.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaProgramSubscribeConfig {
|
||||
account: crate::SolanaAccountSubscribeConfig,
|
||||
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
|
||||
with_context: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
impl SolanaProgramSubscribeConfig {
|
||||
/// Creates an explicit `programSubscribe` configuration.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
account: crate::SolanaAccountSubscribeConfig,
|
||||
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
|
||||
with_context: std::option::Option<bool>,
|
||||
) -> Self {
|
||||
return Self { account, filters, with_context };
|
||||
}
|
||||
|
||||
/// Returns the shared WebSocket account configuration.
|
||||
#[must_use]
|
||||
pub const fn account(&self) -> &crate::SolanaAccountSubscribeConfig {
|
||||
return &self.account;
|
||||
}
|
||||
|
||||
/// Returns the ordered program-account filters.
|
||||
#[must_use]
|
||||
pub fn filters(&self) -> &[crate::SolanaProgramAccountFilter] {
|
||||
return self.filters.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the optional `withContext` request; omission uses the upstream default `false`.
|
||||
#[must_use]
|
||||
pub const fn with_context(&self) -> std::option::Option<bool> {
|
||||
return self.with_context;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.account.is_empty() && self.filters.is_empty() && self.with_context.is_none();
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
let account = self.account.to_json_value();
|
||||
let mut object = match account {
|
||||
serde_json::Value::Object(object) => object,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
if !self.filters.is_empty() {
|
||||
let filters = self.filters.iter().map(crate::SolanaProgramAccountFilter::to_json_value).collect::<std::vec::Vec<_>>();
|
||||
object.insert("filters".to_owned(), serde_json::Value::Array(filters));
|
||||
}
|
||||
if let std::option::Option::Some(with_context) = self.with_context {
|
||||
object.insert("withContext".to_owned(), serde_json::Value::Bool(with_context));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// One `programNotification` payload, preserving whether the upstream wire result was contextualized.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaProgramNotification {
|
||||
/// Program account payload without a surrounding RPC context.
|
||||
Account(crate::SolanaKeyedAccount),
|
||||
/// Program account payload wrapped in an RPC context.
|
||||
Context(crate::SolanaRpcResponse<crate::SolanaKeyedAccount>),
|
||||
}
|
||||
|
||||
impl SolanaProgramNotification {
|
||||
/// Returns the program account regardless of the upstream context-wrapper form.
|
||||
#[must_use]
|
||||
pub const fn account(&self) -> &crate::SolanaKeyedAccount {
|
||||
return match self {
|
||||
Self::Account(account) => account,
|
||||
Self::Context(response) => response.value(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the RPC context when the upstream notification included one.
|
||||
#[must_use]
|
||||
pub const fn context(&self) -> std::option::Option<&crate::SolanaRpcContext> {
|
||||
return match self {
|
||||
Self::Account(_) => std::option::Option::None,
|
||||
Self::Context(response) => std::option::Option::Some(response.context()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WsSession {
|
||||
/// Subscribes to changes for one Solana account through standard `accountSubscribe`.
|
||||
pub async fn account_subscribe(
|
||||
&self,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
|
||||
let mut params = std::vec![serde_json::Value::String(account.to_string())];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Account, params, |value| return decode_account_notification("accountSubscribe", value))
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to account changes owned by one Solana program through standard `programSubscribe`.
|
||||
pub async fn program_subscribe(
|
||||
&self,
|
||||
program_id: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
|
||||
if let std::option::Option::Some(config) = config {
|
||||
let validation = validate_program_subscribe_filters(config.filters());
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
let mut params = std::vec![serde_json::Value::String(program_id.to_string())];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Program, params, |value| return decode_program_notification("programSubscribe", value))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponse {
|
||||
context: serde_json::Value,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum WireProgramNotification {
|
||||
Context(WireRpcResponse),
|
||||
Account(serde_json::Value),
|
||||
}
|
||||
|
||||
fn decode_account_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaAccount>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let account = crate::SolanaAccount::decode_wire(method, wire.value);
|
||||
let account = match account {
|
||||
std::result::Result::Ok(account) => account,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, account));
|
||||
}
|
||||
|
||||
fn decode_program_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaProgramNotification> {
|
||||
let decoded = crate::decode_wire_json::<WireProgramNotification>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return match wire {
|
||||
WireProgramNotification::Account(value) => {
|
||||
let account = crate::SolanaKeyedAccount::decode_wire(method, value);
|
||||
match account {
|
||||
std::result::Result::Ok(account) => std::result::Result::Ok(crate::SolanaProgramNotification::Account(account)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
WireProgramNotification::Context(wire) => {
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let account = crate::SolanaKeyedAccount::decode_wire(method, wire.value);
|
||||
let account = match account {
|
||||
std::result::Result::Ok(account) => account,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
std::result::Result::Ok(crate::SolanaProgramNotification::Context(crate::SolanaRpcResponse::new(context, account)))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_program_subscribe_filters(filters: &[crate::SolanaProgramAccountFilter]) -> ksp_core_lib::Result<()> {
|
||||
if filters.len() > MAX_PROGRAM_SUBSCRIBE_FILTERS {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "programSubscribe accepts at most 4 filters on the targeted Agave runtime")
|
||||
.with_context("rpc_method", "programSubscribe")
|
||||
.with_context("filter_count", filters.len().to_string()),
|
||||
);
|
||||
}
|
||||
for filter in filters {
|
||||
if let crate::SolanaProgramAccountFilter::Memcmp(memcmp) = filter
|
||||
&& let crate::SolanaMemcmpBytes::Bytes(bytes) = memcmp.bytes()
|
||||
&& bytes.len() > MAX_PROGRAM_SUBSCRIBE_RAW_MEMCMP_BYTES
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "raw programSubscribe memcmp data accepts at most 128 bytes")
|
||||
.with_context("rpc_method", "programSubscribe")
|
||||
.with_context("memcmp_byte_count", bytes.len().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
impl crate::SolanaStandardWsSession {
|
||||
/// Subscribes to changes for one Solana account through standard `accountSubscribe`.
|
||||
pub async fn account_subscribe(
|
||||
&self,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
|
||||
return self.physical_session().account_subscribe(account, config).await;
|
||||
}
|
||||
|
||||
/// Subscribes to account changes owned by one Solana program through standard `programSubscribe`.
|
||||
pub async fn program_subscribe(
|
||||
&self,
|
||||
program_id: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
|
||||
return self.physical_session().program_subscribe(program_id, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HeliusLaserStreamWsSession {
|
||||
/// Subscribes to account changes through the standard `accountSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn account_subscribe(
|
||||
&self,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaAccountSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaAccount>>> {
|
||||
return self.physical_session().account_subscribe(account, config).await;
|
||||
}
|
||||
|
||||
/// Subscribes to program-owned account changes through the standard `programSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn program_subscribe(
|
||||
&self,
|
||||
program_id: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::SolanaProgramSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaProgramNotification>> {
|
||||
return self.physical_session().program_subscribe(program_id, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_accounts.rs"]
|
||||
mod tests;
|
||||
226
crates/ksp-onchain-transport-lib/src/ws_blocks.rs
Normal file
226
crates/ksp-onchain-transport-lib/src/ws_blocks.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_blocks.rs
|
||||
// version: 2
|
||||
|
||||
/// Filter accepted by unstable Solana `blockSubscribe`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SolanaBlockSubscribeFilter {
|
||||
/// Subscribe to every block that reaches the configured commitment.
|
||||
All,
|
||||
/// Subscribe only to blocks containing a transaction that mentions the account or program.
|
||||
MentionsAccountOrProgram(ksp_core_lib::Pubkey),
|
||||
}
|
||||
|
||||
impl SolanaBlockSubscribeFilter {
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
return match self {
|
||||
Self::All => serde_json::Value::String("all".to_owned()),
|
||||
Self::MentionsAccountOrProgram(pubkey) => serde_json::json!({"mentionsAccountOrProgram": pubkey.to_string()}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional configuration accepted by unstable Solana `blockSubscribe`.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaBlockSubscribeConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
encoding: std::option::Option<crate::SolanaTransactionEncoding>,
|
||||
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
|
||||
max_supported_transaction_version: std::option::Option<u8>,
|
||||
show_rewards: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
impl SolanaBlockSubscribeConfig {
|
||||
/// Creates an explicit unstable block-subscription configuration.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
encoding: std::option::Option<crate::SolanaTransactionEncoding>,
|
||||
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
|
||||
max_supported_transaction_version: std::option::Option<u8>,
|
||||
show_rewards: std::option::Option<bool>,
|
||||
) -> Self {
|
||||
return Self { commitment, encoding, transaction_details, max_supported_transaction_version, show_rewards };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns the optional transaction encoding.
|
||||
#[must_use]
|
||||
pub const fn encoding(&self) -> std::option::Option<crate::SolanaTransactionEncoding> {
|
||||
return self.encoding;
|
||||
}
|
||||
|
||||
/// Returns the optional transaction detail level.
|
||||
#[must_use]
|
||||
pub const fn transaction_details(&self) -> std::option::Option<crate::SolanaTransactionDetails> {
|
||||
return self.transaction_details;
|
||||
}
|
||||
|
||||
/// Returns the highest transaction version the caller declares it can consume.
|
||||
#[must_use]
|
||||
pub const fn max_supported_transaction_version(&self) -> std::option::Option<u8> {
|
||||
return self.max_supported_transaction_version;
|
||||
}
|
||||
|
||||
/// Returns whether rewards were explicitly requested for block notifications.
|
||||
#[must_use]
|
||||
pub const fn show_rewards(&self) -> std::option::Option<bool> {
|
||||
return self.show_rewards;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.commitment.is_none()
|
||||
&& self.encoding.is_none()
|
||||
&& self.transaction_details.is_none()
|
||||
&& self.max_supported_transaction_version.is_none()
|
||||
&& self.show_rewards.is_none();
|
||||
}
|
||||
|
||||
fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
if self.commitment == std::option::Option::Some(crate::SolanaCommitment::Processed) {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
|
||||
"blockSubscribe commitment must be confirmed or finalized when explicitly provided",
|
||||
)
|
||||
.with_context("rpc_method", "blockSubscribe")
|
||||
.with_context("commitment", "processed"),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn to_json_value(self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(encoding) = self.encoding {
|
||||
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(transaction_details) = self.transaction_details {
|
||||
object.insert("transactionDetails".to_owned(), serde_json::Value::String(transaction_details.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(version) = self.max_supported_transaction_version {
|
||||
object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into()));
|
||||
}
|
||||
if let std::option::Option::Some(show_rewards) = self.show_rewards {
|
||||
object.insert("showRewards".to_owned(), serde_json::Value::Bool(show_rewards));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed value carried inside an unstable Solana `blockNotification` response.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaBlockNotification {
|
||||
slot: u64,
|
||||
block: std::option::Option<crate::SolanaConfirmedBlock>,
|
||||
err: std::option::Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl SolanaBlockNotification {
|
||||
/// Returns the slot associated with this block update.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the decoded block when the unstable notification contains one.
|
||||
#[must_use]
|
||||
pub const fn block(&self) -> std::option::Option<&crate::SolanaConfirmedBlock> {
|
||||
return self.block.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the nullable publication error without interpreting its unstable wire shape.
|
||||
#[must_use]
|
||||
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
|
||||
return self.err.as_ref();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WsSession {
|
||||
/// Subscribes to unstable standard Solana block notifications through `blockSubscribe`.
|
||||
///
|
||||
/// Solana documents this method as unstable and requires validator-side block-subscription support. KSP emits a warning through its logging facade when
|
||||
/// this family is requested. An explicitly supplied commitment must be `confirmed` or `finalized`.
|
||||
pub async fn block_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaBlockSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaBlockSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaBlockNotification>>> {
|
||||
if let std::option::Option::Some(config) = config {
|
||||
let validated = config.validate();
|
||||
if let std::result::Result::Err(error) = validated {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
let mut params = std::vec![filter.to_json_value()];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push((*config).to_json_value());
|
||||
}
|
||||
return self.subscribe_typed(crate::WsSubscriptionKind::Block, params, |value| return decode_block_notification("blockSubscribe", value)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireBlockNotification {
|
||||
slot: u64,
|
||||
block: std::option::Option<serde_json::Value>,
|
||||
err: serde_json::Value,
|
||||
}
|
||||
|
||||
fn decode_block_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaBlockNotification>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let block = match wire.value.block {
|
||||
std::option::Option::Some(value) => {
|
||||
let decoded = crate::SolanaConfirmedBlock::decode_wire(method, value);
|
||||
match decoded {
|
||||
std::result::Result::Ok(block) => std::option::Option::Some(block),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
let err = match wire.value.err {
|
||||
serde_json::Value::Null => std::option::Option::None,
|
||||
value => std::option::Option::Some(value),
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, crate::SolanaBlockNotification { slot: wire.value.slot, block, err }));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponse {
|
||||
context: serde_json::Value,
|
||||
value: WireBlockNotification,
|
||||
}
|
||||
|
||||
impl crate::SolanaStandardWsSession {
|
||||
/// Subscribes to unstable standard Solana block notifications through `blockSubscribe`.
|
||||
pub async fn block_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaBlockSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaBlockSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaBlockNotification>>> {
|
||||
return self.physical_session().block_subscribe(filter, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_blocks.rs"]
|
||||
mod tests;
|
||||
446
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
Normal file
446
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
Normal file
@@ -0,0 +1,446 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs
|
||||
// version: 5
|
||||
|
||||
/// Slot relationship reported by the standard Solana `slotNotification` WebSocket method.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaSlotNotification {
|
||||
slot: u64,
|
||||
parent: u64,
|
||||
root: u64,
|
||||
}
|
||||
|
||||
impl SolanaSlotNotification {
|
||||
/// Returns the newly processed slot.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the parent slot reported by the validator.
|
||||
#[must_use]
|
||||
pub const fn parent(&self) -> u64 {
|
||||
return self.parent;
|
||||
}
|
||||
|
||||
/// Returns the current root slot reported alongside this slot update.
|
||||
#[must_use]
|
||||
pub const fn root(&self) -> u64 {
|
||||
return self.root;
|
||||
}
|
||||
}
|
||||
|
||||
/// Execution statistics attached to unstable Solana `slotsUpdatesNotification` frozen updates.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaSlotUpdateStats {
|
||||
max_transactions_per_entry: u64,
|
||||
num_failed_transactions: u64,
|
||||
num_successful_transactions: u64,
|
||||
num_transaction_entries: u64,
|
||||
}
|
||||
|
||||
impl SolanaSlotUpdateStats {
|
||||
/// Returns the maximum transactions per entry observed for the frozen bank.
|
||||
#[must_use]
|
||||
pub const fn max_transactions_per_entry(&self) -> u64 {
|
||||
return self.max_transactions_per_entry;
|
||||
}
|
||||
|
||||
/// Returns the failed transaction count.
|
||||
#[must_use]
|
||||
pub const fn num_failed_transactions(&self) -> u64 {
|
||||
return self.num_failed_transactions;
|
||||
}
|
||||
|
||||
/// Returns the successful transaction count.
|
||||
#[must_use]
|
||||
pub const fn num_successful_transactions(&self) -> u64 {
|
||||
return self.num_successful_transactions;
|
||||
}
|
||||
|
||||
/// Returns the transaction-entry count.
|
||||
#[must_use]
|
||||
pub const fn num_transaction_entries(&self) -> u64 {
|
||||
return self.num_transaction_entries;
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed unstable Solana slot-lifecycle update with an unknown-variant fallback.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaSlotUpdate {
|
||||
/// The first shred for a slot was received.
|
||||
FirstShredReceived {
|
||||
/// Slot whose first shred was observed.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
},
|
||||
/// All shreds for a slot were received.
|
||||
Completed {
|
||||
/// Slot whose shred set completed.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
},
|
||||
/// A bank was created for the slot.
|
||||
CreatedBank {
|
||||
/// Slot whose bank was created.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
/// Parent slot used to create the bank.
|
||||
parent: u64,
|
||||
},
|
||||
/// A bank was frozen and execution statistics are available.
|
||||
Frozen {
|
||||
/// Slot whose bank was frozen.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
/// Execution statistics reported for the frozen bank.
|
||||
stats: crate::SolanaSlotUpdateStats,
|
||||
},
|
||||
/// The slot was marked dead.
|
||||
Dead {
|
||||
/// Slot marked dead by the validator.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
/// Upstream diagnostic string explaining why the slot was marked dead.
|
||||
error: std::string::String,
|
||||
},
|
||||
/// The slot reached the current unstable optimistic-confirmation marker.
|
||||
OptimisticConfirmation {
|
||||
/// Slot that reached optimistic confirmation.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
},
|
||||
/// The slot became root.
|
||||
Root {
|
||||
/// Slot that became root.
|
||||
slot: u64,
|
||||
/// Millisecond Unix timestamp reported by the validator.
|
||||
timestamp: i64,
|
||||
},
|
||||
/// A future upstream variant that KSP does not yet interpret.
|
||||
///
|
||||
/// `raw` is bounded by the physical session's configured inbound WebSocket message limit before JSON decoding.
|
||||
Unknown {
|
||||
/// Upstream `type` discriminator that KSP does not yet recognize.
|
||||
update_type: std::string::String,
|
||||
/// Complete bounded JSON object preserved for forward-compatible inspection.
|
||||
raw: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl SolanaSlotUpdate {
|
||||
/// Returns the slot for known variants, or the optional slot found in an unknown raw variant.
|
||||
#[must_use]
|
||||
pub fn slot(&self) -> std::option::Option<u64> {
|
||||
return match self {
|
||||
Self::FirstShredReceived { slot, .. }
|
||||
| Self::Completed { slot, .. }
|
||||
| Self::CreatedBank { slot, .. }
|
||||
| Self::Frozen { slot, .. }
|
||||
| Self::Dead { slot, .. }
|
||||
| Self::OptimisticConfirmation { slot, .. }
|
||||
| Self::Root { slot, .. } => std::option::Option::Some(*slot),
|
||||
Self::Unknown { raw, .. } => raw.get("slot").and_then(serde_json::Value::as_u64),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the millisecond Unix timestamp for known variants, or an optional timestamp from an unknown raw variant.
|
||||
#[must_use]
|
||||
pub fn timestamp(&self) -> std::option::Option<i64> {
|
||||
return match self {
|
||||
Self::FirstShredReceived { timestamp, .. }
|
||||
| Self::Completed { timestamp, .. }
|
||||
| Self::CreatedBank { timestamp, .. }
|
||||
| Self::Frozen { timestamp, .. }
|
||||
| Self::Dead { timestamp, .. }
|
||||
| Self::OptimisticConfirmation { timestamp, .. }
|
||||
| Self::Root { timestamp, .. } => std::option::Option::Some(*timestamp),
|
||||
Self::Unknown { raw, .. } => raw.get("timestamp").and_then(serde_json::Value::as_i64),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the upstream `type` string, including unknown future values.
|
||||
#[must_use]
|
||||
pub fn update_type(&self) -> &str {
|
||||
return match self {
|
||||
Self::FirstShredReceived { .. } => "firstShredReceived",
|
||||
Self::Completed { .. } => "completed",
|
||||
Self::CreatedBank { .. } => "createdBank",
|
||||
Self::Frozen { .. } => "frozen",
|
||||
Self::Dead { .. } => "dead",
|
||||
Self::OptimisticConfirmation { .. } => "optimisticConfirmation",
|
||||
Self::Root { .. } => "root",
|
||||
Self::Unknown { update_type, .. } => update_type.as_str(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the bounded raw object only for an unknown future upstream variant.
|
||||
#[must_use]
|
||||
pub const fn unknown_raw(&self) -> std::option::Option<&serde_json::Value> {
|
||||
return match self {
|
||||
Self::Unknown { raw, .. } => std::option::Option::Some(raw),
|
||||
_ => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed unstable gossip-vote notification delivered by standard Solana `voteSubscribe`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaVoteNotification {
|
||||
vote_pubkey: ksp_core_lib::Pubkey,
|
||||
slots: std::vec::Vec<u64>,
|
||||
hash: std::string::String,
|
||||
timestamp: std::option::Option<i64>,
|
||||
signature: std::string::String,
|
||||
}
|
||||
|
||||
impl SolanaVoteNotification {
|
||||
/// Returns the vote-account public key.
|
||||
#[must_use]
|
||||
pub const fn vote_pubkey(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.vote_pubkey;
|
||||
}
|
||||
|
||||
/// Returns the ordered slots covered by the observed vote.
|
||||
#[must_use]
|
||||
pub fn slots(&self) -> &[u64] {
|
||||
return self.slots.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the vote hash exactly as reported by the unstable upstream wire.
|
||||
#[must_use]
|
||||
pub fn hash(&self) -> &str {
|
||||
return self.hash.as_str();
|
||||
}
|
||||
|
||||
/// Returns the optional vote timestamp, preserving omitted and explicit-null wire forms as `None`.
|
||||
#[must_use]
|
||||
pub const fn timestamp(&self) -> std::option::Option<i64> {
|
||||
return self.timestamp;
|
||||
}
|
||||
|
||||
/// Returns the vote transaction signature exactly as reported by the unstable upstream wire.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> &str {
|
||||
return self.signature.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WsSession {
|
||||
/// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`.
|
||||
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| {
|
||||
return decode_slot_notification("slotSubscribe", value);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to standard Solana root-slot notifications through `rootSubscribe`.
|
||||
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
|
||||
return crate::decode_wire_json::<u64>("rootSubscribe", value);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable standard Solana slot-lifecycle notifications through `slotsUpdatesSubscribe`.
|
||||
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::SlotsUpdates, std::vec::Vec::new(), |value| {
|
||||
return decode_slots_update_notification("slotsUpdatesSubscribe", value);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable pre-consensus gossip vote notifications through `voteSubscribe`.
|
||||
pub async fn vote_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaVoteNotification>> {
|
||||
return self
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Vote, std::vec::Vec::new(), |value| {
|
||||
return decode_vote_notification("voteSubscribe", value);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireSlotNotification {
|
||||
slot: u64,
|
||||
parent: u64,
|
||||
root: u64,
|
||||
}
|
||||
|
||||
fn decode_slot_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaSlotNotification> {
|
||||
let decoded = crate::decode_wire_json::<WireSlotNotification>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaSlotNotification { slot: wire.slot, parent: wire.parent, root: wire.root });
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireSlotUpdateStats {
|
||||
max_transactions_per_entry: u64,
|
||||
num_failed_transactions: u64,
|
||||
num_successful_transactions: u64,
|
||||
num_transaction_entries: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireVoteNotification {
|
||||
#[serde(rename = "votePubkey")]
|
||||
vote_pubkey: std::string::String,
|
||||
slots: std::vec::Vec<u64>,
|
||||
hash: std::string::String,
|
||||
#[serde(default)]
|
||||
timestamp: std::option::Option<i64>,
|
||||
signature: std::string::String,
|
||||
}
|
||||
|
||||
fn decode_slots_update_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaSlotUpdate> {
|
||||
let object = match value.as_object() {
|
||||
std::option::Option::Some(object) => object,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification must be an object")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
},
|
||||
};
|
||||
let update_type = match object.get("type").and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(update_type) => update_type,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "slotsUpdatesSubscribe notification is missing a string type")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
},
|
||||
};
|
||||
if !matches!(update_type, "firstShredReceived" | "completed" | "createdBank" | "frozen" | "dead" | "optimisticConfirmation" | "root") {
|
||||
return std::result::Result::Ok(crate::SolanaSlotUpdate::Unknown { update_type: update_type.to_owned(), raw: value.clone() });
|
||||
}
|
||||
let slot = match object.get("slot").and_then(serde_json::Value::as_u64) {
|
||||
std::option::Option::Some(slot) => slot,
|
||||
std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric slot"),
|
||||
};
|
||||
let timestamp = match object.get("timestamp").and_then(serde_json::Value::as_i64) {
|
||||
std::option::Option::Some(timestamp) => timestamp,
|
||||
std::option::Option::None => return invalid_slots_update(method, "known slots update is missing numeric timestamp"),
|
||||
};
|
||||
return match update_type {
|
||||
"firstShredReceived" => std::result::Result::Ok(crate::SolanaSlotUpdate::FirstShredReceived { slot, timestamp }),
|
||||
"completed" => std::result::Result::Ok(crate::SolanaSlotUpdate::Completed { slot, timestamp }),
|
||||
"createdBank" => match object.get("parent").and_then(serde_json::Value::as_u64) {
|
||||
std::option::Option::Some(parent) => std::result::Result::Ok(crate::SolanaSlotUpdate::CreatedBank { slot, timestamp, parent }),
|
||||
std::option::Option::None => invalid_slots_update(method, "createdBank update is missing numeric parent"),
|
||||
},
|
||||
"frozen" => {
|
||||
let stats = match object.get("stats") {
|
||||
std::option::Option::Some(stats) => crate::decode_wire_json::<WireSlotUpdateStats>(method, stats.clone()),
|
||||
std::option::Option::None => return invalid_slots_update(method, "frozen update is missing stats"),
|
||||
};
|
||||
let stats = match stats {
|
||||
std::result::Result::Ok(stats) => stats,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
std::result::Result::Ok(crate::SolanaSlotUpdate::Frozen {
|
||||
slot,
|
||||
timestamp,
|
||||
stats: crate::SolanaSlotUpdateStats {
|
||||
max_transactions_per_entry: stats.max_transactions_per_entry,
|
||||
num_failed_transactions: stats.num_failed_transactions,
|
||||
num_successful_transactions: stats.num_successful_transactions,
|
||||
num_transaction_entries: stats.num_transaction_entries,
|
||||
},
|
||||
})
|
||||
},
|
||||
"dead" => match object.get("err").and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(error) => std::result::Result::Ok(crate::SolanaSlotUpdate::Dead { slot, timestamp, error: error.to_owned() }),
|
||||
std::option::Option::None => invalid_slots_update(method, "dead update is missing string err"),
|
||||
},
|
||||
"optimisticConfirmation" => std::result::Result::Ok(crate::SolanaSlotUpdate::OptimisticConfirmation { slot, timestamp }),
|
||||
"root" => std::result::Result::Ok(crate::SolanaSlotUpdate::Root { slot, timestamp }),
|
||||
_ => invalid_slots_update(method, "known slots update type dispatch failed"),
|
||||
};
|
||||
}
|
||||
|
||||
fn invalid_slots_update(method: &str, message: &'static str) -> ksp_core_lib::Result<crate::SolanaSlotUpdate> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method));
|
||||
}
|
||||
|
||||
fn decode_vote_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaVoteNotification> {
|
||||
let decoded = crate::decode_wire_json::<WireVoteNotification>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let vote_pubkey = wire.vote_pubkey.parse::<ksp_core_lib::Pubkey>();
|
||||
let vote_pubkey = match vote_pubkey {
|
||||
std::result::Result::Ok(vote_pubkey) => vote_pubkey,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "voteSubscribe notification contains an invalid votePubkey")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaVoteNotification {
|
||||
vote_pubkey,
|
||||
slots: wire.slots,
|
||||
hash: wire.hash,
|
||||
timestamp: wire.timestamp,
|
||||
signature: wire.signature,
|
||||
});
|
||||
}
|
||||
|
||||
impl crate::SolanaStandardWsSession {
|
||||
/// Subscribes to standard Solana slot-processing notifications through `slotSubscribe`.
|
||||
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
|
||||
return self.physical_session().slot_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to standard Solana root-slot notifications through `rootSubscribe`.
|
||||
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
|
||||
return self.physical_session().root_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable standard Solana slot-lifecycle notifications through `slotsUpdatesSubscribe`.
|
||||
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
|
||||
return self.physical_session().slots_updates_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable pre-consensus gossip vote notifications through `voteSubscribe`.
|
||||
pub async fn vote_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaVoteNotification>> {
|
||||
return self.physical_session().vote_subscribe().await;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HeliusLaserStreamWsSession {
|
||||
/// Subscribes to slot-processing notifications through the standard `slotSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn slot_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotNotification>> {
|
||||
return self.physical_session().slot_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to root-slot notifications through the standard `rootSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn root_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<u64>> {
|
||||
return self.physical_session().root_subscribe().await;
|
||||
}
|
||||
|
||||
/// Subscribes to unstable slot-lifecycle notifications through the standard `slotsUpdatesSubscribe` wire currently documented by Helius LaserStream
|
||||
/// WebSocket.
|
||||
pub async fn slots_updates_subscribe(&self) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaSlotUpdate>> {
|
||||
return self.physical_session().slots_updates_subscribe().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_cluster.rs"]
|
||||
mod tests;
|
||||
608
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
Normal file
608
crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
Normal file
@@ -0,0 +1,608 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_helius_transactions.rs
|
||||
// version: 5
|
||||
|
||||
const MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS: usize = 50_000;
|
||||
|
||||
/// Helius `tokenAccounts` expansion mode accepted by `transactionSubscribe`.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum HeliusTokenAccountsFilter {
|
||||
/// Disable token-account owner expansion explicitly; equivalent to omitting `tokenAccounts`.
|
||||
None,
|
||||
/// Match transactions where a token balance owned by an included account changes or its token account closes.
|
||||
BalanceChanged,
|
||||
/// Match transactions referencing any token account owned by an included account, even if the balance does not change.
|
||||
All,
|
||||
}
|
||||
|
||||
impl HeliusTokenAccountsFilter {
|
||||
/// Returns the exact Helius WebSocket wire string.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::None => "none",
|
||||
Self::BalanceChanged => "balanceChanged",
|
||||
Self::All => "all",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Transaction encoding accepted by Helius `transactionSubscribe`.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum HeliusTransactionSubscribeEncoding {
|
||||
/// Base58 encoded transaction bytes.
|
||||
Base58,
|
||||
/// Base64 encoded transaction bytes.
|
||||
Base64,
|
||||
/// Parsed JSON transaction representation.
|
||||
JsonParsed,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSubscribeEncoding {
|
||||
/// Returns the exact Helius WebSocket wire string.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Base58 => "base58",
|
||||
Self::Base64 => "base64",
|
||||
Self::JsonParsed => "jsonParsed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Helius-specific filter object accepted as the first `transactionSubscribe` parameter.
|
||||
///
|
||||
/// Debug output intentionally exposes only filter presence, modes and account counts. Transaction signatures and account values are omitted so routine
|
||||
/// diagnostics cannot accidentally disclose the caller's complete provider filter payload.
|
||||
#[derive(Clone, Default, Eq, PartialEq)]
|
||||
pub struct HeliusTransactionSubscribeFilter {
|
||||
vote: std::option::Option<bool>,
|
||||
failed: std::option::Option<bool>,
|
||||
signature: std::option::Option<std::string::String>,
|
||||
account_include: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
account_exclude: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
account_required: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
token_accounts: std::option::Option<crate::HeliusTokenAccountsFilter>,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSubscribeFilter {
|
||||
/// Creates a complete Helius transaction filter while preserving omitted versus explicitly empty account arrays.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
vote: std::option::Option<bool>,
|
||||
failed: std::option::Option<bool>,
|
||||
signature: std::option::Option<std::string::String>,
|
||||
account_include: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
account_exclude: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
account_required: std::option::Option<std::vec::Vec<ksp_core_lib::Pubkey>>,
|
||||
token_accounts: std::option::Option<crate::HeliusTokenAccountsFilter>,
|
||||
) -> Self {
|
||||
return Self { vote, failed, signature, account_include, account_exclude, account_required, token_accounts };
|
||||
}
|
||||
|
||||
/// Returns the optional vote-transaction filter flag.
|
||||
#[must_use]
|
||||
pub const fn vote(&self) -> std::option::Option<bool> {
|
||||
return self.vote;
|
||||
}
|
||||
|
||||
/// Returns the optional failed-transaction filter flag.
|
||||
#[must_use]
|
||||
pub const fn failed(&self) -> std::option::Option<bool> {
|
||||
return self.failed;
|
||||
}
|
||||
|
||||
/// Returns the optional exact transaction signature filter.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> std::option::Option<&str> {
|
||||
return match self.signature.as_ref() {
|
||||
std::option::Option::Some(signature) => std::option::Option::Some(signature.as_str()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the optional OR-style account inclusion list.
|
||||
#[must_use]
|
||||
pub fn account_include(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> {
|
||||
return match self.account_include.as_ref() {
|
||||
std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the optional account exclusion list.
|
||||
#[must_use]
|
||||
pub fn account_exclude(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> {
|
||||
return match self.account_exclude.as_ref() {
|
||||
std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the optional AND-style required-account list.
|
||||
#[must_use]
|
||||
pub fn account_required(&self) -> std::option::Option<&[ksp_core_lib::Pubkey]> {
|
||||
return match self.account_required.as_ref() {
|
||||
std::option::Option::Some(accounts) => std::option::Option::Some(accounts.as_slice()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the optional Helius token-account owner-expansion mode.
|
||||
#[must_use]
|
||||
pub const fn token_accounts(&self) -> std::option::Option<crate::HeliusTokenAccountsFilter> {
|
||||
return self.token_accounts;
|
||||
}
|
||||
|
||||
fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
let include = validate_account_list("accountInclude", self.account_include.as_deref());
|
||||
if let std::result::Result::Err(error) = include {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let exclude = validate_account_list("accountExclude", self.account_exclude.as_deref());
|
||||
if let std::result::Result::Err(error) = exclude {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let required = validate_account_list("accountRequired", self.account_required.as_deref());
|
||||
if let std::result::Result::Err(error) = required {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(vote) = self.vote {
|
||||
object.insert("vote".to_owned(), serde_json::Value::Bool(vote));
|
||||
}
|
||||
if let std::option::Option::Some(failed) = self.failed {
|
||||
object.insert("failed".to_owned(), serde_json::Value::Bool(failed));
|
||||
}
|
||||
if let std::option::Option::Some(signature) = self.signature.as_ref() {
|
||||
object.insert("signature".to_owned(), serde_json::Value::String(signature.clone()));
|
||||
}
|
||||
insert_account_list(&mut object, "accountInclude", self.account_include.as_deref());
|
||||
insert_account_list(&mut object, "accountExclude", self.account_exclude.as_deref());
|
||||
insert_account_list(&mut object, "accountRequired", self.account_required.as_deref());
|
||||
if let std::option::Option::Some(token_accounts) = self.token_accounts {
|
||||
object.insert("tokenAccounts".to_owned(), serde_json::Value::String(token_accounts.as_str().to_owned()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusTransactionSubscribeFilter {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("HeliusTransactionSubscribeFilter")
|
||||
.field("vote", &self.vote)
|
||||
.field("failed", &self.failed)
|
||||
.field("signature_present", &self.signature.is_some())
|
||||
.field("account_include_count", &self.account_include.as_ref().map(std::vec::Vec::len))
|
||||
.field("account_exclude_count", &self.account_exclude.as_ref().map(std::vec::Vec::len))
|
||||
.field("account_required_count", &self.account_required.as_ref().map(std::vec::Vec::len))
|
||||
.field("token_accounts", &self.token_accounts)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional Helius `transactionSubscribe` result-shaping configuration.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct HeliusTransactionSubscribeOptions {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
encoding: std::option::Option<crate::HeliusTransactionSubscribeEncoding>,
|
||||
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
|
||||
show_rewards: std::option::Option<bool>,
|
||||
max_supported_transaction_version: std::option::Option<u8>,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSubscribeOptions {
|
||||
/// Creates a complete optional Helius transaction-subscription configuration.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
encoding: std::option::Option<crate::HeliusTransactionSubscribeEncoding>,
|
||||
transaction_details: std::option::Option<crate::SolanaTransactionDetails>,
|
||||
show_rewards: std::option::Option<bool>,
|
||||
max_supported_transaction_version: std::option::Option<u8>,
|
||||
) -> Self {
|
||||
return Self { commitment, encoding, transaction_details, show_rewards, max_supported_transaction_version };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns the optional Helius transaction encoding.
|
||||
#[must_use]
|
||||
pub const fn encoding(&self) -> std::option::Option<crate::HeliusTransactionSubscribeEncoding> {
|
||||
return self.encoding;
|
||||
}
|
||||
|
||||
/// Returns the optional transaction detail level.
|
||||
#[must_use]
|
||||
pub const fn transaction_details(&self) -> std::option::Option<crate::SolanaTransactionDetails> {
|
||||
return self.transaction_details;
|
||||
}
|
||||
|
||||
/// Returns whether rewards were explicitly requested.
|
||||
#[must_use]
|
||||
pub const fn show_rewards(&self) -> std::option::Option<bool> {
|
||||
return self.show_rewards;
|
||||
}
|
||||
|
||||
/// Returns the highest transaction version the caller declares it can consume.
|
||||
#[must_use]
|
||||
pub const fn max_supported_transaction_version(&self) -> std::option::Option<u8> {
|
||||
return self.max_supported_transaction_version;
|
||||
}
|
||||
|
||||
fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
let requires_version =
|
||||
matches!(self.transaction_details, std::option::Option::Some(crate::SolanaTransactionDetails::Full | crate::SolanaTransactionDetails::Accounts));
|
||||
if requires_version && self.max_supported_transaction_version.is_none() {
|
||||
let detail = match self.transaction_details {
|
||||
std::option::Option::Some(detail) => detail.as_str(),
|
||||
std::option::Option::None => "omitted",
|
||||
};
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
|
||||
"Helius transactionSubscribe requires maxSupportedTransactionVersion for full or accounts transaction details",
|
||||
)
|
||||
.with_context("rpc_method", "transactionSubscribe")
|
||||
.with_context("field", "maxSupportedTransactionVersion")
|
||||
.with_context("transaction_details", detail),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn to_json_value(self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(encoding) = self.encoding {
|
||||
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(transaction_details) = self.transaction_details {
|
||||
object.insert("transactionDetails".to_owned(), serde_json::Value::String(transaction_details.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(show_rewards) = self.show_rewards {
|
||||
object.insert("showRewards".to_owned(), serde_json::Value::Bool(show_rewards));
|
||||
}
|
||||
if let std::option::Option::Some(version) = self.max_supported_transaction_version {
|
||||
object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete typed request contract for Helius `transactionSubscribe`.
|
||||
///
|
||||
/// The request owns the exact provider filter and optional result-shaping object. Validation and serialization occur before actor registration so deterministic
|
||||
/// provider constraints fail without WebSocket I/O.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct HeliusTransactionSubscribeRequest {
|
||||
filter: crate::HeliusTransactionSubscribeFilter,
|
||||
options: std::option::Option<crate::HeliusTransactionSubscribeOptions>,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSubscribeRequest {
|
||||
/// Creates one typed Helius transaction-subscription request.
|
||||
#[must_use]
|
||||
pub fn new(filter: crate::HeliusTransactionSubscribeFilter, options: std::option::Option<crate::HeliusTransactionSubscribeOptions>) -> Self {
|
||||
return Self { filter, options };
|
||||
}
|
||||
|
||||
/// Returns the provider transaction filter.
|
||||
#[must_use]
|
||||
pub const fn filter(&self) -> &crate::HeliusTransactionSubscribeFilter {
|
||||
return &self.filter;
|
||||
}
|
||||
|
||||
/// Returns the optional provider result-shaping configuration.
|
||||
#[must_use]
|
||||
pub const fn options(&self) -> std::option::Option<&crate::HeliusTransactionSubscribeOptions> {
|
||||
return self.options.as_ref();
|
||||
}
|
||||
|
||||
/// Validates deterministic Helius request constraints before any WebSocket I/O.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
let filter = self.filter.validate();
|
||||
if let std::result::Result::Err(error) = filter {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::option::Option::Some(options) = self.options {
|
||||
let options = options.validate();
|
||||
if let std::result::Result::Err(error) = options {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusTransactionSubscribeRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("HeliusTransactionSubscribeRequest").field("filter", &self.filter).field("options", &self.options).finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn helius_transaction_subscribe_params(request: &crate::HeliusTransactionSubscribeRequest) -> ksp_core_lib::Result<std::vec::Vec<serde_json::Value>> {
|
||||
let validation = request.validate();
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mut params = std::vec![request.filter.to_json_value()];
|
||||
if let std::option::Option::Some(options) = request.options {
|
||||
params.push(options.to_json_value());
|
||||
}
|
||||
return std::result::Result::Ok(params);
|
||||
}
|
||||
|
||||
/// Full/accounts-mode notification delivered by Helius `transactionSubscribe`.
|
||||
///
|
||||
/// The nested transaction payload is deliberately retained as JSON because its exact Solana wire representation depends on the requested encoding and detail
|
||||
/// mode. KSP types the stable provider envelope while preserving the full nested payload without Program-specific decoding.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct HeliusFullTransactionNotification {
|
||||
transaction: serde_json::Value,
|
||||
signature: std::string::String,
|
||||
slot: u64,
|
||||
transaction_index: u64,
|
||||
}
|
||||
|
||||
impl HeliusFullTransactionNotification {
|
||||
/// Returns the provider transaction/status payload without interpreting Program-specific contents.
|
||||
#[must_use]
|
||||
pub const fn transaction(&self) -> &serde_json::Value {
|
||||
return &self.transaction;
|
||||
}
|
||||
|
||||
/// Returns the base58 transaction signature reported by Helius.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> &str {
|
||||
return self.signature.as_str();
|
||||
}
|
||||
|
||||
/// Returns the slot in which the transaction was processed.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the zero-based transaction position within the block.
|
||||
#[must_use]
|
||||
pub const fn transaction_index(&self) -> u64 {
|
||||
return self.transaction_index;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusFullTransactionNotification {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("HeliusFullTransactionNotification")
|
||||
.field("transaction", &"<omitted>")
|
||||
.field("signature", &"<omitted>")
|
||||
.field("slot", &self.slot)
|
||||
.field("transaction_index", &self.transaction_index)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Signatures-mode notification delivered by Helius `transactionSubscribe`.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct HeliusTransactionSignatureNotification {
|
||||
signature: std::string::String,
|
||||
slot: u64,
|
||||
transaction_index: u64,
|
||||
err: crate::SolanaWireField<serde_json::Value>,
|
||||
memo: crate::SolanaWireField<std::string::String>,
|
||||
block_time: crate::SolanaWireField<i64>,
|
||||
confirmation_status: crate::SolanaWireField<std::string::String>,
|
||||
}
|
||||
|
||||
impl HeliusTransactionSignatureNotification {
|
||||
/// Returns the base58 transaction signature reported by Helius.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> &str {
|
||||
return self.signature.as_str();
|
||||
}
|
||||
|
||||
/// Returns the slot in which the transaction was processed.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the zero-based transaction position within the block.
|
||||
#[must_use]
|
||||
pub const fn transaction_index(&self) -> u64 {
|
||||
return self.transaction_index;
|
||||
}
|
||||
|
||||
/// Returns the optional transaction error while preserving omitted/null/value wire states.
|
||||
#[must_use]
|
||||
pub const fn err(&self) -> &crate::SolanaWireField<serde_json::Value> {
|
||||
return &self.err;
|
||||
}
|
||||
|
||||
/// Returns the optional memo while preserving omitted/null/value wire states.
|
||||
#[must_use]
|
||||
pub const fn memo(&self) -> &crate::SolanaWireField<std::string::String> {
|
||||
return &self.memo;
|
||||
}
|
||||
|
||||
/// Returns the optional block time while preserving omitted/null/value wire states.
|
||||
#[must_use]
|
||||
pub const fn block_time(&self) -> &crate::SolanaWireField<i64> {
|
||||
return &self.block_time;
|
||||
}
|
||||
|
||||
/// Returns the optional confirmation-status label while preserving omitted/null/value wire states.
|
||||
#[must_use]
|
||||
pub const fn confirmation_status(&self) -> &crate::SolanaWireField<std::string::String> {
|
||||
return &self.confirmation_status;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusTransactionSignatureNotification {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("HeliusTransactionSignatureNotification")
|
||||
.field("signature", &"<omitted>")
|
||||
.field("slot", &self.slot)
|
||||
.field("transaction_index", &self.transaction_index)
|
||||
.field("err", &wire_field_debug_state(&self.err))
|
||||
.field("memo", &wire_field_debug_state(&self.memo))
|
||||
.field("block_time", &wire_field_debug_state(&self.block_time))
|
||||
.field("confirmation_status", &wire_field_debug_state(&self.confirmation_status))
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_field_debug_state<T>(field: &crate::SolanaWireField<T>) -> &'static str {
|
||||
if field.is_omitted() {
|
||||
return "omitted";
|
||||
}
|
||||
if field.is_null() {
|
||||
return "null";
|
||||
}
|
||||
return "value";
|
||||
}
|
||||
|
||||
/// Typed Helius `transactionNotification` payload union.
|
||||
///
|
||||
/// `Full` also covers the provider `accounts` detail mode because both contain the nested `transaction` member. `Signature` covers the lightweight
|
||||
/// signatures mode. `Unknown` preserves `none` mode and forward-compatible provider shapes instead of failing the logical subscription.
|
||||
#[derive(Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum HeliusTransactionNotification {
|
||||
/// Full/accounts notification carrying the nested transaction payload.
|
||||
Full(crate::HeliusFullTransactionNotification),
|
||||
/// Lightweight signatures notification.
|
||||
Signature(crate::HeliusTransactionSignatureNotification),
|
||||
/// Provider shape not currently typed by KSP, preserved losslessly.
|
||||
Unknown(serde_json::Value),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusTransactionNotification {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return match self {
|
||||
Self::Full(notification) => formatter.debug_tuple("Full").field(notification).finish(),
|
||||
Self::Signature(notification) => formatter.debug_tuple("Signature").field(notification).finish(),
|
||||
Self::Unknown(_) => formatter.debug_tuple("Unknown").field(&"<omitted>").finish(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HeliusLaserStreamWsSession {
|
||||
/// Opens one Helius `transactionSubscribe` logical subscription through the shared physical actor.
|
||||
///
|
||||
/// The returned handle keeps a stable local identity across physical reconnects. Helius remote subscription IDs stay actor-private and are remapped after
|
||||
/// resubscribe. Calling [`crate::WsSubscription::unsubscribe`] removes the remote mapping before sending `transactionUnsubscribe`, so provider messages
|
||||
/// already in flight after cancellation are ignored without reactivating the logical subscription.
|
||||
pub async fn transaction_subscribe(
|
||||
&self,
|
||||
request: &crate::HeliusTransactionSubscribeRequest,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::HeliusTransactionNotification>> {
|
||||
let params = helius_transaction_subscribe_params(request);
|
||||
let params = match params {
|
||||
std::result::Result::Ok(params) => params,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return self
|
||||
.physical_session()
|
||||
.subscribe_typed(crate::WsSubscriptionKind::HeliusTransaction, params, |value| return decode_helius_transaction_notification(value))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireHeliusFullTransactionNotification {
|
||||
transaction: serde_json::Value,
|
||||
signature: std::string::String,
|
||||
slot: u64,
|
||||
transaction_index: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireHeliusTransactionSignatureNotification {
|
||||
signature: std::string::String,
|
||||
slot: u64,
|
||||
transaction_index: u64,
|
||||
#[serde(default)]
|
||||
err: crate::SolanaWireField<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
memo: crate::SolanaWireField<std::string::String>,
|
||||
#[serde(default)]
|
||||
block_time: crate::SolanaWireField<i64>,
|
||||
#[serde(default)]
|
||||
confirmation_status: crate::SolanaWireField<std::string::String>,
|
||||
}
|
||||
|
||||
fn decode_helius_transaction_notification(value: serde_json::Value) -> ksp_core_lib::Result<crate::HeliusTransactionNotification> {
|
||||
if value.get("transaction").is_some() {
|
||||
let decoded = crate::decode_wire_json::<WireHeliusFullTransactionNotification>("transactionNotification", value.clone());
|
||||
if let std::result::Result::Ok(decoded) = decoded {
|
||||
return std::result::Result::Ok(crate::HeliusTransactionNotification::Full(crate::HeliusFullTransactionNotification {
|
||||
transaction: decoded.transaction,
|
||||
signature: decoded.signature,
|
||||
slot: decoded.slot,
|
||||
transaction_index: decoded.transaction_index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if value.get("signature").is_some() && value.get("slot").is_some() && value.get("transactionIndex").is_some() {
|
||||
let decoded = crate::decode_wire_json::<WireHeliusTransactionSignatureNotification>("transactionNotification", value.clone());
|
||||
if let std::result::Result::Ok(decoded) = decoded {
|
||||
return std::result::Result::Ok(crate::HeliusTransactionNotification::Signature(crate::HeliusTransactionSignatureNotification {
|
||||
signature: decoded.signature,
|
||||
slot: decoded.slot,
|
||||
transaction_index: decoded.transaction_index,
|
||||
err: decoded.err,
|
||||
memo: decoded.memo,
|
||||
block_time: decoded.block_time,
|
||||
confirmation_status: decoded.confirmation_status,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(crate::HeliusTransactionNotification::Unknown(value));
|
||||
}
|
||||
|
||||
fn validate_account_list(field: &'static str, accounts: std::option::Option<&[ksp_core_lib::Pubkey]>) -> ksp_core_lib::Result<()> {
|
||||
if let std::option::Option::Some(accounts) = accounts
|
||||
&& accounts.len() > MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "Helius transactionSubscribe account filter exceeds the provider limit")
|
||||
.with_context("rpc_method", "transactionSubscribe")
|
||||
.with_context("field", field)
|
||||
.with_context("actual_count", accounts.len().to_string())
|
||||
.with_context("max_count", MAX_HELIUS_TRANSACTION_FILTER_ACCOUNTS.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn insert_account_list(
|
||||
object: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
||||
field: &'static str,
|
||||
accounts: std::option::Option<&[ksp_core_lib::Pubkey]>,
|
||||
) {
|
||||
if let std::option::Option::Some(accounts) = accounts {
|
||||
let values = accounts.iter().map(|account| return serde_json::Value::String(account.to_string())).collect::<std::vec::Vec<_>>();
|
||||
object.insert(field.to_owned(), serde_json::Value::Array(values));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_helius_transactions.rs"]
|
||||
mod tests;
|
||||
366
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
Normal file
366
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||
// version: 7
|
||||
|
||||
/// Stable local identity assigned to one physical WebSocket session.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WsSessionId(std::num::NonZeroU64);
|
||||
|
||||
impl WsSessionId {
|
||||
/// Creates a session identity from a non-zero local value.
|
||||
#[must_use]
|
||||
pub const fn new(value: std::num::NonZeroU64) -> Self {
|
||||
return Self(value);
|
||||
}
|
||||
|
||||
/// Returns the stable local numeric value.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
return self.0.get();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable local identity assigned to one logical WebSocket subscription.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WsSubscriptionId(std::num::NonZeroU64);
|
||||
|
||||
impl WsSubscriptionId {
|
||||
/// Creates a subscription identity from a non-zero local value.
|
||||
#[must_use]
|
||||
pub const fn new(value: std::num::NonZeroU64) -> Self {
|
||||
return Self(value);
|
||||
}
|
||||
|
||||
/// Returns the stable local numeric value.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
return self.0.get();
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable lifecycle state of one physical WebSocket session.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WsSessionState {
|
||||
/// No physical connection is currently active and no connection attempt is running.
|
||||
Disconnected,
|
||||
/// The actor is establishing the physical connection.
|
||||
Connecting,
|
||||
/// The physical connection is active.
|
||||
Active,
|
||||
/// The actor is reconnecting after an unexpected physical disconnect.
|
||||
Reconnecting {
|
||||
/// One-based reconnect attempt currently in progress or waiting for backoff.
|
||||
attempt: u32,
|
||||
},
|
||||
/// Explicit shutdown has started and new subscriptions are refused.
|
||||
Closing,
|
||||
/// Explicit shutdown completed.
|
||||
Closed,
|
||||
/// The session reached a terminal failure state.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Observable lifecycle state of one logical WebSocket subscription.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WsSubscriptionState {
|
||||
/// The local subscription exists but its initial subscribe request has not completed.
|
||||
Requested,
|
||||
/// The logical subscription is bound to an active remote subscription.
|
||||
Active,
|
||||
/// The logical subscription is being restored after reconnect.
|
||||
Resubscribing,
|
||||
/// Local cancellation has won and remote cleanup is in progress when possible.
|
||||
Cancelling,
|
||||
/// The logical subscription reached a non-error terminal state.
|
||||
Closed,
|
||||
/// The logical subscription reached a terminal failure state.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// WebSocket subscription family represented by one logical subscription.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WsSubscriptionKind {
|
||||
/// `accountSubscribe` family.
|
||||
Account,
|
||||
/// `blockSubscribe` family.
|
||||
Block,
|
||||
/// `logsSubscribe` family.
|
||||
Logs,
|
||||
/// `programSubscribe` family.
|
||||
Program,
|
||||
/// `rootSubscribe` family.
|
||||
Root,
|
||||
/// `signatureSubscribe` family.
|
||||
Signature,
|
||||
/// `slotSubscribe` family.
|
||||
Slot,
|
||||
/// `slotsUpdatesSubscribe` family.
|
||||
SlotsUpdates,
|
||||
/// `voteSubscribe` family.
|
||||
Vote,
|
||||
/// Helius LaserStream WebSocket `transactionSubscribe` extension family.
|
||||
HeliusTransaction,
|
||||
}
|
||||
|
||||
impl WsSubscriptionKind {
|
||||
/// Returns the stable KSP descriptor for this WebSocket subscription family.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "account",
|
||||
Self::Block => "block",
|
||||
Self::Logs => "logs",
|
||||
Self::Program => "program",
|
||||
Self::Root => "root",
|
||||
Self::Signature => "signature",
|
||||
Self::Slot => "slot",
|
||||
Self::SlotsUpdates => "slots_updates",
|
||||
Self::Vote => "vote",
|
||||
Self::HeliusTransaction => "helius_transaction",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact subscribe JSON-RPC method for this family.
|
||||
pub(crate) const fn subscribe_method(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "accountSubscribe",
|
||||
Self::Block => "blockSubscribe",
|
||||
Self::Logs => "logsSubscribe",
|
||||
Self::Program => "programSubscribe",
|
||||
Self::Root => "rootSubscribe",
|
||||
Self::Signature => "signatureSubscribe",
|
||||
Self::Slot => "slotSubscribe",
|
||||
Self::SlotsUpdates => "slotsUpdatesSubscribe",
|
||||
Self::Vote => "voteSubscribe",
|
||||
Self::HeliusTransaction => "transactionSubscribe",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact unsubscribe JSON-RPC method for this family.
|
||||
pub(crate) const fn unsubscribe_method(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "accountUnsubscribe",
|
||||
Self::Block => "blockUnsubscribe",
|
||||
Self::Logs => "logsUnsubscribe",
|
||||
Self::Program => "programUnsubscribe",
|
||||
Self::Root => "rootUnsubscribe",
|
||||
Self::Signature => "signatureUnsubscribe",
|
||||
Self::Slot => "slotUnsubscribe",
|
||||
Self::SlotsUpdates => "slotsUpdatesUnsubscribe",
|
||||
Self::Vote => "voteUnsubscribe",
|
||||
Self::HeliusTransaction => "transactionUnsubscribe",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact notification method emitted for this family.
|
||||
pub(crate) const fn notification_method(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "accountNotification",
|
||||
Self::Block => "blockNotification",
|
||||
Self::Logs => "logsNotification",
|
||||
Self::Program => "programNotification",
|
||||
Self::Root => "rootNotification",
|
||||
Self::Signature => "signatureNotification",
|
||||
Self::Slot => "slotNotification",
|
||||
Self::SlotsUpdates => "slotsUpdatesNotification",
|
||||
Self::Vote => "voteNotification",
|
||||
Self::HeliusTransaction => "transactionNotification",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns whether Solana documents this standard subscription family as unstable.
|
||||
///
|
||||
/// Provider extensions are stable here unless explicitly classified otherwise.
|
||||
pub(crate) const fn is_unstable(self) -> bool {
|
||||
return matches!(self, Self::Block | Self::SlotsUpdates | Self::Vote);
|
||||
}
|
||||
|
||||
/// Emits the centralized KSP warning required before opening an unstable standard subscription.
|
||||
pub(crate) fn warn_if_unstable(self) {
|
||||
if self.is_unstable() {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
rpc_method = self.subscribe_method(),
|
||||
subscription_kind = self.as_str(),
|
||||
documentation_status = "unstable",
|
||||
"unstable Solana WebSocket subscription requested"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe lifecycle projection for one logical WebSocket subscription.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsSubscriptionSnapshot {
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state: crate::WsSubscriptionState,
|
||||
remote_bound: bool,
|
||||
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
}
|
||||
|
||||
impl WsSubscriptionSnapshot {
|
||||
/// Creates one safe subscription lifecycle projection for Transport runtime internals.
|
||||
#[must_use]
|
||||
pub(crate) const fn new(
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state: crate::WsSubscriptionState,
|
||||
remote_bound: bool,
|
||||
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
) -> Self {
|
||||
return Self { id, kind, state, remote_bound, terminal_error_code };
|
||||
}
|
||||
|
||||
/// Returns the stable local subscription identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSubscriptionId {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the logical WebSocket subscription family.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> crate::WsSubscriptionKind {
|
||||
return self.kind;
|
||||
}
|
||||
|
||||
/// Returns the current logical lifecycle state.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> crate::WsSubscriptionState {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
/// Returns whether a current remote subscription ID is bound internally.
|
||||
///
|
||||
/// The remote ID itself is deliberately absent because it is ephemeral across reconnects.
|
||||
#[must_use]
|
||||
pub const fn remote_bound(&self) -> bool {
|
||||
return self.remote_bound;
|
||||
}
|
||||
|
||||
/// Returns the safe terminal error code when this subscription ended because of a failure.
|
||||
#[must_use]
|
||||
pub const fn terminal_error_code(&self) -> std::option::Option<ksp_core_lib::ErrorCode> {
|
||||
return self.terminal_error_code;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe runtime snapshot for one physical WebSocket session.
|
||||
///
|
||||
/// The snapshot deliberately contains logical endpoint metadata and local identities only. It never stores the endpoint URL, credentials, request payloads or
|
||||
/// raw notifications.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsSessionSnapshot {
|
||||
id: crate::WsSessionId,
|
||||
endpoint_name: std::string::String,
|
||||
provider: crate::WsProviderName,
|
||||
cluster: crate::WsClusterName,
|
||||
protocol: crate::WsProtocolKind,
|
||||
state: crate::WsSessionState,
|
||||
pending_request_count: usize,
|
||||
continuity_gap_count: u64,
|
||||
overflow_count: u64,
|
||||
subscriptions: std::vec::Vec<crate::WsSubscriptionSnapshot>,
|
||||
}
|
||||
|
||||
impl WsSessionSnapshot {
|
||||
/// Creates one safe session projection for Transport runtime internals.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
id: crate::WsSessionId,
|
||||
endpoint_name: impl std::convert::Into<std::string::String>,
|
||||
provider: crate::WsProviderName,
|
||||
cluster: crate::WsClusterName,
|
||||
protocol: crate::WsProtocolKind,
|
||||
state: crate::WsSessionState,
|
||||
pending_request_count: usize,
|
||||
continuity_gap_count: u64,
|
||||
overflow_count: u64,
|
||||
subscriptions: std::vec::Vec<crate::WsSubscriptionSnapshot>,
|
||||
) -> Self {
|
||||
return Self {
|
||||
id,
|
||||
endpoint_name: endpoint_name.into(),
|
||||
provider,
|
||||
cluster,
|
||||
protocol,
|
||||
state,
|
||||
pending_request_count,
|
||||
continuity_gap_count,
|
||||
overflow_count,
|
||||
subscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the stable local session identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSessionId {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the safe logical endpoint name.
|
||||
#[must_use]
|
||||
pub fn endpoint_name(&self) -> &str {
|
||||
return self.endpoint_name.as_str();
|
||||
}
|
||||
|
||||
/// Returns the provider descriptor without endpoint credentials.
|
||||
#[must_use]
|
||||
pub const fn provider(&self) -> &crate::WsProviderName {
|
||||
return &self.provider;
|
||||
}
|
||||
|
||||
/// Returns the cluster descriptor.
|
||||
#[must_use]
|
||||
pub const fn cluster(&self) -> &crate::WsClusterName {
|
||||
return &self.cluster;
|
||||
}
|
||||
|
||||
/// Returns the WebSocket protocol family.
|
||||
#[must_use]
|
||||
pub const fn protocol(&self) -> crate::WsProtocolKind {
|
||||
return self.protocol;
|
||||
}
|
||||
|
||||
/// Returns the current physical session lifecycle state.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> crate::WsSessionState {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
/// Returns the number of JSON-RPC requests currently awaiting responses.
|
||||
#[must_use]
|
||||
pub const fn pending_request_count(&self) -> usize {
|
||||
return self.pending_request_count;
|
||||
}
|
||||
|
||||
/// Returns the number of observed physical continuity gaps for this session.
|
||||
#[must_use]
|
||||
pub const fn continuity_gap_count(&self) -> u64 {
|
||||
return self.continuity_gap_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative number of notification queue overflows observed by this session.
|
||||
#[must_use]
|
||||
pub const fn overflow_count(&self) -> u64 {
|
||||
return self.overflow_count;
|
||||
}
|
||||
|
||||
/// Returns safe lifecycle projections for logical subscriptions owned by this session.
|
||||
#[must_use]
|
||||
pub fn subscriptions(&self) -> &[crate::WsSubscriptionSnapshot] {
|
||||
return self.subscriptions.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the number of logical subscriptions currently projected by the session.
|
||||
#[must_use]
|
||||
pub fn subscription_count(&self) -> usize {
|
||||
return self.subscriptions.len();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_lifecycle.rs"]
|
||||
mod tests;
|
||||
149
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
Normal file
149
crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
|
||||
// version: 6
|
||||
|
||||
/// Typed facade for one standard Solana WebSocket physical session.
|
||||
///
|
||||
/// The facade delegates to the same [`crate::WsSession`] actor used by the compatibility API. It owns no socket, registry, reconnect loop or queue of its
|
||||
/// own and therefore does not duplicate the physical WebSocket runtime. Subscription wrappers are implemented beside their wire owners in the
|
||||
/// `ws_accounts`, `ws_blocks`, `ws_cluster` and `ws_transactions` modules.
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// async fn unsupported_helius_transaction(
|
||||
/// session: &ksp_onchain_transport_lib::SolanaStandardWsSession,
|
||||
/// request: &ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest,
|
||||
/// ) {
|
||||
/// let _ = session.transaction_subscribe(request).await;
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct SolanaStandardWsSession {
|
||||
inner: crate::WsSession,
|
||||
}
|
||||
|
||||
impl SolanaStandardWsSession {
|
||||
/// Opens one standard Solana WebSocket session through the shared physical actor.
|
||||
pub async fn connect(endpoint: crate::WsEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||
let connected = crate::WsSession::connect_for_protocol(endpoint, crate::WsProtocolKind::SolanaStandard).await;
|
||||
return match connected {
|
||||
std::result::Result::Ok(inner) => std::result::Result::Ok(Self { inner }),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the stable local session identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSessionId {
|
||||
return self.inner.id();
|
||||
}
|
||||
|
||||
/// Returns the latest safe runtime snapshot published by the shared actor.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> crate::WsSessionSnapshot {
|
||||
return self.inner.snapshot();
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
return self.inner.state();
|
||||
}
|
||||
|
||||
/// Explicitly closes the shared physical session under its configured close timeout.
|
||||
pub async fn close(&self) -> ksp_core_lib::Result<()> {
|
||||
return self.inner.close().await;
|
||||
}
|
||||
|
||||
/// Returns the crate-private shared physical session used by domain-specific facade wrappers.
|
||||
pub(crate) fn physical_session(&self) -> &crate::WsSession {
|
||||
return &self.inner;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SolanaStandardWsSession {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("SolanaStandardWsSession").field("id", &self.id()).field("snapshot", &self.snapshot()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed facade for one Helius LaserStream WebSocket physical session.
|
||||
///
|
||||
/// The facade exposes the seven standard Solana subscription families that the current Helius method pages support or document as available plus the
|
||||
/// Helius-specific typed
|
||||
/// `transactionSubscribe` lifecycle. Transaction notifications, reconnect/resubscribe, unsubscribe races and bounded backpressure all delegate to the same
|
||||
/// shared [`crate::WsSession`] actor; the facade owns no second socket, registry or queue. No public inner handle is exposed, so callers cannot bypass the
|
||||
/// provider-specific surface by recovering a generic [`crate::WsSession`].
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// async fn unsupported_block(session: &ksp_onchain_transport_lib::HeliusLaserStreamWsSession) {
|
||||
/// let _ = session.block_subscribe().await;
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// async fn unsupported_vote(session: &ksp_onchain_transport_lib::HeliusLaserStreamWsSession) {
|
||||
/// let _ = session.vote_subscribe().await;
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// fn no_escape_hatch(session: ksp_onchain_transport_lib::HeliusLaserStreamWsSession) {
|
||||
/// let _ = session.into_inner();
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct HeliusLaserStreamWsSession {
|
||||
inner: crate::WsSession,
|
||||
}
|
||||
|
||||
impl HeliusLaserStreamWsSession {
|
||||
/// Opens one Helius LaserStream WebSocket session through the shared physical actor.
|
||||
pub async fn connect(endpoint: crate::WsEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||
let connected = crate::WsSession::connect_for_protocol(endpoint, crate::WsProtocolKind::HeliusLaserStream).await;
|
||||
return match connected {
|
||||
std::result::Result::Ok(inner) => std::result::Result::Ok(Self { inner }),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the stable local session identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSessionId {
|
||||
return self.inner.id();
|
||||
}
|
||||
|
||||
/// Returns the latest safe runtime snapshot published by the shared actor.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> crate::WsSessionSnapshot {
|
||||
return self.inner.snapshot();
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
return self.inner.state();
|
||||
}
|
||||
|
||||
/// Explicitly closes the shared physical session under its configured close timeout.
|
||||
pub async fn close(&self) -> ksp_core_lib::Result<()> {
|
||||
return self.inner.close().await;
|
||||
}
|
||||
|
||||
/// Returns the crate-private shared physical session used by domain-specific facade wrappers.
|
||||
pub(crate) fn physical_session(&self) -> &crate::WsSession {
|
||||
return &self.inner;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HeliusLaserStreamWsSession {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("HeliusLaserStreamWsSession").field("id", &self.id()).field("snapshot", &self.snapshot()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_helius_standard.rs"]
|
||||
mod helius_standard_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_protocol_session.rs"]
|
||||
mod tests;
|
||||
2427
crates/ksp-onchain-transport-lib/src/ws_session.rs
Normal file
2427
crates/ksp-onchain-transport-lib/src/ws_session.rs
Normal file
File diff suppressed because it is too large
Load Diff
574
crates/ksp-onchain-transport-lib/src/ws_settings.rs
Normal file
574
crates/ksp-onchain-transport-lib/src/ws_settings.rs
Normal file
@@ -0,0 +1,574 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_settings.rs
|
||||
// version: 4
|
||||
|
||||
const DEFAULT_WS_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const DEFAULT_WS_COMMAND_QUEUE_CAPACITY: usize = 128;
|
||||
const DEFAULT_WS_COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const DEFAULT_WS_MAX_ACTIVE_SUBSCRIPTIONS: usize = 1_024;
|
||||
const DEFAULT_WS_MAX_FRAME_SIZE_BYTES: usize = 16 * 1024 * 1024;
|
||||
const DEFAULT_WS_MAX_MESSAGE_SIZE_BYTES: usize = 64 * 1024 * 1024;
|
||||
const DEFAULT_WS_MAX_PENDING_REQUESTS: usize = 128;
|
||||
const DEFAULT_WS_MAX_WRITE_BUFFER_SIZE_BYTES: usize = 1024 * 1024;
|
||||
const DEFAULT_WS_NOTIFICATION_QUEUE_CAPACITY: usize = 256;
|
||||
const DEFAULT_WS_RECONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
|
||||
const DEFAULT_WS_RECONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const DEFAULT_WS_RECONNECT_MAX_RETRIES: u32 = 5;
|
||||
|
||||
/// Runtime WebSocket endpoint URL owned by Transport.
|
||||
///
|
||||
/// The actual URL can contain provider credentials. Its [`std::fmt::Debug`] implementation is intentionally redacted.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct WsEndpointUrl {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl WsEndpointUrl {
|
||||
/// Parses and validates one WebSocket endpoint URL.
|
||||
pub fn parse(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating WebSocket endpoint URL");
|
||||
let value = value.into();
|
||||
let parsed = match reqwest::Url::parse(value.as_str()) {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "ws_endpoints.url", "rejected invalid WebSocket endpoint URL");
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL is invalid")
|
||||
.with_context("field", "ws_endpoints.url")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if parsed.scheme() != "ws" && parsed.scheme() != "wss" {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
field = "ws_endpoints.url",
|
||||
scheme = parsed.scheme(),
|
||||
"rejected WebSocket endpoint URL with unsupported scheme"
|
||||
);
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL must use ws or wss")
|
||||
.with_context("field", "ws_endpoints.url")
|
||||
.with_context("scheme", parsed.scheme()),
|
||||
);
|
||||
}
|
||||
if parsed.host_str().is_none() {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = "ws_endpoints.url", "rejected WebSocket endpoint URL without host");
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint URL must contain a host")
|
||||
.with_context("field", "ws_endpoints.url"),
|
||||
);
|
||||
}
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, scheme = parsed.scheme(), "validated WebSocket endpoint URL syntax");
|
||||
return std::result::Result::Ok(Self { value });
|
||||
}
|
||||
|
||||
/// Returns the sensitive runtime URL text.
|
||||
///
|
||||
/// Callers must not write this value to logs, generic diagnostics or snapshots.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WsEndpointUrl {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("WsEndpointUrl(<redacted>)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Open provider descriptor used by WebSocket endpoint settings.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct WsProviderName {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl WsProviderName {
|
||||
/// Creates an open provider descriptor. Validation is performed by [`WsTransportSettings::validate`].
|
||||
#[must_use]
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||
return Self { value: value.into() };
|
||||
}
|
||||
|
||||
/// Returns the provider descriptor text.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Open cluster or network descriptor used by WebSocket endpoint settings.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct WsClusterName {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl WsClusterName {
|
||||
/// Creates an open cluster descriptor. Validation is performed by [`WsTransportSettings::validate`].
|
||||
#[must_use]
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> Self {
|
||||
return Self { value: value.into() };
|
||||
}
|
||||
|
||||
/// Returns the cluster descriptor text.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket protocol family understood by KSP Transport.
|
||||
///
|
||||
/// The protocol discriminator belongs specifically to the WebSocket runtime. Provider products using another transport, including a future Helius
|
||||
/// LaserStream gRPC backend, require a distinct transport-owned descriptor instead of reusing this enum.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WsProtocolKind {
|
||||
/// Standard Solana JSON-RPC WebSocket PubSub.
|
||||
SolanaStandard,
|
||||
/// Helius LaserStream WebSocket protocol surface.
|
||||
HeliusLaserStream,
|
||||
}
|
||||
|
||||
impl WsProtocolKind {
|
||||
/// Returns the stable KSP descriptor for this protocol family.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::SolanaStandard => "solana_standard",
|
||||
Self::HeliusLaserStream => "helius_laserstream",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded reconnect settings owned by the WebSocket transport runtime.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsReconnectSettings {
|
||||
max_retries: u32,
|
||||
initial_backoff: std::time::Duration,
|
||||
max_backoff: std::time::Duration,
|
||||
}
|
||||
|
||||
impl WsReconnectSettings {
|
||||
/// Creates bounded reconnect settings.
|
||||
#[must_use]
|
||||
pub const fn new(max_retries: u32, initial_backoff: std::time::Duration, max_backoff: std::time::Duration) -> Self {
|
||||
return Self { max_retries, initial_backoff, max_backoff };
|
||||
}
|
||||
|
||||
/// Returns the number of reconnect attempts allowed after the connection is lost.
|
||||
#[must_use]
|
||||
pub const fn max_retries(&self) -> u32 {
|
||||
return self.max_retries;
|
||||
}
|
||||
|
||||
/// Returns the initial reconnect backoff.
|
||||
#[must_use]
|
||||
pub const fn initial_backoff(&self) -> std::time::Duration {
|
||||
return self.initial_backoff;
|
||||
}
|
||||
|
||||
/// Returns the maximum reconnect backoff.
|
||||
#[must_use]
|
||||
pub const fn max_backoff(&self) -> std::time::Duration {
|
||||
return self.max_backoff;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for WsReconnectSettings {
|
||||
fn default() -> Self {
|
||||
return Self::new(DEFAULT_WS_RECONNECT_MAX_RETRIES, DEFAULT_WS_RECONNECT_INITIAL_BACKOFF, DEFAULT_WS_RECONNECT_MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
|
||||
/// Policy controlling whether logical subscriptions are restored after a successful reconnect.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
|
||||
pub enum WsResubscribePolicy {
|
||||
/// Never restore subscriptions automatically after the physical connection is replaced.
|
||||
Never,
|
||||
/// Restore subscriptions that are still logically desired when reconnect completes.
|
||||
#[default]
|
||||
ActiveSubscriptions,
|
||||
}
|
||||
|
||||
/// Runtime limits and lifecycle settings for one physical WebSocket session.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsSessionSettings {
|
||||
command_timeout: std::time::Duration,
|
||||
close_timeout: std::time::Duration,
|
||||
reconnect: crate::WsReconnectSettings,
|
||||
resubscribe: crate::WsResubscribePolicy,
|
||||
command_queue_capacity: usize,
|
||||
notification_queue_capacity: usize,
|
||||
max_active_subscriptions: usize,
|
||||
max_pending_requests: usize,
|
||||
max_message_size_bytes: usize,
|
||||
max_frame_size_bytes: usize,
|
||||
max_write_buffer_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl WsSessionSettings {
|
||||
/// Creates complete runtime settings for one physical WebSocket session.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub const fn new(
|
||||
command_timeout: std::time::Duration,
|
||||
close_timeout: std::time::Duration,
|
||||
reconnect: crate::WsReconnectSettings,
|
||||
resubscribe: crate::WsResubscribePolicy,
|
||||
command_queue_capacity: usize,
|
||||
notification_queue_capacity: usize,
|
||||
max_active_subscriptions: usize,
|
||||
max_pending_requests: usize,
|
||||
max_message_size_bytes: usize,
|
||||
max_frame_size_bytes: usize,
|
||||
max_write_buffer_size_bytes: usize,
|
||||
) -> Self {
|
||||
return Self {
|
||||
command_timeout,
|
||||
close_timeout,
|
||||
reconnect,
|
||||
resubscribe,
|
||||
command_queue_capacity,
|
||||
notification_queue_capacity,
|
||||
max_active_subscriptions,
|
||||
max_pending_requests,
|
||||
max_message_size_bytes,
|
||||
max_frame_size_bytes,
|
||||
max_write_buffer_size_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the deadline applied to bounded session commands and JSON-RPC control requests.
|
||||
#[must_use]
|
||||
pub const fn command_timeout(&self) -> std::time::Duration {
|
||||
return self.command_timeout;
|
||||
}
|
||||
|
||||
/// Returns the total bounded close/shutdown deadline.
|
||||
#[must_use]
|
||||
pub const fn close_timeout(&self) -> std::time::Duration {
|
||||
return self.close_timeout;
|
||||
}
|
||||
|
||||
/// Returns the reconnect policy.
|
||||
#[must_use]
|
||||
pub const fn reconnect(&self) -> &crate::WsReconnectSettings {
|
||||
return &self.reconnect;
|
||||
}
|
||||
|
||||
/// Returns the resubscribe policy.
|
||||
#[must_use]
|
||||
pub const fn resubscribe(&self) -> crate::WsResubscribePolicy {
|
||||
return self.resubscribe;
|
||||
}
|
||||
|
||||
/// Returns the bounded session-command queue capacity.
|
||||
#[must_use]
|
||||
pub const fn command_queue_capacity(&self) -> usize {
|
||||
return self.command_queue_capacity;
|
||||
}
|
||||
|
||||
/// Returns the bounded notification queue capacity allocated per logical subscription.
|
||||
#[must_use]
|
||||
pub const fn notification_queue_capacity(&self) -> usize {
|
||||
return self.notification_queue_capacity;
|
||||
}
|
||||
|
||||
/// Returns the maximum number of logical subscriptions allowed on one physical session.
|
||||
#[must_use]
|
||||
pub const fn max_active_subscriptions(&self) -> usize {
|
||||
return self.max_active_subscriptions;
|
||||
}
|
||||
|
||||
/// Returns the maximum number of JSON-RPC requests allowed to await responses concurrently.
|
||||
#[must_use]
|
||||
pub const fn max_pending_requests(&self) -> usize {
|
||||
return self.max_pending_requests;
|
||||
}
|
||||
|
||||
/// Returns the maximum accepted complete WebSocket message size in bytes.
|
||||
#[must_use]
|
||||
pub const fn max_message_size_bytes(&self) -> usize {
|
||||
return self.max_message_size_bytes;
|
||||
}
|
||||
|
||||
/// Returns the maximum accepted WebSocket frame size in bytes.
|
||||
#[must_use]
|
||||
pub const fn max_frame_size_bytes(&self) -> usize {
|
||||
return self.max_frame_size_bytes;
|
||||
}
|
||||
|
||||
/// Returns the maximum WebSocket write-buffer size in bytes.
|
||||
#[must_use]
|
||||
pub const fn max_write_buffer_size_bytes(&self) -> usize {
|
||||
return self.max_write_buffer_size_bytes;
|
||||
}
|
||||
|
||||
/// Validates runtime bounds without reading Config or environment state.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, "validating WebSocket session settings");
|
||||
if self.command_timeout.is_zero() {
|
||||
return ws_invalid_settings("WebSocket command timeout must be greater than zero", "ws_session.command_timeout");
|
||||
}
|
||||
if self.close_timeout.is_zero() {
|
||||
return ws_invalid_settings("WebSocket close timeout must be greater than zero", "ws_session.close_timeout");
|
||||
}
|
||||
if self.reconnect.initial_backoff().is_zero() {
|
||||
return ws_invalid_settings("initial WebSocket reconnect backoff must be greater than zero", "ws_session.reconnect.initial_backoff");
|
||||
}
|
||||
if self.reconnect.max_backoff().is_zero() {
|
||||
return ws_invalid_settings("maximum WebSocket reconnect backoff must be greater than zero", "ws_session.reconnect.max_backoff");
|
||||
}
|
||||
if self.reconnect.max_backoff() < self.reconnect.initial_backoff() {
|
||||
return ws_invalid_settings(
|
||||
"maximum WebSocket reconnect backoff must not be lower than initial reconnect backoff",
|
||||
"ws_session.reconnect.max_backoff",
|
||||
);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.command_queue_capacity, "ws_session.command_queue_capacity") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.notification_queue_capacity, "ws_session.notification_queue_capacity") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_active_subscriptions, "ws_session.max_active_subscriptions") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_pending_requests, "ws_session.max_pending_requests") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_message_size_bytes, "ws_session.max_message_size_bytes") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_frame_size_bytes, "ws_session.max_frame_size_bytes") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_non_zero_bound(self.max_write_buffer_size_bytes, "ws_session.max_write_buffer_size_bytes") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
command_queue_capacity = self.command_queue_capacity,
|
||||
notification_queue_capacity = self.notification_queue_capacity,
|
||||
max_active_subscriptions = self.max_active_subscriptions,
|
||||
max_pending_requests = self.max_pending_requests,
|
||||
max_message_size_bytes = self.max_message_size_bytes,
|
||||
max_frame_size_bytes = self.max_frame_size_bytes,
|
||||
max_write_buffer_size_bytes = self.max_write_buffer_size_bytes,
|
||||
reconnect_max_retries = self.reconnect.max_retries(),
|
||||
resubscribe = self.resubscribe.as_str(),
|
||||
"validated WebSocket session settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for WsSessionSettings {
|
||||
fn default() -> Self {
|
||||
return Self::new(
|
||||
DEFAULT_WS_COMMAND_TIMEOUT,
|
||||
DEFAULT_WS_CLOSE_TIMEOUT,
|
||||
crate::WsReconnectSettings::default(),
|
||||
crate::WsResubscribePolicy::default(),
|
||||
DEFAULT_WS_COMMAND_QUEUE_CAPACITY,
|
||||
DEFAULT_WS_NOTIFICATION_QUEUE_CAPACITY,
|
||||
DEFAULT_WS_MAX_ACTIVE_SUBSCRIPTIONS,
|
||||
DEFAULT_WS_MAX_PENDING_REQUESTS,
|
||||
DEFAULT_WS_MAX_MESSAGE_SIZE_BYTES,
|
||||
DEFAULT_WS_MAX_FRAME_SIZE_BYTES,
|
||||
DEFAULT_WS_MAX_WRITE_BUFFER_SIZE_BYTES,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl WsResubscribePolicy {
|
||||
/// Returns the stable KSP descriptor for this policy.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Never => "never",
|
||||
Self::ActiveSubscriptions => "active_subscriptions",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime settings for one named WebSocket endpoint.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsEndpointSettings {
|
||||
name: std::string::String,
|
||||
enabled: bool,
|
||||
provider: crate::WsProviderName,
|
||||
cluster: crate::WsClusterName,
|
||||
protocol: crate::WsProtocolKind,
|
||||
url: crate::WsEndpointUrl,
|
||||
session: crate::WsSessionSettings,
|
||||
}
|
||||
|
||||
impl WsEndpointSettings {
|
||||
/// Creates explicit settings for one logical WebSocket endpoint.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
name: impl std::convert::Into<std::string::String>,
|
||||
enabled: bool,
|
||||
provider: crate::WsProviderName,
|
||||
cluster: crate::WsClusterName,
|
||||
protocol: crate::WsProtocolKind,
|
||||
url: crate::WsEndpointUrl,
|
||||
session: crate::WsSessionSettings,
|
||||
) -> Self {
|
||||
return Self { name: name.into(), enabled, provider, cluster, protocol, url, session };
|
||||
}
|
||||
|
||||
/// Returns the logical endpoint name.
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
return self.name.as_str();
|
||||
}
|
||||
|
||||
/// Returns whether this endpoint can be used to create physical sessions.
|
||||
#[must_use]
|
||||
pub const fn enabled(&self) -> bool {
|
||||
return self.enabled;
|
||||
}
|
||||
|
||||
/// Returns the open provider descriptor.
|
||||
#[must_use]
|
||||
pub const fn provider(&self) -> &crate::WsProviderName {
|
||||
return &self.provider;
|
||||
}
|
||||
|
||||
/// Returns the open cluster descriptor.
|
||||
#[must_use]
|
||||
pub const fn cluster(&self) -> &crate::WsClusterName {
|
||||
return &self.cluster;
|
||||
}
|
||||
|
||||
/// Returns the WebSocket protocol family.
|
||||
#[must_use]
|
||||
pub const fn protocol(&self) -> crate::WsProtocolKind {
|
||||
return self.protocol;
|
||||
}
|
||||
|
||||
/// Returns the sensitive WebSocket endpoint URL wrapper.
|
||||
#[must_use]
|
||||
pub const fn url(&self) -> &crate::WsEndpointUrl {
|
||||
return &self.url;
|
||||
}
|
||||
|
||||
/// Returns the effective settings applied to every physical session explicitly created from this endpoint.
|
||||
#[must_use]
|
||||
pub const fn session(&self) -> &crate::WsSessionSettings {
|
||||
return &self.session;
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WsTransportSettings {
|
||||
endpoints: std::vec::Vec<crate::WsEndpointSettings>,
|
||||
}
|
||||
|
||||
impl WsTransportSettings {
|
||||
/// Creates complete WebSocket transport runtime settings.
|
||||
#[must_use]
|
||||
pub fn new(endpoints: std::vec::Vec<crate::WsEndpointSettings>) -> Self {
|
||||
return Self { endpoints };
|
||||
}
|
||||
|
||||
/// Returns configured WebSocket endpoints in declaration order.
|
||||
#[must_use]
|
||||
pub fn endpoints(&self) -> &[crate::WsEndpointSettings] {
|
||||
return self.endpoints.as_slice();
|
||||
}
|
||||
|
||||
/// Validates structural runtime invariants without reading Config or environment state.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, endpoint_count = self.endpoints.len(), "validating WebSocket transport settings");
|
||||
if self.endpoints.is_empty() {
|
||||
return ws_invalid_settings("at least one WebSocket endpoint must be configured", "ws_endpoints");
|
||||
}
|
||||
let mut enabled_endpoint_count = 0_usize;
|
||||
for (endpoint_index, endpoint) in self.endpoints.iter().enumerate() {
|
||||
if let std::result::Result::Err(error) = validate_ws_endpoint(endpoint, endpoint_index) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if endpoint.enabled() {
|
||||
enabled_endpoint_count += 1;
|
||||
}
|
||||
for previous in &self.endpoints[..endpoint_index] {
|
||||
if previous.name() == endpoint.name() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "WebSocket endpoint names must be unique")
|
||||
.with_context("field", format!("ws_endpoints[{endpoint_index}].name"))
|
||||
.with_context("endpoint_name", endpoint.name()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if enabled_endpoint_count == 0 {
|
||||
return ws_invalid_settings("at least one WebSocket endpoint must be enabled", "ws_endpoints.enabled");
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_count = self.endpoints.len(),
|
||||
enabled_endpoint_count,
|
||||
"validated WebSocket transport settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ws_endpoint(endpoint: &crate::WsEndpointSettings, endpoint_index: usize) -> ksp_core_lib::Result<()> {
|
||||
let name_field = format!("ws_endpoints[{endpoint_index}].name");
|
||||
if let std::result::Result::Err(error) = validate_ws_descriptor(endpoint.name(), name_field.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let provider_field = format!("ws_endpoints[{endpoint_index}].provider");
|
||||
if let std::result::Result::Err(error) = validate_ws_descriptor(endpoint.provider().as_str(), provider_field.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let cluster_field = format!("ws_endpoints[{endpoint_index}].cluster");
|
||||
if let std::result::Result::Err(error) = validate_ws_descriptor(endpoint.cluster().as_str(), cluster_field.as_str()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = endpoint.session().validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
endpoint_name = endpoint.name(),
|
||||
provider = endpoint.provider().as_str(),
|
||||
cluster = endpoint.cluster().as_str(),
|
||||
protocol = endpoint.protocol().as_str(),
|
||||
enabled = endpoint.enabled(),
|
||||
"validated WebSocket endpoint settings"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_ws_descriptor(value: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return ws_invalid_settings("WebSocket transport descriptor must not be empty", field);
|
||||
}
|
||||
if value.trim() != value {
|
||||
return ws_invalid_settings("WebSocket transport descriptor must not contain leading or trailing whitespace", field);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_non_zero_bound(value: usize, field: &str) -> ksp_core_lib::Result<()> {
|
||||
if value == 0 {
|
||||
return ws_invalid_settings("WebSocket runtime bound must be greater than zero", field);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn ws_invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, reason = message, "rejected WebSocket transport settings");
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_settings.rs"]
|
||||
mod tests;
|
||||
246
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
Normal file
246
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs
|
||||
// version: 6
|
||||
|
||||
/// Typed handle for one logical WebSocket subscription.
|
||||
///
|
||||
/// The handle owns the bounded typed notification receiver while the physical session actor owns the remote subscription identity and socket. The remote
|
||||
/// numeric subscription identifier is intentionally never exposed because it is transient and is remapped by the session actor after reconnect.
|
||||
pub struct WsSubscription<T> {
|
||||
session_id: crate::WsSessionId,
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
notification_rx: tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>,
|
||||
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
|
||||
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
|
||||
command_tx: tokio::sync::mpsc::Sender<crate::WsSessionCommand>,
|
||||
command_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl<T> WsSubscription<T> {
|
||||
/// Creates a typed subscription handle from one actor registration.
|
||||
pub(crate) fn new(
|
||||
session_id: crate::WsSessionId,
|
||||
registration: WsSubscriptionRegistration,
|
||||
notification_rx: tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>,
|
||||
command_tx: tokio::sync::mpsc::Sender<crate::WsSessionCommand>,
|
||||
command_timeout: std::time::Duration,
|
||||
) -> Self {
|
||||
return Self {
|
||||
session_id,
|
||||
id: registration.id,
|
||||
kind: registration.kind,
|
||||
notification_rx,
|
||||
state_rx: registration.state_rx,
|
||||
terminal_error_rx: registration.terminal_error_rx,
|
||||
command_tx,
|
||||
command_timeout,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the stable local logical subscription identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSubscriptionId {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the logical WebSocket subscription family.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> crate::WsSubscriptionKind {
|
||||
return self.kind;
|
||||
}
|
||||
|
||||
/// Returns the latest observable lifecycle state for this logical subscription.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSubscriptionState {
|
||||
return *self.state_rx.borrow();
|
||||
}
|
||||
|
||||
/// Returns the safe terminal error code when this logical subscription failed.
|
||||
///
|
||||
/// Successful local cancellation and normal completion use `None`. The value never contains remote payloads or endpoint credentials.
|
||||
#[must_use]
|
||||
pub fn terminal_error_code(&self) -> std::option::Option<ksp_core_lib::ErrorCode> {
|
||||
return *self.terminal_error_rx.borrow();
|
||||
}
|
||||
|
||||
/// Receives the next typed notification or terminal typed-decoding error.
|
||||
///
|
||||
/// The underlying queue is bounded by `WsSessionSettings::notification_queue_capacity`. `None` means the actor closed this logical subscription and no
|
||||
/// further notifications can arrive.
|
||||
pub async fn recv(&mut self) -> std::option::Option<ksp_core_lib::Result<T>> {
|
||||
return self.notification_rx.recv().await;
|
||||
}
|
||||
|
||||
/// Cancels this logical subscription and sends the matching protocol unsubscribe request when a remote binding still exists.
|
||||
///
|
||||
/// The returned boolean preserves the standard Solana unsubscribe result when the current remote binding is reachable. Local cancellation is terminal
|
||||
/// for this handle; during reconnect it wins before resubscribe selection, and a late remote acknowledgement is cleaned up best-effort without
|
||||
/// reactivation.
|
||||
pub async fn unsubscribe(&mut self) -> ksp_core_lib::Result<bool> {
|
||||
match self.state() {
|
||||
crate::WsSubscriptionState::Closed => return std::result::Result::Ok(false),
|
||||
crate::WsSubscriptionState::Failed => {
|
||||
return std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket subscription is already failed"));
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
let command = crate::WsSessionCommand::Unsubscribe { subscription_id: self.id, response_tx };
|
||||
let send_wait = tokio::time::timeout(self.command_timeout, self.command_tx.send(command)).await;
|
||||
match send_wait {
|
||||
std::result::Result::Ok(std::result::Result::Ok(())) => {},
|
||||
std::result::Result::Ok(std::result::Result::Err(_)) => {
|
||||
return std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket session command channel is closed"));
|
||||
},
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(subscription_timeout_error(
|
||||
self.session_id,
|
||||
self.id,
|
||||
"WebSocket unsubscribe command queue remained unavailable until timeout",
|
||||
));
|
||||
},
|
||||
}
|
||||
return match response_rx.await {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => {
|
||||
std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket session ended before unsubscribe completion"))
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for WsSubscription<T> {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("WsSubscription")
|
||||
.field("session_id", &self.session_id)
|
||||
.field("id", &self.id)
|
||||
.field("kind", &self.kind)
|
||||
.field("state", &self.state())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal result of dispatching one decoded wire notification into a bounded typed channel.
|
||||
pub(crate) enum WsNotificationDispatchOutcome {
|
||||
Delivered,
|
||||
DeliveredTerminal,
|
||||
ReceiverClosed,
|
||||
QueueFull,
|
||||
DecodeFailed { code: ksp_core_lib::ErrorCode },
|
||||
}
|
||||
|
||||
/// Type-erased actor-owned dispatcher for one heterogeneous typed notification channel.
|
||||
pub(crate) type WsNotificationDispatcher = std::boxed::Box<dyn Fn(serde_json::Value) -> WsNotificationDispatchOutcome + std::marker::Send + std::marker::Sync>;
|
||||
|
||||
/// Creates one bounded typed notification receiver whose dispatcher can mark a successfully delivered value as terminal.
|
||||
pub(crate) fn typed_notification_channel_with_completion<T, F, C>(
|
||||
capacity: usize,
|
||||
decoder: F,
|
||||
is_terminal: C,
|
||||
) -> (WsNotificationDispatcher, tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>)
|
||||
where
|
||||
T: std::marker::Send + 'static,
|
||||
F: Fn(serde_json::Value) -> ksp_core_lib::Result<T> + std::marker::Send + std::marker::Sync + 'static,
|
||||
C: Fn(&T) -> bool + std::marker::Send + std::marker::Sync + 'static,
|
||||
{
|
||||
let (notification_tx, notification_rx) = tokio::sync::mpsc::channel(capacity);
|
||||
let dispatcher = move |value: serde_json::Value| -> WsNotificationDispatchOutcome {
|
||||
let permit = match notification_tx.try_reserve() {
|
||||
std::result::Result::Ok(permit) => permit,
|
||||
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
|
||||
return WsNotificationDispatchOutcome::ReceiverClosed;
|
||||
},
|
||||
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
|
||||
return WsNotificationDispatchOutcome::QueueFull;
|
||||
},
|
||||
};
|
||||
return match decoder(value) {
|
||||
std::result::Result::Ok(notification) => {
|
||||
let terminal = is_terminal(¬ification);
|
||||
permit.send(std::result::Result::Ok(notification));
|
||||
if terminal {
|
||||
return WsNotificationDispatchOutcome::DeliveredTerminal;
|
||||
}
|
||||
WsNotificationDispatchOutcome::Delivered
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
let code = error.code();
|
||||
permit.send(std::result::Result::Err(error));
|
||||
WsNotificationDispatchOutcome::DecodeFailed { code }
|
||||
},
|
||||
};
|
||||
};
|
||||
return (std::boxed::Box::new(dispatcher), notification_rx);
|
||||
}
|
||||
|
||||
/// Internal registration returned after a remote subscribe acknowledgement becomes atomically bound.
|
||||
pub(crate) struct WsSubscriptionRegistration {
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
|
||||
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
|
||||
}
|
||||
|
||||
impl WsSubscriptionRegistration {
|
||||
/// Creates one successful actor registration without exposing the transient remote identifier.
|
||||
pub(crate) fn new(
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
|
||||
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
|
||||
) -> Self {
|
||||
return Self { id, kind, state_rx, terminal_error_rx };
|
||||
}
|
||||
}
|
||||
|
||||
/// Actor-owned runtime entry for one local logical WebSocket subscription.
|
||||
pub(crate) struct WsSubscriptionRuntime {
|
||||
/// Stable local identity.
|
||||
pub(crate) id: crate::WsSubscriptionId,
|
||||
/// WebSocket subscription family.
|
||||
pub(crate) kind: crate::WsSubscriptionKind,
|
||||
/// Current logical lifecycle state.
|
||||
pub(crate) state: crate::WsSubscriptionState,
|
||||
/// Original subscribe parameters retained internally for deterministic resubscribe.
|
||||
pub(crate) params: std::vec::Vec<serde_json::Value>,
|
||||
/// Current transient remote subscription identity when bound.
|
||||
pub(crate) remote_id: std::option::Option<u64>,
|
||||
/// Lifecycle publisher observed by the public typed handle.
|
||||
pub(crate) state_tx: tokio::sync::watch::Sender<crate::WsSubscriptionState>,
|
||||
/// Safe terminal failure code publisher observed by the public typed handle.
|
||||
pub(crate) terminal_error_tx: tokio::sync::watch::Sender<std::option::Option<ksp_core_lib::ErrorCode>>,
|
||||
/// Type-erased dispatcher into the bounded typed notification channel.
|
||||
pub(crate) dispatcher: WsNotificationDispatcher,
|
||||
}
|
||||
|
||||
impl WsSubscriptionRuntime {
|
||||
/// Builds the safe session-snapshot projection for this runtime entry.
|
||||
pub(crate) fn snapshot(&self) -> crate::WsSubscriptionSnapshot {
|
||||
return crate::WsSubscriptionSnapshot::new(self.id, self.kind, self.state, self.remote_id.is_some(), *self.terminal_error_tx.borrow());
|
||||
}
|
||||
|
||||
/// Updates the runtime state and publishes it to the typed handle.
|
||||
pub(crate) fn set_state(&mut self, state: crate::WsSubscriptionState) {
|
||||
self.state = state;
|
||||
self.state_tx.send_replace(state);
|
||||
}
|
||||
|
||||
/// Publishes a terminal failure code before moving the logical subscription to `Failed`.
|
||||
pub(crate) fn fail_with_code(&mut self, code: ksp_core_lib::ErrorCode) {
|
||||
self.terminal_error_tx.send_replace(std::option::Option::Some(code));
|
||||
self.set_state(crate::WsSubscriptionState::Failed);
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription_closed_error(session_id: crate::WsSessionId, subscription_id: crate::WsSubscriptionId, message: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_WS_SESSION_CLOSED, message)
|
||||
.with_context("session_id", session_id.get().to_string())
|
||||
.with_context("subscription_id", subscription_id.get().to_string());
|
||||
}
|
||||
|
||||
fn subscription_timeout_error(session_id: crate::WsSessionId, subscription_id: crate::WsSubscriptionId, message: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message)
|
||||
.with_context("session_id", session_id.get().to_string())
|
||||
.with_context("subscription_id", subscription_id.get().to_string());
|
||||
}
|
||||
291
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
Normal file
291
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_transactions.rs
|
||||
// version: 4
|
||||
|
||||
/// Optional configuration accepted by standard Solana `signatureSubscribe`.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SolanaSignatureSubscribeConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
enable_received_notification: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
impl SolanaSignatureSubscribeConfig {
|
||||
/// Creates an explicit signature-subscription configuration.
|
||||
#[must_use]
|
||||
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, enable_received_notification: std::option::Option<bool>) -> Self {
|
||||
return Self { commitment, enable_received_notification };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns whether the server should emit the early `receivedSignature` notification when explicitly configured.
|
||||
#[must_use]
|
||||
pub const fn enable_received_notification(&self) -> std::option::Option<bool> {
|
||||
return self.enable_received_notification;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.commitment.is_none() && self.enable_received_notification.is_none();
|
||||
}
|
||||
|
||||
fn to_json_value(self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(enable_received_notification) = self.enable_received_notification {
|
||||
object.insert("enableReceivedNotification".to_owned(), serde_json::Value::Bool(enable_received_notification));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed value carried by standard Solana `signatureNotification` messages.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SolanaSignatureNotification {
|
||||
/// Early notification emitted when the RPC node first receives the signature and `enableReceivedNotification` is enabled.
|
||||
ReceivedSignature,
|
||||
/// Terminal processing notification emitted when the configured commitment is reached.
|
||||
Processed {
|
||||
/// Nullable transaction-error wire value; `None` means the transaction succeeded at the requested commitment.
|
||||
err: std::option::Option<serde_json::Value>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SolanaSignatureNotification {
|
||||
/// Returns whether this notification terminates the server-side one-shot subscription.
|
||||
#[must_use]
|
||||
pub const fn is_terminal(&self) -> bool {
|
||||
return match self {
|
||||
Self::ReceivedSignature => false,
|
||||
Self::Processed { .. } => true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the transaction-error wire value for a terminal processing notification when present.
|
||||
#[must_use]
|
||||
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
|
||||
return match self {
|
||||
Self::ReceivedSignature | Self::Processed { err: std::option::Option::None } => std::option::Option::None,
|
||||
Self::Processed { err: std::option::Option::Some(err) } => std::option::Option::Some(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter accepted by the standard Solana `logsSubscribe` WebSocket method.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SolanaLogsSubscribeFilter {
|
||||
/// Subscribe to all transactions except simple vote transactions.
|
||||
All,
|
||||
/// Subscribe to all transactions including simple vote transactions.
|
||||
AllWithVotes,
|
||||
/// Subscribe only to transactions mentioning exactly one public key.
|
||||
Mentions(ksp_core_lib::Pubkey),
|
||||
}
|
||||
|
||||
impl SolanaLogsSubscribeFilter {
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
return match self {
|
||||
Self::All => serde_json::Value::String("all".to_owned()),
|
||||
Self::AllWithVotes => serde_json::Value::String("allWithVotes".to_owned()),
|
||||
Self::Mentions(pubkey) => serde_json::json!({"mentions": [pubkey.to_string()]}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed value carried by a contextual Solana `logsNotification`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaLogsNotification {
|
||||
signature: std::string::String,
|
||||
err: std::option::Option<serde_json::Value>,
|
||||
logs: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
impl SolanaLogsNotification {
|
||||
/// Returns the base58 transaction signature exactly as reported by the RPC node.
|
||||
#[must_use]
|
||||
pub fn signature(&self) -> &str {
|
||||
return self.signature.as_str();
|
||||
}
|
||||
|
||||
/// Returns the nullable transaction-error wire value without interpreting Program/runtime error semantics.
|
||||
#[must_use]
|
||||
pub const fn err(&self) -> std::option::Option<&serde_json::Value> {
|
||||
return self.err.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the ordered transaction log messages.
|
||||
#[must_use]
|
||||
pub fn logs(&self) -> &[std::string::String] {
|
||||
return self.logs.as_slice();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WsSession {
|
||||
/// Subscribes to one Solana transaction signature through standard `signatureSubscribe`.
|
||||
///
|
||||
/// The server automatically terminates this subscription after the terminal processed notification. When
|
||||
/// `enableReceivedNotification` is enabled, an earlier `ReceivedSignature` value may be delivered first without closing the logical handle.
|
||||
pub async fn signature_subscribe(
|
||||
&self,
|
||||
signature: &str,
|
||||
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
|
||||
let mut params = std::vec![serde_json::Value::String(signature.to_owned())];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push((*config).to_json_value());
|
||||
}
|
||||
return self
|
||||
.subscribe_typed_with_completion(
|
||||
crate::WsSubscriptionKind::Signature,
|
||||
params,
|
||||
|value| return decode_signature_notification("signatureSubscribe", value),
|
||||
|notification| return notification.value().is_terminal(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribes to Solana transaction logs through standard `logsSubscribe`.
|
||||
pub async fn logs_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaLogsSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaLogsNotification>>> {
|
||||
let mut params = std::vec![filter.to_json_value()];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& config.commitment().is_some()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
return self.subscribe_typed(crate::WsSubscriptionKind::Logs, params, |value| return decode_logs_notification("logsSubscribe", value)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum WireSignatureNotification {
|
||||
Received(std::string::String),
|
||||
Processed(WireSignatureProcessed),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireSignatureProcessed {
|
||||
err: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponseSignature {
|
||||
context: serde_json::Value,
|
||||
value: WireSignatureNotification,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcResponse {
|
||||
context: serde_json::Value,
|
||||
value: WireLogsNotification,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireLogsNotification {
|
||||
signature: std::string::String,
|
||||
err: serde_json::Value,
|
||||
logs: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
fn decode_signature_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponseSignature>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let notification = match wire.value {
|
||||
WireSignatureNotification::Received(value) if value == "receivedSignature" => crate::SolanaSignatureNotification::ReceivedSignature,
|
||||
WireSignatureNotification::Received(_) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "signatureSubscribe notification contains an unknown string variant")
|
||||
.with_context("rpc_method", method),
|
||||
);
|
||||
},
|
||||
WireSignatureNotification::Processed(processed) => {
|
||||
let err = match processed.err {
|
||||
serde_json::Value::Null => std::option::Option::None,
|
||||
value => std::option::Option::Some(value),
|
||||
};
|
||||
crate::SolanaSignatureNotification::Processed { err }
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, notification));
|
||||
}
|
||||
|
||||
fn decode_logs_notification(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaLogsNotification>> {
|
||||
let decoded = crate::decode_wire_json::<WireRpcResponse>(method, value);
|
||||
let wire = match decoded {
|
||||
std::result::Result::Ok(wire) => wire,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
|
||||
let context = match context {
|
||||
std::result::Result::Ok(context) => context,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let err = match wire.value.err {
|
||||
serde_json::Value::Null => std::option::Option::None,
|
||||
value => std::option::Option::Some(value),
|
||||
};
|
||||
let notification = crate::SolanaLogsNotification { signature: wire.value.signature, err, logs: wire.value.logs };
|
||||
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, notification));
|
||||
}
|
||||
|
||||
impl crate::SolanaStandardWsSession {
|
||||
/// Subscribes to one Solana transaction signature through standard `signatureSubscribe`.
|
||||
pub async fn signature_subscribe(
|
||||
&self,
|
||||
signature: &str,
|
||||
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
|
||||
return self.physical_session().signature_subscribe(signature, config).await;
|
||||
}
|
||||
|
||||
/// Subscribes to Solana transaction logs through standard `logsSubscribe`.
|
||||
pub async fn logs_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaLogsSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaLogsNotification>>> {
|
||||
return self.physical_session().logs_subscribe(filter, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HeliusLaserStreamWsSession {
|
||||
/// Subscribes to one transaction signature through the standard `signatureSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn signature_subscribe(
|
||||
&self,
|
||||
signature: &str,
|
||||
config: std::option::Option<&crate::SolanaSignatureSubscribeConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaSignatureNotification>>> {
|
||||
return self.physical_session().signature_subscribe(signature, config).await;
|
||||
}
|
||||
|
||||
/// Subscribes to transaction logs through the standard `logsSubscribe` wire supported by Helius LaserStream WebSocket.
|
||||
pub async fn logs_subscribe(
|
||||
&self,
|
||||
filter: &crate::SolanaLogsSubscribeFilter,
|
||||
config: std::option::Option<&crate::SolanaCommitmentConfig>,
|
||||
) -> ksp_core_lib::Result<crate::WsSubscription<crate::SolanaRpcResponse<crate::SolanaLogsNotification>>> {
|
||||
return self.physical_session().logs_subscribe(filter, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_transactions.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
// version: 24
|
||||
// version: 38
|
||||
|
||||
//! 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_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");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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]
|
||||
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!(historical_current, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_7_pre_012_websocket_surface_accounts_for_all_nine_standard_pairs() {
|
||||
let kinds = [
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
|
||||
];
|
||||
let expected_names = ["account", "block", "logs", "program", "root", "signature", "slot", "slots_updates", "vote"];
|
||||
let actual_names = kinds.map(ksp_onchain_transport_lib::WsSubscriptionKind::as_str);
|
||||
assert_eq!(actual_names, expected_names);
|
||||
assert_eq!(kinds.len() * 2, 18);
|
||||
let _account = ksp_onchain_transport_lib::WsSession::account_subscribe;
|
||||
let _block = ksp_onchain_transport_lib::WsSession::block_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::WsSession::logs_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::WsSession::program_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::WsSession::root_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::WsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::WsSession::slot_subscribe;
|
||||
let _slots_updates = ksp_onchain_transport_lib::WsSession::slots_updates_subscribe;
|
||||
let _vote = ksp_onchain_transport_lib::WsSession::vote_subscribe;
|
||||
let _unsubscribe = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::unsubscribe;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_7_pre_012_http_inventory_remains_52_current_plus_14_historical() {
|
||||
let current = ksp_onchain_transport_lib::current_http_rpc_methods();
|
||||
let historical = ksp_onchain_transport_lib::historical_http_rpc_methods();
|
||||
assert_eq!(current.len(), 52);
|
||||
assert_eq!(historical.len(), 14);
|
||||
assert!(current.iter().all(|descriptor| return descriptor.runtime_status() == ksp_onchain_transport_lib::RpcRuntimeStatus::Supported));
|
||||
assert!(historical.iter().all(|descriptor| return descriptor.runtime_status() == ksp_onchain_transport_lib::RpcRuntimeStatus::Removed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_002_protocol_facades_preserve_the_standard_partition() {
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
|
||||
let standard_kinds = [
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
|
||||
];
|
||||
assert_eq!(standard_kinds.len(), 9);
|
||||
assert_eq!(
|
||||
std::any::type_name::<ksp_onchain_transport_lib::SolanaStandardWsSession>().rsplit("::").next(),
|
||||
std::option::Option::Some("SolanaStandardWsSession")
|
||||
);
|
||||
assert_eq!(
|
||||
std::any::type_name::<ksp_onchain_transport_lib::HeliusLaserStreamWsSession>().rsplit("::").next(),
|
||||
std::option::Option::Some("HeliusLaserStreamWsSession")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_003_original_six_standard_families_remain_available_after_provider_evolution() {
|
||||
let _account = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::account_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::program_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::logs_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slot_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::root_subscribe;
|
||||
let source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(source.contains("unsupported_block"));
|
||||
assert!(source.contains("unsupported_vote"));
|
||||
assert!(!source.contains("pub async fn transaction_subscribe"));
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_005_helius_transaction_request_surface_is_typed_before_actor_integration() {
|
||||
let filter = ksp_onchain_transport_lib::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTokenAccountsFilter::All),
|
||||
);
|
||||
let options = ksp_onchain_transport_lib::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::HeliusTransactionSubscribeEncoding::Base64),
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Signatures),
|
||||
std::option::Option::Some(true),
|
||||
std::option::Option::None,
|
||||
);
|
||||
let request = ksp_onchain_transport_lib::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
|
||||
assert!(request.validate().is_ok());
|
||||
let facade_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(!facade_source.contains("pub async fn transaction_subscribe"));
|
||||
assert_eq!(ksp_onchain_transport_lib::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_006_helius_transaction_lifecycle_is_actor_integrated_without_advancing_heartbeat() {
|
||||
let _transaction = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe;
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction");
|
||||
let source = include_str!("../src/ws_helius_transactions.rs");
|
||||
assert!(source.contains("transactionNotification"));
|
||||
assert!(source.contains("WsSubscriptionKind::HeliusTransaction"));
|
||||
assert!(!source.contains("tokio_tungstenite::connect_async"));
|
||||
let protocol_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(protocol_source.contains("unsupported_block"));
|
||||
assert!(protocol_source.contains("unsupported_vote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_007_helius_heartbeat_is_provider_owned_by_shared_actor_only() {
|
||||
let actor_source = include_str!("../src/ws_session.rs");
|
||||
assert!(actor_source.contains("HELIUS_WS_HEARTBEAT_INTERVAL"));
|
||||
assert!(actor_source.contains("std::time::Duration::from_secs(60)"));
|
||||
assert!(actor_source.contains("WsProtocolKind::HeliusLaserStream"));
|
||||
assert!(actor_source.contains("tungstenite::Message::Ping"));
|
||||
assert!(actor_source.contains("send_helius_heartbeat"));
|
||||
let settings_source = include_str!("../src/ws_settings.rs");
|
||||
assert!(!settings_source.contains("heartbeat_interval"));
|
||||
assert!(!settings_source.contains("heartbeat_enabled"));
|
||||
let protocol_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(!protocol_source.contains("heartbeat_interval"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_008_adversarial_guards_preserve_provider_isolation_and_safe_diagnostics() {
|
||||
let protocol_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(protocol_source.contains("unsupported_helius_transaction"));
|
||||
assert!(protocol_source.contains("unsupported_block"));
|
||||
assert!(protocol_source.contains("unsupported_vote"));
|
||||
let helius_source = include_str!("../src/ws_helius_transactions.rs");
|
||||
assert!(helius_source.contains("impl std::fmt::Debug for HeliusFullTransactionNotification"));
|
||||
assert!(helius_source.contains("impl std::fmt::Debug for HeliusTransactionSignatureNotification"));
|
||||
assert!(helius_source.contains("impl std::fmt::Debug for HeliusTransactionNotification"));
|
||||
assert!(helius_source.contains("Self::Unknown(_)"));
|
||||
assert!(helius_source.contains("<omitted>"));
|
||||
let actor_source = include_str!("../src/ws_session.rs");
|
||||
assert!(actor_source.contains("max_message_size_bytes"));
|
||||
assert!(actor_source.contains("max_frame_size_bytes"));
|
||||
assert!(actor_source.contains("WsNotificationDispatchOutcome::QueueFull"));
|
||||
assert!(actor_source.contains("ERROR_CODE_WS_BACKPRESSURE_OVERFLOW"));
|
||||
assert!(actor_source.contains("remote_to_local.remove"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_009_http_and_standard_websocket_inventories_remain_exact() {
|
||||
assert_eq!(ksp_onchain_transport_lib::current_http_rpc_methods().len(), 52);
|
||||
assert_eq!(ksp_onchain_transport_lib::historical_http_rpc_methods().len(), 14);
|
||||
let standard = [
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Account,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Block,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Logs,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Program,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Root,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Signature,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Slot,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates,
|
||||
ksp_onchain_transport_lib::WsSubscriptionKind::Vote,
|
||||
];
|
||||
assert_eq!(standard.len(), 9);
|
||||
let lifecycle_source = include_str!("../src/ws_lifecycle.rs");
|
||||
for method in [
|
||||
"accountSubscribe",
|
||||
"accountUnsubscribe",
|
||||
"blockSubscribe",
|
||||
"blockUnsubscribe",
|
||||
"logsSubscribe",
|
||||
"logsUnsubscribe",
|
||||
"programSubscribe",
|
||||
"programUnsubscribe",
|
||||
"rootSubscribe",
|
||||
"rootUnsubscribe",
|
||||
"signatureSubscribe",
|
||||
"signatureUnsubscribe",
|
||||
"slotSubscribe",
|
||||
"slotUnsubscribe",
|
||||
"slotsUpdatesSubscribe",
|
||||
"slotsUpdatesUnsubscribe",
|
||||
"voteSubscribe",
|
||||
"voteUnsubscribe",
|
||||
] {
|
||||
assert!(lifecycle_source.contains(method), "missing standard WebSocket method mapping: {method}");
|
||||
}
|
||||
let _account = ksp_onchain_transport_lib::SolanaStandardWsSession::account_subscribe;
|
||||
let _block = ksp_onchain_transport_lib::SolanaStandardWsSession::block_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::SolanaStandardWsSession::logs_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::SolanaStandardWsSession::program_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::SolanaStandardWsSession::root_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::SolanaStandardWsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::SolanaStandardWsSession::slot_subscribe;
|
||||
let _slots_updates = ksp_onchain_transport_lib::SolanaStandardWsSession::slots_updates_subscribe;
|
||||
let _vote = ksp_onchain_transport_lib::SolanaStandardWsSession::vote_subscribe;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_009_helius_surface_is_seven_standard_families_plus_transaction() {
|
||||
let _account = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::account_subscribe;
|
||||
let _program = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::program_subscribe;
|
||||
let _logs = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::logs_subscribe;
|
||||
let _signature = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::signature_subscribe;
|
||||
let _slot = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slot_subscribe;
|
||||
let _root = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::root_subscribe;
|
||||
let _slots_updates = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::slots_updates_subscribe;
|
||||
let _transaction = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::transaction_subscribe;
|
||||
let protocol_source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(protocol_source.contains("unsupported_block"));
|
||||
assert!(protocol_source.contains("unsupported_vote"));
|
||||
assert!(!protocol_source.contains("unsupported_slots_updates"));
|
||||
let cluster_source = include_str!("../src/ws_cluster.rs");
|
||||
assert!(cluster_source.contains("impl crate::HeliusLaserStreamWsSession"));
|
||||
assert!(cluster_source.contains("pub async fn slots_updates_subscribe"));
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::SlotsUpdates.as_str(), "slots_updates");
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::HeliusTransaction.as_str(), "helius_transaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_009_config_secret_and_dependency_boundaries_remain_wired() {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport integration test must resolve the workspace root");
|
||||
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
|
||||
for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] {
|
||||
assert!(!transport_manifest.contains(forbidden), "forbidden direct Transport dependency detected: {forbidden}");
|
||||
}
|
||||
let config_manifest = std::fs::read_to_string(workspace.join("crates/ksp-config-lib/Cargo.toml")).expect("Config manifest must be readable");
|
||||
assert!(config_manifest.contains("ksp-onchain-transport-lib"));
|
||||
let config_transport =
|
||||
std::fs::read_to_string(workspace.join("crates/ksp-config-lib/src/transport.rs")).expect("Config Transport adapter source must be readable");
|
||||
assert!(config_transport.contains("WsProtocolKind::HeliusLaserStream"));
|
||||
let transport_example = std::fs::read_to_string(workspace.join("config/examples/std.transport.example.json")).expect("Transport example must be readable");
|
||||
assert!(transport_example.contains("\"kind\": \"helius_laserstream\""));
|
||||
assert!(transport_example.contains("${KSP_SECRET_HELIUS_API_KEY"));
|
||||
let env_example = std::fs::read_to_string(workspace.join(".env.example")).expect(".env.example must be readable");
|
||||
assert!(env_example.contains("KSP_SECRET_HELIUS_API_KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_2_8_pre_010_live_smoke_policy_preserves_secret_and_dependency_ownership() {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport integration test must resolve the workspace root");
|
||||
let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable");
|
||||
assert!(!transport_manifest.contains("ksp-config-lib"));
|
||||
let transport_ws_smoke =
|
||||
std::fs::read_to_string(manifest_directory.join("tests/websocket_devnet_smoke.rs")).expect("Transport WebSocket smoke must be readable");
|
||||
assert!(!transport_ws_smoke.contains("KSP_SECRET_HELIUS_API_KEY"));
|
||||
assert!(!transport_ws_smoke.contains("ConfigEnvironment"));
|
||||
assert!(!workspace.join("crates/ksp-config-lib/tests/helius_websocket_smoke.rs").exists());
|
||||
let readme = std::fs::read_to_string(manifest_directory.join("README.md")).expect("Transport README must be readable");
|
||||
assert!(readme.contains("Aucun smoke Helius live supplémentaire n’est committé en `0.2.8-pre.010`"));
|
||||
assert!(readme.contains("KSP_SECRET_HELIUS_API_KEY"));
|
||||
let usage = std::fs::read_to_string(manifest_directory.join("USAGE.md")).expect("Transport USAGE must be readable");
|
||||
assert!(usage.contains("### Smoke Helius live"));
|
||||
assert!(usage.contains("surface KSP d’intégration/orchestration dédiée"));
|
||||
assert!(usage.contains("cargo tree -p ksp-onchain-transport-lib"));
|
||||
let env_example = std::fs::read_to_string(workspace.join(".env.example")).expect(".env.example must be readable");
|
||||
assert!(env_example.contains("KSP_SECRET_HELIUS_API_KEY"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
|
||||
// version: 9
|
||||
// version: 10
|
||||
|
||||
#[test]
|
||||
fn transaction_encoding_strings_match_current_and_legacy_wire_labels() {
|
||||
@@ -325,15 +325,36 @@ fn serve_transaction_once(body: &'static str) -> (std::string::String, std::thre
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
|
||||
let request = read_transaction_request(&mut stream);
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body,);
|
||||
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
||||
return request;
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
|
||||
let request = read_transaction_request(&mut stream);
|
||||
if !transaction_request_complete(request.as_bytes()) {
|
||||
continue;
|
||||
}
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body,);
|
||||
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
||||
return request;
|
||||
}
|
||||
});
|
||||
return (format!("http://{address}"), handle);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn transaction_fixture_ignores_abandoned_connection_before_complete_request() {
|
||||
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.null.json"));
|
||||
let address = url.strip_prefix("http://").expect("fixture URL must use HTTP");
|
||||
let abandoned = std::net::TcpStream::connect(address).expect("abandoned fixture connection must connect");
|
||||
drop(abandoned);
|
||||
let pool = transaction_pool_for_url(url.as_str());
|
||||
let result = pool
|
||||
.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::None)
|
||||
.await
|
||||
.expect("fixture must remain available after abandoned pre-request connection");
|
||||
assert!(result.is_none());
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("getTransaction"));
|
||||
}
|
||||
|
||||
fn serve_transaction_status_and_count(status_line: &'static str) -> (std::string::String, std::thread::JoinHandle<(usize, std::string::String)>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
|
||||
let address = listener.local_addr().expect("fixture listener address must resolve");
|
||||
|
||||
202
crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
Normal file
202
crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
|
||||
// version: 1
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_accounts",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, method: &str, remote_id: u64, result: serde_json::Value) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":method,"params":{"result":result,"subscription":remote_id}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn account_wire(lamports: u64) -> serde_json::Value {
|
||||
return serde_json::json!({
|
||||
"lamports": lamports,
|
||||
"data": ["AQID", "base64"],
|
||||
"owner": "11111111111111111111111111111111",
|
||||
"executable": false,
|
||||
"rentEpoch": 7,
|
||||
"space": 3
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_subscribe_config_preserves_effective_websocket_options_without_min_context_slot() {
|
||||
let config = crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64Zstd),
|
||||
std::option::Option::Some(crate::SolanaDataSliceConfig::new(4, 16)),
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
);
|
||||
assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaAccountEncoding::Base64Zstd));
|
||||
assert_eq!(config.data_slice(), std::option::Option::Some(crate::SolanaDataSliceConfig::new(4, 16)));
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.to_json_value(), serde_json::json!({"encoding":"base64+zstd","dataSlice":{"offset":4,"length":16},"commitment":"confirmed"}));
|
||||
assert!(config.to_json_value().get("minContextSlot").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_subscribe_config_preserves_filters_with_context_and_deterministic_bounds() {
|
||||
let config = crate::SolanaProgramSubscribeConfig::new(
|
||||
crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(crate::SolanaCommitment::Finalized),
|
||||
),
|
||||
std::vec![
|
||||
crate::SolanaProgramAccountFilter::DataSize(80),
|
||||
crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(4, crate::SolanaMemcmpBytes::Bytes(std::vec![1, 2, 3]))),
|
||||
crate::SolanaProgramAccountFilter::TokenAccountState,
|
||||
],
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
assert_eq!(config.with_context(), std::option::Option::Some(true));
|
||||
assert_eq!(config.filters().len(), 3);
|
||||
assert_eq!(
|
||||
config.to_json_value(),
|
||||
serde_json::json!({
|
||||
"encoding":"base64",
|
||||
"commitment":"finalized",
|
||||
"filters":[{"dataSize":80},{"memcmp":{"offset":4,"bytes":[1,2,3],"encoding":"bytes"}},"tokenAccountState"],
|
||||
"withContext":true
|
||||
})
|
||||
);
|
||||
let too_many = std::vec![
|
||||
crate::SolanaProgramAccountFilter::DataSize(1),
|
||||
crate::SolanaProgramAccountFilter::DataSize(2),
|
||||
crate::SolanaProgramAccountFilter::DataSize(3),
|
||||
crate::SolanaProgramAccountFilter::DataSize(4),
|
||||
crate::SolanaProgramAccountFilter::DataSize(5),
|
||||
];
|
||||
let error = super::validate_program_subscribe_filters(too_many.as_slice()).expect_err("five programSubscribe filters must reject locally");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
let oversized =
|
||||
std::vec![crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Bytes(std::vec![0; 129]),))];
|
||||
let error = super::validate_program_subscribe_filters(oversized.as_slice()).expect_err("oversized raw memcmp bytes must reject locally");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_notification_decoder_accepts_contextual_and_non_contextual_wire_forms() {
|
||||
let keyed = serde_json::json!({"pubkey":"11111111111111111111111111111111","account":account_wire(42)});
|
||||
let bare = super::decode_program_notification("programSubscribe", keyed.clone()).expect("bare program notification must decode");
|
||||
assert!(bare.context().is_none());
|
||||
assert_eq!(bare.account().account().lamports(), 42);
|
||||
let contextual = super::decode_program_notification("programSubscribe", serde_json::json!({"context":{"slot":99,"apiVersion":"4.2.1"},"value":keyed}))
|
||||
.expect("contextual program notification must decode");
|
||||
assert_eq!(contextual.context().expect("context must be retained").slot(), 99);
|
||||
assert_eq!(contextual.account().account().lamports(), 42);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stable_account_and_program_wrappers_use_exact_methods_decode_notifications_and_unsubscribe_by_handle() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let account_request = read_request(&mut websocket).await;
|
||||
assert_eq!(account_request["method"], serde_json::json!("accountSubscribe"));
|
||||
assert_eq!(
|
||||
account_request["params"],
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"base64","dataSlice":{"offset":1,"length":2},"commitment":"confirmed"}])
|
||||
);
|
||||
send_result(&mut websocket, &account_request, serde_json::json!(51)).await;
|
||||
send_notification(&mut websocket, "accountNotification", 51, serde_json::json!({"context":{"slot":700},"value":account_wire(123)})).await;
|
||||
let account_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(account_unsubscribe["method"], serde_json::json!("accountUnsubscribe"));
|
||||
assert_eq!(account_unsubscribe["params"], serde_json::json!([51]));
|
||||
send_result(&mut websocket, &account_unsubscribe, serde_json::json!(true)).await;
|
||||
let program_request = read_request(&mut websocket).await;
|
||||
assert_eq!(program_request["method"], serde_json::json!("programSubscribe"));
|
||||
assert_eq!(
|
||||
program_request["params"],
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"jsonParsed","filters":[{"dataSize":80}],"withContext":true}])
|
||||
);
|
||||
send_result(&mut websocket, &program_request, serde_json::json!(73)).await;
|
||||
send_notification(
|
||||
&mut websocket,
|
||||
"programNotification",
|
||||
73,
|
||||
serde_json::json!({
|
||||
"context":{"slot":701},
|
||||
"value":{"pubkey":"11111111111111111111111111111111","account":account_wire(456)}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let program_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(program_unsubscribe["method"], serde_json::json!("programUnsubscribe"));
|
||||
assert_eq!(program_unsubscribe["params"], serde_json::json!([73]));
|
||||
send_result(&mut websocket, &program_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let account_pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("account pubkey fixture must parse");
|
||||
let account_config = crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::Some(crate::SolanaDataSliceConfig::new(1, 2)),
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
);
|
||||
let mut account = session.account_subscribe(&account_pubkey, std::option::Option::Some(&account_config)).await.expect("accountSubscribe must register");
|
||||
assert_eq!(account.kind(), crate::WsSubscriptionKind::Account);
|
||||
let notification = account.recv().await.expect("account notification must arrive").expect("account notification must decode");
|
||||
assert_eq!(notification.context().slot(), 700);
|
||||
assert_eq!(notification.value().lamports(), 123);
|
||||
assert!(account.unsubscribe().await.expect("account unsubscribe must complete"));
|
||||
let program_config = crate::SolanaProgramSubscribeConfig::new(
|
||||
crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
std::vec![crate::SolanaProgramAccountFilter::DataSize(80)],
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
let mut program = session.program_subscribe(&account_pubkey, std::option::Option::Some(&program_config)).await.expect("programSubscribe must register");
|
||||
assert_eq!(program.kind(), crate::WsSubscriptionKind::Program);
|
||||
let notification = program.recv().await.expect("program notification must arrive").expect("program notification must decode");
|
||||
assert_eq!(notification.context().expect("program context must be retained").slot(), 701);
|
||||
assert_eq!(notification.account().account().lamports(), 456);
|
||||
assert!(program.unsubscribe().await.expect("program unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
222
crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs
Normal file
222
crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_blocks.rs
|
||||
// version: 1
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn fixture_pubkey() -> ksp_core_lib::Pubkey {
|
||||
return "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
||||
}
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_blocks",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn send_error(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, code: i64, message: &str) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local error response must send");
|
||||
}
|
||||
|
||||
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, remote_id: u64, result: serde_json::Value) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":"blockNotification","params":{"result":result,"subscription":remote_id}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_subscribe_config_preserves_all_unstable_options_and_rejects_processed_commitment() {
|
||||
let config = crate::SolanaBlockSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Accounts),
|
||||
std::option::Option::Some(7),
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed));
|
||||
assert_eq!(config.transaction_details(), std::option::Option::Some(crate::SolanaTransactionDetails::Accounts));
|
||||
assert_eq!(config.max_supported_transaction_version(), std::option::Option::Some(7));
|
||||
assert_eq!(config.show_rewards(), std::option::Option::Some(true));
|
||||
assert_eq!(
|
||||
config.to_json_value(),
|
||||
serde_json::json!({
|
||||
"commitment": "confirmed",
|
||||
"encoding": "jsonParsed",
|
||||
"transactionDetails": "accounts",
|
||||
"maxSupportedTransactionVersion": 7,
|
||||
"showRewards": true
|
||||
})
|
||||
);
|
||||
let invalid = crate::SolanaBlockSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Processed),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert_eq!(invalid.validate().expect_err("processed commitment must be rejected").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_notification_decoder_preserves_nulls_and_shared_confirmed_block_shape() {
|
||||
let nulls = super::decode_block_notification(
|
||||
"blockSubscribe",
|
||||
serde_json::json!({"context":{"slot":51},"value":{"slot":51,"block":null,"err":{"reason":"missing"}}}),
|
||||
)
|
||||
.expect("nullable block notification must decode");
|
||||
assert_eq!(nulls.context().slot(), 51);
|
||||
assert_eq!(nulls.value().slot(), 51);
|
||||
assert!(nulls.value().block().is_none());
|
||||
assert_eq!(nulls.value().err(), std::option::Option::Some(&serde_json::json!({"reason":"missing"})));
|
||||
let block = super::decode_block_notification(
|
||||
"blockSubscribe",
|
||||
serde_json::json!({
|
||||
"context":{"slot":52},
|
||||
"value":{
|
||||
"slot":52,
|
||||
"block":{
|
||||
"previousBlockhash":"prev",
|
||||
"blockhash":"current",
|
||||
"parentSlot":51,
|
||||
"signatures":["sig-a"],
|
||||
"rewards":null,
|
||||
"numRewardPartitions":4,
|
||||
"blockTime":123,
|
||||
"blockHeight":9
|
||||
},
|
||||
"err":null
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("shared confirmed block shape must decode");
|
||||
assert_eq!(block.value().block().expect("block must exist").blockhash(), "current");
|
||||
assert!(block.value().err().is_none());
|
||||
let large_transaction = "A".repeat(2_048);
|
||||
let large_wire = serde_json::json!({
|
||||
"context":{"slot":53},
|
||||
"value":{
|
||||
"slot":53,
|
||||
"block":{
|
||||
"previousBlockhash":"prev",
|
||||
"blockhash":"large-current",
|
||||
"parentSlot":52,
|
||||
"transactions":[{"transaction":[large_transaction,"base64"],"meta":{"err":null,"fee":5000},"version":"legacy"}],
|
||||
"rewards":[],
|
||||
"blockTime":123,
|
||||
"blockHeight":10
|
||||
},
|
||||
"err":null
|
||||
}
|
||||
});
|
||||
assert!(large_wire.to_string().len() > 1_232);
|
||||
assert!(super::decode_block_notification("blockSubscribe", large_wire).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn unstable_block_wrapper_uses_exact_request_notification_and_handle_unsubscribe() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let pubkey = fixture_pubkey();
|
||||
let server_pubkey = pubkey;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("blockSubscribe"));
|
||||
assert_eq!(
|
||||
subscribe["params"],
|
||||
serde_json::json!([
|
||||
{"mentionsAccountOrProgram":server_pubkey.to_string()},
|
||||
{"commitment":"confirmed","encoding":"base64","transactionDetails":"signatures","maxSupportedTransactionVersion":3,"showRewards":false}
|
||||
])
|
||||
);
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(301)).await;
|
||||
send_notification(&mut websocket, 301, serde_json::json!({"context":{"slot":77},"value":{"slot":77,"block":null,"err":null}})).await;
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("blockUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([301]));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let config = crate::SolanaBlockSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(crate::SolanaTransactionEncoding::Base64),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Signatures),
|
||||
std::option::Option::Some(3),
|
||||
std::option::Option::Some(false),
|
||||
);
|
||||
let mut subscription = session
|
||||
.block_subscribe(&crate::SolanaBlockSubscribeFilter::MentionsAccountOrProgram(pubkey), std::option::Option::Some(&config))
|
||||
.await
|
||||
.expect("blockSubscribe must register");
|
||||
let notification = subscription.recv().await.expect("block notification must arrive").expect("block notification must decode");
|
||||
assert_eq!(notification.value().slot(), 77);
|
||||
assert!(notification.value().block().is_none());
|
||||
assert!(subscription.unsubscribe().await.expect("block unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn unstable_block_validator_capability_rpc_error_does_not_fail_physical_session() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let block_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(block_subscribe["method"], serde_json::json!("blockSubscribe"));
|
||||
send_error(&mut websocket, &block_subscribe, -32601, "block subscription disabled").await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(302)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let error = session
|
||||
.block_subscribe(&crate::SolanaBlockSubscribeFilter::All, std::option::Option::None)
|
||||
.await
|
||||
.expect_err("validator capability application error must surface to the caller");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
let root = session.root_subscribe().await.expect("session must remain usable after block application error");
|
||||
assert_eq!(root.state(), crate::WsSubscriptionState::Active);
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
233
crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
Normal file
233
crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_cluster.rs
|
||||
// version: 2
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_cluster",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, method: &str, remote_id: u64, result: serde_json::Value) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":method,"params":{"result":result,"subscription":remote_id}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_notification_decoder_preserves_slot_parent_and_root() {
|
||||
let notification =
|
||||
super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75,"root":44})).expect("slot notification must decode");
|
||||
assert_eq!(notification.slot(), 76);
|
||||
assert_eq!(notification.parent(), 75);
|
||||
assert_eq!(notification.root(), 44);
|
||||
assert!(super::decode_slot_notification("slotSubscribe", serde_json::json!({"slot":76,"parent":75})).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stable_slot_and_root_wrappers_use_no_params_decode_exact_notifications_and_unsubscribe_by_handle() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let slot_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(slot_subscribe["method"], serde_json::json!("slotSubscribe"));
|
||||
assert_eq!(slot_subscribe["params"], serde_json::json!([]));
|
||||
send_result(&mut websocket, &slot_subscribe, serde_json::json!(201)).await;
|
||||
send_notification(&mut websocket, "slotNotification", 201, serde_json::json!({"slot":76,"parent":75,"root":44})).await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
assert_eq!(root_subscribe["params"], serde_json::json!([]));
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(202)).await;
|
||||
send_notification(&mut websocket, "rootNotification", 202, serde_json::json!(42)).await;
|
||||
let slot_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(slot_unsubscribe["method"], serde_json::json!("slotUnsubscribe"));
|
||||
assert_eq!(slot_unsubscribe["params"], serde_json::json!([201]));
|
||||
send_result(&mut websocket, &slot_unsubscribe, serde_json::json!(true)).await;
|
||||
let root_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
|
||||
assert_eq!(root_unsubscribe["params"], serde_json::json!([202]));
|
||||
send_result(&mut websocket, &root_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mut slot_subscription = session.slot_subscribe().await.expect("slotSubscribe must register");
|
||||
let slot = slot_subscription.recv().await.expect("slot notification must arrive").expect("slot notification must decode");
|
||||
assert_eq!((slot.slot(), slot.parent(), slot.root()), (76, 75, 44));
|
||||
let mut root_subscription = session.root_subscribe().await.expect("rootSubscribe must register");
|
||||
let root = root_subscription.recv().await.expect("root notification must arrive").expect("root notification must decode");
|
||||
assert_eq!(root, 42);
|
||||
assert!(slot_subscription.unsubscribe().await.expect("slot unsubscribe must complete"));
|
||||
assert!(root_subscription.unsubscribe().await.expect("root unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_update_decoder_preserves_all_known_variants_and_unknown_bounded_fallback() {
|
||||
let cases = [
|
||||
(serde_json::json!({"slot":1,"timestamp":10,"type":"firstShredReceived"}), "firstShredReceived"),
|
||||
(serde_json::json!({"slot":2,"timestamp":20,"type":"completed"}), "completed"),
|
||||
(serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank","parent":2}), "createdBank"),
|
||||
(
|
||||
serde_json::json!({
|
||||
"slot":4,
|
||||
"timestamp":40,
|
||||
"type":"frozen",
|
||||
"stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3}
|
||||
}),
|
||||
"frozen",
|
||||
),
|
||||
(serde_json::json!({"slot":5,"timestamp":50,"type":"dead","err":"fixture dead"}), "dead"),
|
||||
(serde_json::json!({"slot":6,"timestamp":60,"type":"optimisticConfirmation"}), "optimisticConfirmation"),
|
||||
(serde_json::json!({"slot":7,"timestamp":70,"type":"root"}), "root"),
|
||||
];
|
||||
for (wire, expected_type) in cases {
|
||||
let update = super::decode_slots_update_notification("slotsUpdatesSubscribe", wire).expect("known slot update must decode");
|
||||
assert_eq!(update.update_type(), expected_type);
|
||||
assert!(update.slot().is_some());
|
||||
assert!(update.timestamp().is_some());
|
||||
assert!(update.unknown_raw().is_none());
|
||||
}
|
||||
let frozen = super::decode_slots_update_notification(
|
||||
"slotsUpdatesSubscribe",
|
||||
serde_json::json!({
|
||||
"slot":4,
|
||||
"timestamp":40,
|
||||
"type":"frozen",
|
||||
"stats":{"maxTransactionsPerEntry":64,"numFailedTransactions":1,"numSuccessfulTransactions":9,"numTransactionEntries":3}
|
||||
}),
|
||||
)
|
||||
.expect("frozen update must decode");
|
||||
match frozen {
|
||||
crate::SolanaSlotUpdate::Frozen { stats, .. } => {
|
||||
assert_eq!(stats.max_transactions_per_entry(), 64);
|
||||
assert_eq!(stats.num_failed_transactions(), 1);
|
||||
assert_eq!(stats.num_successful_transactions(), 9);
|
||||
assert_eq!(stats.num_transaction_entries(), 3);
|
||||
},
|
||||
_ => panic!("fixture must decode as frozen"),
|
||||
}
|
||||
let unknown_wire = serde_json::json!({"slot":8,"timestamp":80,"type":"futureBankState","futureField":{"x":1}});
|
||||
let unknown = super::decode_slots_update_notification("slotsUpdatesSubscribe", unknown_wire.clone()).expect("unknown update must remain consumable");
|
||||
assert_eq!(unknown.update_type(), "futureBankState");
|
||||
assert_eq!(unknown.slot(), std::option::Option::Some(8));
|
||||
assert_eq!(unknown.timestamp(), std::option::Option::Some(80));
|
||||
assert_eq!(unknown.unknown_raw(), std::option::Option::Some(&unknown_wire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_update_known_variants_require_their_variant_specific_fields() {
|
||||
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":3,"timestamp":30,"type":"createdBank"})).is_err());
|
||||
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":4,"timestamp":40,"type":"frozen"})).is_err());
|
||||
assert!(super::decode_slots_update_notification("slotsUpdatesSubscribe", serde_json::json!({"slot":5,"timestamp":50,"type":"dead"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vote_notification_decoder_preserves_timestamp_omitted_null_and_value() {
|
||||
let pubkey = "11111111111111111111111111111111";
|
||||
for (wire, expected_timestamp) in [
|
||||
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-a","signature":"sig-a"}), std::option::Option::None),
|
||||
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-b","timestamp":null,"signature":"sig-b"}), std::option::Option::None),
|
||||
(serde_json::json!({"votePubkey":pubkey,"slots":[1,2],"hash":"hash-c","timestamp":123,"signature":"sig-c"}), std::option::Option::Some(123)),
|
||||
] {
|
||||
let vote = super::decode_vote_notification("voteSubscribe", wire).expect("vote notification must decode");
|
||||
assert_eq!(vote.vote_pubkey().to_string(), pubkey);
|
||||
assert_eq!(vote.slots(), &[1, 2]);
|
||||
assert_eq!(vote.timestamp(), expected_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn unstable_slots_updates_and_vote_wrappers_use_no_params_and_handle_unsubscribe() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let slots_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(slots_subscribe["method"], serde_json::json!("slotsUpdatesSubscribe"));
|
||||
assert_eq!(slots_subscribe["params"], serde_json::json!([]));
|
||||
send_result(&mut websocket, &slots_subscribe, serde_json::json!(401)).await;
|
||||
send_notification(
|
||||
&mut websocket,
|
||||
"slotsUpdatesNotification",
|
||||
401,
|
||||
serde_json::json!({"slot":76,"timestamp":1625081266243_i64,"type":"optimisticConfirmation"}),
|
||||
)
|
||||
.await;
|
||||
let vote_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(vote_subscribe["method"], serde_json::json!("voteSubscribe"));
|
||||
assert_eq!(vote_subscribe["params"], serde_json::json!([]));
|
||||
send_result(&mut websocket, &vote_subscribe, serde_json::json!(402)).await;
|
||||
send_notification(
|
||||
&mut websocket,
|
||||
"voteNotification",
|
||||
402,
|
||||
serde_json::json!({
|
||||
"votePubkey":"11111111111111111111111111111111",
|
||||
"slots":[75,76],
|
||||
"hash":"fixture-hash",
|
||||
"timestamp":null,
|
||||
"signature":"fixture-signature"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let slots_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(slots_unsubscribe["method"], serde_json::json!("slotsUpdatesUnsubscribe"));
|
||||
assert_eq!(slots_unsubscribe["params"], serde_json::json!([401]));
|
||||
send_result(&mut websocket, &slots_unsubscribe, serde_json::json!(true)).await;
|
||||
let vote_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(vote_unsubscribe["method"], serde_json::json!("voteUnsubscribe"));
|
||||
assert_eq!(vote_unsubscribe["params"], serde_json::json!([402]));
|
||||
send_result(&mut websocket, &vote_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mut slots_subscription = session.slots_updates_subscribe().await.expect("slotsUpdatesSubscribe must register");
|
||||
let update = slots_subscription.recv().await.expect("slots update must arrive").expect("slots update must decode");
|
||||
assert_eq!(update.update_type(), "optimisticConfirmation");
|
||||
let mut vote_subscription = session.vote_subscribe().await.expect("voteSubscribe must register");
|
||||
let vote = vote_subscription.recv().await.expect("vote notification must arrive").expect("vote notification must decode");
|
||||
assert_eq!(vote.slots(), &[75, 76]);
|
||||
assert!(vote.timestamp().is_none());
|
||||
assert!(slots_subscription.unsubscribe().await.expect("slots update unsubscribe must complete"));
|
||||
assert!(vote_subscription.unsubscribe().await.expect("vote unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_helius_standard.rs
|
||||
// version: 2
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn helius_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_helius_standard_fixture",
|
||||
true,
|
||||
crate::WsProviderName::new("helius"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::HeliusLaserStream,
|
||||
crate::WsEndpointUrl::parse(url).expect("local Helius WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn expect_pair(
|
||||
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
|
||||
subscribe_method: &str,
|
||||
expected_params: serde_json::Value,
|
||||
unsubscribe_method: &str,
|
||||
remote_id: u64,
|
||||
) {
|
||||
let subscribe = read_request(websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::Value::String(subscribe_method.to_owned()));
|
||||
assert_eq!(subscribe["params"], expected_params);
|
||||
send_result(websocket, &subscribe, serde_json::json!(remote_id)).await;
|
||||
let unsubscribe = read_request(websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::Value::String(unsubscribe_method.to_owned()));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([remote_id]));
|
||||
send_result(websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_facade_reuses_exact_standard_wire_for_all_seven_currently_supported_families() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
expect_pair(
|
||||
&mut websocket,
|
||||
"accountSubscribe",
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"base64","commitment":"confirmed"}]),
|
||||
"accountUnsubscribe",
|
||||
101,
|
||||
)
|
||||
.await;
|
||||
expect_pair(
|
||||
&mut websocket,
|
||||
"programSubscribe",
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"jsonParsed","filters":[{"dataSize":80}],"withContext":true}]),
|
||||
"programUnsubscribe",
|
||||
102,
|
||||
)
|
||||
.await;
|
||||
expect_pair(&mut websocket, "logsSubscribe", serde_json::json!(["all", {"commitment":"finalized"}]), "logsUnsubscribe", 103).await;
|
||||
expect_pair(
|
||||
&mut websocket,
|
||||
"signatureSubscribe",
|
||||
serde_json::json!(["fixture-signature", {"commitment":"confirmed","enableReceivedNotification":true}]),
|
||||
"signatureUnsubscribe",
|
||||
104,
|
||||
)
|
||||
.await;
|
||||
expect_pair(&mut websocket, "slotSubscribe", serde_json::json!([]), "slotUnsubscribe", 105).await;
|
||||
expect_pair(&mut websocket, "rootSubscribe", serde_json::json!([]), "rootUnsubscribe", 106).await;
|
||||
expect_pair(&mut websocket, "slotsUpdatesSubscribe", serde_json::json!([]), "slotsUpdatesUnsubscribe", 107).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect");
|
||||
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
||||
let account_config = crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
);
|
||||
let mut account = session.account_subscribe(&pubkey, std::option::Option::Some(&account_config)).await.expect("Helius accountSubscribe must register");
|
||||
assert!(account.unsubscribe().await.expect("Helius accountUnsubscribe must complete"));
|
||||
let program_config = crate::SolanaProgramSubscribeConfig::new(
|
||||
crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
std::vec![crate::SolanaProgramAccountFilter::DataSize(80)],
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
let mut program = session.program_subscribe(&pubkey, std::option::Option::Some(&program_config)).await.expect("Helius programSubscribe must register");
|
||||
assert!(program.unsubscribe().await.expect("Helius programUnsubscribe must complete"));
|
||||
let logs_config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
|
||||
let mut logs = session
|
||||
.logs_subscribe(&crate::SolanaLogsSubscribeFilter::All, std::option::Option::Some(&logs_config))
|
||||
.await
|
||||
.expect("Helius logsSubscribe must register");
|
||||
assert!(logs.unsubscribe().await.expect("Helius logsUnsubscribe must complete"));
|
||||
let signature_config =
|
||||
crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(true));
|
||||
let mut signature = session
|
||||
.signature_subscribe("fixture-signature", std::option::Option::Some(&signature_config))
|
||||
.await
|
||||
.expect("Helius signatureSubscribe must register");
|
||||
assert!(signature.unsubscribe().await.expect("Helius signatureUnsubscribe must complete"));
|
||||
let mut slot = session.slot_subscribe().await.expect("Helius slotSubscribe must register");
|
||||
assert!(slot.unsubscribe().await.expect("Helius slotUnsubscribe must complete"));
|
||||
let mut root = session.root_subscribe().await.expect("Helius rootSubscribe must register");
|
||||
assert!(root.unsubscribe().await.expect("Helius rootUnsubscribe must complete"));
|
||||
let mut slots_updates = session.slots_updates_subscribe().await.expect("Helius slotsUpdatesSubscribe must register");
|
||||
assert!(slots_updates.unsubscribe().await.expect("Helius slotsUpdatesUnsubscribe must complete"));
|
||||
session.close().await.expect("Helius facade close must complete");
|
||||
server.await.expect("local Helius peer task must complete");
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_helius_transactions.rs
|
||||
// version: 5
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn pubkey(value: &str) -> ksp_core_lib::Pubkey {
|
||||
return value.parse::<ksp_core_lib::Pubkey>().expect("fixture public key must parse");
|
||||
}
|
||||
|
||||
fn base_filter() -> crate::HeliusTransactionSubscribeFilter {
|
||||
return crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some("fixture-signature-secret-canary".to_owned()),
|
||||
std::option::Option::Some(std::vec![pubkey("11111111111111111111111111111111")]),
|
||||
std::option::Option::Some(std::vec![pubkey("SysvarC1ock11111111111111111111111111111111")]),
|
||||
std::option::Option::Some(std::vec![pubkey("Vote111111111111111111111111111111111111111")]),
|
||||
std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged),
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_oversized_filter_rejected(filter: crate::HeliusTransactionSubscribeFilter) {
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::None);
|
||||
let error = request.validate().expect_err("50,001 Helius account filters must fail before I/O");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
assert!(error.to_string().contains("invalid_rpc_parameters"));
|
||||
assert!(!error.to_string().contains("11111111111111111111111111111111"));
|
||||
}
|
||||
|
||||
fn helius_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return helius_endpoint_with_session(url, crate::WsSessionSettings::default());
|
||||
}
|
||||
|
||||
fn helius_endpoint_with_session(url: &str, session: crate::WsSessionSettings) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_helius_transaction_fixture",
|
||||
true,
|
||||
crate::WsProviderName::new("helius"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::HeliusLaserStream,
|
||||
crate::WsEndpointUrl::parse(url).expect("local Helius WebSocket URL must parse"),
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
fn reconnect_session_settings(backoff: std::time::Duration) -> crate::WsSessionSettings {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
return crate::WsSessionSettings::new(
|
||||
std::time::Duration::from_millis(250),
|
||||
std::time::Duration::from_millis(200),
|
||||
crate::WsReconnectSettings::new(2, backoff, backoff),
|
||||
crate::WsResubscribePolicy::ActiveSubscriptions,
|
||||
defaults.command_queue_capacity(),
|
||||
defaults.notification_queue_capacity(),
|
||||
defaults.max_active_subscriptions(),
|
||||
defaults.max_pending_requests(),
|
||||
defaults.max_message_size_bytes(),
|
||||
defaults.max_frame_size_bytes(),
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
fn backpressure_session_settings() -> crate::WsSessionSettings {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
return crate::WsSessionSettings::new(
|
||||
std::time::Duration::from_millis(250),
|
||||
std::time::Duration::from_millis(200),
|
||||
crate::WsReconnectSettings::new(0, std::time::Duration::from_millis(10), std::time::Duration::from_millis(10)),
|
||||
crate::WsResubscribePolicy::ActiveSubscriptions,
|
||||
defaults.command_queue_capacity(),
|
||||
1,
|
||||
2,
|
||||
defaults.max_pending_requests(),
|
||||
defaults.max_message_size_bytes(),
|
||||
defaults.max_frame_size_bytes(),
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
fn adversarial_payload_session_settings() -> crate::WsSessionSettings {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
return crate::WsSessionSettings::new(
|
||||
std::time::Duration::from_millis(250),
|
||||
std::time::Duration::from_millis(200),
|
||||
crate::WsReconnectSettings::new(2, std::time::Duration::from_millis(20), std::time::Duration::from_millis(20)),
|
||||
crate::WsResubscribePolicy::ActiveSubscriptions,
|
||||
defaults.command_queue_capacity(),
|
||||
defaults.notification_queue_capacity(),
|
||||
defaults.max_active_subscriptions(),
|
||||
defaults.max_pending_requests(),
|
||||
256,
|
||||
128,
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
return;
|
||||
}
|
||||
|
||||
async fn send_error(
|
||||
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
|
||||
request: &serde_json::Value,
|
||||
code: i64,
|
||||
message: &str,
|
||||
data: serde_json::Value,
|
||||
) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message,"data":data}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local error response must send");
|
||||
return;
|
||||
}
|
||||
|
||||
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, subscription: u64, result: serde_json::Value) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":"transactionNotification","params":{"subscription":subscription,"result":result}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
return;
|
||||
}
|
||||
|
||||
async fn send_root_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, subscription: u64, root: u64) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":"rootNotification","params":{"subscription":subscription,"result":root}});
|
||||
websocket
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into()))
|
||||
.await
|
||||
.expect("local root notification must send");
|
||||
return;
|
||||
}
|
||||
|
||||
async fn wait_for_gap_count(session: &crate::HeliusLaserStreamWsSession, expected: u64) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
if session.snapshot().continuity_gap_count() >= expected && session.state() == crate::WsSessionState::Active {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "Helius session continuity gap count must advance before timeout");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_overflow_count(session: &crate::HeliusLaserStreamWsSession, expected: u64) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
if session.snapshot().overflow_count() >= expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "Helius session overflow count must advance before timeout");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_session_subscription_count(session: &crate::HeliusLaserStreamWsSession, expected: usize) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
if session.snapshot().subscription_count() == expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "Helius session subscription count must settle before timeout");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_subscription_state<T>(subscription: &crate::WsSubscription<T>, expected: crate::WsSubscriptionState) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
if subscription.state() == expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "Helius logical subscription state must advance before timeout");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_filter_and_option_enums_match_documented_wire_labels() {
|
||||
assert_eq!(crate::HeliusTokenAccountsFilter::None.as_str(), "none");
|
||||
assert_eq!(crate::HeliusTokenAccountsFilter::BalanceChanged.as_str(), "balanceChanged");
|
||||
assert_eq!(crate::HeliusTokenAccountsFilter::All.as_str(), "all");
|
||||
assert_eq!(crate::HeliusTransactionSubscribeEncoding::Base58.as_str(), "base58");
|
||||
assert_eq!(crate::HeliusTransactionSubscribeEncoding::Base64.as_str(), "base64");
|
||||
assert_eq!(crate::HeliusTransactionSubscribeEncoding::JsonParsed.as_str(), "jsonParsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_subscribe_request_serializes_complete_documented_filter_and_options() {
|
||||
let filter = base_filter();
|
||||
let options = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Accounts),
|
||||
std::option::Option::Some(true),
|
||||
std::option::Option::Some(0),
|
||||
);
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
|
||||
let params = super::helius_transaction_subscribe_params(&request).expect("complete documented Helius request must validate");
|
||||
assert_eq!(
|
||||
params,
|
||||
std::vec![
|
||||
serde_json::json!({
|
||||
"vote": false,
|
||||
"failed": false,
|
||||
"signature": "fixture-signature-secret-canary",
|
||||
"accountInclude": ["11111111111111111111111111111111"],
|
||||
"accountExclude": ["SysvarC1ock11111111111111111111111111111111"],
|
||||
"accountRequired": ["Vote111111111111111111111111111111111111111"],
|
||||
"tokenAccounts": "balanceChanged"
|
||||
}),
|
||||
serde_json::json!({
|
||||
"commitment": "confirmed",
|
||||
"encoding": "jsonParsed",
|
||||
"transactionDetails": "accounts",
|
||||
"showRewards": true,
|
||||
"maxSupportedTransactionVersion": 0
|
||||
})
|
||||
]
|
||||
);
|
||||
assert_eq!(request.filter().vote(), std::option::Option::Some(false));
|
||||
assert_eq!(request.filter().failed(), std::option::Option::Some(false));
|
||||
assert_eq!(request.filter().signature(), std::option::Option::Some("fixture-signature-secret-canary"));
|
||||
assert_eq!(request.filter().account_include().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1));
|
||||
assert_eq!(request.filter().account_exclude().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1));
|
||||
assert_eq!(request.filter().account_required().map(<[ksp_core_lib::Pubkey]>::len), std::option::Option::Some(1));
|
||||
assert_eq!(request.filter().token_accounts(), std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged));
|
||||
let options = request.options().expect("options must remain available");
|
||||
assert_eq!(options.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
assert_eq!(options.encoding(), std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed));
|
||||
assert_eq!(options.transaction_details(), std::option::Option::Some(crate::SolanaTransactionDetails::Accounts));
|
||||
assert_eq!(options.show_rewards(), std::option::Option::Some(true));
|
||||
assert_eq!(options.max_supported_transaction_version(), std::option::Option::Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_request_preserves_omitted_explicit_empty_and_explicit_none_states() {
|
||||
let omitted = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
|
||||
assert_eq!(
|
||||
super::helius_transaction_subscribe_params(&omitted).expect("fully omitted optional request must validate"),
|
||||
std::vec![serde_json::json!({})]
|
||||
);
|
||||
let explicit = crate::HeliusTransactionSubscribeRequest::new(
|
||||
crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec::Vec::new()),
|
||||
std::option::Option::Some(std::vec::Vec::new()),
|
||||
std::option::Option::Some(std::vec::Vec::new()),
|
||||
std::option::Option::Some(crate::HeliusTokenAccountsFilter::None),
|
||||
),
|
||||
std::option::Option::Some(crate::HeliusTransactionSubscribeOptions::default()),
|
||||
);
|
||||
assert_eq!(
|
||||
super::helius_transaction_subscribe_params(&explicit).expect("explicit empty Helius request states must validate"),
|
||||
std::vec![serde_json::json!({"accountInclude":[],"accountExclude":[],"accountRequired":[],"tokenAccounts":"none"}), serde_json::json!({})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_filter_enforces_each_documented_fifty_thousand_account_bound() {
|
||||
let key = pubkey("11111111111111111111111111111111");
|
||||
let maximum = std::vec![key; 50_000];
|
||||
let accepted = crate::HeliusTransactionSubscribeRequest::new(
|
||||
crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(maximum),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
std::option::Option::None,
|
||||
);
|
||||
assert!(accepted.validate().is_ok());
|
||||
assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec![key; 50_001]),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
));
|
||||
assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec![key; 50_001]),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
));
|
||||
assert_oversized_filter_rejected(crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec![key; 50_001]),
|
||||
std::option::Option::None,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_details_require_max_supported_version_only_for_accounts_and_full() {
|
||||
for details in [crate::SolanaTransactionDetails::Full, crate::SolanaTransactionDetails::Accounts] {
|
||||
let options = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(details),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
);
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(options));
|
||||
let error = request.validate().expect_err("full/accounts details must require maxSupportedTransactionVersion");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
}
|
||||
for details in [crate::SolanaTransactionDetails::Signatures, crate::SolanaTransactionDetails::None] {
|
||||
let options = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(details),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
);
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(options));
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
let full_with_version = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Full),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(0),
|
||||
);
|
||||
let request =
|
||||
crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::Some(full_with_version));
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_notification_decoder_preserves_full_signature_and_unknown_shapes() {
|
||||
let full_value = serde_json::json!({
|
||||
"transaction":{"transaction":["AAAA","base64"],"meta":{"err":null}},
|
||||
"signature":"full-signature",
|
||||
"slot":224341380,
|
||||
"transactionIndex":42
|
||||
});
|
||||
let full = super::decode_helius_transaction_notification(full_value.clone()).expect("full Helius notification must decode");
|
||||
match full {
|
||||
crate::HeliusTransactionNotification::Full(notification) => {
|
||||
assert_eq!(notification.transaction(), &full_value["transaction"]);
|
||||
assert_eq!(notification.signature(), "full-signature");
|
||||
assert_eq!(notification.slot(), 224341380);
|
||||
assert_eq!(notification.transaction_index(), 42);
|
||||
},
|
||||
_ => panic!("transaction member must select the full Helius notification variant"),
|
||||
}
|
||||
let signature_value = serde_json::json!({
|
||||
"signature":"signature-only",
|
||||
"slot":224341381,
|
||||
"transactionIndex":43,
|
||||
"err":null,
|
||||
"memo":"memo-canary",
|
||||
"blockTime":1720000000,
|
||||
"confirmationStatus":"confirmed"
|
||||
});
|
||||
let signature = super::decode_helius_transaction_notification(signature_value).expect("signature Helius notification must decode");
|
||||
match signature {
|
||||
crate::HeliusTransactionNotification::Signature(notification) => {
|
||||
assert_eq!(notification.signature(), "signature-only");
|
||||
assert_eq!(notification.slot(), 224341381);
|
||||
assert_eq!(notification.transaction_index(), 43);
|
||||
assert!(matches!(notification.err(), crate::SolanaWireField::Null));
|
||||
assert!(matches!(notification.memo(), crate::SolanaWireField::Value(value) if value == "memo-canary"));
|
||||
assert!(matches!(notification.block_time(), crate::SolanaWireField::Value(1720000000)));
|
||||
assert!(matches!(notification.confirmation_status(), crate::SolanaWireField::Value(value) if value == "confirmed"));
|
||||
},
|
||||
_ => panic!("signature envelope must select the lightweight Helius notification variant"),
|
||||
}
|
||||
let unknown_value = serde_json::json!({"futureProviderShape":{"value":7}});
|
||||
let unknown = super::decode_helius_transaction_notification(unknown_value.clone()).expect("unknown Helius notification must remain forward-compatible");
|
||||
assert!(matches!(unknown, crate::HeliusTransactionNotification::Unknown(value) if value == unknown_value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_filter_debug_omits_signature_and_account_values() {
|
||||
let filter = base_filter();
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::None);
|
||||
let debug = format!("{request:?}");
|
||||
assert!(debug.contains("signature_present"));
|
||||
assert!(debug.contains("account_include_count"));
|
||||
assert!(!debug.contains("fixture-signature-secret-canary"));
|
||||
assert!(!debug.contains("11111111111111111111111111111111"));
|
||||
assert!(!debug.contains("SysvarC1ock11111111111111111111111111111111"));
|
||||
assert!(!debug.contains("Vote111111111111111111111111111111111111111"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_notification_debug_omits_raw_provider_payloads() {
|
||||
let full_value = serde_json::json!({
|
||||
"transaction":{"raw":"MASSIVE-RAW-PAYLOAD-CANARY"},
|
||||
"signature":"FULL-SIGNATURE-CANARY",
|
||||
"slot":77,
|
||||
"transactionIndex":3
|
||||
});
|
||||
let full = super::decode_helius_transaction_notification(full_value).expect("full Helius notification must decode");
|
||||
let signature_value = serde_json::json!({
|
||||
"signature":"SIGNATURE-MODE-CANARY",
|
||||
"slot":78,
|
||||
"transactionIndex":4,
|
||||
"err":{"secret":"ERROR-DATA-CANARY"},
|
||||
"memo":"MEMO-CANARY",
|
||||
"blockTime":123,
|
||||
"confirmationStatus":"CONFIRMATION-CANARY"
|
||||
});
|
||||
let signature = super::decode_helius_transaction_notification(signature_value).expect("signature Helius notification must decode");
|
||||
let unknown = super::decode_helius_transaction_notification(serde_json::json!({"provider":"UNKNOWN-PAYLOAD-CANARY"}))
|
||||
.expect("unknown Helius notification must remain forward-compatible");
|
||||
let rendered = format!("{full:?} {signature:?} {unknown:?}");
|
||||
assert!(rendered.contains("transaction: \"<omitted>\""));
|
||||
assert!(rendered.contains("signature: \"<omitted>\""));
|
||||
assert!(rendered.contains("err: \"value\""));
|
||||
assert!(rendered.contains("memo: \"value\""));
|
||||
assert!(rendered.contains("Unknown(\"<omitted>\")"));
|
||||
for forbidden in [
|
||||
"MASSIVE-RAW-PAYLOAD-CANARY",
|
||||
"FULL-SIGNATURE-CANARY",
|
||||
"SIGNATURE-MODE-CANARY",
|
||||
"ERROR-DATA-CANARY",
|
||||
"MEMO-CANARY",
|
||||
"CONFIRMATION-CANARY",
|
||||
"UNKNOWN-PAYLOAD-CANARY",
|
||||
] {
|
||||
assert!(!rendered.contains(forbidden));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_provider_rpc_application_error_is_safe_and_does_not_fail_session() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept Helius client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
|
||||
let transaction_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(transaction_subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
send_error(
|
||||
&mut websocket,
|
||||
&transaction_subscribe,
|
||||
-32602,
|
||||
"PROVIDER-MESSAGE-SECRET-CANARY",
|
||||
serde_json::json!({"apiKey":"PROVIDER-ERROR-SECRET-CANARY","payload":"X".repeat(4096)}),
|
||||
)
|
||||
.await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(72)).await;
|
||||
send_root_notification(&mut websocket, 72, 88).await;
|
||||
let root_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
|
||||
assert_eq!(root_unsubscribe["params"], serde_json::json!([72]));
|
||||
send_result(&mut websocket, &root_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let endpoint_url = format!("{url}/?api-key=HELIUS-ENDPOINT-SECRET-CANARY");
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(endpoint_url.as_str())).await.expect("Helius facade must connect");
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(base_filter(), std::option::Option::None);
|
||||
let error = session.transaction_subscribe(&request).await.expect_err("provider application error must reject only the logical subscribe request");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
||||
assert!(error.context().iter().any(|entry| return entry.key() == "rpc_code" && entry.value() == "-32602"));
|
||||
assert!(error.context().iter().any(|entry| return entry.key() == "method" && entry.value() == "transactionSubscribe"));
|
||||
let rendered = format!("{error:?} {error} {session:?} {:?}", session.snapshot());
|
||||
for forbidden in ["PROVIDER-MESSAGE-SECRET-CANARY", "PROVIDER-ERROR-SECRET-CANARY", "HELIUS-ENDPOINT-SECRET-CANARY", "fixture-signature-secret-canary"] {
|
||||
assert!(!rendered.contains(forbidden));
|
||||
}
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
wait_for_session_subscription_count(&session, 0).await;
|
||||
let mut root = session.root_subscribe().await.expect("session must accept a healthy subscription after provider application error");
|
||||
assert_eq!(root.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 88);
|
||||
assert!(root.unsubscribe().await.expect("healthy root unsubscribe must complete"));
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("provider error fixture server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_notification_method_mismatch_fails_only_transaction_subscription() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept Helius client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
|
||||
let transaction_subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &transaction_subscribe, serde_json::json!(41)).await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(42)).await;
|
||||
send_root_notification(&mut websocket, 41, 5).await;
|
||||
let cleanup = read_request(&mut websocket).await;
|
||||
assert_eq!(cleanup["method"], serde_json::json!("transactionUnsubscribe"));
|
||||
assert_eq!(cleanup["params"], serde_json::json!([41]));
|
||||
send_result(&mut websocket, &cleanup, serde_json::json!(true)).await;
|
||||
send_root_notification(&mut websocket, 42, 99).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect");
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
|
||||
let mut transaction = session.transaction_subscribe(&request).await.expect("transaction subscription must register");
|
||||
let mut root = session.root_subscribe().await.expect("root subscription must register");
|
||||
wait_for_subscription_state(&transaction, crate::WsSubscriptionState::Failed).await;
|
||||
assert_eq!(transaction.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_PROTOCOL_ERROR));
|
||||
assert!(transaction.recv().await.is_none());
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
wait_for_session_subscription_count(&session, 1).await;
|
||||
assert_eq!(root.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 99);
|
||||
assert_eq!(root.state(), crate::WsSubscriptionState::Active);
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("notification mismatch fixture server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_oversized_inbound_payload_reconnects_before_provider_json_decode() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (first_stream, _) = listener.accept().await.expect("initial Helius client must connect");
|
||||
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial Helius handshake must succeed");
|
||||
first
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text("PROVIDER-PAYLOAD-CANARY".repeat(32).into()))
|
||||
.await
|
||||
.expect("oversized provider fixture payload must send");
|
||||
let (replacement_stream, _) = listener.accept().await.expect("replacement Helius client must connect");
|
||||
let mut replacement = tokio_tungstenite::accept_async(replacement_stream).await.expect("replacement Helius handshake must succeed");
|
||||
let root_subscribe = read_request(&mut replacement).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
send_result(&mut replacement, &root_subscribe, serde_json::json!(91)).await;
|
||||
let root_unsubscribe = read_request(&mut replacement).await;
|
||||
assert_eq!(root_unsubscribe["method"], serde_json::json!("rootUnsubscribe"));
|
||||
send_result(&mut replacement, &root_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut replacement).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), adversarial_payload_session_settings()))
|
||||
.await
|
||||
.expect("Helius facade must connect before adversarial payload");
|
||||
wait_for_gap_count(&session, 1).await;
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
assert_eq!(session.snapshot().continuity_gap_count(), 1);
|
||||
let mut root = session.root_subscribe().await.expect("recovered Helius session must remain usable");
|
||||
assert!(root.unsubscribe().await.expect("recovered root subscription must unsubscribe"));
|
||||
session.close().await.expect("recovered Helius session must close");
|
||||
server.await.expect("oversized provider payload fixture server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_transaction_live_handle_decodes_notification_and_unsubscribes_through_shared_actor() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
assert_eq!(
|
||||
subscribe["params"],
|
||||
serde_json::json!([
|
||||
{"failed":false,"accountInclude":["11111111111111111111111111111111"],"tokenAccounts":"balanceChanged"},
|
||||
{"commitment":"confirmed","encoding":"jsonParsed","transactionDetails":"full","showRewards":false,"maxSupportedTransactionVersion":0}
|
||||
])
|
||||
);
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(4242)).await;
|
||||
send_notification(
|
||||
&mut websocket,
|
||||
4242,
|
||||
serde_json::json!({
|
||||
"transaction":{"transaction":["AAAA","base64"],"meta":{"err":null}},
|
||||
"signature":"live-signature",
|
||||
"slot":99,
|
||||
"transactionIndex":7
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("transactionUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([4242]));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint(url.as_str())).await.expect("Helius facade must connect");
|
||||
let filter = crate::HeliusTransactionSubscribeFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(std::vec![pubkey("11111111111111111111111111111111")]),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(crate::HeliusTokenAccountsFilter::BalanceChanged),
|
||||
);
|
||||
let options = crate::HeliusTransactionSubscribeOptions::new(
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(crate::HeliusTransactionSubscribeEncoding::JsonParsed),
|
||||
std::option::Option::Some(crate::SolanaTransactionDetails::Full),
|
||||
std::option::Option::Some(false),
|
||||
std::option::Option::Some(0),
|
||||
);
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(filter, std::option::Option::Some(options));
|
||||
let mut subscription = session.transaction_subscribe(&request).await.expect("public Helius transaction subscription must register");
|
||||
assert_eq!(subscription.kind(), crate::WsSubscriptionKind::HeliusTransaction);
|
||||
let notification = subscription.recv().await.expect("Helius transaction notification must arrive").expect("Helius notification must decode");
|
||||
match notification {
|
||||
crate::HeliusTransactionNotification::Full(notification) => {
|
||||
assert_eq!(notification.signature(), "live-signature");
|
||||
assert_eq!(notification.slot(), 99);
|
||||
assert_eq!(notification.transaction_index(), 7);
|
||||
},
|
||||
_ => panic!("full live payload must decode as HeliusTransactionNotification::Full"),
|
||||
}
|
||||
assert!(subscription.unsubscribe().await.expect("transactionUnsubscribe must complete"));
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("local Helius transaction server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_transaction_reconnect_remaps_remote_id_and_ignores_late_notification_after_unsubscribe() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (first_stream, _) = listener.accept().await.expect("initial Helius client must connect");
|
||||
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial Helius handshake must succeed");
|
||||
let first_subscribe = read_request(&mut first).await;
|
||||
assert_eq!(first_subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
send_result(&mut first, &first_subscribe, serde_json::json!(41)).await;
|
||||
send_notification(&mut first, 41, serde_json::json!({"signature":"generation-one","slot":1,"transactionIndex":0})).await;
|
||||
drop(first);
|
||||
let (second_stream, _) = listener.accept().await.expect("replacement Helius client must connect");
|
||||
let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("replacement Helius handshake must succeed");
|
||||
let second_subscribe = read_request(&mut second).await;
|
||||
assert_eq!(second_subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
assert_eq!(second_subscribe["params"], first_subscribe["params"]);
|
||||
send_result(&mut second, &second_subscribe, serde_json::json!(99)).await;
|
||||
send_notification(&mut second, 99, serde_json::json!({"signature":"generation-two","slot":2,"transactionIndex":1})).await;
|
||||
let unsubscribe = read_request(&mut second).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("transactionUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([99]));
|
||||
send_notification(&mut second, 99, serde_json::json!({"signature":"late-after-cancel","slot":3,"transactionIndex":2})).await;
|
||||
send_result(&mut second, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut second).await;
|
||||
});
|
||||
let settings = reconnect_session_settings(std::time::Duration::from_millis(20));
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), settings)).await.expect("Helius facade must connect");
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
|
||||
let mut subscription = session.transaction_subscribe(&request).await.expect("initial Helius transaction subscription must register");
|
||||
let stable_id = subscription.id();
|
||||
let first = subscription.recv().await.expect("first generation notification must arrive").expect("first generation notification must decode");
|
||||
assert!(matches!(first, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "generation-one"));
|
||||
let second = tokio::time::timeout(std::time::Duration::from_secs(2), subscription.recv())
|
||||
.await
|
||||
.expect("resubscribed Helius notification must remain bounded")
|
||||
.expect("resubscribed Helius channel must remain open")
|
||||
.expect("resubscribed Helius notification must decode");
|
||||
assert!(matches!(second, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "generation-two"));
|
||||
assert_eq!(subscription.id(), stable_id);
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Active);
|
||||
wait_for_gap_count(&session, 1).await;
|
||||
assert_eq!(session.snapshot().continuity_gap_count(), 1);
|
||||
assert!(subscription.unsubscribe().await.expect("Helius transaction cancellation must complete"));
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
|
||||
assert!(tokio::time::timeout(std::time::Duration::from_millis(100), subscription.recv()).await.expect("closed Helius channel must settle").is_none());
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("local reconnect Helius server must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helius_transaction_backpressure_fails_only_slow_subscription_and_uses_transaction_unsubscribe_cleanup() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept Helius client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local Helius handshake must succeed");
|
||||
let transaction_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(transaction_subscribe["method"], serde_json::json!("transactionSubscribe"));
|
||||
send_result(&mut websocket, &transaction_subscribe, serde_json::json!(41)).await;
|
||||
let root_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(root_subscribe["method"], serde_json::json!("rootSubscribe"));
|
||||
send_result(&mut websocket, &root_subscribe, serde_json::json!(42)).await;
|
||||
send_notification(&mut websocket, 41, serde_json::json!({"signature":"queued","slot":1,"transactionIndex":0})).await;
|
||||
send_notification(&mut websocket, 41, serde_json::json!({"signature":"overflow","slot":2,"transactionIndex":1})).await;
|
||||
let cleanup = read_request(&mut websocket).await;
|
||||
assert_eq!(cleanup["method"], serde_json::json!("transactionUnsubscribe"));
|
||||
assert_eq!(cleanup["params"], serde_json::json!([41]));
|
||||
send_result(&mut websocket, &cleanup, serde_json::json!(true)).await;
|
||||
send_root_notification(&mut websocket, 42, 99).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::HeliusLaserStreamWsSession::connect(helius_endpoint_with_session(url.as_str(), backpressure_session_settings()))
|
||||
.await
|
||||
.expect("Helius facade must connect");
|
||||
let request = crate::HeliusTransactionSubscribeRequest::new(crate::HeliusTransactionSubscribeFilter::default(), std::option::Option::None);
|
||||
let mut slow = session.transaction_subscribe(&request).await.expect("slow Helius transaction subscription must register");
|
||||
let mut healthy = session.root_subscribe().await.expect("healthy Helius root subscription must register");
|
||||
wait_for_subscription_state(&slow, crate::WsSubscriptionState::Failed).await;
|
||||
wait_for_overflow_count(&session, 1).await;
|
||||
assert_eq!(slow.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW));
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
assert_eq!(session.snapshot().subscription_count(), 1);
|
||||
let queued = slow.recv().await.expect("first Helius notification must remain queued").expect("queued Helius notification must decode");
|
||||
assert!(matches!(queued, crate::HeliusTransactionNotification::Signature(ref value) if value.signature() == "queued"));
|
||||
assert!(slow.recv().await.is_none());
|
||||
assert_eq!(healthy.recv().await.expect("healthy root notification must arrive").expect("healthy root notification must decode"), 99);
|
||||
assert_eq!(healthy.state(), crate::WsSubscriptionState::Active);
|
||||
assert_eq!(healthy.terminal_error_code(), std::option::Option::None);
|
||||
session.close().await.expect("Helius fixture session must close");
|
||||
server.await.expect("local Helius backpressure server must finish");
|
||||
}
|
||||
135
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
Normal file
135
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
|
||||
// version: 5
|
||||
|
||||
fn non_zero(value: u64) -> std::num::NonZeroU64 {
|
||||
return std::num::NonZeroU64::new(value).expect("test ID must be non-zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_local_ids_preserve_ordering_and_numeric_identity() {
|
||||
let first_session = crate::WsSessionId::new(non_zero(1));
|
||||
let second_session = crate::WsSessionId::new(non_zero(2));
|
||||
let subscription = crate::WsSubscriptionId::new(non_zero(7));
|
||||
assert!(first_session < second_session);
|
||||
assert_eq!(subscription.get(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_state_models_expose_concurrent_lifecycle_states() {
|
||||
assert_eq!(crate::WsSessionState::Reconnecting { attempt: 3 }, crate::WsSessionState::Reconnecting { attempt: 3 });
|
||||
assert_eq!(crate::WsSubscriptionState::Resubscribing, crate::WsSubscriptionState::Resubscribing);
|
||||
assert_ne!(crate::WsSubscriptionState::Cancelling, crate::WsSubscriptionState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_subscription_kinds_cover_all_nine_standard_families() {
|
||||
let kinds = [
|
||||
crate::WsSubscriptionKind::Account,
|
||||
crate::WsSubscriptionKind::Block,
|
||||
crate::WsSubscriptionKind::Logs,
|
||||
crate::WsSubscriptionKind::Program,
|
||||
crate::WsSubscriptionKind::Root,
|
||||
crate::WsSubscriptionKind::Signature,
|
||||
crate::WsSubscriptionKind::Slot,
|
||||
crate::WsSubscriptionKind::SlotsUpdates,
|
||||
crate::WsSubscriptionKind::Vote,
|
||||
];
|
||||
assert_eq!(kinds.len(), 9);
|
||||
assert_eq!(kinds[7].as_str(), "slots_updates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_snapshots_expose_safe_metadata_without_remote_ids_or_urls() {
|
||||
let subscription = crate::WsSubscriptionSnapshot::new(
|
||||
crate::WsSubscriptionId::new(non_zero(9)),
|
||||
crate::WsSubscriptionKind::Slot,
|
||||
crate::WsSubscriptionState::Active,
|
||||
true,
|
||||
std::option::Option::None,
|
||||
);
|
||||
let snapshot = crate::WsSessionSnapshot::new(
|
||||
crate::WsSessionId::new(non_zero(3)),
|
||||
"devnet_public",
|
||||
crate::WsProviderName::new("solana-public"),
|
||||
crate::WsClusterName::new("devnet"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsSessionState::Active,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
std::vec![subscription],
|
||||
);
|
||||
assert_eq!(snapshot.id().get(), 3);
|
||||
assert_eq!(snapshot.endpoint_name(), "devnet_public");
|
||||
assert_eq!(snapshot.pending_request_count(), 2);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 1);
|
||||
assert_eq!(snapshot.subscription_count(), 1);
|
||||
assert!(snapshot.subscriptions()[0].remote_bound());
|
||||
assert_eq!(snapshot.subscriptions()[0].terminal_error_code(), std::option::Option::None);
|
||||
assert_eq!(snapshot.overflow_count(), 0);
|
||||
let rendered = format!("{snapshot:?}");
|
||||
assert!(!rendered.contains("wss://"));
|
||||
assert!(!rendered.contains("remote_subscription_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_subscription_snapshot_preserves_only_safe_terminal_error_code() {
|
||||
let snapshot = crate::WsSubscriptionSnapshot::new(
|
||||
crate::WsSubscriptionId::new(non_zero(10)),
|
||||
crate::WsSubscriptionKind::Logs,
|
||||
crate::WsSubscriptionState::Failed,
|
||||
false,
|
||||
std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW),
|
||||
);
|
||||
assert_eq!(snapshot.state(), crate::WsSubscriptionState::Failed);
|
||||
assert_eq!(snapshot.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW));
|
||||
assert!(!format!("{snapshot:?}").contains("remote_subscription_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_subscription_kinds_map_exact_standard_method_triplets() {
|
||||
let cases = [
|
||||
(crate::WsSubscriptionKind::Account, "accountSubscribe", "accountUnsubscribe", "accountNotification"),
|
||||
(crate::WsSubscriptionKind::Block, "blockSubscribe", "blockUnsubscribe", "blockNotification"),
|
||||
(crate::WsSubscriptionKind::Logs, "logsSubscribe", "logsUnsubscribe", "logsNotification"),
|
||||
(crate::WsSubscriptionKind::Program, "programSubscribe", "programUnsubscribe", "programNotification"),
|
||||
(crate::WsSubscriptionKind::Root, "rootSubscribe", "rootUnsubscribe", "rootNotification"),
|
||||
(crate::WsSubscriptionKind::Signature, "signatureSubscribe", "signatureUnsubscribe", "signatureNotification"),
|
||||
(crate::WsSubscriptionKind::Slot, "slotSubscribe", "slotUnsubscribe", "slotNotification"),
|
||||
(crate::WsSubscriptionKind::SlotsUpdates, "slotsUpdatesSubscribe", "slotsUpdatesUnsubscribe", "slotsUpdatesNotification"),
|
||||
(crate::WsSubscriptionKind::Vote, "voteSubscribe", "voteUnsubscribe", "voteNotification"),
|
||||
];
|
||||
for (kind, subscribe, unsubscribe, notification) in cases {
|
||||
assert_eq!(kind.subscribe_method(), subscribe);
|
||||
assert_eq!(kind.unsubscribe_method(), unsubscribe);
|
||||
assert_eq!(kind.notification_method(), notification);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_unstable_subscription_partition_is_exact() {
|
||||
let cases = [
|
||||
(crate::WsSubscriptionKind::Account, false),
|
||||
(crate::WsSubscriptionKind::Block, true),
|
||||
(crate::WsSubscriptionKind::Logs, false),
|
||||
(crate::WsSubscriptionKind::Program, false),
|
||||
(crate::WsSubscriptionKind::Root, false),
|
||||
(crate::WsSubscriptionKind::Signature, false),
|
||||
(crate::WsSubscriptionKind::Slot, false),
|
||||
(crate::WsSubscriptionKind::SlotsUpdates, true),
|
||||
(crate::WsSubscriptionKind::Vote, true),
|
||||
];
|
||||
for (kind, unstable) in cases {
|
||||
assert_eq!(kind.is_unstable(), unstable);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helius_transaction_subscription_kind_maps_exact_provider_method_triplet_without_expanding_standard_partition() {
|
||||
let kind = crate::WsSubscriptionKind::HeliusTransaction;
|
||||
assert_eq!(kind.as_str(), "helius_transaction");
|
||||
assert_eq!(kind.subscribe_method(), "transactionSubscribe");
|
||||
assert_eq!(kind.unsubscribe_method(), "transactionUnsubscribe");
|
||||
assert_eq!(kind.notification_method(), "transactionNotification");
|
||||
assert!(!kind.is_unstable());
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_protocol_session.rs
|
||||
// version: 3
|
||||
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn endpoint(url: &str, protocol: crate::WsProtocolKind) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_protocol_fixture",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
protocol,
|
||||
crate::WsEndpointUrl::parse(url).expect("local WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn accept_until_close(listener: tokio::net::TcpListener) {
|
||||
let (stream, _) = listener.accept().await.expect("local peer must accept connection");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
while let std::option::Option::Some(message) = websocket.next().await {
|
||||
let message = message.expect("local peer message must decode");
|
||||
if message.is_close() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_facades_share_the_existing_physical_session_path() {
|
||||
let (standard_listener, standard_url) = bind_local_listener().await;
|
||||
let standard_server = tokio::spawn(accept_until_close(standard_listener));
|
||||
let standard = crate::SolanaStandardWsSession::connect(endpoint(standard_url.as_str(), crate::WsProtocolKind::SolanaStandard))
|
||||
.await
|
||||
.expect("standard facade must connect");
|
||||
assert_eq!(standard.snapshot().protocol(), crate::WsProtocolKind::SolanaStandard);
|
||||
standard.close().await.expect("standard facade must close");
|
||||
standard_server.await.expect("standard peer task must finish");
|
||||
let (helius_listener, helius_url) = bind_local_listener().await;
|
||||
let helius_server = tokio::spawn(accept_until_close(helius_listener));
|
||||
let helius_url = format!("{helius_url}/?api-key=SECRET-CANARY");
|
||||
let helius = crate::HeliusLaserStreamWsSession::connect(endpoint(helius_url.as_str(), crate::WsProtocolKind::HeliusLaserStream))
|
||||
.await
|
||||
.expect("Helius facade must connect");
|
||||
assert_eq!(helius.snapshot().protocol(), crate::WsProtocolKind::HeliusLaserStream);
|
||||
let rendered = format!("{helius:?}");
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains(helius_url.as_str()));
|
||||
helius.close().await.expect("Helius facade must close");
|
||||
helius_server.await.expect("Helius peer task must finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn historical_generic_constructor_remains_standard_only_before_network_io() {
|
||||
let endpoint = endpoint("ws://127.0.0.1:9", crate::WsProtocolKind::HeliusLaserStream);
|
||||
let error = crate::WsSession::connect(endpoint).await.expect_err("generic historical constructor must reject Helius protocol");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
assert_eq!(
|
||||
error.context().iter().find(|entry| return entry.key() == "expected_protocol").map(|entry| return entry.value()),
|
||||
std::option::Option::Some("solana_standard")
|
||||
);
|
||||
assert_eq!(
|
||||
error.context().iter().find(|entry| return entry.key() == "actual_protocol").map(|entry| return entry.value()),
|
||||
std::option::Option::Some("helius_laserstream")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn typed_facades_reject_protocol_mismatch_before_network_io() {
|
||||
let helius_error = crate::HeliusLaserStreamWsSession::connect(endpoint("ws://127.0.0.1:9", crate::WsProtocolKind::SolanaStandard))
|
||||
.await
|
||||
.expect_err("Helius facade must reject standard endpoint");
|
||||
assert_eq!(helius_error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
let standard_error = crate::SolanaStandardWsSession::connect(endpoint("ws://127.0.0.1:9", crate::WsProtocolKind::HeliusLaserStream))
|
||||
.await
|
||||
.expect_err("standard facade must reject Helius endpoint");
|
||||
assert_eq!(standard_error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_facades_define_no_second_actor_socket_or_public_inner_escape_hatch() {
|
||||
let source = include_str!("../src/ws_protocol_session.rs");
|
||||
assert!(!source.contains("tokio::spawn"));
|
||||
assert!(!source.contains("tokio_tungstenite"));
|
||||
assert!(!source.contains("WsSessionCommand"));
|
||||
assert!(!source.contains("pub fn inner("));
|
||||
assert!(!source.contains("pub fn into_inner("));
|
||||
assert!(!source.contains("pub async fn account_subscribe"));
|
||||
assert!(!source.contains("pub async fn block_subscribe"));
|
||||
assert!(!source.contains("pub async fn logs_subscribe"));
|
||||
assert!(!source.contains("pub async fn program_subscribe"));
|
||||
assert!(!source.contains("pub async fn root_subscribe"));
|
||||
assert!(!source.contains("pub async fn signature_subscribe"));
|
||||
assert!(!source.contains("pub async fn slot_subscribe"));
|
||||
assert!(!source.contains("pub async fn slots_updates_subscribe"));
|
||||
assert!(!source.contains("pub async fn vote_subscribe"));
|
||||
}
|
||||
1357
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
Normal file
1357
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
Normal file
File diff suppressed because it is too large
Load Diff
150
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
Normal file
150
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
|
||||
// version: 2
|
||||
|
||||
fn valid_endpoint(name: &str, url_text: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
name,
|
||||
true,
|
||||
crate::WsProviderName::new("solana-public"),
|
||||
crate::WsClusterName::new("devnet"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url_text).expect("test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_endpoint_url_accepts_ws_and_wss() {
|
||||
assert!(crate::WsEndpointUrl::parse("wss://api.devnet.solana.com").is_ok());
|
||||
assert!(crate::WsEndpointUrl::parse("ws://127.0.0.1:8900").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_endpoint_url_rejects_http_schemes() {
|
||||
let result = crate::WsEndpointUrl::parse("https://api.devnet.solana.com");
|
||||
let error = result.expect_err("HTTP URL must not be accepted by WebSocket settings");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_endpoint_url_debug_redacts_secret_material() {
|
||||
let url = crate::WsEndpointUrl::parse("wss://user:password@provider.invalid/path?api-key=SECRET-CANARY").expect("test URL must parse");
|
||||
let rendered = format!("{url:?}");
|
||||
assert_eq!(rendered, "WsEndpointUrl(<redacted>)");
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
assert!(!rendered.contains("password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_endpoint_url_errors_do_not_echo_sensitive_url() {
|
||||
let result = crate::WsEndpointUrl::parse("http://user:password@provider.invalid/path?api-key=SECRET-CANARY");
|
||||
let error = result.expect_err("unsupported scheme must fail");
|
||||
let rendered = format!("{error:?}");
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
assert!(!rendered.contains("password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_protocol_kind_distinguishes_standard_and_helius_laserstream_websocket() {
|
||||
assert_eq!(crate::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
|
||||
assert_eq!(crate::WsProtocolKind::HeliusLaserStream.as_str(), "helius_laserstream");
|
||||
assert_ne!(crate::WsProtocolKind::SolanaStandard, crate::WsProtocolKind::HeliusLaserStream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_session_defaults_are_bounded_and_validate() {
|
||||
let settings = crate::WsSessionSettings::default();
|
||||
assert!(settings.validate().is_ok());
|
||||
assert!(settings.command_queue_capacity() > 0);
|
||||
assert!(settings.notification_queue_capacity() > 0);
|
||||
assert!(settings.max_active_subscriptions() > 0);
|
||||
assert!(settings.max_pending_requests() > 0);
|
||||
assert!(settings.max_message_size_bytes() > 0);
|
||||
assert!(settings.max_frame_size_bytes() > 0);
|
||||
assert!(settings.max_write_buffer_size_bytes() > 0);
|
||||
assert_eq!(settings.resubscribe(), crate::WsResubscribePolicy::ActiveSubscriptions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_session_settings_reject_zero_runtime_bounds() {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
let settings = crate::WsSessionSettings::new(
|
||||
defaults.command_timeout(),
|
||||
defaults.close_timeout(),
|
||||
defaults.reconnect().clone(),
|
||||
defaults.resubscribe(),
|
||||
0,
|
||||
defaults.notification_queue_capacity(),
|
||||
defaults.max_active_subscriptions(),
|
||||
defaults.max_pending_requests(),
|
||||
defaults.max_message_size_bytes(),
|
||||
defaults.max_frame_size_bytes(),
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
let error = settings.validate().expect_err("zero command queue capacity must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_session_settings_reject_reversed_reconnect_backoff() {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
let settings = crate::WsSessionSettings::new(
|
||||
defaults.command_timeout(),
|
||||
defaults.close_timeout(),
|
||||
crate::WsReconnectSettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
|
||||
defaults.resubscribe(),
|
||||
defaults.command_queue_capacity(),
|
||||
defaults.notification_queue_capacity(),
|
||||
defaults.max_active_subscriptions(),
|
||||
defaults.max_pending_requests(),
|
||||
defaults.max_message_size_bytes(),
|
||||
defaults.max_frame_size_bytes(),
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_transport_settings_validate_unique_enabled_endpoints() {
|
||||
let settings = crate::WsTransportSettings::new(std::vec![
|
||||
valid_endpoint("devnet_primary", "wss://api.devnet.solana.com"),
|
||||
valid_endpoint("devnet_secondary", "wss://example.invalid/ws"),
|
||||
]);
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_transport_settings_reject_duplicate_endpoint_names() {
|
||||
let settings =
|
||||
crate::WsTransportSettings::new(std::vec![valid_endpoint("duplicate", "wss://one.invalid/ws"), valid_endpoint("duplicate", "wss://two.invalid/ws"),]);
|
||||
let error = settings.validate().expect_err("duplicate endpoint names must fail");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_transport_settings_require_one_enabled_endpoint() {
|
||||
let endpoint = crate::WsEndpointSettings::new(
|
||||
"disabled",
|
||||
false,
|
||||
crate::WsProviderName::new("provider"),
|
||||
crate::WsClusterName::new("devnet"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse("wss://provider.invalid/ws").expect("test URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
let settings = crate::WsTransportSettings::new(std::vec![endpoint]);
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_transport_settings_debug_never_exposes_endpoint_url() {
|
||||
let settings =
|
||||
crate::WsTransportSettings::new(std::vec![valid_endpoint("secret_endpoint", "wss://user:password@provider.invalid/path?api-key=SECRET-CANARY",)]);
|
||||
let rendered = format!("{settings:?}");
|
||||
assert!(rendered.contains("WsEndpointUrl(<redacted>)"));
|
||||
assert!(!rendered.contains("SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
assert!(!rendered.contains("password"));
|
||||
}
|
||||
246
crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
Normal file
246
crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
|
||||
// version: 2
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_transactions",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_subscribe_filters_preserve_all_all_with_votes_and_exactly_one_mention() {
|
||||
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("mention fixture must parse");
|
||||
assert_eq!(crate::SolanaLogsSubscribeFilter::All.to_json_value(), serde_json::json!("all"));
|
||||
assert_eq!(crate::SolanaLogsSubscribeFilter::AllWithVotes.to_json_value(), serde_json::json!("allWithVotes"));
|
||||
assert_eq!(crate::SolanaLogsSubscribeFilter::Mentions(pubkey).to_json_value(), serde_json::json!({"mentions":["11111111111111111111111111111111"]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_notification_decoder_preserves_context_signature_nullable_error_and_ordered_logs() {
|
||||
let success = super::decode_logs_notification(
|
||||
"logsSubscribe",
|
||||
serde_json::json!({
|
||||
"context":{"slot":81,"apiVersion":"4.2.1"},
|
||||
"value":{"signature":"fixture-signature","err":null,"logs":["first","second"]}
|
||||
}),
|
||||
)
|
||||
.expect("successful logs notification must decode");
|
||||
assert_eq!(success.context().slot(), 81);
|
||||
assert_eq!(success.value().signature(), "fixture-signature");
|
||||
assert!(success.value().err().is_none());
|
||||
assert_eq!(success.value().logs(), &["first".to_owned(), "second".to_owned()]);
|
||||
let failed = super::decode_logs_notification(
|
||||
"logsSubscribe",
|
||||
serde_json::json!({"context":{"slot":82},"value":{"signature":"fixture-signature-2","err":{"InstructionError":[0,"Custom"]},"logs":[]}}),
|
||||
)
|
||||
.expect("failed logs notification must preserve transaction error wire value");
|
||||
assert_eq!(failed.context().slot(), 82);
|
||||
assert!(failed.value().err().is_some());
|
||||
let missing_err =
|
||||
super::decode_logs_notification("logsSubscribe", serde_json::json!({"context":{"slot":83},"value":{"signature":"fixture-signature-3","logs":[]}}));
|
||||
assert!(missing_err.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stable_logs_wrapper_uses_exact_filter_config_notification_and_handle_unsubscribe() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("logsSubscribe"));
|
||||
assert_eq!(subscribe["params"], serde_json::json!([{"mentions":["11111111111111111111111111111111"]},{"commitment":"finalized"}]));
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(88)).await;
|
||||
let id = subscribe.get("id").and_then(serde_json::Value::as_u64).expect("subscribe request id must exist");
|
||||
assert!(id > 0);
|
||||
let notification = serde_json::json!({
|
||||
"jsonrpc":"2.0",
|
||||
"method":"logsNotification",
|
||||
"params":{
|
||||
"result":{"context":{"slot":900},"value":{"signature":"fixture-signature","err":null,"logs":["Program fixture success"]}},
|
||||
"subscription":88
|
||||
}
|
||||
});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("logs notification must send");
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("logsUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([88]));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mention = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("mention fixture must parse");
|
||||
let filter = crate::SolanaLogsSubscribeFilter::Mentions(mention);
|
||||
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
|
||||
let mut subscription = session.logs_subscribe(&filter, std::option::Option::Some(&config)).await.expect("logsSubscribe must register");
|
||||
assert_eq!(subscription.kind(), crate::WsSubscriptionKind::Logs);
|
||||
let notification = subscription.recv().await.expect("logs notification must arrive").expect("logs notification must decode");
|
||||
assert_eq!(notification.context().slot(), 900);
|
||||
assert_eq!(notification.value().signature(), "fixture-signature");
|
||||
assert_eq!(notification.value().logs(), &["Program fixture success".to_owned()]);
|
||||
assert!(subscription.unsubscribe().await.expect("logs unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_subscribe_config_and_decoder_preserve_all_documented_wire_variants() {
|
||||
let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(false));
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.enable_received_notification(), std::option::Option::Some(false));
|
||||
assert_eq!(config.to_json_value(), serde_json::json!({"commitment":"confirmed","enableReceivedNotification":false}));
|
||||
let received = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":90},"value":"receivedSignature"}))
|
||||
.expect("receivedSignature notification must decode");
|
||||
assert_eq!(received.context().slot(), 90);
|
||||
assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature);
|
||||
assert!(!received.value().is_terminal());
|
||||
let success = super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":91},"value":{"err":null}}))
|
||||
.expect("terminal successful signature notification must decode");
|
||||
assert!(success.value().is_terminal());
|
||||
assert!(success.value().err().is_none());
|
||||
let failure = super::decode_signature_notification(
|
||||
"signatureSubscribe",
|
||||
serde_json::json!({"context":{"slot":92},"value":{"err":{"InstructionError":[0,"Custom"]}}}),
|
||||
)
|
||||
.expect("terminal failed signature notification must decode");
|
||||
assert!(failure.value().is_terminal());
|
||||
assert!(failure.value().err().is_some());
|
||||
assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":93},"value":"futureVariant"})).is_err());
|
||||
assert!(super::decode_signature_notification("signatureSubscribe", serde_json::json!({"context":{"slot":94},"value":{}})).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn signature_unsubscribe_before_terminal_notification_uses_current_remote_id() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("signatureSubscribe"));
|
||||
assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature"]));
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(301)).await;
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("signatureUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([301]));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let empty_config = crate::SolanaSignatureSubscribeConfig::default();
|
||||
let mut subscription =
|
||||
session.signature_subscribe("fixture-signature", std::option::Option::Some(&empty_config)).await.expect("signatureSubscribe must register");
|
||||
assert!(subscription.unsubscribe().await.expect("signature unsubscribe must complete"));
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn signature_terminal_notification_closes_handle_and_is_not_resubscribed_after_reconnect() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let (send_terminal_tx, send_terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let (replacement_ready_tx, replacement_ready_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept initial client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("initial WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("signatureSubscribe"));
|
||||
assert_eq!(subscribe["params"], serde_json::json!(["fixture-signature",{"commitment":"finalized","enableReceivedNotification":true}]));
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(401)).await;
|
||||
let received = serde_json::json!({
|
||||
"jsonrpc":"2.0",
|
||||
"method":"signatureNotification",
|
||||
"params":{"result":{"context":{"slot":100},"value":"receivedSignature"},"subscription":401}
|
||||
});
|
||||
websocket
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(received.to_string().into()))
|
||||
.await
|
||||
.expect("receivedSignature notification must send");
|
||||
send_terminal_rx.await.expect("client must observe early signature notification before terminal send");
|
||||
let terminal = serde_json::json!({
|
||||
"jsonrpc":"2.0",
|
||||
"method":"signatureNotification",
|
||||
"params":{"result":{"context":{"slot":101},"value":{"err":null}},"subscription":401}
|
||||
});
|
||||
websocket
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(terminal.to_string().into()))
|
||||
.await
|
||||
.expect("terminal signature notification must send");
|
||||
let unexpected_cleanup = tokio::time::timeout(std::time::Duration::from_millis(100), websocket.next()).await;
|
||||
assert!(unexpected_cleanup.is_err(), "server-terminal signature notification must not trigger signatureUnsubscribe");
|
||||
drop(websocket);
|
||||
let (replacement_stream, _) = listener.accept().await.expect("local server must accept replacement client");
|
||||
let mut replacement = tokio_tungstenite::accept_async(replacement_stream).await.expect("replacement WebSocket handshake must succeed");
|
||||
let unexpected = tokio::time::timeout(std::time::Duration::from_millis(100), replacement.next()).await;
|
||||
assert!(unexpected.is_err(), "terminal signature subscription must not be replayed after reconnect");
|
||||
replacement_ready_tx.send(()).expect("replacement-ready signal must send");
|
||||
wait_for_close_frame(&mut replacement).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let config = crate::SolanaSignatureSubscribeConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(true));
|
||||
let mut subscription =
|
||||
session.signature_subscribe("fixture-signature", std::option::Option::Some(&config)).await.expect("signatureSubscribe must register");
|
||||
let received = subscription.recv().await.expect("receivedSignature must arrive").expect("receivedSignature must decode");
|
||||
assert_eq!(*received.value(), crate::SolanaSignatureNotification::ReceivedSignature);
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Active);
|
||||
send_terminal_tx.send(()).expect("terminal-send signal must reach fixture");
|
||||
let terminal = subscription.recv().await.expect("terminal signature notification must arrive").expect("terminal signature notification must decode");
|
||||
assert!(terminal.value().is_terminal());
|
||||
assert!(terminal.value().err().is_none());
|
||||
let closed = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
loop {
|
||||
if subscription.state() == crate::WsSubscriptionState::Closed {
|
||||
return;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(closed.is_ok());
|
||||
assert!(subscription.recv().await.is_none());
|
||||
assert!(subscription.terminal_error_code().is_none());
|
||||
assert!(!subscription.unsubscribe().await.expect("already terminal signature unsubscribe must be local-only"));
|
||||
replacement_ready_rx.await.expect("replacement connection must be observed without signature replay");
|
||||
assert_eq!(session.snapshot().subscription_count(), 0);
|
||||
assert!(session.snapshot().continuity_gap_count() >= 1);
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- file: crates/ksp-wallet-lib/README.md -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# `ksp-wallet-lib`
|
||||
|
||||
Statut : **stable depuis KSP `0.2.5` ; surface V2/multi-version stable depuis `0.2.6`**.
|
||||
Statut : **stable ; surface V1/V2 et façade multi-version validées**.
|
||||
|
||||
`ksp-wallet-lib` est la bibliothèque KSP propriétaire du Wallet Solana natif. Elle possède le format autonome `.kspwallet` V1 et le format binaire V2 canonique, les capacités indépendantes VIEW/OWNER, la protection du secret Solana, la signature, l'administration des metadata, les rotations de credentials, la persistence native et les adapters d'import/export explicitement supportés. Depuis KSP `0.2.6`, les APIs non versionnées créent/importent en V2 par default explicite et lisent V1/V2 par détection bornée. La migration V1 -> V2 est explicite et OWNER-authentifiée, sans migration à l'ouverture.
|
||||
`ksp-wallet-lib` est la bibliothèque KSP propriétaire du Wallet Solana natif. Elle possède le format autonome `.kspwallet` V1 et le format binaire V2 canonique, les capacités indépendantes VIEW/OWNER, la protection du secret Solana, la signature, l'administration des metadata, les rotations de credentials, la persistence native et les adapters d'import/export explicitement supportés. Les APIs non versionnées créent/importent en V2 par default explicite et lisent V1/V2 par détection bornée. La migration V1 -> V2 est explicite et OWNER-authentifiée, sans migration à l'ouverture.
|
||||
|
||||
La crate est volontairement indépendante de Config, du réseau et de Tauri. Un consumer fournit les chemins, passwords et metadata ; Wallet ouvre, protège, signe et persiste sans décider d'une policy de dépense ni contacter un RPC.
|
||||
|
||||
@@ -191,13 +191,13 @@ Elles couvrent le wire, Argon2id/XChaCha20-Poly1305, l'ouverture VIEW/OWNER, la
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — exemples des principales surfaces publiques ;
|
||||
- [`../../docs/formats/KSPWALLET_V1.md`](../../docs/formats/KSPWALLET_V1.md) — spécification normative indépendante de Rust ;
|
||||
- [`../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan historique et threat model de `0.2.5` ;
|
||||
- [`../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](../../docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan historique et threat model de la fondation Wallet ;
|
||||
- [`../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) — matrice de sécurité/interoperabilité/compliance ;
|
||||
- [`../../prompts/011-V0_2_6_START_PROMPT.md`](../../prompts/011-V0_2_6_START_PROMPT.md) — reprise vers Wallet Desk après publication stable de `0.2.5`.
|
||||
- [`../../prompts/011-V0_2_6_START_PROMPT.md`](../../prompts/011-V0_2_6_START_PROMPT.md) — prompt historique de reprise vers Wallet Desk.
|
||||
|
||||
## V2 stable depuis `0.2.6`
|
||||
## Wire/runtime V2 et façade multi-version
|
||||
|
||||
`pre.015` a figé le wire structurel V2, son codec et ses transcripts/AAD. `pre.016` matérialise le runtime V2 complet et la façade multi-version :
|
||||
Le wire structurel V2, son codec et ses transcripts/AAD sont figés ; le runtime V2 complet et la façade multi-version exposent :
|
||||
|
||||
```text
|
||||
DEFAULT_WALLET_FORMAT = V2
|
||||
@@ -211,7 +211,7 @@ open/inspect génériques -> détection V1/V2
|
||||
open/inspect _v1/_v2 -> format forcé strict
|
||||
```
|
||||
|
||||
`WalletOwner` et `WalletView` conservent le format natif qu'ils ont ouvert : metadata, rotations OWNER/VIEW, disable/recreate VIEW, self-rotation VIEW, signature et export ne transcodent jamais implicitement le fichier. Le default est une décision explicite et ne suit pas automatiquement une future V3. `pre.017` matérialise la migration authentifiée V1 -> V2 comme opération séparée ; aucune lecture ou mutation ordinaire ne migre implicitement.
|
||||
`WalletOwner` et `WalletView` conservent le format natif qu'ils ont ouvert : metadata, rotations OWNER/VIEW, disable/recreate VIEW, self-rotation VIEW, signature et export ne transcodent jamais implicitement le fichier. Le default est une décision explicite et ne suit pas automatiquement une future V3. La migration authentifiée V1 -> V2 est une opération séparée ; aucune lecture ou mutation ordinaire ne migre implicitement.
|
||||
### Migration explicite V1 -> V2
|
||||
|
||||
```text
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-wallet-lib/USAGE.md -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# Utilisation de `ksp-wallet-lib`
|
||||
|
||||
@@ -278,9 +278,9 @@ ksp-onchain-transport-lib
|
||||
-> utilise cette Pubkey pour getBalance et autres lectures réseau
|
||||
```
|
||||
|
||||
Cette composition est le rôle de `0.2.6 — ksp-app-wallet-desk`, pas de `ksp-wallet-lib`.
|
||||
Cette composition appartient à `ksp-app-wallet-desk`, pas à `ksp-wallet-lib`.
|
||||
|
||||
## Wire/runtime V2 stable (`0.2.6`)
|
||||
## Wire/runtime V2
|
||||
|
||||
Le codec structurel V2 reste disponible directement pour les outils qui travaillent explicitement au niveau wire :
|
||||
|
||||
|
||||
147
deltas/0.2.7/pre.001-fix.001.md
Normal file
147
deltas/0.2.7/pre.001-fix.001.md
Normal file
@@ -0,0 +1,147 @@
|
||||
<!-- file: deltas/0.2.7/pre.001-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.001-fix.001` — correction documentaire du gate WebSocket
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison immédiatement précédente :
|
||||
|
||||
```text
|
||||
0.2.7-pre.001
|
||||
workspace.package.version = 0.2.7-pre.1
|
||||
```
|
||||
|
||||
Ce fix est **documentaire uniquement**. Conformément à `VERSION_WORKFLOW.md` et `FILE_CONTRACTS.md`, il ne modifie pas `Cargo.toml` et conserve :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.7-pre.1
|
||||
commit = v0.2.7-pre.001-fix.001
|
||||
aucun tag prerelease
|
||||
```
|
||||
|
||||
## Objet
|
||||
|
||||
Corriger et renforcer le gate `pre.001` avant toute implémentation WebSocket :
|
||||
|
||||
- partir des versions du plan `014` et de la compliance `010` réalignées par l'opérateur ;
|
||||
- interdire les séparateurs `|` à l'intérieur des cellules de tableaux Markdown, en utilisant `ou`, du texte ou une autre forme non ambiguë ;
|
||||
- remplacer le cross-check Agave `v3.1.8` par la baseline Git actuelle **Agave `v4.2.1`** ;
|
||||
- appliquer au WebSocket la même discipline que la compliance HTTP : documentation Solana, tag Git Agave courant et audit SIMD ;
|
||||
- ajouter un audit SIMD ciblé des changements pouvant affecter le wire, la sémantique ou les bornes de ressources WebSocket ;
|
||||
- préparer `std.transport` V2 à plusieurs familles WebSocket via un discriminateur explicite, sans ajouter de paramètres Helius dans `0.2.7`.
|
||||
|
||||
## Baseline Agave corrigée
|
||||
|
||||
Le tag Git audité est :
|
||||
|
||||
```text
|
||||
anza-xyz/agave v4.2.1
|
||||
```
|
||||
|
||||
Le cross-check `v4.2.1` confirme :
|
||||
|
||||
```text
|
||||
9 subscribe + 9 unsubscribe = 18 opérations standard actuelles
|
||||
aucune famille PubSub standard supplémentaire détectée
|
||||
accountSubscribe.minContextSlot toujours ignoré côté handler PubSub
|
||||
programSubscribe.withContext conservé
|
||||
a vote.timestamp optionnel côté RpcVote
|
||||
7 variantes SlotUpdate actuelles inchangées
|
||||
```
|
||||
|
||||
La documentation Solana reste l'autorité pour la **surface publique annoncée**. Le tag Agave courant sert de contrôle d'implémentation, même si une page documentaire contient encore un lien vers une révision source plus ancienne.
|
||||
|
||||
## Audit SIMD ajouté
|
||||
|
||||
Le plan et la compliance suivent désormais explicitement les SIMDs pertinents :
|
||||
|
||||
| SIMD | Statut | Décision KSP principale |
|
||||
|-----------------------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `0118` Partitioned Epoch Rewards Distribution | Activated | réutiliser les DTOs bloc/rewards lossless déjà acquis par HTTP |
|
||||
| `0291` Commission Rate in Basis Points | Review | ne pas dériver localement une représentation de commission depuis une autre |
|
||||
| `0296` Larger Transaction Size | Review | proposition jusqu'à 4096 octets ; aucune limite WS dérivée en dur de l'ancienne taille transaction de 1232 octets |
|
||||
| `0298` Bank Hash in Block Footer | Idea | aucun `bankHash` spéculatif |
|
||||
| `0301` parent bank hash | PR fermé | aucun `parentBankHash` spéculatif ; PR non mergée |
|
||||
| `0307` Add Block Footer | Review | aucun `footer` spéculatif ; réaudit lorsque l'upstream l'expose |
|
||||
| `0326` Alpenglow | Review | ne pas figer les sémantiques TowerBFT des flux unstable |
|
||||
| `0337` Alpenglow Fast Leader Handover Markers | Review | surveiller l'impact futur sur shape/taille des blocs |
|
||||
| `0384` Alpenglow migration | Review | ne pas supposer une séquence exhaustive de notifications commitment/optimistic confirmation |
|
||||
| `0385` Transaction V1 | Review | conserver versions transaction et `maxSupportedTransactionVersion` génériques |
|
||||
|
||||
Aucun de ces SIMDs n'ajoute, dans la baseline Agave `v4.2.1`, une dixième famille WebSocket standard.
|
||||
|
||||
## Préparation multi-familles WebSocket
|
||||
|
||||
Le shape Config V2 planifié devient explicitement extensible :
|
||||
|
||||
```text
|
||||
profiles[].ws_endpoints[].kind
|
||||
```
|
||||
|
||||
Pour `0.2.7` :
|
||||
|
||||
```text
|
||||
kind = solana_standard supporté
|
||||
autre kind rejet explicite
|
||||
```
|
||||
|
||||
Le type Transport correspondant est planifié `#[non_exhaustive]`. Une release ultérieure pourra ainsi ajouter notamment une famille **Helius Enhanced WebSocket** sans refondre `profiles[].ws_endpoints[]` et sans contaminer `WsSessionSettings` ou les wrappers Solana standard avec des options provider-specific.
|
||||
|
||||
Ce fix ne décide pas encore si Helius Enhanced WebSocket et LaserStream WebSocket partageront exactement la même famille de settings ou le même moteur ; ce point reste soumis à l'audit provider-specific prévu après `0.2.7`.
|
||||
|
||||
## Markdown
|
||||
|
||||
Correction appliquée dans la matrice lifecycle :
|
||||
|
||||
```text
|
||||
avant : Never PIPE ActiveSubscriptions
|
||||
après : Never ou ActiveSubscriptions
|
||||
```
|
||||
|
||||
Les caractères `|` restent uniquement les délimiteurs structurels nécessaires aux tableaux Markdown ; ils ne sont pas utilisés comme séparateurs sémantiques dans une cellule.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
```
|
||||
|
||||
## Fichier ajouté
|
||||
|
||||
```text
|
||||
deltas/0.2.7/pre.001-fix.001.md
|
||||
```
|
||||
|
||||
## Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
deltas/0.2.7/pre.001.md
|
||||
docs/000-README.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/validation/000-README.md
|
||||
CHANGELOG.md
|
||||
ROADMAP.md
|
||||
config/**
|
||||
crates/**
|
||||
```
|
||||
|
||||
Les index ne changent pas : ils référencent déjà les documents `014` et `010`.
|
||||
|
||||
## Validation documentaire du fix
|
||||
|
||||
À vérifier avant commit :
|
||||
|
||||
```text
|
||||
aucun pipe sémantique dans une cellule de tableau Markdown
|
||||
nombre de colonnes cohérent pour chaque table modifiée
|
||||
18 opérations WebSocket toujours présentes dans la compliance
|
||||
Agave v4.2.1 utilisé comme baseline Git
|
||||
SIMDs ciblés documentés avec leur statut courant
|
||||
Cargo.toml absent du delta fix
|
||||
```
|
||||
|
||||
Ce fix ne change aucun code, build, runtime, configuration effective ou migration ; les gates Cargo ne sont donc pas redéclarés comme exécutés par ce delta documentaire.
|
||||
245
deltas/0.2.7/pre.001.md
Normal file
245
deltas/0.2.7/pre.001.md
Normal file
@@ -0,0 +1,245 @@
|
||||
<!-- file: deltas/0.2.7/pre.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.001` — audit WebSocket Solana, threat model, dependencies et sizing
|
||||
|
||||
## Base requise
|
||||
|
||||
Release stable attendue et auditée :
|
||||
|
||||
```text
|
||||
v0.2.6
|
||||
workspace.package.version = 0.2.6 avant ouverture
|
||||
```
|
||||
|
||||
L'archive Gitea fournie contient `deltas/0.2.6/rel.001.md` et annonce `0.2.7 — WebSocket Solana standard` comme prochaine release. Elle est utilisée comme autorité primaire.
|
||||
|
||||
## Type de livraison
|
||||
|
||||
```text
|
||||
ksp-general-0.2.7-pre.001.zip
|
||||
```
|
||||
|
||||
L'archive d'échange est un delta applicable depuis la racine de `v0.2.6` et contient uniquement les fichiers ajoutés/modifiés par cette tranche.
|
||||
|
||||
## Objet
|
||||
|
||||
`pre.001` reste volontairement un gate de lecture/audit/conception. Aucun client WebSocket, session runtime, wrapper subscribe ou dependency réseau nouvelle n'est encore ajouté.
|
||||
|
||||
Le gate :
|
||||
|
||||
- relit les règles, architecture, plans, validations et contrats réels requis ;
|
||||
- vérifie la stabilité `v0.2.6` et l'héritage HTTP/Wallet Desk ;
|
||||
- réaudite `ksp-onchain-transport-lib` et l'adapter Config actuel ;
|
||||
- constate que `std.transport` V1 est strictement HTTP et décide un V2 explicite HTTP+WS avec backward V1 ;
|
||||
- audite l'archive bot3 comme référence historique seulement ;
|
||||
- réaudite la documentation Solana WebSocket officielle du 2026-08-22 et cross-checke Agave `v3.1.8` sur les ambiguïtés ;
|
||||
- documente notamment `accountSubscribe.minContextSlot` comme option partagée mais ignorée en PubSub, et `vote.timestamp` comme `Option<i64>` ;
|
||||
- compte exactement **18 méthodes = 9 subscribe + 9 unsubscribe** ;
|
||||
- classe `block`, `slotsUpdates` et `vote` comme paires unstable ;
|
||||
- crée la matrice compliance initiale `010` ;
|
||||
- audite les crates candidates et retient `tokio-tungstenite 0.30.0` + `futures-util 0.3.34` pour une tranche ultérieure ;
|
||||
- fixe le modèle actor/session, IDs locaux, state machines, reconnect/resubscribe, continuity gaps, backpressure et shutdown ;
|
||||
- fixe les exigences de redaction URL/credentials et de bornes de ressources ;
|
||||
- regranularise la release jusqu'à un forecast nominal `pre.014` sans imposer ce numéro comme deadline.
|
||||
|
||||
## Décisions principales
|
||||
|
||||
### Cardinalité
|
||||
|
||||
```text
|
||||
endpoint -> N sessions physiques explicites -> N subscriptions par session
|
||||
aucun pool/scheduler automatique en 0.2.7
|
||||
```
|
||||
|
||||
### Identités
|
||||
|
||||
```text
|
||||
WsSessionId stable local KSP
|
||||
WsSubscriptionId stable local KSP
|
||||
remote id éphémère et interne, remappé après reconnect
|
||||
```
|
||||
|
||||
### Reconnect / resubscribe
|
||||
|
||||
```text
|
||||
budget fini
|
||||
backoff exponentiel borné
|
||||
pas de jitter en 0.2.7
|
||||
policy Never | ActiveSubscriptions
|
||||
ordre de restore déterministe par ID local
|
||||
continuity gap explicite après toute reconnexion
|
||||
aucune garantie lossless / aucun backfill HTTP Transport
|
||||
```
|
||||
|
||||
### Backpressure
|
||||
|
||||
```text
|
||||
command queue bounded
|
||||
notification queue bounded par subscription
|
||||
overflow -> subscription Failed explicite + best-effort unsubscribe
|
||||
aucun drop silencieux
|
||||
les autres subscriptions restent actives
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
```text
|
||||
std.transport V1 reste strict et lisible
|
||||
std.transport V2 = HTTP existant + ws_defaults + profiles[].ws_endpoints
|
||||
Config -> Transport uniquement
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
```text
|
||||
tokio-tungstenite ^0.30, default-features=false, connect + rustls-tls-webpki-roots
|
||||
futures-util ^0.3, default-features=false, std + sink
|
||||
```
|
||||
|
||||
Ces dependencies sont **planifiées seulement** ; le graphe n'est pas modifié dans `pre.001`.
|
||||
|
||||
## Prévision souple recalibrée
|
||||
|
||||
```text
|
||||
pre.001 audit + matrice + threat model + dependencies + sizing
|
||||
pre.002 settings/IDs/states/snapshots/redaction
|
||||
pre.003 std.transport V2 + adapter Config
|
||||
pre.004 actor session physique + deps WS + local server
|
||||
pre.005 limits/control/cancellation/shutdown
|
||||
pre.006 registry + generic subscribe/unsubscribe + channels typed
|
||||
pre.007 reconnect/resubscribe/gap/races
|
||||
pre.008 backpressure/limits/leaks adversarial
|
||||
pre.009 account/program/logs
|
||||
pre.010 signature/slot/root
|
||||
pre.011 block/slotsUpdates/vote unstable
|
||||
pre.012 compliance 18/18 + Config composition + HTTP regression
|
||||
pre.013 smoke live + README/USAGE + cargo trees
|
||||
pre.014 workspace final + docs/compliance + prompt 0.2.8
|
||||
rel.001 publication stable
|
||||
```
|
||||
|
||||
Le sizing reste positif : la release n'est pas scindée fonctionnellement, mais le forecast initial `pre.008` est volontairement décompressé.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
deltas/0.2.7/pre.001.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
docs/000-README.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/validation/000-README.md
|
||||
```
|
||||
|
||||
## Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
CHANGELOG.md
|
||||
ROADMAP.md
|
||||
.env.example
|
||||
config/**
|
||||
crates/**
|
||||
docs/architecture/**
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
```
|
||||
|
||||
`ROADMAP.md` reste global et possède déjà l'entrée `0.2.7`. README/USAGE Transport ne sont pas modifiés avant qu'une surface runtime WebSocket existe réellement.
|
||||
|
||||
## Version technique
|
||||
|
||||
Conformément au workflow non-fix :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.7-pre.1
|
||||
commit = v0.2.7-pre.001
|
||||
aucun tag prerelease
|
||||
```
|
||||
|
||||
## Audit officiel WebSocket
|
||||
|
||||
Index :
|
||||
|
||||
```text
|
||||
https://solana.com/docs/rpc/websocket
|
||||
```
|
||||
|
||||
Inventaire exact au 2026-08-22 :
|
||||
|
||||
```text
|
||||
accountSubscribe/accountUnsubscribe
|
||||
blockSubscribe/blockUnsubscribe unstable pair
|
||||
logsSubscribe/logsUnsubscribe
|
||||
programSubscribe/programUnsubscribe
|
||||
rootSubscribe/rootUnsubscribe
|
||||
signatureSubscribe/signatureUnsubscribe
|
||||
slotSubscribe/slotUnsubscribe
|
||||
slotsUpdatesSubscribe/slotsUpdatesUnsubscribe unstable pair
|
||||
voteSubscribe/voteUnsubscribe unstable pair
|
||||
```
|
||||
|
||||
Aucune méthode de cet index n'est marquée Deprecated.
|
||||
|
||||
## Audit bot3
|
||||
|
||||
Référence inspectée :
|
||||
|
||||
```text
|
||||
ks-onchain-transport/src/standard_ws.rs
|
||||
ks-onchain-transport/src/ws_client.rs
|
||||
ks-onchain-transport/src/ws_pool.rs
|
||||
ks-onchain-transport/src/ws_session.rs
|
||||
```
|
||||
|
||||
Repris comme concepts : session multiplexée, ID local/remote séparé, reconnect/resubscribe borné. Rejetés : Transport -> Config, tracing direct, scheduler/pool, unsubscribe public par remote ID et broadcast data comme contrat principal.
|
||||
|
||||
## Validations exécutées avant modification
|
||||
|
||||
```text
|
||||
archive stable v0.2.6 extraite/auditée OK
|
||||
documents internes obligatoires relus OK
|
||||
inventory Transport + Config OK
|
||||
archive bot3 auditée OK
|
||||
documentation Solana WebSocket actuelle auditée OK
|
||||
dependencies Rust candidates auditée OK
|
||||
python3 scripts/audit_rust_workspace_rules.py OK
|
||||
```
|
||||
|
||||
Sortie audit Python :
|
||||
|
||||
```text
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
```
|
||||
|
||||
## Validations tentées mais impossibles dans le sandbox
|
||||
|
||||
Le binaire `cargo` n'est pas installé. Tentatives avant modification :
|
||||
|
||||
```text
|
||||
cargo fmt --all code 127
|
||||
cargo check --workspace code 127
|
||||
cargo clippy --workspace --all-targets code 127
|
||||
```
|
||||
|
||||
Aucune de ces commandes n'est déclarée réussie.
|
||||
|
||||
## Validation opérateur requise avant commit
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Aucun test Transport, Config, workspace ou smoke live n'est déclaré vert dans ce sandbox tant qu'il n'a pas été effectivement exécuté par l'opérateur. Aucun build Tauri n'est requis pour `0.2.7-pre.001`.
|
||||
156
deltas/0.2.7/pre.002-fix.001.md
Normal file
156
deltas/0.2.7/pre.002-fix.001.md
Normal file
@@ -0,0 +1,156 @@
|
||||
<!-- file: deltas/0.2.7/pre.002-fix.001.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Delta `0.2.7-pre.002-fix.001` — Clippy strict, canaris desktop et signal Cargo synchronisé
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
```text
|
||||
0.2.7-pre.002 appliquée
|
||||
workspace.package.version = 0.2.7-pre.2
|
||||
```
|
||||
|
||||
La validation opérateur de `0.2.7-pre.002` confirme :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
python3 scripts/audit_rust_workspace_rules.py clean
|
||||
cargo check --workspace OK avec 2 warnings dead_code WS
|
||||
cargo test -p ksp-onchain-transport-lib 256 unit + 28 public API + 22 release completeness OK
|
||||
```
|
||||
|
||||
`cargo clippy --workspace --all-targets` échoue sur les règles workspace strictes `clippy::implicit_return` et `clippy::question_mark_used`. `cargo test --workspace` atteint ensuite le canari Config Desk qui compare encore la version packagée stable `0.2.6` à la prerelease Cargo courante.
|
||||
|
||||
Cette version 2 du fichier delta accompagne l'archive d'échange **corrigée** de `pre.002-fix.001`. L'archive `pre.002-fix.001` précédemment transmise sans mise à jour du `Cargo.toml` racine est révoquée avant application/commit : elle ne doit pas être utilisée.
|
||||
|
||||
## 2. Signal technique
|
||||
|
||||
Le correctif touche des sources Rust et des tests Rust. Conformément à `VER-ID-007`, `VER-ID-010` et au contrat `Cargo.toml` de `FILE_CONTRACTS.md`, le signal Cargo est synchronisé :
|
||||
|
||||
```text
|
||||
livraison = 0.2.7-pre.002-fix.001
|
||||
workspace.package.version = 0.2.7-pre.2.fix.1
|
||||
commit = v0.2.7-pre.002-fix.001
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
Toutes les crates qui utilisent `version.workspace = true` héritent automatiquement de `0.2.7-pre.2.fix.1`.
|
||||
|
||||
Le manifeste racine passe aussi :
|
||||
|
||||
```text
|
||||
# version: 193 -> 194
|
||||
```
|
||||
|
||||
Aucune dépendance, feature, membre workspace ou lint n'est modifié.
|
||||
|
||||
## 3. Corrections Transport
|
||||
|
||||
`crates/ksp-onchain-transport-lib/src/ws_settings.rs` et `ws_lifecycle.rs` sont alignés avec les lints workspace stricts :
|
||||
|
||||
- les helpers `as_str()` utilisent un `return match` explicite ;
|
||||
- les propagations d'erreur n'utilisent plus l'opérateur `?`, interdit par `clippy::question_mark_used` ;
|
||||
- les constructeurs crate-internal de snapshots, encore réservés aux tests dans `pre.002`, sont compilés uniquement sous `cfg(test)` afin de supprimer les warnings `dead_code` avant leur consommation runtime future.
|
||||
|
||||
Aucun contrat public, default WebSocket, logging target ou règle de redaction n'est modifié. Le tracing reste exclusivement émis via `ksp-logging-lib` avec le `TRACING_TARGET` de `ksp-onchain-transport-lib`; aucun `tracing` direct n'est introduit.
|
||||
|
||||
## 4. Canaris desktop packagés
|
||||
|
||||
Le test Config Desk :
|
||||
|
||||
```text
|
||||
pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root
|
||||
```
|
||||
|
||||
ne compare plus `tauri.conf.json` et `package.json` à chaque valeur de `CARGO_PKG_VERSION`. Cette égalité rendait le test faux dès l'ouverture d'une prerelease Cargo alors que les ressources desktop packagées restaient volontairement sur la baseline stable `0.2.6`.
|
||||
|
||||
Le canari Wallet Desk analogue est corrigé dans la même livraison afin d'éviter le même faux négatif lors du `cargo test --workspace` suivant.
|
||||
|
||||
Les garanties conservées sont :
|
||||
|
||||
```text
|
||||
version tauri >= 0.2.6
|
||||
version package >= 0.2.6
|
||||
version tauri == version package
|
||||
```
|
||||
|
||||
Le cœur SemVer est comparé sur `major.minor.patch`; un suffixe prerelease éventuel ne change pas ce floor. Wallet Desk conserve en plus la vérification que le HTML embarque la version packagée réellement déclarée.
|
||||
|
||||
Les versions npm/Tauri ne sont pas forcées à suivre chaque prerelease Cargo par ce correctif.
|
||||
|
||||
## 5. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.2.7/pre.002-fix.001.md
|
||||
```
|
||||
|
||||
## 6. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||
crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
||||
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||
crates/ksp-onchain-transport-lib/src/ws_settings.rs
|
||||
```
|
||||
|
||||
## 7. Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## 8. Validations exécutées pour la préparation de l'archive corrigée
|
||||
|
||||
Sur la reconstruction `0.2.7-pre.002 + pre.002-fix.001 corrigé` :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py OK / clean
|
||||
inspection workspace.package.version 0.2.7-pre.2.fix.1
|
||||
inspection absence de tracing direct OK
|
||||
inspection absence de ? dans ws_settings.rs OK
|
||||
inspection contenu archive OK
|
||||
```
|
||||
|
||||
## 9. Validations non exécutées dans le sandbox
|
||||
|
||||
Cargo n'est pas disponible dans l'environnement de préparation. Aucun gate Cargo n'est déclaré réussi pour cette archive corrigée.
|
||||
|
||||
## 10. Validation opérateur requise
|
||||
|
||||
Après application de **cette archive corrigée uniquement** :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-app-config-desk --test desktop_contract
|
||||
cargo test -p ksp-app-wallet-desk --test desktop_contract
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Si ce checkpoint est vert, le commit attendu est :
|
||||
|
||||
```text
|
||||
v0.2.7-pre.002-fix.001
|
||||
```
|
||||
|
||||
La tranche suivante reste `0.2.7-pre.003`.
|
||||
|
||||
## 11. Décisions prises
|
||||
|
||||
```text
|
||||
le fix code porte bien un signal Cargo fix.1
|
||||
les canaris desktop conservent un floor >= 0.2.6 au lieu d'une égalité à CARGO_PKG_VERSION
|
||||
aucune modification fonctionnelle supplémentaire de la surface WebSocket
|
||||
aucune synchronisation forcée des versions npm/Tauri avec les prereleases Cargo
|
||||
```
|
||||
|
||||
## 12. Questions ouvertes
|
||||
|
||||
```text
|
||||
aucune pour ce correctif
|
||||
```
|
||||
263
deltas/0.2.7/pre.002.md
Normal file
263
deltas/0.2.7/pre.002.md
Normal file
@@ -0,0 +1,263 @@
|
||||
<!-- file: deltas/0.2.7/pre.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.002` — WebSocket settings + lifecycle contracts
|
||||
|
||||
## 1. Objet
|
||||
|
||||
Cette tranche matérialise la première surface Rust WebSocket de `ksp-onchain-transport-lib` sans ouvrir encore de socket physique et sans ajouter de dépendance WebSocket externe.
|
||||
|
||||
Version workspace :
|
||||
|
||||
```text
|
||||
0.2.7-pre.2
|
||||
```
|
||||
|
||||
Livraison :
|
||||
|
||||
```text
|
||||
0.2.7-pre.002
|
||||
```
|
||||
|
||||
Commit attendu :
|
||||
|
||||
```text
|
||||
v0.2.7-pre.002
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
## 2. Surface Transport ajoutée
|
||||
|
||||
Settings publics :
|
||||
|
||||
```text
|
||||
WsEndpointUrl
|
||||
WsProviderName
|
||||
WsClusterName
|
||||
WsProtocolKind
|
||||
WsReconnectSettings
|
||||
WsResubscribePolicy
|
||||
WsSessionSettings
|
||||
WsEndpointSettings
|
||||
WsTransportSettings
|
||||
```
|
||||
|
||||
Lifecycle public :
|
||||
|
||||
```text
|
||||
WsSessionId
|
||||
WsSubscriptionId
|
||||
WsSessionState
|
||||
WsSubscriptionState
|
||||
WsSubscriptionKind
|
||||
WsSessionSnapshot
|
||||
WsSubscriptionSnapshot
|
||||
```
|
||||
|
||||
`WsProtocolKind` est `#[non_exhaustive]` et ne fournit pour `0.2.7` que `SolanaStandard`. Cette forme prépare l'ajout futur d'une famille provider-specific telle que Helius Enhanced WebSocket sans ajouter de paramètres Helius dans les settings Solana standard.
|
||||
|
||||
Les IDs locaux reposent sur `NonZeroU64`. Aucun ID serveur WebSocket n'entre dans le contrat public de contrôle.
|
||||
|
||||
## 3. URL et secrets
|
||||
|
||||
`WsEndpointUrl` :
|
||||
|
||||
- accepte uniquement `ws://` et `wss://` ;
|
||||
- exige un host ;
|
||||
- conserve la valeur sensible uniquement pour le futur code de connexion ;
|
||||
- rend `WsEndpointUrl(<redacted>)` en `Debug` ;
|
||||
- ne copie pas la valeur URL dans les erreurs de validation.
|
||||
|
||||
`WsEndpointSettings` et `WsTransportSettings` peuvent conserver `Debug` dérivé car le sous-type URL est lui-même redacted.
|
||||
|
||||
Les snapshots ne contiennent jamais :
|
||||
|
||||
```text
|
||||
URL complète
|
||||
credential/query token
|
||||
request body
|
||||
raw notification
|
||||
remote subscription id
|
||||
```
|
||||
|
||||
## 4. Settings session bornés
|
||||
|
||||
Defaults initiaux Transport, explicitement policies KSP locales :
|
||||
|
||||
```text
|
||||
command timeout 10 s
|
||||
close timeout 5 s
|
||||
reconnect retries 5
|
||||
reconnect initial backoff 250 ms
|
||||
reconnect maximum backoff 5 s
|
||||
command queue 128
|
||||
notification queue per sub 256
|
||||
active subscriptions 1024
|
||||
pending JSON-RPC requests 128
|
||||
maximum message 64 MiB
|
||||
maximum frame 16 MiB
|
||||
maximum write buffer 1 MiB
|
||||
resubscribe default ActiveSubscriptions
|
||||
```
|
||||
|
||||
Ces valeurs ne sont pas présentées comme des limites Solana. `pre.004`/`pre.005` devront les appliquer réellement à l'actor/socket et pourront les recalibrer si les fixtures adversariales le justifient.
|
||||
|
||||
Validation structurelle :
|
||||
|
||||
- timeouts non nuls ;
|
||||
- reconnect backoff non nul et ordonné ;
|
||||
- capacités/limites strictement positives ;
|
||||
- au moins un endpoint WS configuré et enabled ;
|
||||
- noms endpoint uniques ;
|
||||
- name/provider/cluster non vides et sans whitespace de bord.
|
||||
|
||||
## 5. Lifecycle et snapshots
|
||||
|
||||
États session matérialisés :
|
||||
|
||||
```text
|
||||
Disconnected
|
||||
Connecting
|
||||
Active
|
||||
Reconnecting { attempt }
|
||||
Closing
|
||||
Closed
|
||||
Failed
|
||||
```
|
||||
|
||||
États subscription matérialisés :
|
||||
|
||||
```text
|
||||
Requested
|
||||
Active
|
||||
Resubscribing
|
||||
Cancelling
|
||||
Closed
|
||||
Failed
|
||||
```
|
||||
|
||||
`WsSubscriptionKind` couvre les neuf familles standard auditées : account, block, logs, program, root, signature, slot, slotsUpdates et vote.
|
||||
|
||||
`WsSessionSnapshot` expose uniquement des metadata sûres : local session ID, endpoint logical name, provider, cluster, protocol, state, pending request count, continuity gap count, overflow count et projections de subscriptions.
|
||||
|
||||
`WsSubscriptionSnapshot` expose local subscription ID, kind, state et `remote_bound`; l'ID distant reste interne et remappable.
|
||||
|
||||
Les constructeurs de snapshots sont crate-internal : les consumers ne peuvent pas fabriquer de faux états runtime.
|
||||
|
||||
## 6. Logging et tracing
|
||||
|
||||
La constante existante reste l'autorité crate-wide :
|
||||
|
||||
```rust
|
||||
TRACING_TARGET = "ksp-onchain-transport-lib"
|
||||
```
|
||||
|
||||
Elle est définie dans `crates/ksp-onchain-transport-lib/src/constants.rs`.
|
||||
|
||||
Toute nouvelle émission passe par `ksp-logging-lib` :
|
||||
|
||||
- `trace` pour entrée/succès de validations et metadata endpoint sûres ;
|
||||
- `debug` pour settings validés, compteurs et bornes ;
|
||||
- `warn` pour rejets de settings/URL ;
|
||||
- aucun `error` artificiel pour une erreur de validation caller.
|
||||
|
||||
Aucun appel direct à `tracing` n'est ajouté. Les logs n'incluent jamais la valeur de `WsEndpointUrl`.
|
||||
|
||||
## 7. Tests ajoutés
|
||||
|
||||
Tests unitaires settings :
|
||||
|
||||
```text
|
||||
ws/wss acceptés
|
||||
HTTP rejeté
|
||||
Debug URL redacted
|
||||
erreur de scheme sans secret
|
||||
protocol kind standard
|
||||
settings defaults bornés
|
||||
zero bound rejeté
|
||||
reconnect backoff inversé rejeté
|
||||
transport endpoints valides
|
||||
endpoint names dupliqués rejetés
|
||||
au moins un endpoint enabled
|
||||
Debug transport sans URL/credential
|
||||
```
|
||||
|
||||
Tests unitaires lifecycle :
|
||||
|
||||
```text
|
||||
IDs locaux non-zéro et ordonnables
|
||||
états reconnect/resubscribe/cancelling distincts
|
||||
9 familles standard couvertes
|
||||
snapshot sans URL ni remote subscription id
|
||||
```
|
||||
|
||||
Un canari `tests/public_api.rs` vérifie l'accès crate-root aux nouveaux contrats.
|
||||
|
||||
## 8. Fichiers principaux
|
||||
|
||||
Nouveaux :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/src/ws_settings.rs
|
||||
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
|
||||
deltas/0.2.7/pre.002.md
|
||||
```
|
||||
|
||||
Modifiés :
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-onchain-transport-lib/src/constants.rs
|
||||
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
```
|
||||
|
||||
Aucune dependency externe n'est ajoutée dans cette tranche.
|
||||
|
||||
## 9. Validation disponible dans le sandbox
|
||||
|
||||
Exécuté après modifications :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
```
|
||||
|
||||
Le sandbox ne fournit toujours pas `cargo`; les gates Rust compilés ne sont donc pas déclarés réussis ici.
|
||||
|
||||
Baseline opérateur reçue avant `pre.002` : `cargo fmt`, audit Python, `cargo check` et `cargo clippy` verts sur `0.2.7-pre.1`. `cargo test --workspace` n'échoue que sur le canari Config Desk qui compare encore la ressource packagée `0.2.6` à la version workspace `0.2.7-pre.1`; aucune régression WebSocket n'y est impliquée.
|
||||
|
||||
## 10. Gates opérateur avant commit
|
||||
|
||||
Après application du delta :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
```
|
||||
|
||||
Le test workspace complet peut encore reproduire le canari de version packagée Config Desk tant que cette ressource n'est volontairement resynchronisée.
|
||||
|
||||
## 11. Suite
|
||||
|
||||
`0.2.7-pre.003` doit matérialiser :
|
||||
|
||||
```text
|
||||
std.transport V2 HTTP + WS
|
||||
backward read V1 HTTP-only
|
||||
schema/fixtures V2
|
||||
Config -> WsTransportSettings
|
||||
ws_endpoints[].kind = solana_standard
|
||||
```
|
||||
|
||||
La direction reste strictement `Config -> Transport`; aucun reverse dependency n'est autorisé.
|
||||
225
deltas/0.2.7/pre.003.md
Normal file
225
deltas/0.2.7/pre.003.md
Normal file
@@ -0,0 +1,225 @@
|
||||
<!-- file: deltas/0.2.7/pre.003.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.003` — `std.transport` V2 HTTP + WebSocket
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
```text
|
||||
0.2.7-pre.002-fix.001 appliquée
|
||||
workspace.package.version = 0.2.7-pre.2.fix.1
|
||||
```
|
||||
|
||||
Le checkpoint opérateur reçu avant cette tranche est entièrement vert : `cargo fmt`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport ciblés et `cargo test --workspace`.
|
||||
|
||||
## 2. Signal technique
|
||||
|
||||
Cette prerelease non-fix modifie configuration exécutable, Rust et tests. Conformément à `VER-ID-009` :
|
||||
|
||||
```text
|
||||
livraison = 0.2.7-pre.003
|
||||
workspace.package.version = 0.2.7-pre.3
|
||||
commit = v0.2.7-pre.003
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
## 3. `std.transport` V2
|
||||
|
||||
Le document standard livré devient :
|
||||
|
||||
```text
|
||||
format_version = 2
|
||||
retry
|
||||
ws_defaults
|
||||
default_profile
|
||||
profiles[]
|
||||
profile_id
|
||||
endpoints[]
|
||||
ws_endpoints[]
|
||||
kind
|
||||
session?
|
||||
```
|
||||
|
||||
Le HTTP existant reste inchangé dans `endpoints[]`. `ws_defaults` contient les defaults génériques de `WsSessionSettings`; `ws_endpoints[].session` peut surcharger seulement les paramètres génériques nécessaires à un endpoint.
|
||||
|
||||
Le discriminateur est obligatoire :
|
||||
|
||||
```text
|
||||
kind = solana_standard
|
||||
```
|
||||
|
||||
Toute autre famille est rejetée en `0.2.7`. Aucun paramètre Helius, LaserStream ou autre provider-specific n'est pré-implémenté.
|
||||
|
||||
## 4. Backward V1 strict
|
||||
|
||||
Le schema enregistré passe à :
|
||||
|
||||
```text
|
||||
urn:ksp:schema:std.transport:v2
|
||||
```
|
||||
|
||||
Il conserve deux branches strictes discriminées par `format_version` :
|
||||
|
||||
```text
|
||||
V1 -> HTTP-only historique
|
||||
V2 -> HTTP + ws_defaults + ws_endpoints
|
||||
```
|
||||
|
||||
Le V1 n'est pas rendu compatible par un relâchement de `additionalProperties`. Une fixture V1 dédiée prouve le chemin historique.
|
||||
|
||||
Dans l'adapter :
|
||||
|
||||
```text
|
||||
V1 -> HttpTransportSettings + ws_settings = None
|
||||
V2 -> HttpTransportSettings + Some(WsTransportSettings)
|
||||
```
|
||||
|
||||
`WsTransportSettings` conserve donc son invariant `pre.002` : il n'existe jamais comme faux conteneur vide.
|
||||
|
||||
## 5. Adapter Config -> Transport
|
||||
|
||||
`ResolvedTransportConfig` conserve `settings()` pour compatibilité HTTP et ajoute :
|
||||
|
||||
```text
|
||||
http_settings()
|
||||
ws_settings() -> Option<&WsTransportSettings>
|
||||
into_transport_settings() -> (HttpTransportSettings, Option<WsTransportSettings>)
|
||||
```
|
||||
|
||||
Le mapper V2 construit :
|
||||
|
||||
```text
|
||||
WsProviderName
|
||||
WsClusterName
|
||||
WsProtocolKind::SolanaStandard
|
||||
WsEndpointUrl
|
||||
WsReconnectSettings
|
||||
WsResubscribePolicy
|
||||
WsSessionSettings
|
||||
WsEndpointSettings
|
||||
WsTransportSettings
|
||||
```
|
||||
|
||||
La direction reste strictement `Config -> Transport`. Aucun import de Config n'est ajouté à `ksp-onchain-transport-lib`.
|
||||
|
||||
## 6. Overrides session
|
||||
|
||||
Les valeurs de `ws_defaults` correspondent aux defaults KSP matérialisés en `pre.002`. Un endpoint peut surcharger indépendamment :
|
||||
|
||||
```text
|
||||
command_timeout_ms
|
||||
close_timeout_ms
|
||||
reconnect.{max_retries, initial_backoff_ms, max_backoff_ms}
|
||||
resubscribe
|
||||
command_queue_capacity
|
||||
notification_queue_capacity
|
||||
max_active_subscriptions
|
||||
max_pending_requests
|
||||
max_message_size_bytes
|
||||
max_frame_size_bytes
|
||||
max_write_buffer_size_bytes
|
||||
```
|
||||
|
||||
La session résultante est toujours validée par `WsSessionSettings::validate()`.
|
||||
|
||||
## 7. Secrets et logging
|
||||
|
||||
Les URLs HTTP et WebSocket peuvent provenir de `KSP_SECRET_*`; les valeurs réelles restent disponibles au runtime mais la projection Config safe les redacted. Les erreurs d'adaptation ne recopient pas l'URL.
|
||||
|
||||
Le mapping Config utilise exclusivement `ksp-logging-lib` avec le `TRACING_TARGET` existant de `ksp-config-lib` :
|
||||
|
||||
```text
|
||||
trace -> début mapping et chemin backward V1
|
||||
debug -> version, compteurs HTTP/WS après validation
|
||||
```
|
||||
|
||||
Aucun `tracing` direct n'est ajouté.
|
||||
|
||||
## 8. Environment inventory
|
||||
|
||||
Ajouts `.env.example` :
|
||||
|
||||
```text
|
||||
KSP_PUBLIC_SOLANA_DEVNET_WS_URL
|
||||
KSP_PUBLIC_SOLANA_MAINNET_WS_URL
|
||||
# KSP_SECRET_SOLANA_WS_URL
|
||||
```
|
||||
|
||||
Les URLs provider privées restent des valeurs complètes gérées via `KSP_SECRET_*`.
|
||||
|
||||
## 9. Tests ajoutés/étendus
|
||||
|
||||
```text
|
||||
V2 fixture HTTP + WS complète
|
||||
ws_defaults + endpoint overrides
|
||||
V1 strict toujours chargeable
|
||||
V1 n'invente pas de WsTransportSettings vide
|
||||
committed Devnet/Mainnet V2 mappe HTTP + WS
|
||||
provenance ws_defaults globale et ws_endpoints profil
|
||||
secret WebSocket URL disponible au runtime mais redacted en safe/Debug
|
||||
public API canary pour les nouveaux accessors
|
||||
```
|
||||
|
||||
## 10. Fichiers principaux modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
.env.example
|
||||
config/std.transport.json
|
||||
config/examples/std.transport.example.json
|
||||
config/schemas/std.transport.schema.json
|
||||
crates/ksp-config-lib/src/transport.rs
|
||||
crates/ksp-config-lib/src/lib.rs
|
||||
crates/ksp-config-lib/src/registry.rs
|
||||
crates/ksp-config-lib/unit_tests/transport.rs
|
||||
crates/ksp-config-lib/unit_tests/fixtures/std.transport.json
|
||||
crates/ksp-config-lib/tests/public_api.rs
|
||||
crates/ksp-config-lib/README.md
|
||||
crates/ksp-config-lib/USAGE.md
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
```
|
||||
|
||||
Nouveau :
|
||||
|
||||
```text
|
||||
crates/ksp-config-lib/unit_tests/fixtures_v1/std.transport.json
|
||||
deltas/0.2.7/pre.003.md
|
||||
```
|
||||
|
||||
Aucune dépendance externe n'est ajoutée.
|
||||
|
||||
## 11. Validation de préparation
|
||||
|
||||
Le sandbox de génération ne fournit pas Cargo. Sont exécutés ici :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
validation JSON des documents/schema
|
||||
inspection absence de tracing direct
|
||||
inspection version workspace
|
||||
inspection archive delta
|
||||
```
|
||||
|
||||
Les gates compilés restent opérateur-only.
|
||||
|
||||
## 12. Gates opérateur avant commit
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Si le checkpoint est vert :
|
||||
|
||||
```text
|
||||
commit = v0.2.7-pre.003
|
||||
```
|
||||
|
||||
La tranche suivante est `0.2.7-pre.004` : dépendances WebSocket, actor physique, handshake/read/write, pending JSON-RPC et serveur local déterministe.
|
||||
79
deltas/0.2.7/pre.004-fix.001.md
Normal file
79
deltas/0.2.7/pre.004-fix.001.md
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- file: deltas/0.2.7/pre.004-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.004-fix.001` — conformité Clippy du runtime WebSocket
|
||||
|
||||
## 1. Objet
|
||||
|
||||
Ce correctif ferme les écarts de compilation stricte détectés par la validation opérateur de `0.2.7-pre.004` sans modifier la surface fonctionnelle WebSocket, les dépendances ni l'architecture de session.
|
||||
|
||||
Comme le correctif modifie du code Rust, le signal technique Cargo est synchronisé avec l'identité de livraison conformément aux règles KSP :
|
||||
|
||||
```text
|
||||
livraison = 0.2.7-pre.004-fix.001
|
||||
workspace.package.version = 0.2.7-pre.4.fix.1
|
||||
commit = v0.2.7-pre.004-fix.001
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
## 2. Écarts détectés sur `pre.004`
|
||||
|
||||
La validation opérateur a confirmé :
|
||||
|
||||
- `cargo fmt --all` : OK ;
|
||||
- `scripts/audit_rust_workspace_rules.py` : clean ;
|
||||
- `cargo check --workspace` : compilation réussie mais un warning `unreachable_code` dans `ws_session.rs` ;
|
||||
- `cargo clippy --workspace --all-targets` : échec sur trois violations `clippy::implicit_return` plus le warning `unreachable_code` ;
|
||||
- `cargo test --workspace` : tests fonctionnels verts, dont les cinq nouveaux canaris WebSocket de `pre.004`.
|
||||
|
||||
Le problème est donc limité à la conformité aux règles Rust strictes du workspace et non au comportement couvert par les tests.
|
||||
|
||||
## 3. Corrections `ws_session.rs`
|
||||
|
||||
Le runtime WebSocket est conservé fonctionnellement à l'identique.
|
||||
|
||||
Les corrections sont :
|
||||
|
||||
- `handle_session_command` n'utilise plus `return match ...` lorsque toutes les branches divergent déjà par des `return` explicites ; cela supprime l'expression inatteignable signalée par Rust ;
|
||||
- la collecte des requêtes JSON-RPC expirées n'utilise plus une closure `filter_map` à retours implicites ; une boucle explicite construit désormais la liste des identifiants expirés ;
|
||||
- la closure passée à `AtomicU64::fetch_update` retourne explicitement `current.checked_add(1)` afin de respecter `clippy::implicit_return` ;
|
||||
- le header de version de `ws_session.rs` passe de `1` à `2`.
|
||||
|
||||
Aucun changement n'est apporté :
|
||||
|
||||
- à `WsSession` ou à sa surface publique ;
|
||||
- aux états de lifecycle ;
|
||||
- aux limites de message/frame/write buffer ;
|
||||
- au pending map et aux timeouts ;
|
||||
- aux dépendances `tokio-tungstenite` / `futures-util` ;
|
||||
- au firewall de dépendances ;
|
||||
- au tracing : toutes les émissions restent exclusivement via `ksp-logging-lib` et `TRACING_TARGET = "ksp-onchain-transport-lib"`.
|
||||
|
||||
## 4. Version Cargo
|
||||
|
||||
Le `Cargo.toml` racine passe à :
|
||||
|
||||
```toml
|
||||
[workspace.package]
|
||||
version = "0.2.7-pre.4.fix.1"
|
||||
```
|
||||
|
||||
Aucune autre entrée Cargo n'est modifiée.
|
||||
|
||||
## 5. Validation attendue
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Le smoke Devnet reste opt-in et n'est pas requis pour ce correctif.
|
||||
|
||||
## 6. Suite
|
||||
|
||||
Si ce checkpoint est vert, `0.2.7-pre.004` est considéré clos via `pre.004-fix.001` et la série peut poursuivre avec `0.2.7-pre.005` : limites adversariales, control frames, cancellation, close et shutdown borné.
|
||||
240
deltas/0.2.7/pre.004.md
Normal file
240
deltas/0.2.7/pre.004.md
Normal file
@@ -0,0 +1,240 @@
|
||||
<!-- file: deltas/0.2.7/pre.004.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.004` — runtime WebSocket physique + actor JSON-RPC
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
```text
|
||||
0.2.7-pre.003 appliquée
|
||||
workspace.package.version = 0.2.7-pre.3
|
||||
```
|
||||
|
||||
Le checkpoint opérateur reçu avant cette tranche est vert : `cargo fmt --all`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport, tests Config et `cargo test --workspace`.
|
||||
|
||||
## 2. Signal technique
|
||||
|
||||
Cette prerelease non-fix modifie dépendances, runtime Rust et tests. Conformément au workflow KSP :
|
||||
|
||||
```text
|
||||
livraison = 0.2.7-pre.004
|
||||
workspace.package.version = 0.2.7-pre.4
|
||||
commit = v0.2.7-pre.004
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
## 3. Dépendances WebSocket matérialisées
|
||||
|
||||
Le réaudit du 22 août 2026 confirme les versions retenues depuis `pre.001` :
|
||||
|
||||
```text
|
||||
tokio-tungstenite 0.30.0
|
||||
futures-util 0.3.34
|
||||
```
|
||||
|
||||
Le root déclare sans features consumer :
|
||||
|
||||
```toml
|
||||
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||
futures-util = { version = "^0.3", default-features = false }
|
||||
```
|
||||
|
||||
Transport active seulement :
|
||||
|
||||
```text
|
||||
tokio-tungstenite : connect + rustls-tls-webpki-roots
|
||||
futures-util : sink + std
|
||||
tokio : macros + rt + sync + time
|
||||
```
|
||||
|
||||
Le fixture serveur local ajoute `tokio/net` côté dev.
|
||||
|
||||
Aucune dépendance Config, Store, Program, Wallet ou `tracing` direct n'est introduite.
|
||||
|
||||
## 4. `WsSession` physique
|
||||
|
||||
Nouvelle surface publique :
|
||||
|
||||
```text
|
||||
WsSession::connect(WsEndpointSettings)
|
||||
WsSession::id()
|
||||
WsSession::state()
|
||||
WsSession::snapshot()
|
||||
```
|
||||
|
||||
Un appel de `connect` crée exactement une connexion physique. Deux appels avec le même endpoint créent deux sockets indépendants ; aucun singleton, pool ou scheduler automatique n'est ajouté.
|
||||
|
||||
Le caller ne reçoit jamais le socket brut.
|
||||
|
||||
## 5. Actor propriétaire du socket
|
||||
|
||||
Une tâche actor unique possède :
|
||||
|
||||
```text
|
||||
WebSocketStream
|
||||
compteur JSON-RPC request id
|
||||
map pending requests
|
||||
bounded command receiver
|
||||
publication WsSessionSnapshot
|
||||
```
|
||||
|
||||
Le handle communique avec l'actor par `tokio::sync::mpsc` borné selon `command_queue_capacity`.
|
||||
|
||||
Les snapshots sont publiés via `tokio::sync::watch` et conservent seulement les metadata sûres prévues en `pre.002`.
|
||||
|
||||
## 6. Handshake et `WebSocketConfig`
|
||||
|
||||
`WsSession::connect` attend le handshake sous `command_timeout` et configure explicitement :
|
||||
|
||||
```text
|
||||
write_buffer_size = 0
|
||||
max_write_buffer_size = WsSessionSettings.max_write_buffer_size_bytes
|
||||
max_message_size = WsSessionSettings.max_message_size_bytes
|
||||
max_frame_size = WsSessionSettings.max_frame_size_bytes
|
||||
```
|
||||
|
||||
Le `write_buffer_size = 0` évite de rendre la validité de la configuration KSP dépendante du buffer par défaut interne de Tungstenite et garantit que le plafond configuré reste strictement supérieur au target buffer.
|
||||
|
||||
Les tests oversized et les recalibrages éventuels restent le gate `pre.005`.
|
||||
|
||||
## 7. Pending JSON-RPC
|
||||
|
||||
La primitive interne actor :
|
||||
|
||||
```text
|
||||
execute_json_rpc(method, params)
|
||||
```
|
||||
|
||||
reste **`pub(crate)`**. Elle n'est volontairement pas exposée comme API raw provider-extension publique.
|
||||
|
||||
Comportement :
|
||||
|
||||
- ID numérique KSP monotone par session ;
|
||||
- sérialisation via `JsonRpcRequest` existant ;
|
||||
- map `BTreeMap` bornée par `max_pending_requests` ;
|
||||
- deadline par request issue de `command_timeout` ;
|
||||
- dispatch des réponses par `id`, y compris si elles arrivent hors ordre ;
|
||||
- erreurs JSON-RPC applicatives renvoyées au caller concerné sans teardown de la connexion ;
|
||||
- ID réponse inconnu/stale ignoré avec diagnostic sûr ;
|
||||
- JSON structurellement invalide classé erreur protocole session.
|
||||
|
||||
Cette primitive sera consommée par le moteur de subscriptions à partir de `pre.006`.
|
||||
|
||||
## 8. Lifecycle limité à la tranche
|
||||
|
||||
`pre.004` matérialise :
|
||||
|
||||
```text
|
||||
Connecting -> Active
|
||||
connection/read/write failure -> Failed
|
||||
last handle dropped -> cleanup best-effort -> Closed
|
||||
```
|
||||
|
||||
Le reconnect/resubscribe reste `pre.007`.
|
||||
|
||||
Le shutdown async public, les budgets de Close et les fixtures peer hostile restent `pre.005`.
|
||||
|
||||
Ping reçu est répondu par Pong afin de conserver l'interopérabilité du socket. Aucun heartbeat applicatif périodique n'est ajouté.
|
||||
|
||||
## 9. Erreurs
|
||||
|
||||
Nouveaux codes publics :
|
||||
|
||||
```text
|
||||
ws_backpressure_overflow
|
||||
ws_connection_failed
|
||||
ws_protocol_error
|
||||
ws_session_closed
|
||||
```
|
||||
|
||||
Les erreurs de connexion WebSocket ne conservent volontairement pas la source Tungstenite brute : celle-ci pourrait contenir une request/URI ou d'autres détails provider. Les erreurs KSP exposent uniquement `session_id`, endpoint logique, provider et cluster.
|
||||
|
||||
## 10. Logging / tracing
|
||||
|
||||
Toutes les émissions passent exclusivement par `ksp-logging-lib` et réutilisent :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/src/constants.rs
|
||||
TRACING_TARGET = "ksp-onchain-transport-lib"
|
||||
```
|
||||
|
||||
Répartition principale :
|
||||
|
||||
```text
|
||||
trace -> ouverture socket, send/dispatch JSON-RPC, Ping/Pong, notification prématurée ignorée
|
||||
debug -> actor start, handshake actif, réponse stale, timeout pending, remote close
|
||||
warn -> handshake/read/write failure, malformed wire, capacité pending épuisée
|
||||
```
|
||||
|
||||
Ne sont jamais loggés : URL complète, credentials, query token, payload JSON-RPC complet ou notification brute.
|
||||
|
||||
## 11. Serveur local déterministe
|
||||
|
||||
Nouveaux tests runtime sans Internet :
|
||||
|
||||
```text
|
||||
handshake local + round-trip JSON-RPC
|
||||
deux sessions physiques distinctes sur la même URL
|
||||
deux requests concurrentes + réponses inversées
|
||||
application error sans teardown de session
|
||||
connection error sans fuite URL/credential
|
||||
```
|
||||
|
||||
Le serveur utilise `tokio::net::TcpListener` + `tokio_tungstenite::accept_async`.
|
||||
|
||||
## 12. Canaris workspace/public API
|
||||
|
||||
Le canari workspace dependencies est synchronisé avec les nouvelles dépendances/features et continue de vérifier le firewall Transport.
|
||||
|
||||
Le canari public API vérifie la disponibilité de `WsSession` et les quatre nouveaux codes d'erreur.
|
||||
|
||||
## 13. Documentation synchronisée
|
||||
|
||||
Mis à jour :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
```
|
||||
|
||||
`ROADMAP.md` et `CHANGELOG.md` restent inchangés conformément à la politique de série prerelease.
|
||||
|
||||
## 14. Validation de préparation
|
||||
|
||||
Exécuté dans le sandbox :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
|
||||
inspection absence tracing direct OK
|
||||
inspection TRACING_TARGET OK
|
||||
inspection dépendances workspace/member OK
|
||||
inspection Markdown tables OK
|
||||
```
|
||||
|
||||
Cargo n'est pas disponible dans le sandbox de génération ; aucun résultat Cargo local n'est revendiqué.
|
||||
|
||||
## 15. Gates opérateur avant commit
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Si le checkpoint est vert :
|
||||
|
||||
```text
|
||||
commit = v0.2.7-pre.004
|
||||
```
|
||||
|
||||
La tranche suivante est `0.2.7-pre.005` : adversarial limits frame/message/request, control frames, cancellation, close/shutdown explicite et peer hostile.
|
||||
172
deltas/0.2.7/pre.005.md
Normal file
172
deltas/0.2.7/pre.005.md
Normal 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.
|
||||
131
deltas/0.2.7/pre.006-fix.001.md
Normal file
131
deltas/0.2.7/pre.006-fix.001.md
Normal 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
287
deltas/0.2.7/pre.006.md
Normal 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.
|
||||
139
deltas/0.2.7/pre.007-fix.001.md
Normal file
139
deltas/0.2.7/pre.007-fix.001.md
Normal 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
152
deltas/0.2.7/pre.007.md
Normal 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.
|
||||
63
deltas/0.2.7/pre.008-fix.001.md
Normal file
63
deltas/0.2.7/pre.008-fix.001.md
Normal 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
163
deltas/0.2.7/pre.008.md
Normal 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.
|
||||
81
deltas/0.2.7/pre.009-fix.001.md
Normal file
81
deltas/0.2.7/pre.009-fix.001.md
Normal 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.
|
||||
94
deltas/0.2.7/pre.009-fix.002.md
Normal file
94
deltas/0.2.7/pre.009-fix.002.md
Normal 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
197
deltas/0.2.7/pre.009.md
Normal 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.
|
||||
98
deltas/0.2.7/pre.010-fix.001.md
Normal file
98
deltas/0.2.7/pre.010-fix.001.md
Normal 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
189
deltas/0.2.7/pre.010.md
Normal 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`.
|
||||
99
deltas/0.2.7/pre.011-fix.001.md
Normal file
99
deltas/0.2.7/pre.011-fix.001.md
Normal 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
214
deltas/0.2.7/pre.011.md
Normal 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.
|
||||
71
deltas/0.2.7/pre.012-fix.001.md
Normal file
71
deltas/0.2.7/pre.012-fix.001.md
Normal 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
77
deltas/0.2.7/pre.012.md
Normal 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
99
deltas/0.2.7/pre.013.md
Normal 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.
|
||||
279
deltas/0.2.7/pre.014-fix.001.md
Normal file
279
deltas/0.2.7/pre.014-fix.001.md
Normal 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 15–20 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 15–20 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 > 15–20 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
131
deltas/0.2.7/pre.014.md
Normal 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 l’opé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 n’est 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 n’est 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 l’analyse 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 l’historique 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 n’est 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 l’ordre de lecture des sources internes ;
|
||||
- impose un réaudit Helius officiel actuel avant implémentation et référence les points d’entré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 jusqu’au 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 l’anticiper.
|
||||
|
||||
## Runtime / API
|
||||
|
||||
Aucun fichier `src/` n’est modifié. Aucun contrat HTTP/WebSocket, DTO, lifecycle, reconnect, backpressure, Config runtime ou dépendance directe Transport n’est 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 n’ont 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
254
deltas/0.2.7/rel.001.md
Normal 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.
|
||||
269
deltas/0.2.8/pre.001-fix.001.md
Normal file
269
deltas/0.2.8/pre.001-fix.001.md
Normal 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 15–20 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.
|
||||
174
deltas/0.2.8/pre.001-fix.002.md
Normal file
174
deltas/0.2.8/pre.001-fix.002.md
Normal 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 15–20 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
268
deltas/0.2.8/pre.001.md
Normal 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 ~15–20 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.
|
||||
250
deltas/0.2.8/pre.002-fix.001.md
Normal file
250
deltas/0.2.8/pre.002-fix.001.md
Normal 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
278
deltas/0.2.8/pre.002.md
Normal 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
259
deltas/0.2.8/pre.003.md
Normal 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.
|
||||
182
deltas/0.2.8/pre.004-fix.001.md
Normal file
182
deltas/0.2.8/pre.004-fix.001.md
Normal 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.
|
||||
139
deltas/0.2.8/pre.004-fix.002.md
Normal file
139
deltas/0.2.8/pre.004-fix.002.md
Normal 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
174
deltas/0.2.8/pre.004.md
Normal 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.
|
||||
95
deltas/0.2.8/pre.005-fix.001.md
Normal file
95
deltas/0.2.8/pre.005-fix.001.md
Normal 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.
|
||||
58
deltas/0.2.8/pre.005-fix.002.md
Normal file
58
deltas/0.2.8/pre.005-fix.002.md
Normal 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
277
deltas/0.2.8/pre.005.md
Normal 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
235
deltas/0.2.8/pre.006.md
Normal 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.
|
||||
111
deltas/0.2.8/pre.007-fix.001.md
Normal file
111
deltas/0.2.8/pre.007-fix.001.md
Normal 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
Reference in New Issue
Block a user