Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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: 5
|
||||
|
||||
# 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
|
||||
|
||||
# Optional complete private-provider WebSocket endpoint URL used only by the Transport example when explicitly selected.
|
||||
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
||||
# KSP_SECRET_SOLANA_WS_URL=wss://provider.example/?api-key=replace-me
|
||||
|
||||
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
||||
KSP_DESK_SPLASH_FADE_IN_MS=300
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 10 -->
|
||||
<!-- version: 11 -->
|
||||
|
||||
# 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.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: 216
|
||||
|
||||
[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.7"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
@@ -21,6 +21,7 @@ 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 }
|
||||
@@ -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: 82 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -51,7 +51,7 @@ 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.
|
||||
- [X] `0.2.7` — WebSocket Solana standard stable : 9 familles subscribe/unsubscribe typées (18/18 opérations), sessions physiques multiples explicites, subscriptions logiques typées, lifecycle/reconnect/resubscribe/backpressure/shutdown bornés, Config V2, non-régression HTTP 52+14, compliance finale, smoke WebSocket Devnet et audit de dépendances validés ; publication `rel.001` et prompt `0.2.8` prêts.
|
||||
- [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.
|
||||
- [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
|
||||
- [ ] `0.2.10` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
||||
|
||||
@@ -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,28 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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_private_ws",
|
||||
"enabled": true,
|
||||
"provider": "private-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_SECRET_SOLANA_WS_URL:-wss://example.invalid}",
|
||||
"session": {
|
||||
"notification_queue_capacity": 512,
|
||||
"max_active_subscriptions": 2048
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,322 @@
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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: 8 -->
|
||||
|
||||
# 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,18 @@ 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 exige actuellement `kind = "solana_standard"`. 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.
|
||||
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: 3
|
||||
|
||||
/// 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,171 @@ 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),
|
||||
_ => 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,25 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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: 4
|
||||
|
||||
#[test]
|
||||
fn fixture_transport_profile_maps_complete_runtime_contract() {
|
||||
@@ -41,6 +41,49 @@ 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(), 1);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 +102,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 +162,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 +229,36 @@ 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 transport_secret_url_provenance_uses_process_and_process_beats_dotenv() {
|
||||
let engine = fixture_engine();
|
||||
@@ -216,6 +326,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: 4
|
||||
|
||||
//! 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 = [\"net\", \"rt\"] }"));
|
||||
}
|
||||
|
||||
#[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: 5
|
||||
|
||||
[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 = ["net", "rt"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 19 -->
|
||||
|
||||
# `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,134 @@ 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.
|
||||
|
||||
## Résilience
|
||||
|
||||
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
||||
@@ -121,31 +235,39 @@ 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.
|
||||
|
||||
## 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: 19 -->
|
||||
|
||||
# Utilisation de `ksp-onchain-transport-lib`
|
||||
|
||||
@@ -65,7 +65,159 @@ 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.
|
||||
|
||||
### 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 +238,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 +248,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 +277,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 +291,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 +306,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 +320,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 +332,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 +342,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 +358,14 @@ 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.
|
||||
Les endpoints publics Solana sont rate-limités et non destinés à la production. Un échec réseau externe n'est pas assimilé automatiquement à une régression locale ; les fixtures HTTP et WebSocket locales restent les gates reproductibles.
|
||||
|
||||
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: 29
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -16,6 +16,15 @@
|
||||
//! 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.
|
||||
|
||||
mod client;
|
||||
mod constants;
|
||||
@@ -34,6 +43,14 @@ mod rpc_method;
|
||||
mod rpc_tokens;
|
||||
mod rpc_transactions;
|
||||
mod settings;
|
||||
mod ws_accounts;
|
||||
mod ws_blocks;
|
||||
mod ws_cluster;
|
||||
mod ws_lifecycle;
|
||||
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 +86,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 +128,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 +208,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 +316,70 @@ 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;
|
||||
/// 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;
|
||||
/// Standard Solana subscription family represented by one logical WebSocket subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionKind;
|
||||
/// Safe lifecycle projection for one logical WebSocket subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionSnapshot;
|
||||
/// Observable lifecycle state of one logical WebSocket subscription.
|
||||
pub use self::ws_lifecycle::WsSubscriptionState;
|
||||
/// Shareable handle for one explicitly created physical WebSocket session.
|
||||
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 Solana 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 +395,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),
|
||||
),
|
||||
|
||||
282
crates/ksp-onchain-transport-lib/src/ws_accounts.rs
Normal file
282
crates/ksp-onchain-transport-lib/src/ws_accounts.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_accounts.rs
|
||||
// version: 2
|
||||
|
||||
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(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_accounts.rs"]
|
||||
mod tests;
|
||||
215
crates/ksp-onchain-transport-lib/src/ws_blocks.rs
Normal file
215
crates/ksp-onchain-transport-lib/src/ws_blocks.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_blocks.rs
|
||||
// version: 1
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_blocks.rs"]
|
||||
mod tests;
|
||||
406
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
Normal file
406
crates/ksp-onchain-transport-lib/src/ws_cluster.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_cluster.rs
|
||||
// version: 3
|
||||
|
||||
/// 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,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/ws_cluster.rs"]
|
||||
mod tests;
|
||||
358
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
Normal file
358
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||
// version: 6
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Standard Solana subscription family represented by one logical WebSocket 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,
|
||||
}
|
||||
|
||||
impl WsSubscriptionKind {
|
||||
/// Returns the stable KSP descriptor for this standard 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",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact standard Solana 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",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact standard Solana 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",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact standard Solana 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",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns whether Solana documents this standard subscription family as unstable.
|
||||
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 standard 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;
|
||||
2326
crates/ksp-onchain-transport-lib/src/ws_session.rs
Normal file
2326
crates/ksp-onchain-transport-lib/src/ws_session.rs
Normal file
File diff suppressed because it is too large
Load Diff
571
crates/ksp-onchain-transport-lib/src/ws_settings.rs
Normal file
571
crates/ksp-onchain-transport-lib/src/ws_settings.rs
Normal file
@@ -0,0 +1,571 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_settings.rs
|
||||
// version: 3
|
||||
|
||||
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.
|
||||
///
|
||||
/// `0.2.7` exposes only standard Solana WebSocket. The non-exhaustive contract allows later provider-specific families without changing the common endpoint
|
||||
/// container or injecting provider-only options into [`WsSessionSettings`].
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WsProtocolKind {
|
||||
/// Standard Solana JSON-RPC WebSocket PubSub.
|
||||
SolanaStandard,
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: 5
|
||||
|
||||
/// Typed handle for one logical Solana 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 standard Solana 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 Solana 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,
|
||||
/// Standard Solana subscription family.
|
||||
pub(crate) kind: crate::WsSubscriptionKind,
|
||||
/// Current logical lifecycle state.
|
||||
pub(crate) state: crate::WsSubscriptionState,
|
||||
/// Original standard subscribe parameters retained internally for deterministic resubscribe.
|
||||
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());
|
||||
}
|
||||
251
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
Normal file
251
crates/ksp-onchain-transport-lib/src/ws_transactions.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_transactions.rs
|
||||
// version: 3
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
#[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: 33
|
||||
|
||||
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||
|
||||
@@ -521,3 +521,158 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||
// version: 22
|
||||
// version: 24
|
||||
|
||||
//! 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,42 @@ 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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
125
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
Normal file
125
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
|
||||
// version: 4
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
1136
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
Normal file
1136
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
Normal file
File diff suppressed because it is too large
Load Diff
148
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
Normal file
148
crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_settings.rs
|
||||
// version: 1
|
||||
|
||||
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_is_extensible_but_only_standard_is_available_now() {
|
||||
assert_eq!(crate::WsProtocolKind::SolanaStandard.as_str(), "solana_standard");
|
||||
}
|
||||
|
||||
#[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.
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/IDEAS.md -->
|
||||
<!-- version: 24 -->
|
||||
<!-- version: 25 -->
|
||||
|
||||
# Idées à explorer
|
||||
|
||||
@@ -96,6 +96,20 @@ Aucun `ksp-data-api` global n'est prévu actuellement. Les modèles appartiennen
|
||||
|
||||
Réévaluer seulement si les premières implémentations montrent une duplication réellement nuisible impossible à résoudre sans contrat commun supplémentaire.
|
||||
|
||||
### Mesure du trafic et metering futur
|
||||
|
||||
**Status :** À explorer plus tard
|
||||
|
||||
Ne pas introduire de métriques de volume supplémentaires dans `ksp-onchain-transport-lib` pendant `0.2.7`. La surface HTTP/WebSocket doit d'abord être clôturée et les besoins réels doivent être observés avec le futur stockage raw.
|
||||
|
||||
La direction à étudier est de conserver `ksp-onchain-transport-lib` comme source de vérité pour les faits effectivement observés à la frontière réseau, par exemple volumes de payload entrants/sortants, requêtes/réponses, notifications WebSocket, retries et reconnects. Ces mesures doivent rester neutres et ne pas embarquer de modèle de prix, de crédits ou de facturation propre à un fournisseur.
|
||||
|
||||
Le futur Store conservera les données raw nécessaires aux analyses historiques sur les types de réponses, leurs tailles, leur fréquence et leur distribution. Il ne doit cependant pas être considéré comme la source de vérité des octets réellement transportés, car les données persistées peuvent différer de ce qui a été reçu ou envoyé sur le réseau.
|
||||
|
||||
Étudier ultérieurement une crate dédiée, nom provisoire `ksp-metering-lib` ou `ksp-metering-observer-lib`, chargée d'observer/agréger ces mesures sans devenir un filtre obligatoire dans le data path. Son nom, son API, sa relation exacte avec Store et la granularité des événements restent à définir.
|
||||
|
||||
Le besoin visé est d'abord l'observabilité et l'analyse statistique internes KSP ; aucune dépendance aux données de facturation remontées par les fournisseurs n'est requise.
|
||||
|
||||
### Off-chain volontairement hétérogène
|
||||
|
||||
**Status :** Retenue
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/architecture/004-COMPONENT_INVENTORY.md -->
|
||||
<!-- version: 19 -->
|
||||
<!-- version: 20 -->
|
||||
|
||||
# Inventaire initial des composants KSP
|
||||
|
||||
@@ -27,7 +27,7 @@ Ce document maintient l'inventaire synthétique des composants retenus ou presse
|
||||
| Wallet | `ksp-wallet-lib` | lib | Stable | `0.2.5` | `.kspwallet`, VIEW/OWNER, secrets, signature, import/export |
|
||||
| Wallet Desk | `ksp-app-wallet-desk` | app | Stable | `0.2.6` | Wallet + Config composite + HTTP/balance |
|
||||
| Wallet V2 | `ksp-wallet-lib` | lib | Stable | `0.2.6` | wire/runtime V2 + API default/versionnée + migration explicite |
|
||||
| Standard WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.7` | WebSocket Solana complet, sessions/subscriptions |
|
||||
| Standard WS | `ksp-onchain-transport-lib` | lib | Stable | `0.2.7` | WebSocket Solana 18/18, sessions/subscriptions bornées |
|
||||
| Helius WS | `ksp-onchain-transport-lib` | lib | Retenu | `0.2.8` | LaserStream WebSocket comme extension du moteur standard |
|
||||
| Yellowstone | `ksp-onchain-transport-lib` | lib | Pressenti | `0.2.9` | client gRPC standard/provider-neutral |
|
||||
| Off-chain price | `ksp-offchain-transport-lib` | lib | Retenu | `0.2.10` | première abstraction/provider de prix SOL/USD, SOL/EUR |
|
||||
@@ -79,7 +79,7 @@ ksp-data-api
|
||||
|
||||
## Transport
|
||||
|
||||
`ksp-onchain-transport-lib` doit couvrir l'intégralité des opérations documentées de la surface ciblée par chaque release. `0.2.1` stabilise la foundation HTTP et quatre wrappers typés canari, `0.2.2` ajoute 22 wrappers Accounts/Tokens/Cluster et `0.2.3` stabilise les 11 Transactions. La release stable `0.2.4` ajoute les 10 Blocks + 5 Economics et atteint 52/52 méthodes HTTP courantes typées, avec 14/14 historiques Deprecated/Removed conservées pour compliance. Les statuts deprecated/obsolete encore fonctionnels et unstable/experimental restent exposés avec warning runtime KSP.
|
||||
`ksp-onchain-transport-lib` doit couvrir l'intégralité des opérations documentées de la surface ciblée par chaque release. `0.2.1` stabilise la foundation HTTP et quatre wrappers typés canari, `0.2.2` ajoute 22 wrappers Accounts/Tokens/Cluster et `0.2.3` stabilise les 11 Transactions. La release stable `0.2.4` ajoute les 10 Blocks + 5 Economics et atteint 52/52 méthodes HTTP courantes typées, avec 14/14 historiques Deprecated/Removed conservées pour compliance. Les statuts deprecated/obsolete encore fonctionnels et unstable/experimental restent exposés avec warning runtime KSP. La release stable `0.2.7` ajoute le moteur WebSocket Solana standard complet : 9 familles subscribe + 9 unsubscribe typées, sessions physiques explicites, subscriptions logiques, reconnect/resubscribe/backpressure/shutdown bornés et Config Transport V2, sans second composant réseau ni dépendance inverse vers Config.
|
||||
|
||||
La Config standard Transport appartient à `ksp-config-lib`, qui adapte vers les settings publics du transport ; le transport ne dépend jamais de Config.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/000-README.md -->
|
||||
<!-- version: 53 -->
|
||||
<!-- version: 56 -->
|
||||
|
||||
# Plans KSP
|
||||
|
||||
@@ -22,6 +22,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou
|
||||
- [`011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) — plan historique clôturé de la release stable `0.2.4`, ouvert par `pre.001`, exécuté jusqu’à `pre.009`, complété par le fix documentaire Wallet `pre.009-fix.001` puis publié par `rel.001`; il couvre les 10 Blocks + 5 Economics et la compliance finale `52/52 + 14/14` sous `KSP-TRANSPORT-007`.
|
||||
- [`012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.2.5 — Wallet foundation`, ouvert par `pre.001`, livré jusqu’à `pre.010`, renforcé par `pre.010-fix.001`–`fix.003` pour Dalek 3 et la normalisation Rust/audit structurel, puis publié par `rel.001`; il couvre `.kspwallet` V1, VIEW/OWNER, crypto, persistence, administration, transfer et compliance.
|
||||
- [`013-V0_2_6_WALLET_DESK_PLAN.md`](013-V0_2_6_WALLET_DESK_PLAN.md) — plan historique clôturé de la release stable `0.2.6 — Wallet Desk`, ouvert par `pre.001`, étendu en `pre.015`–`pre.017` au wire binaire `.kspwallet` V2, aux APIs multi-version et à la migration V1 -> V2, puis fermé par `pre.018`/`fix.001` avec le runtime Tauri packagé et le build final vert avant publication `rel.001`.
|
||||
- [`014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md`](014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md) — plan historique clôturé de la release stable `0.2.7 — WebSocket Solana standard`, ouvert par `pre.001`, exécuté jusqu’à `pre.014`, corrigé documentairement par `pre.014-fix.001` puis publié par `rel.001`; il conserve l’inventaire officiel 18 méthodes, le modèle session/subscription, le threat model, les preuves de compliance/smoke/dépendances et la préparation de `0.2.8`.
|
||||
|
||||
Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md -->
|
||||
<!-- version: 79 -->
|
||||
<!-- version: 82 -->
|
||||
|
||||
# Séquence des releases fonctionnelles KSP
|
||||
|
||||
@@ -455,9 +455,13 @@ La tranche historique `pre.014` a traité les défauts visuels/templating observ
|
||||
|
||||
### `0.2.7` — WebSocket Solana standard
|
||||
|
||||
Mission : couvrir la surface WebSocket standard officielle ciblée.
|
||||
Mission accomplie : couvrir exhaustivement la surface WebSocket Solana standard officielle ciblée, avec sessions physiques explicites, subscriptions typées, lifecycle borné, reconnexion/resubscribe déterministes et observabilité sûre. Le plan historique clôturé est [`014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md`](014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md) et la matrice finale est [`../validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md`](../validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md).
|
||||
|
||||
Une URL peut avoir plusieurs sessions physiques ; une session peut avoir plusieurs subscriptions. Un pool automatique de sessions est reporté jusqu'à besoin concret.
|
||||
Le gate `0.2.7-pre.001`, audité le 22 août 2026, inventorie exactement 18 opérations WebSocket documentées : 9 subscribe + 9 unsubscribe. `blockSubscribe`, `slotsUpdatesSubscribe` et `voteSubscribe` sont actuellement marquées unstable ; aucune méthode de l'index officiel courant n'est marquée Deprecated.
|
||||
|
||||
Une URL peut avoir plusieurs sessions physiques explicites ; une session peut avoir plusieurs subscriptions. Un pool/scheduler automatique de sessions reste reporté jusqu'à besoin concret. Les IDs de session/subscription KSP sont locaux et stables ; les IDs serveur restent internes et peuvent être remappés après reconnexion.
|
||||
|
||||
La candidate atteint `pre.014` après matérialisation des 9 familles standard, compliance 18/18, composition Config V2, reconnect/resubscribe/backpressure bornés, smoke WebSocket Devnet et audit du graphe Cargo. `pre.014-fix.001` renforce uniquement le prompt suivant, puis `0.2.7-rel.001` publie la surface stable sans nouvelle capacité runtime. `prompts/013-V0_2_8_START_PROMPT.md` devient le contrat actif pour `0.2.8` depuis `v0.2.7`.
|
||||
|
||||
### `0.2.8` — Helius LaserStream WebSocket
|
||||
|
||||
|
||||
1078
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
Normal file
1078
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/validation/000-README.md -->
|
||||
<!-- version: 17 -->
|
||||
<!-- version: 20 -->
|
||||
|
||||
# Validations KSP
|
||||
|
||||
@@ -18,3 +18,4 @@ Documents :
|
||||
- [`007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée de `0.2.4`, inventaire exact 52 current + 14 Deprecated, preuve typed 52/52, audit SIMD final, `KSP-TRANSPORT-007`, workspace complet et deux smokes Devnet passés avant publication stable.
|
||||
- [`008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) — matrice finale validée de la release stable `0.2.5`, threat model V1, canaris adversariaux, reproduction externe des vecteurs, audit de frontières, normalisation Rust/audit structurel, graphes Cargo et checkpoint final `pre.010-fix.003` vert.
|
||||
- [`009-V0_2_6_WALLET_DESK_COMPLIANCE.md`](009-V0_2_6_WALLET_DESK_COMPLIANCE.md) — matrice finale validée de la release stable `0.2.6`, couvrant Wallet Desk, les wires V1/V2, la migration explicite, le runtime Tauri packagé, les frontières sécurité/ownership et le gate opérateur `pre.018-fix.001` avec build final Linux vert.
|
||||
- [`010-V0_2_7_ONCHAIN_WEBSOCKET.md`](010-V0_2_7_ONCHAIN_WEBSOCKET.md) — matrice finale validée de la release stable `0.2.7`, ouverte par `pre.001`, fermée techniquement par `pre.014` puis publiée par `rel.001` : inventaire 9 subscribe + 9 unsubscribe, lifecycle borné, statuts unstable, compliance 18/18, non-régression HTTP 52+14, composition Config V2, smoke WebSocket Devnet et audit de dépendances.
|
||||
|
||||
787
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
Normal file
787
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
Normal file
@@ -0,0 +1,787 @@
|
||||
<!-- file: docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md -->
|
||||
<!-- version: 17 -->
|
||||
|
||||
# Validation `0.2.7` — WebSocket Solana standard
|
||||
|
||||
> **Statut : matrice finale validée, publiée par `0.2.7-rel.001`.** La compliance 18/18, le lifecycle borné, la composition Config V2, la non-régression HTTP, le smoke WebSocket Devnet et l’audit de dépendances sont validés. Le checkpoint opérateur post-`pre.014-fix.001` confirme le workspace avant passage au tag stable.
|
||||
|
||||
## 1. Baseline normative
|
||||
|
||||
Audit officiel effectué le **22 août 2026** contre :
|
||||
|
||||
```text
|
||||
https://solana.com/docs/rpc/websocket
|
||||
```
|
||||
|
||||
Compte exact de l'index courant :
|
||||
|
||||
```text
|
||||
9 subscribe
|
||||
9 unsubscribe
|
||||
18 méthodes WebSocket totales
|
||||
```
|
||||
|
||||
Répartition statut KSP :
|
||||
|
||||
```text
|
||||
12 méthodes appartenant à 6 paires documentées non marquées unstable/deprecated
|
||||
6 méthodes appartenant à 3 paires unstable : block, slotsUpdates, vote
|
||||
0 méthode de l'index courant marquée Deprecated
|
||||
```
|
||||
|
||||
Pour une paire unstable, l'unsubscribe associé est classé `Unstable pair` dans KSP même si sa propre page n'affiche pas nécessairement le bandeau, car il n'existe que pour annuler la subscription unstable correspondante.
|
||||
|
||||
## 2. Matrice exhaustive des 18 opérations
|
||||
|
||||
| # | Méthode | Type | Statut `pre.001` | Paramètres / résultat essentiels | Notification / paire | Stratégie de test | Source officielle | Compliance |
|
||||
|---:|---------------------------|-------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------------|-------------------|
|
||||
| 1 | `accountSubscribe` | subscribe | Stable/documented | pubkey ; config `commitment`, `encoding`, `dataSlice` ; result numeric id ; `minContextSlot` upstream actuellement ignoré, donc non promis | `accountNotification` | fixture encodings/config + subscribe/notify | `https://solana.com/docs/rpc/websocket/accountsubscribe` | Done `pre.009` |
|
||||
| 2 | `accountUnsubscribe` | unsubscribe | Stable/documented | remote id ; `true` or RPC error unknown id | account pair | handle local -> remote id fixture | `https://solana.com/docs/rpc/websocket/accountunsubscribe` | Done `pre.009` |
|
||||
| 3 | `blockSubscribe` | subscribe | **Unstable** | `all`/mentions filter ; confirmed/finalized ; encoding ; tx details ; max tx version ; showRewards | `blockNotification` | all options + null block/error + validator capability fixture | `https://solana.com/docs/rpc/websocket/blocksubscribe` | Done `pre.011` |
|
||||
| 4 | `blockUnsubscribe` | unsubscribe | **Unstable pair** | remote id ; boolean/error | block pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/blockunsubscribe` | Done `pre.011` |
|
||||
| 5 | `logsSubscribe` | subscribe | Stable/documented | `all`, `allWithVotes`, exactly one `mentions`; commitment | `logsNotification` | 3 filters + invalid multi-mention + notification | `https://solana.com/docs/rpc/websocket/logssubscribe` | Done `pre.009` |
|
||||
| 6 | `logsUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | logs pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/logsunsubscribe` | Done `pre.009` |
|
||||
| 7 | `programSubscribe` | subscribe | Stable/documented | program pubkey ; commitment ; filters ; encoding ; dataSlice ; `withContext` | `programNotification` | contexted/non-contexted fixtures + filters | `https://solana.com/docs/rpc/websocket/programsubscribe` | Done `pre.009` |
|
||||
| 8 | `programUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | program pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/programunsubscribe` | Done `pre.009` |
|
||||
| 9 | `rootSubscribe` | subscribe | Stable/documented | no params ; numeric id | `rootNotification` => `u64` | exact root fixture | `https://solana.com/docs/rpc/websocket/rootsubscribe` | Done `pre.010` |
|
||||
| 10 | `rootUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | root pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/rootunsubscribe` | Done `pre.010` |
|
||||
| 11 | `signatureSubscribe` | subscribe | Stable/documented | first transaction signature ; commitment ; `enableReceivedNotification` | `signatureNotification` early string or terminal error object | early + terminal + auto-close/no-resubscribe | `https://solana.com/docs/rpc/websocket/signaturesubscribe` | Done `pre.010` |
|
||||
| 12 | `signatureUnsubscribe` | unsubscribe | Stable/documented | remote id before terminal fire ; boolean/error | signature pair | cancel before terminal + stale after terminal | `https://solana.com/docs/rpc/websocket/signatureunsubscribe` | Done `pre.010` |
|
||||
| 13 | `slotSubscribe` | subscribe | Stable/documented | no params ; numeric id | `slotNotification` `{slot,parent,root}` | exact fixture + live smoke Devnet passé | `https://solana.com/docs/rpc/websocket/slotsubscribe` | Done `pre.010` |
|
||||
| 14 | `slotUnsubscribe` | unsubscribe | Stable/documented | remote id ; boolean/error | slot pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/slotunsubscribe` | Done `pre.010` |
|
||||
| 15 | `slotsUpdatesSubscribe` | subscribe | **Unstable** | no params ; numeric id | tagged `slotsUpdatesNotification` | each known variant + unknown fallback | `https://solana.com/docs/rpc/websocket/slotsupdatessubscribe` | Done `pre.011` |
|
||||
| 16 | `slotsUpdatesUnsubscribe` | unsubscribe | **Unstable pair** | remote id ; boolean/error | slotsUpdates pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/slotsupdatesunsubscribe` | Done `pre.011` |
|
||||
| 17 | `voteSubscribe` | subscribe | **Unstable** | no params ; validator flag required | `voteNotification` | fields + timestamp omitted/null/value + warning | `https://solana.com/docs/rpc/websocket/votesubscribe` | Done `pre.011` |
|
||||
| 18 | `voteUnsubscribe` | unsubscribe | **Unstable pair** | remote id ; boolean/error | vote pair | generic registry unsubscribe | `https://solana.com/docs/rpc/websocket/voteunsubscribe` | Done `pre.011` |
|
||||
|
||||
## 3. Notification matrix
|
||||
|
||||
| Subscribe | Notification | Shape à préserver | Point lossless / lifecycle |
|
||||
|-------------------------|----------------------------|------------------------------------------------|--------------------------------------------------------------------------------------------------|
|
||||
| `accountSubscribe` | `accountNotification` | contextual account payload | reuse account DTOs/encodings/dataSlice |
|
||||
| `programSubscribe` | `programNotification` | keyed account, documenté avec contexte | accepter contexted/non-contexted à cause de l'écart docs/source audité ; préserver `withContext` |
|
||||
| `logsSubscribe` | `logsNotification` | context + `{signature, err, logs}` | err nullable ; filtre mentions exactement une adresse |
|
||||
| `signatureSubscribe` | `signatureNotification` | context + `"receivedSignature"` **ou** `{err}` | terminal object clôt la subscription ; early string ne la clôt pas |
|
||||
| `slotSubscribe` | `slotNotification` | `{slot,parent,root}` | non-contextual |
|
||||
| `rootSubscribe` | `rootNotification` | `u64` | non-contextual |
|
||||
| `blockSubscribe` | `blockNotification` | context + `{slot, block, err}` | unstable ; `block`/`err` nullable ; variants block selon config |
|
||||
| `slotsUpdatesSubscribe` | `slotsUpdatesNotification` | tagged union slot lifecycle | unstable ; fallback unknown/raw borné |
|
||||
| `voteSubscribe` | `voteNotification` | `{votePubkey,slots,hash,timestamp,signature}` | unstable/pre-consensus ; timestamp tolerant wire |
|
||||
|
||||
Les contexts WebSocket documentés omettent `apiVersion`. `SolanaRpcContext.api_version: Option<String>` est compatible avec cette omission.
|
||||
|
||||
## 4. Baselines Git Agave et SIMD
|
||||
|
||||
### 4.1 Hiérarchie de contrôle
|
||||
|
||||
La compliance applique le même modèle que la compliance HTTP finale :
|
||||
|
||||
```text
|
||||
documentation Solana actuelle
|
||||
-> surface publique annoncée
|
||||
|
||||
Git Agave tag courant audité v4.2.1
|
||||
-> implémentation réelle et types wire
|
||||
|
||||
SIMD main
|
||||
-> évolutions Activated, Review, Draft ou Idea à surveiller
|
||||
```
|
||||
|
||||
Les pages Solana peuvent lier une révision Agave plus ancienne. KSP ne confond donc pas le lien source embarqué dans une page documentaire avec la baseline Git courante. Au 2026-08-22, **Agave `v4.2.1`** est disponible sur Git et devient la baseline d'implémentation de `0.2.7`.
|
||||
|
||||
Sources Agave ciblées :
|
||||
|
||||
```text
|
||||
https://github.com/anza-xyz/agave/blob/v4.2.1/rpc/src/rpc_pubsub.rs
|
||||
https://github.com/anza-xyz/agave/blob/v4.2.1/rpc/src/rpc_subscriptions.rs
|
||||
https://github.com/anza-xyz/agave/blob/v4.2.1/rpc-client-types/src/response.rs
|
||||
```
|
||||
|
||||
### 4.2 Constats Agave `v4.2.1`
|
||||
|
||||
Le cross-check du tag courant confirme les **9 subscribe + 9 unsubscribe** déjà inventoriés par la documentation publique et ne révèle aucune opération PubSub standard supplémentaire.
|
||||
|
||||
Constats ciblés :
|
||||
|
||||
- `accountSubscribe` : le type partagé `RpcAccountInfoConfig` expose `min_context_slot`, mais le handler PubSub `v4.2.1` le destructure en `_ // ignored`. KSP ne le compte donc pas comme option WebSocket effective ;
|
||||
- `programSubscribe` : `RpcProgramAccountsConfig.with_context` est lu et doit rester dans le contrat KSP ; la tolérance contexted/non-contexted reste requise pour ne pas convertir les divergences docs/source/provider en perte wire ;
|
||||
- `logsSubscribe` : le handler accepte `all`, `allWithVotes` ou `mentions` et rejette un filtre `mentions` contenant autre chose qu'exactement une adresse ;
|
||||
- `signatureSubscribe` : le handler utilise la signature, `commitment` et `enable_received_notification`, sans option WebSocket supplémentaire ;
|
||||
- `blockSubscribe` : le handler consomme le jeu d'options documenté et impose un commitment au moins `confirmed` ;
|
||||
- `*Unsubscribe` : un ID serveur inconnu produit `InvalidParams` dans le handler audité ;
|
||||
- `voteSubscribe` : `RpcVote.timestamp` est `Option<UnixTimestamp>`, donc optionnel au niveau wire ;
|
||||
- `slotsUpdatesSubscribe` : `SlotUpdate` possède toujours les sept variantes courantes `FirstShredReceived`, `Completed`, `CreatedBank`, `Frozen`, `Dead`, `OptimisticConfirmation` et `Root`.
|
||||
|
||||
Décisions compliance : transmettre `programSubscribe.withContext`, accepter les formes contextée/non-contextée, ne pas promettre `accountSubscribe.minContextSlot` tant qu'il est ignoré upstream, conserver `vote.timestamp` optionnel et garder un fallback borné pour toute future variante `SlotUpdate` inconnue.
|
||||
|
||||
### 4.3 Audit SIMD ciblé
|
||||
|
||||
Aucun SIMD audité n'ajoute actuellement une dixième famille de subscription standard à la surface Agave `v4.2.1`. Ils modifient en revanche des hypothèses que KSP doit éviter de figer.
|
||||
|
||||
| SIMD | Statut au 2026-08-22 | Conséquence WebSocket KSP |
|
||||
|-----------------------------------------------|----------------------|----------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `0118` Partitioned Epoch Rewards Distribution | Activated | conserver les DTOs bloc/rewards HTTP déjà lossless, dont `numRewardPartitions` omitted, `null` ou valeur |
|
||||
| `0291` Commission Rate in Basis Points | Review | conserver les représentations de commission upstream indépendantes lorsqu'elles sont présentes |
|
||||
| `0296` Larger Transaction Size | Review | proposition jusqu'à 4096 octets : ne pas déduire les limites WS de l'ancienne taille transaction 1232 ; surveiller `blockSubscribe` |
|
||||
| `0298` Bank Hash in Block Footer | Idea | aucun `bankHash` spéculatif dans le wire courant ; surveiller le DTO bloc partagé |
|
||||
| `0301` parent bank hash | PR fermé, non mergé | aucun `parentBankHash` spéculatif ; même conclusion que la compliance HTTP |
|
||||
| `0307` Add Block Footer | Review | aucun `footer` spéculatif ; réauditer `blockSubscribe` lorsque l'upstream l'expose réellement |
|
||||
| `0326` Alpenglow | Review | ne pas figer les sémantiques TowerBFT de `voteSubscribe` ou `optimisticConfirmation` dans un contrat durable |
|
||||
| `0337` Alpenglow Fast Leader Handover Markers | Review | nouveaux block markers futurs : surveiller shape et taille, sans ajout WS anticipé |
|
||||
| `0384` Alpenglow migration | Review | des notifications RPC commitment/optimistic confirmation peuvent être suspendues pendant migration ; aucune hypothèse de séquence exhaustive |
|
||||
| `0385` Transaction V1 | Review | `maxSupportedTransactionVersion` et les versions transaction restent génériques ; ne pas caper KSP à v0 |
|
||||
|
||||
Sources SIMD :
|
||||
|
||||
```text
|
||||
https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0118-partitioned-epoch-reward-distribution.md
|
||||
https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0291-commission-rate-in-basis-points.md
|
||||
https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0296-larger-transactions.md
|
||||
https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0298-bank-hash-in-block-footer.md
|
||||
https://github.com/solana-foundation/solana-improvement-documents/pull/301
|
||||
https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0307-add-block-footer.md
|
||||
https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0326-alpenglow.md
|
||||
https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0337-parent-ready-update-marker.md
|
||||
https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0384-alpenglow-migration.md
|
||||
https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0385-transaction-v1.md
|
||||
```
|
||||
|
||||
Les limites WebSocket sont donc des bornes KSP configurables et finies, jamais une transposition en dur de l'ancienne limite transaction de 1232 octets.
|
||||
|
||||
## 5. Méthodes unstable
|
||||
|
||||
### `blockSubscribe`
|
||||
|
||||
Conditions officielles :
|
||||
|
||||
```text
|
||||
--rpc-pubsub-enable-block-subscription
|
||||
--enable-rpc-transaction-history
|
||||
```
|
||||
|
||||
KSP : warning centralisé à la création, test fixture toujours disponible, smoke live non requis.
|
||||
|
||||
### `slotsUpdatesSubscribe`
|
||||
|
||||
Le format est explicitement annoncé comme susceptible de changer. Variants actuels :
|
||||
|
||||
```text
|
||||
firstShredReceived slot,timestamp
|
||||
completed slot,timestamp
|
||||
createdBank slot,parent,timestamp
|
||||
frozen slot,timestamp,stats
|
||||
dead slot,timestamp,err
|
||||
optimisticConfirmation slot,timestamp
|
||||
root slot,timestamp
|
||||
```
|
||||
|
||||
`stats` actuel : `numTransactionEntries`, `numSuccessfulTransactions`, `numFailedTransactions`, `maxTransactionsPerEntry`.
|
||||
|
||||
### `voteSubscribe`
|
||||
|
||||
Condition officielle :
|
||||
|
||||
```text
|
||||
--rpc-pubsub-enable-vote-subscription
|
||||
```
|
||||
|
||||
Les votes observés sont gossip/pre-consensus ; aucune garantie d'entrée dans le ledger. Transport les livre comme wire, sans interprétation métier.
|
||||
|
||||
## 6. Lifecycle compliance initiale
|
||||
|
||||
| Contrat | Décision `pre.001` | Gate cible |
|
||||
|-------------------------------|-------------------------------------------------------------------|--------------------------------------------|
|
||||
| plusieurs sessions / même URL | création physique explicite ; aucun singleton/pool automatique | **Done `pre.004`**, canary final `pre.012` |
|
||||
| plusieurs subs / session | registry actor par session | **Done `pre.006`** |
|
||||
| ID public subscription | local KSP stable | **Done `pre.006`** |
|
||||
| ID serveur | éphémère interne et remappé | **Done `pre.006` puis `pre.007`** |
|
||||
| session states | Disconnected/Connecting/Active/Reconnecting/Closing/Closed/Failed | **Done `pre.002`** |
|
||||
| subscription states | Requested/Active/Resubscribing/Cancelling/Closed/Failed | **Done `pre.002`** |
|
||||
| reconnect | physique uniquement, budget/backoff finis | **Done `pre.007`** |
|
||||
| resubscribe | policy `Never` ou `ActiveSubscriptions`, ordre local déterministe | **Done `pre.007`** |
|
||||
| continuity | gap observable, aucune promesse lossless | **Done `pre.007`** |
|
||||
| backpressure | queue par sub bounded ; overflow => fail local explicite | **Done `pre.008`** |
|
||||
| shutdown | explicite, bounded, annule reconnect et subscriptions | **Done `pre.005`** |
|
||||
| keepalive | pas de ping applicatif périodique sans besoin démontré | **Done `pre.005`** |
|
||||
|
||||
## 7. Threat/security compliance initiale
|
||||
|
||||
| Invariant | Preuve attendue | Statut |
|
||||
|---------------------------------------------------|----------------------------------------------|--------------------------------------------------------|
|
||||
| URL/credentials absents de `Debug` | unit tests URL wrapper | **Done `pre.002`** |
|
||||
| URL/credentials absents des erreurs | validation URL + connection errors safe | **Done through `pre.004`** |
|
||||
| URL/credentials absents des logs | actor logs only safe endpoint metadata | **Done through `pre.011`, source audit `pre.013`** |
|
||||
| snapshots sans URL/raw payload | unit shape + public contract | **Done `pre.002`** |
|
||||
| frame/message finis | settings bornés + `WebSocketConfig` raccordé | **Done `pre.005`** |
|
||||
| JSON borné indirectement par message | oversized + malformed fixture | **Done `pre.005`** |
|
||||
| queues notifications bornées | queue typed bornée + overflow isolé | **Done `pre.008`** |
|
||||
| pending RPC borné + timeout | map actor bornée + timeout request | **Done `pre.004`** |
|
||||
| reconnect loop bornée | repeated disconnect fixture | Done `pre.007` |
|
||||
| unsubscribe pendant reconnect ne resubscribe pas | race fixture | Done `pre.007` |
|
||||
| signature terminale ne resubscribe pas | terminal fixture | **Done `pre.010`** |
|
||||
| shutdown ne bloque pas | peer hostile/no close ack fixture | **Done `pre.005`** |
|
||||
| no Store/Program/Wallet/Config dep dans Transport | cargo tree + source canary | **Done `pre.013`, source + cargo tree opérateur** |
|
||||
| no direct `tracing` dans Transport | workspace audit + source audit | **Done `pre.002`** |
|
||||
|
||||
## 8. Dependency compliance initiale
|
||||
|
||||
Candidates auditées à l'ouverture de release :
|
||||
|
||||
```text
|
||||
tokio-tungstenite 0.30.x
|
||||
futures-util 0.3.x
|
||||
```
|
||||
|
||||
Le workspace conserve des contraintes caret `^0.30` et `^0.3` sans lockfile versionné. La version transitive effectivement résolue n'est donc pas figée dans cette matrice ; `pre.013` exige l'inspection du `cargo tree` opérateur correspondant au checkout validé.
|
||||
|
||||
Features retenues :
|
||||
|
||||
```text
|
||||
tokio-tungstenite: default-features=false + connect + rustls-tls-webpki-roots
|
||||
futures-util: default-features=false + std + sink
|
||||
```
|
||||
|
||||
Sources :
|
||||
|
||||
```text
|
||||
https://docs.rs/crate/tokio-tungstenite/0.30.0
|
||||
https://docs.rs/crate/tokio-tungstenite/0.30.0/features
|
||||
https://docs.rs/crate/futures-util/0.3.34
|
||||
```
|
||||
|
||||
Alternatives auditées mais non retenues : `tokio-websockets 0.13.3`, `fastwebsockets 0.10.0`.
|
||||
|
||||
`pre.004` matérialise `tokio-tungstenite` et `futures-util` dans `[workspace.dependencies]` sans features consumer au root. Transport active seulement `connect`, `rustls-tls-webpki-roots`, `std` et `sink`; `tokio/net` est ajouté côté dev fixture local.
|
||||
|
||||
## 9. Config compliance initiale
|
||||
|
||||
V1 hérité : HTTP-only strict. Contrat de compatibilité :
|
||||
|
||||
```text
|
||||
V1 -> support de lecture conservé, WS vide
|
||||
V2 -> HTTP existant + ws_defaults + profiles[].ws_endpoints
|
||||
profiles[].ws_endpoints[].kind -> discriminateur de famille/protocole WS
|
||||
0.2.7 -> seule valeur supportée : solana_standard
|
||||
Config -> WsTransportSettings
|
||||
Transport -X-> Config
|
||||
```
|
||||
|
||||
Le type Transport correspondant au discriminateur est prévu `#[non_exhaustive]`. L'objectif est de pouvoir ajouter ultérieurement une famille telle que Helius Enhanced WebSocket sans créer un nouveau conteneur Config ni injecter des options provider-specific dans les settings Solana standard. Toute valeur inconnue reste explicitement rejetée en `0.2.7`; aucun paramètre Helius n'est implémenté par anticipation.
|
||||
|
||||
`pre.003` remplace le schema enregistré par `urn:ksp:schema:std.transport:v2`, avec deux branches strictes : V1 HTTP-only et V2 HTTP + WebSocket. La compatibilité V1 ne relâche donc ni `additionalProperties`, ni la shape historique.
|
||||
|
||||
Preuves `pre.003` :
|
||||
|
||||
```text
|
||||
config/std.transport.json format_version = 2
|
||||
config/examples/std.transport.example.json format_version = 2
|
||||
V1 fixture dédiée load + HTTP mapping OK attendu
|
||||
V1 ws_settings = None
|
||||
V2 ws_settings = Some(validated)
|
||||
ws_defaults globals
|
||||
profiles[].ws_endpoints profile-local
|
||||
ws_endpoints[].kind enum schema solana_standard
|
||||
ws_endpoints[].session overrides génériques optionnels
|
||||
Config -> Transport seule direction de dépendance
|
||||
```
|
||||
|
||||
`ResolvedTransportConfig::settings()` reste l'accesseur HTTP historique. `http_settings()` l'explicite et `ws_settings()` expose `Option<&WsTransportSettings>` afin que le V1 backward ne force jamais un faux `WsTransportSettings` vide. `into_transport_settings()` permet de consommer les deux contrats ensemble.
|
||||
|
||||
## 9.1 Checkpoint settings/lifecycle `pre.002`
|
||||
|
||||
Surface publique matérialisée :
|
||||
|
||||
```text
|
||||
WsEndpointUrl
|
||||
WsProviderName
|
||||
WsClusterName
|
||||
WsProtocolKind::SolanaStandard
|
||||
WsReconnectSettings
|
||||
WsResubscribePolicy::{Never, ActiveSubscriptions}
|
||||
WsSessionSettings
|
||||
WsEndpointSettings
|
||||
WsTransportSettings
|
||||
WsSessionId
|
||||
WsSubscriptionId
|
||||
WsSessionState
|
||||
WsSubscriptionState
|
||||
WsSubscriptionKind
|
||||
WsSessionSnapshot
|
||||
WsSubscriptionSnapshot
|
||||
```
|
||||
|
||||
Gates déterministes ajoutés :
|
||||
|
||||
```text
|
||||
ws:// et wss:// acceptés
|
||||
HTTP rejeté par WsEndpointUrl
|
||||
Debug URL redacted
|
||||
erreur de scheme sans URL/credential
|
||||
settings session default bornés
|
||||
zero runtime bound rejeté
|
||||
reconnect backoff inversé rejeté
|
||||
endpoint names uniques
|
||||
au moins un endpoint enabled
|
||||
Debug WsTransportSettings sans URL/credential
|
||||
9 familles standard WsSubscriptionKind
|
||||
snapshots sans URL ni remote subscription id
|
||||
public API canary crate-root
|
||||
```
|
||||
|
||||
Logging : `TRACING_TARGET` reste défini dans `constants.rs`; les nouveaux événements utilisent exclusivement `ksp_logging_lib::trace!`, `debug!` et `warn!` avec des fields sûrs. Aucun `tracing` direct n'est ajouté.
|
||||
|
||||
Les defaults de taille/queue sont des **policies KSP locales**, pas des limites Solana. Leur enforcement réel et leurs tests oversized/slow-consumer restent attendus dans les tranches socket/backpressure.
|
||||
|
||||
## 9.2 Checkpoint runtime physique `pre.004`
|
||||
|
||||
Surface matérialisée :
|
||||
|
||||
```text
|
||||
WsSession::connect(endpoint) public, une connexion physique par appel
|
||||
actor socket propriétaire exclusif du WebSocket
|
||||
command queue tokio mpsc bounded
|
||||
pending JSON-RPC BTreeMap bounded par max_pending_requests
|
||||
request timeout command_timeout
|
||||
response dispatch par id numérique KSP
|
||||
state snapshot watch + WsSessionSnapshot
|
||||
raw JSON-RPC public non, primitive pub(crate)
|
||||
reconnect/resubscribe non, gates futurs
|
||||
```
|
||||
|
||||
Fixtures déterministes `pre.004` :
|
||||
|
||||
```text
|
||||
handshake local + JSON-RPC round-trip
|
||||
deux sessions physiques distinctes sur la même URL
|
||||
deux requests concurrentes + réponses inversées
|
||||
erreur RPC applicative sans teardown de session
|
||||
erreur de connexion sans URL/credential dans Error Debug
|
||||
```
|
||||
|
||||
Les limites `max_message_size`, `max_frame_size` et `max_write_buffer_size` sont raccordées à `tungstenite::WebSocketConfig`. Les fixtures oversized et le shutdown/control-frame hostile restent explicitement `pre.005`.
|
||||
|
||||
## 9.3 Checkpoint limits/control/shutdown `pre.005`
|
||||
|
||||
Gates déterministes ajoutés :
|
||||
|
||||
```text
|
||||
max_pending_requests saturé -> seule la request excédentaire est rejetée
|
||||
outbound JSON-RPC > borne message/frame -> rejet avant socket write, session Active
|
||||
inbound frame/message oversized -> rejet Tungstenite avant parse JSON ; depuis pre.007, reconnect borné
|
||||
pending request silencieuse -> timeout + purge de capacité, session Active
|
||||
Ping distant -> Pong automatique flushé, session Active
|
||||
Close distant propre -> checkpoint `pre.005` Closed ; à partir de `pre.007`, perte physique reprise par reconnect borné
|
||||
WsSession::close() -> Closing -> Closed
|
||||
close() annule les pending requests avec ws_session_closed
|
||||
peer hostile qui ne répond pas au Close -> shutdown borné
|
||||
cycles connect/close répétés -> terminaison bornée
|
||||
```
|
||||
|
||||
Le signal de shutdown est indépendant de la command queue et les opérations socket longues de l'actor surveillent ce signal. Aucun reconnect/resubscribe n'est activé par cette tranche.
|
||||
|
||||
## 9.4 Checkpoint registry subscriptions `pre.006`
|
||||
|
||||
Surface matérialisée :
|
||||
|
||||
```text
|
||||
WsSubscription<T> handle public typed
|
||||
WsSubscriptionId local stable, actor-assigned
|
||||
remote subscription id interne/transient uniquement
|
||||
registry local BTreeMap ordonnée par local ID
|
||||
remote -> local mapping actor-owned
|
||||
subscribe generic crate-private, wrappers publics futurs
|
||||
unsubscribe handle public, bool Solana préservé
|
||||
notification queue typed + bounded
|
||||
reconnect/resubscribe non à ce checkpoint ; matérialisé en pre.007
|
||||
backpressure adversarial complet oui, pre.008
|
||||
```
|
||||
|
||||
Gates déterministes ajoutés :
|
||||
|
||||
```text
|
||||
subscribe ACK -> binding remote/local atomique
|
||||
notification -> bonne subscription typed
|
||||
2 familles -> IDs locaux 1 puis 2, remote IDs indépendants
|
||||
unknown/stale remote ID -> safe drop, session Active
|
||||
notification method mismatch -> subscription Failed seulement
|
||||
typed decoder failure -> erreur livrée + subscription Failed seulement
|
||||
unsubscribe -> exact *Unsubscribe avec remote ID interne
|
||||
unsubscribe bool -> préservé au caller
|
||||
snapshot -> local IDs + remote_bound uniquement
|
||||
```
|
||||
|
||||
Le registry est détenu par le même actor que le socket et la pending map JSON-RPC. Aucun caller ne manipule le socket ni le remote ID. Les wrappers publics `accountSubscribe`, `programSubscribe`, etc. restent volontairement différés aux lots `pre.009+` afin de ne pas exposer une API raw provider-extension intermédiaire.
|
||||
|
||||
## 9.5 Checkpoint reconnect/resubscribe `pre.007`
|
||||
|
||||
Surface matérialisée :
|
||||
|
||||
```text
|
||||
reconnect triggers EOF, Close distant inattendu, I/O/TLS/WebSocket/protocole structurel
|
||||
reconnect budget fini, configurable
|
||||
backoff exponentiel borné, sans jitter
|
||||
shutdown pendant reconnect prioritaire, annule backoff/handshake
|
||||
continuity_gap_count incrémenté une fois par perte de continuité logique
|
||||
remote IDs après perte invalidés immédiatement
|
||||
ActiveSubscriptions resubscribe local-ID croissant
|
||||
Never aucune restauration automatique
|
||||
budget reset seulement après retour complet à Active
|
||||
unsubscribe pendant reconnect cancellation locale gagnante
|
||||
ACK resubscribe tardif cleanup distant best-effort, aucune réactivation locale
|
||||
backfill HTTP aucun
|
||||
lossless guarantee aucune
|
||||
```
|
||||
|
||||
Gates déterministes ajoutés :
|
||||
|
||||
```text
|
||||
remote Close + budget insuffisant -> Reconnecting puis Failed, jamais boucle infinie
|
||||
2 subscriptions -> resubscribe dans l'ordre WsSubscriptionId 1 puis 2
|
||||
remote IDs 101/202 -> remappés 301/302 sans changer les IDs locaux
|
||||
params de subscribe -> conservés et rejoués à l'identique
|
||||
unsubscribe pendant backoff -> sub Closed et absente de la sélection resubscribe
|
||||
unsubscribe après émission resubscribe -> ACK tardif nettoyé par *Unsubscribe best-effort
|
||||
policy Never -> session reconnectée Active, subscription terminale Failed
|
||||
2 pertes séparées avec max_retries=1 -> deux recoveries possibles, budget réinitialisé après Active
|
||||
continuity_gap_count -> 1 puis 2 sur deux pertes distinctes
|
||||
```
|
||||
|
||||
Les requests applicatives en vol au moment d'une perte physique échouent ; Transport ne les rejoue pas implicitement. La restauration ne promet aucune continuité lossless et n'effectue aucun backfill HTTP. Les remote subscription IDs restent strictement internes et sont remplacés à chaque ACK de resubscribe.
|
||||
|
||||
## 9.6 Checkpoint backpressure/lifecycle `pre.008`
|
||||
|
||||
Surface matérialisée :
|
||||
|
||||
```text
|
||||
notification_queue_capacity borne effective par WsSubscription<T>
|
||||
overflow_count compteur session saturating, réellement incrémenté
|
||||
terminal_error_code ErrorCode KSP sûr sur le handle Failed
|
||||
queue pleine fail local uniquement, aucun drop silencieux
|
||||
remote cleanup *Unsubscribe best-effort après overflow/receiver drop/erreur locale
|
||||
max_active_subscriptions admission bornée, capacité réutilisable après terminaison
|
||||
receiver abandonné détecté sur notification suivante, registry libéré
|
||||
reconnect après overflow subscription Failed absente de toute restauration future
|
||||
```
|
||||
|
||||
Gates déterministes ajoutés :
|
||||
|
||||
```text
|
||||
queue capacité 1 + 2 notifications -> overflow_count 1 + ws_backpressure_overflow
|
||||
subscription lente en overflow -> Failed, première notification déjà queueée reste lisible puis channel fermé
|
||||
subscription saine parallèle -> notification livrée et état Active
|
||||
session après overflow isolé -> Active
|
||||
binding distant overflow -> *Unsubscribe best-effort exact
|
||||
max_active_subscriptions atteint -> création excédentaire rejetée sans incrémenter overflow_count
|
||||
unsubscribe terminal -> capacité locale libérée puis nouvelle subscription acceptée
|
||||
receiver droppé -> notification suivante déclenche cleanup distant et libère la capacité
|
||||
method mismatch -> ws_protocol_error terminal sur le handle, session Active
|
||||
decode typed invalide -> code invalid_response terminal sur le handle, session Active
|
||||
policy Never après perte physique -> ws_connection_failed terminal sur le handle
|
||||
resubscribe RPC application error -> rpc_application_error terminal sur le handle
|
||||
```
|
||||
|
||||
`overflow_count` et `continuity_gap_count` sont deux signaux distincts : le premier mesure les saturations locales de consumers, le second les ruptures de continuité physique. Aucun des deux ne déclenche de replay ou de backfill automatique. Les causes terminales n'exposent qu'un `ErrorCode` KSP, jamais le payload distant, le remote subscription ID ou l'URL.
|
||||
|
||||
## 9.7 Checkpoint wrappers stables lot A `pre.009`
|
||||
|
||||
Surface publique ajoutée :
|
||||
|
||||
```text
|
||||
WsSession::account_subscribe
|
||||
SolanaAccountSubscribeConfig
|
||||
WsSubscription<SolanaRpcResponse<SolanaAccount>>
|
||||
|
||||
WsSession::program_subscribe
|
||||
SolanaProgramSubscribeConfig
|
||||
SolanaProgramNotification = bare keyed account | contextual keyed account
|
||||
|
||||
WsSession::logs_subscribe
|
||||
SolanaLogsSubscribeFilter = All | AllWithVotes | Mentions(Pubkey)
|
||||
SolanaLogsNotification = signature + err nullable + logs ordonnés
|
||||
```
|
||||
|
||||
Gates déterministes ajoutés :
|
||||
|
||||
```text
|
||||
accountSubscribe -> params exacts pubkey + encoding/dataSlice/commitment
|
||||
accountSubscribe -> aucun minContextSlot promis ou sérialisable par le config WS
|
||||
accountNotification -> SolanaRpcContext + SolanaAccount partagé
|
||||
account handle unsubscribe -> accountUnsubscribe avec remote ID interne
|
||||
programSubscribe -> filters + withContext préservés, sortResults absent
|
||||
program filters -> >4 et raw memcmp >128 rejetés avant émission subscribe
|
||||
programNotification -> forme bare acceptée
|
||||
programNotification -> forme contextualisée acceptée
|
||||
program handle unsubscribe -> programUnsubscribe exact
|
||||
logs filter -> all/allWithVotes/mentions exactement une pubkey
|
||||
logsNotification -> context + signature + err null/object + ordre logs préservés
|
||||
logsNotification sans champ err requis -> invalid_response typed, session non concernée
|
||||
logs handle unsubscribe -> logsUnsubscribe exact
|
||||
API publique -> aucun subscribe(method, raw params) exposé
|
||||
```
|
||||
|
||||
Les trois wrappers passent par le même moteur actor/registry acquis en `pre.006`–`pre.008`; les remote IDs ne deviennent donc pas publics et les paramètres typés initiaux restent les specs rejouées lors d'un resubscribe `ActiveSubscriptions`.
|
||||
|
||||
## 9.8 Checkpoint wrappers stables lot B `pre.010`
|
||||
|
||||
Surface publique ajoutée :
|
||||
|
||||
```text
|
||||
WsSession::signature_subscribe
|
||||
SolanaSignatureSubscribeConfig = commitment + enableReceivedNotification
|
||||
SolanaSignatureNotification = ReceivedSignature | Processed { err }
|
||||
|
||||
WsSession::slot_subscribe
|
||||
SolanaSlotNotification = slot + parent + root
|
||||
|
||||
WsSession::root_subscribe
|
||||
notification = u64
|
||||
```
|
||||
|
||||
Gates déterministes ajoutés :
|
||||
|
||||
```text
|
||||
signatureSubscribe -> signature + config commitment/enableReceivedNotification exacts
|
||||
config signature vide explicite -> second paramètre omis
|
||||
receivedSignature -> notification typed non terminale, handle reste Active
|
||||
Processed { err:null } -> notification terminale success, handle Closed sans terminal_error_code
|
||||
Processed { err:object } -> transaction error wire préservée comme valeur terminale, pas erreur Transport
|
||||
string signature inconnue ou objet sans err -> invalid_response typed
|
||||
cancellation avant terminal -> signatureUnsubscribe exact avec remote ID interne
|
||||
terminal déjà observé -> unsubscribe local-only false, aucun remote cleanup inutile
|
||||
perte physique après terminal -> reconnect session possible mais signature jamais resubscribe
|
||||
slotSubscribe -> params vides + {slot,parent,root} exact
|
||||
slotUnsubscribe -> remote ID interne via handle
|
||||
rootSubscribe -> params vides + root u64 exact
|
||||
rootUnsubscribe -> remote ID interne via handle
|
||||
API publique -> aucun remote subscription ID ni méthode raw arbitraire exposés
|
||||
```
|
||||
|
||||
La terminaison signature est classée après livraison dans la queue typed : le consumer reçoit donc toujours la valeur terminale avant fermeture du canal. Le registry retire ensuite la subscription one-shot avant toute future sélection de resubscribe. `slot` et `root` restent des subscriptions continues et conservent les règles génériques de reconnect, backpressure et cancellation.
|
||||
|
||||
## 9.9 Checkpoint familles unstable `pre.011`
|
||||
|
||||
Surface publique ajoutée :
|
||||
|
||||
```text
|
||||
WsSession::block_subscribe
|
||||
SolanaBlockSubscribeFilter = All | MentionsAccountOrProgram(Pubkey)
|
||||
SolanaBlockSubscribeConfig = commitment + encoding + transactionDetails + maxSupportedTransactionVersion + showRewards
|
||||
SolanaBlockNotification = slot + block nullable + err nullable
|
||||
|
||||
WsSession::slots_updates_subscribe
|
||||
SolanaSlotUpdate = 7 variantes connues + Unknown raw borné
|
||||
SolanaSlotUpdateStats
|
||||
|
||||
WsSession::vote_subscribe
|
||||
SolanaVoteNotification = votePubkey + slots + hash + timestamp optionnel + signature
|
||||
```
|
||||
|
||||
Gates déterministes ajoutés :
|
||||
|
||||
```text
|
||||
partition unstable exacte = block + slotsUpdates + vote
|
||||
warning centralisé dans le moteur typed avant création d'une famille unstable
|
||||
block filter all + mentionsAccountOrProgram
|
||||
block config -> confirmed/finalized seulement ; processed rejeté avant I/O
|
||||
block config -> binary/base58/base64/json/jsonParsed + full/accounts/signatures/none
|
||||
block maxSupportedTransactionVersion -> valeur numérique générique, pas de hardcode 0
|
||||
block notification -> context + slot + block nullable + err nullable
|
||||
block payload > 1232 octets -> accepté tant qu'il reste sous les limites WS KSP
|
||||
block capability RPC error -> erreur applicative caller, session physique reste Active
|
||||
blockUnsubscribe -> remote ID interne via handle
|
||||
slotsUpdates -> 7 variantes actuelles exactes
|
||||
slotsUpdates future type -> Unknown avec raw conservé sous la borne message
|
||||
createdBank/frozen/dead -> champs spécifiques requis
|
||||
slotsUpdatesSubscribe/Unsubscribe -> params vides + remote ID interne
|
||||
vote timestamp omitted/null/value -> None/None/Some(i64)
|
||||
votePubkey -> Pubkey typed ; slots ordonnés ; hash/signature opaques
|
||||
voteSubscribe/Unsubscribe -> params vides + remote ID interne
|
||||
```
|
||||
|
||||
La protection `Unknown` de `slotsUpdates` est volontairement limitée aux **nouvelles variantes de type**. Une variante connue avec des champs obligatoires invalides reste `invalid_response` pour la subscription concernée : KSP ne masque pas une rupture du contrat déjà audité.
|
||||
|
||||
Le warning unstable ne contient ni filtre, ni pubkey, ni payload, ni remote subscription ID, ni URL. Les trois familles restent continues et réutilisent le lifecycle générique reconnect/resubscribe/backpressure/cancellation.
|
||||
|
||||
Comptages attendus après compilation :
|
||||
|
||||
```text
|
||||
Transport unit tests = 309
|
||||
Transport public API tests = 35
|
||||
release completeness = 22
|
||||
```
|
||||
|
||||
## 9.10 Checkpoint compliance consolidée `pre.012`
|
||||
|
||||
`pre.012` n'ajoute aucune nouvelle famille WebSocket. Il transforme les preuves acquises de `pre.002` à `pre.011` en gates de release explicites et vérifie simultanément que la surface HTTP héritée reste intacte.
|
||||
|
||||
Le réaudit de l'index public Solana effectué le **23 août 2026** confirme que la surface courante reste inchangée à 9 subscribe + 9 unsubscribe.
|
||||
|
||||
Gates ajoutés ou consolidés :
|
||||
|
||||
```text
|
||||
9 WsSubscriptionKind publics exactement couverts
|
||||
9 wrappers subscribe publics disponibles depuis la crate root
|
||||
1 handle WsSubscription<T>::unsubscribe commun aux 9 familles
|
||||
9 paires subscribe/unsubscribe = 18 opérations standard comptabilisées
|
||||
triplets exacts subscribe/unsubscribe/notification toujours verrouillés par le canari ws_lifecycle
|
||||
partition unstable exacte toujours verrouillée : block + slotsUpdates + vote
|
||||
Config V2 committed -> ResolvedTransportConfig -> WsTransportSettings validé
|
||||
WsEndpointSettings produit par Config -> type accepté directement par WsSession::connect
|
||||
Config V1 -> HTTP-only reste couvert par le checkpoint pre.003
|
||||
HTTP courant -> 52 méthodes Supported
|
||||
HTTP historique -> 14 méthodes Removed
|
||||
surface API publique WebSocket complète -> canari crate-root dédié
|
||||
```
|
||||
|
||||
La composition Config est testée sans réseau : l'async future `WsSession::connect` est construite à partir de l'endpoint produit par Config et laissée non pollée jusqu'à sa destruction en fin de portée. Le gate vérifie donc la compatibilité de types et de contrats entre Config et Transport sans transformer `pre.012` en smoke live ; le vrai smoke reste réservé à `pre.013`.
|
||||
|
||||
Comptages attendus après compilation :
|
||||
|
||||
```text
|
||||
Transport unit tests = 309
|
||||
Transport public API tests = 36
|
||||
Transport release completeness = 24
|
||||
Config unit tests = 109
|
||||
```
|
||||
|
||||
## 9.11 Checkpoint smoke live et audit de dépendances `pre.013`
|
||||
|
||||
Le smoke WebSocket live retenu est un test Transport pur `#[ignore]` qui ne lit ni Config ni environnement :
|
||||
|
||||
```text
|
||||
endpoint programmatique wss://api.devnet.solana.com
|
||||
WsSession::connect
|
||||
slotSubscribe stable
|
||||
attente bornée d'une slotNotification
|
||||
slot > 0
|
||||
slotUnsubscribe via handle
|
||||
close explicite de la session
|
||||
```
|
||||
|
||||
Les familles `block`, `slotsUpdates` et `vote` ne sont pas utilisées dans ce smoke car leur disponibilité dépend de capabilities validator unstable. Les fixtures locales restent les preuves reproductibles de ces familles et du lifecycle.
|
||||
|
||||
Le canari workspace de dépendances verrouille désormais les noms directs 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/tracing direct. L'inspection du graphe résolu reste une preuve opérateur avec :
|
||||
|
||||
```bash
|
||||
cargo tree -p ksp-onchain-transport-lib
|
||||
cargo tree -p ksp-onchain-transport-lib --duplicates
|
||||
cargo tree --duplicates
|
||||
```
|
||||
|
||||
Une duplication transitive n'est pas considérée automatiquement comme un défaut : elle doit être comprise et n'est supprimée que si KSP peut la résoudre sans downgrade, pin artificiel ou violation des responsabilités upstream.
|
||||
|
||||
Comptages déterministes attendus inchangés côté Transport, plus un smoke live ignoré et un canari workspace supplémentaire dans Core :
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Preuve opérateur `pre.013` : tous les contrôles déterministes ci-dessus sont verts, `cargo test --workspace` est vert et le smoke WebSocket ignoré a été exécuté explicitement avec succès sur Devnet. L'audit du graphe Transport résout notamment `reqwest 0.13.4`, `tokio 1.53.1`, `tokio-tungstenite 0.30.0` et `futures-util 0.3.34` sur ce checkout sans lockfile versionné. Les doublons `syn` 2/3 et `webpki-roots` 0.26/1.0 sont transitifs/upstream et acceptés ; aucune unification artificielle n'est requise.
|
||||
|
||||
## 10. Validation du gate `pre.001`
|
||||
|
||||
Exécuté dans le sandbox :
|
||||
|
||||
```text
|
||||
archive stable v0.2.6 vérifiée OK
|
||||
lecture règles/architecture/plans/validation OK
|
||||
inventaire Transport/Config réel OK
|
||||
archive bot3 WebSocket auditée OK
|
||||
audit docs officielles WebSocket OK
|
||||
cross-check Git Agave v4.2.1 OK
|
||||
audit SIMD WebSocket ciblé OK
|
||||
inventaire exact 18 = 9+9 OK
|
||||
audit dependencies candidates OK
|
||||
state machines / reconnect / resubscribe DECIDED
|
||||
backpressure / cancellation / shutdown / secrets DECIDED
|
||||
shape Config V2 + discriminateur famille WS DECIDED
|
||||
plan/sizing OK
|
||||
python3 scripts/audit_rust_workspace_rules.py baseline OK
|
||||
```
|
||||
|
||||
Tenté mais non exécutable dans le sandbox :
|
||||
|
||||
```text
|
||||
cargo fmt --all cargo absent
|
||||
cargo check --workspace cargo absent
|
||||
cargo clippy --workspace --all-targets cargo absent
|
||||
```
|
||||
|
||||
La matrice ne considère donc pas `pre.001` techniquement validé par Cargo tant que l'opérateur n'a pas exécuté ces gates sur son checkout.
|
||||
|
||||
## 11. Critères finaux à transformer en preuves
|
||||
|
||||
Avant `rel.001`, cette matrice doit obtenir :
|
||||
|
||||
```text
|
||||
18/18 méthodes official-index accounted (**Done `pre.012`**)
|
||||
9/9 subscribe wrappers public typed (**Done `pre.012`**)
|
||||
9/9 unsubscribe couverts par handles/registry (**Done `pre.012`**)
|
||||
0 option officielle perdue
|
||||
0 fuite URL/credential
|
||||
N sessions same URL prouvé
|
||||
N subscriptions same session prouvé
|
||||
reconnect/resubscribe/backpressure/shutdown gates verts
|
||||
unstable warnings centralisés
|
||||
HTTP 52+14 non régressé (**Done `pre.012`**)
|
||||
Config V1 backward + V2 WS validés (**Done `pre.003`, revalidé opérateur `pre.013`**)
|
||||
smoke live opt-in documenté (**Done `pre.013`**)
|
||||
cargo tree inspecté (**Done opérateur `pre.013`**)
|
||||
cargo test --workspace vert (**Done opérateur `pre.013`**)
|
||||
README/USAGE synchronisés et réaudités durables (**Done `pre.014`**)
|
||||
prompt 0.2.8 préparé (**Done `pre.014`**)
|
||||
```
|
||||
|
||||
## 12. Clôture `0.2.7-rel.001`
|
||||
|
||||
Preuve opérateur finale fournie le **23 août 2026** après `0.2.7-pre.014-fix.001` :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS — audits général/export/workspace clean
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test -p ksp-onchain-transport-lib PASS
|
||||
unit 309/309
|
||||
public API 36/36
|
||||
release completeness 24/24
|
||||
HTTP Devnet smoke ignored par défaut
|
||||
WebSocket Devnet smoke ignored par défaut
|
||||
cargo test --workspace PASS
|
||||
```
|
||||
|
||||
Le workspace complet couvre également les canaries Config, Core, Logging, Wallet et les deux Desks. Les tests explicitement operator-only/diagnostic/live restent ignorés par défaut conformément à leur contrat. Aucun échec n'est observé.
|
||||
|
||||
Le smoke WebSocket Devnet explicite de `pre.013` reste la preuve live retenue : `slotSubscribe -> slotNotification -> unsubscribe -> close`. Les changements ultérieurs `pre.014` et `pre.014-fix.001` sont documentaires/versionnés ou documentaires uniquement et ne touchent pas le runtime WebSocket ni le graphe de dépendances.
|
||||
|
||||
Verdict final :
|
||||
|
||||
```text
|
||||
18/18 WebSocket standard accounted PASS
|
||||
9/9 wrappers subscribe publics typés PASS
|
||||
9/9 unsubscribe via handle/registry PASS
|
||||
KSP-TRANSPORT-007 / wire/options PASS
|
||||
reconnect/resubscribe/backpressure/shutdown PASS
|
||||
redaction URL/credential PASS
|
||||
Config V1 backward + V2 WS PASS
|
||||
HTTP 52 current + 14 historical PASS
|
||||
smoke WebSocket Devnet opt-in PASS (pre.013)
|
||||
dependency firewall / graph audit PASS
|
||||
workspace final post-fix PASS
|
||||
README/USAGE PASS
|
||||
prompt 0.2.8 version 2 PASS
|
||||
```
|
||||
|
||||
La matrice est fermée positivement par `0.2.7-rel.001`.
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: prompts/000-README.md -->
|
||||
<!-- version: 21 -->
|
||||
<!-- version: 23 -->
|
||||
|
||||
# Prompts KSP
|
||||
|
||||
@@ -32,4 +32,5 @@ Le prompt générique `0.1.x` a été affiné pendant `0.0.3` puis remplacé par
|
||||
- [`009-V0_2_4_START_PROMPT.md`](009-V0_2_4_START_PROMPT.md) — prompt préparé par `0.2.3-pre.009`, destiné à ouvrir `0.2.4 — HTTP Blocks + Economics + compliance HTTP finale` après publication stable de `0.2.3`; il cible les 15 wrappers restants et impose `KSP-TRANSPORT-007` ainsi qu'un nouvel audit/sizing à `pre.001`.
|
||||
- [`010-V0_2_5_START_PROMPT.md`](010-V0_2_5_START_PROMPT.md) — prompt préparé par `0.2.4-pre.009` puis finalisé en version 2 par `pre.009-fix.001`, destiné à ouvrir `0.2.5 — Wallet foundation` sur la base stable `v0.2.4`; il impose audit/threat-model/sizing avant choix cryptographiques et cadre `.kspwallet` interopérable, capacités indépendantes VIEW/OWNER, metadata protégées, key slots/rotations, signature, persistence atomique et import/export extensible sans `WalletPolicy`.
|
||||
- [`011-V0_2_6_START_PROMPT.md`](011-V0_2_6_START_PROMPT.md) — prompt préparé par `0.2.5-pre.010` puis renforcé pendant `pre.010-fix.001`–`fix.003`, prompt historique consommé pour ouvrir `0.2.6 — Wallet Desk` après le tag stable `v0.2.5`; il impose une première tranche audit/sizing, rappelle les règles Rust/audit structurel, cadre Config composite + Wallet + HTTP `getBalance`, lifecycle VIEW/OWNER, sécurité password/export, validation frontend/Tauri et conserve le TODO `0.2.11` d’intégration des prix offchain dans Wallet Desk après validation de la Price Desk spécialisée.
|
||||
- [`012-V0_2_7_START_PROMPT.md`](012-V0_2_7_START_PROMPT.md) — prompt réaligné par `0.2.6-pre.015` puis renforcé en contrat de reprise autonome par `0.2.6-pre.018-fix.002`; il est le prochain prompt actif et ouvre `0.2.7 — WebSocket Solana standard` depuis `v0.2.6`, impose les lectures/règles ordonnées, l’audit officiel et historique, le threat-model session/subscription/reconnect/backpressure, la matrice de compliance, un gate `pre.001` strict et une prévision souple de prereleases avant toute implémentation lourde.
|
||||
- [`012-V0_2_7_START_PROMPT.md`](012-V0_2_7_START_PROMPT.md) — prompt réaligné par `0.2.6-pre.015` puis renforcé en contrat de reprise autonome par `0.2.6-pre.018-fix.002`; prompt historique consommé pour ouvrir `0.2.7 — WebSocket Solana standard` depuis `v0.2.6`, avec lectures/règles ordonnées, audit officiel et historique, threat-model session/subscription/reconnect/backpressure, matrice de compliance, gate `pre.001` strict et prévision souple de prereleases avant toute implémentation lourde.
|
||||
- [`013-V0_2_8_START_PROMPT.md`](013-V0_2_8_START_PROMPT.md) — prompt actif après publication `0.2.7-rel.001` et tag stable `v0.2.7`, préparé par `0.2.7-pre.014` puis renforcé en version 2 par `pre.014-fix.001`; il ouvre `0.2.8 — Helius LaserStream WebSocket` avec relecture des règles et de la surface WebSocket finale, réaudit Helius actuel, gate `pre.001` strict audit/brainstorming/sizing, réutilisation du moteur actor standard sans second client, frontières provider/Config/secrets explicites et forecast souple recalibrable avant toute implémentation lourde.
|
||||
|
||||
1073
prompts/013-V0_2_8_START_PROMPT.md
Normal file
1073
prompts/013-V0_2_8_START_PROMPT.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user