diff --git a/Cargo.toml b/Cargo.toml index ef26475..8e109cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 240 +# version: 241 [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.9-pre.2.fix.2" +version = "0.2.9-pre.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" @@ -23,6 +23,7 @@ ed25519-dalek = { version = "^3.0", default-features = false } fs2 = { version = "^0.4" } futures-util = { version = "^0.3", default-features = false } getrandom = { version = "^0.4", default-features = false } +http = { version = "^1.5", default-features = false } jsonschema = { version = "^0.51", default-features = false } reqwest = { version = "^0.13", default-features = false } serde = { version = "^1.0" } @@ -40,6 +41,7 @@ tracing-appender = { version = "^0.2", default-features = false } tokio = { version = "^1.53", default-features = false } tokio-tungstenite = { version = "^0.30", default-features = false } tonic = { version = "^0.14", default-features = false } +tonic-prost = { version = "^0.14", default-features = false } ts-rs = { version = "^12.0" } yellowstone-grpc-proto = { version = "^12.6", default-features = false } zeroize = { version = "^1.9" } diff --git a/crates/ksp-core-lib/tests/workspace_dependencies.rs b/crates/ksp-core-lib/tests/workspace_dependencies.rs index 377f11a..31eb24b 100644 --- a/crates/ksp-core-lib/tests/workspace_dependencies.rs +++ b/crates/ksp-core-lib/tests/workspace_dependencies.rs @@ -1,5 +1,5 @@ // file: crates/ksp-core-lib/tests/workspace_dependencies.rs -// version: 6 +// version: 7 //! Workspace-level dependency policy canaries owned by the foundational KSP test surface. @@ -62,13 +62,17 @@ fn transport_manifest_preserves_ksp_dependency_firewall() { assert!(manifest.contains("ksp-core-lib")); assert!(manifest.contains("ksp-logging-lib")); assert!(manifest.contains("futures-util = { workspace = true, features = [\"sink\", \"std\"] }")); + assert!(manifest.contains("http.workspace = true")); assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }")); assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"net\", \"rt\", \"sync\", \"time\"] }")); assert!(manifest.contains("tokio-tungstenite = { workspace = true, features = [\"connect\", \"rustls-tls-webpki-roots\"] }")); - assert!(manifest.contains("tonic = { workspace = true, features = [\"channel\"] }")); + assert!(manifest.contains("tonic = { workspace = true, features = [\"channel\", \"tls-aws-lc\", \"tls-webpki-roots\"] }")); + assert!(manifest.contains("tonic-prost.workspace = true")); assert!(manifest.contains("yellowstone-grpc-proto.workspace = true")); assert!(manifest.contains("[dev-dependencies]")); assert!(manifest.contains("tokio = { workspace = true, features = [\"io-util\", \"net\", \"rt\", \"test-util\"] }")); + assert!(manifest.contains("tonic = { workspace = true, features = [\"codegen\", \"server\"] }")); + assert!(manifest.contains("yellowstone-grpc-proto = { workspace = true, features = [\"tonic\"] }")); } #[test] @@ -90,6 +94,7 @@ fn transport_manifest_runtime_and_dev_dependency_names_are_exact() { dependency_names, std::vec![ "futures-util", + "http", "ksp-core-lib", "ksp-logging-lib", "reqwest", @@ -98,7 +103,8 @@ fn transport_manifest_runtime_and_dev_dependency_names_are_exact() { "tokio", "tokio-tungstenite", "tonic", - "yellowstone-grpc-proto" + "tonic-prost", + "yellowstone-grpc-proto", ] ); let dev_dependencies_tail = manifest.split("[dev-dependencies]").nth(1); @@ -111,7 +117,7 @@ fn transport_manifest_runtime_and_dev_dependency_names_are_exact() { std::option::Option::Some(value) => value, std::option::Option::None => return, }; - assert_eq!(manifest_dependency_names(dev_dependencies), std::vec!["tokio"]); + assert_eq!(manifest_dependency_names(dev_dependencies), std::vec!["tokio", "tonic", "yellowstone-grpc-proto"]); } fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> { diff --git a/crates/ksp-onchain-transport-lib/Cargo.toml b/crates/ksp-onchain-transport-lib/Cargo.toml index a59d876..1f7ddb2 100644 --- a/crates/ksp-onchain-transport-lib/Cargo.toml +++ b/crates/ksp-onchain-transport-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-onchain-transport-lib/Cargo.toml -# version: 7 +# version: 8 [package] name = "ksp-onchain-transport-lib" @@ -11,16 +11,20 @@ repository.workspace = true ksp-core-lib = { path = "../ksp-core-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" } futures-util = { workspace = true, features = ["sink", "std"] } +http.workspace = true reqwest = { workspace = true, features = ["rustls"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] } tokio-tungstenite = { workspace = true, features = ["connect", "rustls-tls-webpki-roots"] } -tonic = { workspace = true, features = ["channel"] } +tonic = { workspace = true, features = ["channel", "tls-aws-lc", "tls-webpki-roots"] } +tonic-prost.workspace = true yellowstone-grpc-proto.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["io-util", "net", "rt", "test-util"] } +tonic = { workspace = true, features = ["codegen", "server"] } +yellowstone-grpc-proto = { workspace = true, features = ["tonic"] } [lints] workspace = true diff --git a/crates/ksp-onchain-transport-lib/src/error.rs b/crates/ksp-onchain-transport-lib/src/error.rs index 1e1bc15..6c1f828 100644 --- a/crates/ksp-onchain-transport-lib/src/error.rs +++ b/crates/ksp-onchain-transport-lib/src/error.rs @@ -1,10 +1,12 @@ // file: crates/ksp-onchain-transport-lib/src/error.rs -// version: 5 +// version: 6 /// Error code used when no logical endpoint can satisfy a request. pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed"); /// Error code used when a Yellowstone gRPC channel cannot be prepared safely. pub const ERROR_CODE_GRPC_CHANNEL_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_channel_failed"); +/// Error code used when a Yellowstone gRPC endpoint returns a remote gRPC status. +pub const ERROR_CODE_GRPC_STATUS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "grpc_status"); /// Error code used when an HTTP connection cannot be established. pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_connection_failed"); /// Error code used when an HTTP request fails after a connection exists. diff --git a/crates/ksp-onchain-transport-lib/src/grpc_channel.rs b/crates/ksp-onchain-transport-lib/src/grpc_channel.rs index fc9f91c..e0e4f18 100644 --- a/crates/ksp-onchain-transport-lib/src/grpc_channel.rs +++ b/crates/ksp-onchain-transport-lib/src/grpc_channel.rs @@ -1,80 +1,96 @@ // file: crates/ksp-onchain-transport-lib/src/grpc_channel.rs -// version: 2 +// version: 3 -/// Prepared Yellowstone gRPC channel owned by KSP Transport. +/// Prepared or connected Yellowstone gRPC channel owned by KSP Transport. /// -/// `pre.002` intentionally prepares a lazy HTTP/2 channel without performing network I/O. TLS configuration, provider-neutral metadata injection and live -/// connection/error semantics are added by `pre.003`. The underlying Tonic channel and upstream Yellowstone protobuf types are never exposed publicly. +/// The underlying Tonic channel, endpoint URL and request metadata remain private. Callers use the KSP-owned typed Yellowstone surfaces layered on this +/// physical channel instead of receiving a raw Tonic escape hatch. +#[derive(Clone)] pub struct YellowstoneGrpcChannel { endpoint_name: std::string::String, provider: crate::YellowstoneGrpcProviderName, cluster: crate::YellowstoneGrpcClusterName, - _channel: tonic::transport::Channel, + channel: tonic::transport::Channel, + metadata: std::vec::Vec, + session: crate::YellowstoneGrpcSessionSettings, } impl YellowstoneGrpcChannel { - /// Prepares one lazy Yellowstone gRPC channel from validated Transport-owned endpoint settings. + /// Prepares one lazy HTTP/2 channel without establishing a network connection. /// - /// This operation performs no network connection. HTTPS endpoints remain syntactically accepted here; TLS is deliberately completed in `pre.003` - /// before any live call is allowed. - pub fn prepare(endpoint: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result { - if let std::result::Result::Err(error) = endpoint.validate() { + /// HTTPS endpoints receive the KSP TLS configuration immediately, so invalid local TLS setup is rejected before a typed client is created. Tonic lazy + /// channels require an active Tokio runtime even though no socket is opened yet. + pub fn prepare(settings: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result { + if let std::result::Result::Err(error) = settings.validate() { return std::result::Result::Err(error); } - if !endpoint.enabled() { + if !settings.enabled() { return std::result::Result::Err( - ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "disabled Yellowstone gRPC endpoint cannot prepare a channel") - .with_context("field", "grpc_endpoint.enabled") - .with_context("endpoint_name", endpoint.name()), + ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "disabled Yellowstone gRPC endpoint cannot prepare a channel") + .with_context("endpoint_name", settings.name()), ); } if tokio::runtime::Handle::try_current().is_err() { - ksp_logging_lib::warn!( - target: crate::TRACING_TARGET, - endpoint_name = endpoint.name(), - provider = endpoint.provider().as_str(), - cluster = endpoint.cluster().as_str(), - "Yellowstone gRPC channel preparation requires an active Tokio runtime" - ); + return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC channel preparation requires an active Tokio runtime")); + } + let endpoint = match build_tonic_endpoint(settings) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let channel = endpoint.connect_lazy(); + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + endpoint_name = settings.name(), + provider = settings.provider().as_str(), + cluster = settings.cluster().as_str(), + tls = settings.url().uses_tls(), + metadata_count = settings.metadata().len(), + "prepared lazy Yellowstone gRPC channel" + ); + return std::result::Result::Ok(Self::from_parts(settings, channel)); + } + + /// Establishes one Yellowstone gRPC HTTP/2 channel with bounded connect timeout and configured TLS roots. + pub async fn connect(settings: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result { + if let std::result::Result::Err(error) = settings.validate() { + return std::result::Result::Err(error); + } + if !settings.enabled() { return std::result::Result::Err( - ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "Yellowstone gRPC channel requires an active Tokio runtime") - .with_context("endpoint_name", endpoint.name()), + ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "disabled Yellowstone gRPC endpoint cannot connect") + .with_context("endpoint_name", settings.name()), ); } - let tonic_endpoint = match tonic::transport::Endpoint::from_shared(endpoint.url().as_str().to_owned()) { + if tokio::runtime::Handle::try_current().is_err() { + return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC connection requires an active Tokio runtime")); + } + let endpoint = match build_tonic_endpoint(settings) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let channel = match endpoint.connect().await { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => { ksp_logging_lib::warn!( target: crate::TRACING_TARGET, - endpoint_name = endpoint.name(), - provider = endpoint.provider().as_str(), - cluster = endpoint.cluster().as_str(), - "failed to prepare Yellowstone gRPC channel URI" - ); - return std::result::Result::Err( - ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, "Yellowstone gRPC channel could not be prepared") - .with_context("endpoint_name", endpoint.name()), + endpoint_name = settings.name(), + provider = settings.provider().as_str(), + cluster = settings.cluster().as_str(), + tls = settings.url().uses_tls(), + "Yellowstone gRPC channel connection failed" ); + return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC channel connection failed")); }, }; - let tonic_endpoint = tonic_endpoint - .connect_timeout(endpoint.session().connect_timeout()) - .timeout(endpoint.session().unary_timeout()) - .buffer_size(endpoint.session().request_channel_capacity()); - let channel = tonic_endpoint.connect_lazy(); ksp_logging_lib::debug!( target: crate::TRACING_TARGET, - endpoint_name = endpoint.name(), - provider = endpoint.provider().as_str(), - cluster = endpoint.cluster().as_str(), - "prepared lazy Yellowstone gRPC channel" + endpoint_name = settings.name(), + provider = settings.provider().as_str(), + cluster = settings.cluster().as_str(), + tls = settings.url().uses_tls(), + "connected Yellowstone gRPC channel" ); - return std::result::Result::Ok(Self { - endpoint_name: endpoint.name().to_owned(), - provider: endpoint.provider().clone(), - cluster: endpoint.cluster().clone(), - _channel: channel, - }); + return std::result::Result::Ok(Self::from_parts(settings, channel)); } /// Returns the safe logical endpoint name. @@ -83,17 +99,34 @@ impl YellowstoneGrpcChannel { return self.endpoint_name.as_str(); } - /// Returns the safe open provider descriptor. + /// Returns the open provider descriptor. #[must_use] pub const fn provider(&self) -> &crate::YellowstoneGrpcProviderName { return &self.provider; } - /// Returns the safe open cluster descriptor. + /// Returns the open cluster descriptor. #[must_use] pub const fn cluster(&self) -> &crate::YellowstoneGrpcClusterName { return &self.cluster; } + + /// Creates the standard Solana Yellowstone unary facade over this physical channel. + #[must_use] + pub fn standard_unary_client(&self) -> crate::SolanaYellowstoneGrpcUnaryClient { + return crate::SolanaYellowstoneGrpcUnaryClient::new(self.channel.clone(), self.metadata.clone(), self.session.clone()); + } + + fn from_parts(settings: &crate::YellowstoneGrpcEndpointSettings, channel: tonic::transport::Channel) -> Self { + return Self { + endpoint_name: settings.name().to_owned(), + provider: settings.provider().clone(), + cluster: settings.cluster().clone(), + channel, + metadata: settings.metadata().to_vec(), + session: settings.session().clone(), + }; + } } impl std::fmt::Debug for YellowstoneGrpcChannel { @@ -103,10 +136,39 @@ impl std::fmt::Debug for YellowstoneGrpcChannel { .field("endpoint_name", &self.endpoint_name) .field("provider", &self.provider) .field("cluster", &self.cluster) + .field("metadata_count", &self.metadata.len()) + .field("channel", &"") .finish(); } } +fn build_tonic_endpoint(settings: &crate::YellowstoneGrpcEndpointSettings) -> ksp_core_lib::Result { + let endpoint = match tonic::transport::Endpoint::from_shared(settings.url().as_str().to_owned()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC endpoint URI cannot be prepared by the HTTP/2 transport")); + }, + }; + let endpoint = endpoint.connect_timeout(settings.session().connect_timeout()).buffer_size(settings.session().request_channel_capacity()); + if settings.url().uses_tls() { + let tls = tonic::transport::ClientTlsConfig::new().with_webpki_roots().timeout(settings.session().connect_timeout()); + return match endpoint.tls_config(tls) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => { + std::result::Result::Err(grpc_channel_error(settings, "Yellowstone gRPC TLS configuration failed before connection")) + }, + }; + } + return std::result::Result::Ok(endpoint); +} + +fn grpc_channel_error(settings: &crate::YellowstoneGrpcEndpointSettings, message: &str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_GRPC_CHANNEL_FAILED, message) + .with_context("endpoint_name", settings.name()) + .with_context("provider", settings.provider().as_str()) + .with_context("cluster", settings.cluster().as_str()); +} + #[cfg(test)] #[path = "../unit_tests/grpc_channel.rs"] mod tests; diff --git a/crates/ksp-onchain-transport-lib/src/grpc_settings.rs b/crates/ksp-onchain-transport-lib/src/grpc_settings.rs index c509c56..12c8dfa 100644 --- a/crates/ksp-onchain-transport-lib/src/grpc_settings.rs +++ b/crates/ksp-onchain-transport-lib/src/grpc_settings.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/grpc_settings.rs -// version: 1 +// version: 2 const DEFAULT_GRPC_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); const DEFAULT_GRPC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -16,6 +16,9 @@ const MAX_GRPC_DESCRIPTOR_LENGTH_BYTES: usize = 128; const MAX_GRPC_ENDPOINT_COUNT: usize = 128; const MAX_GRPC_ENDPOINT_URL_LENGTH_BYTES: usize = 8 * 1024; const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 512 * 1024 * 1024; +const MAX_GRPC_METADATA_ENTRY_COUNT: usize = 64; +const MAX_GRPC_METADATA_KEY_LENGTH_BYTES: usize = 128; +const MAX_GRPC_METADATA_VALUE_LENGTH_BYTES: usize = 8 * 1024; const MAX_GRPC_RECONNECT_RETRIES: u32 = 100; const MAX_GRPC_RUNTIME_DURATION: std::time::Duration = std::time::Duration::from_secs(300); @@ -25,6 +28,7 @@ const MAX_GRPC_RUNTIME_DURATION: std::time::Duration = std::time::Duration::from #[derive(Clone, Eq, PartialEq)] pub struct YellowstoneGrpcEndpointUrl { value: std::string::String, + uses_tls: bool, } impl YellowstoneGrpcEndpointUrl { @@ -70,7 +74,7 @@ impl YellowstoneGrpcEndpointUrl { ); } ksp_logging_lib::trace!(target: crate::TRACING_TARGET, scheme = parsed.scheme(), "validated Yellowstone gRPC endpoint URL syntax"); - return std::result::Result::Ok(Self { value }); + return std::result::Result::Ok(Self { value, uses_tls: parsed.scheme() == "https" }); } /// Returns the sensitive runtime URL text. @@ -80,6 +84,12 @@ impl YellowstoneGrpcEndpointUrl { pub fn as_str(&self) -> &str { return self.value.as_str(); } + + /// Returns whether this endpoint URL requires TLS. + #[must_use] + pub const fn uses_tls(&self) -> bool { + return self.uses_tls; + } } impl std::fmt::Debug for YellowstoneGrpcEndpointUrl { @@ -88,6 +98,107 @@ impl std::fmt::Debug for YellowstoneGrpcEndpointUrl { } } +/// One validated ASCII metadata entry attached to Yellowstone gRPC requests. +/// +/// Metadata values are intentionally omitted from [`std::fmt::Debug`] for both public and secret entries. Secret entries are additionally marked sensitive on +/// the Tonic metadata value before transmission so the HTTP/2 stack avoids indexing them where supported. Binary `*-bin` metadata is not part of the +/// `0.2.9-pre.003` contract. +#[derive(Clone, Eq, PartialEq)] +pub struct YellowstoneGrpcMetadataEntry { + key: std::string::String, + value: std::string::String, + secret: bool, +} + +impl YellowstoneGrpcMetadataEntry { + /// Creates one non-secret ASCII metadata entry. + pub fn public(key: impl std::convert::Into, value: impl std::convert::Into) -> ksp_core_lib::Result { + return Self::new(key.into(), value.into(), false); + } + + /// Creates one secret ASCII metadata entry with redacted diagnostics. + pub fn secret(key: impl std::convert::Into, value: impl std::convert::Into) -> ksp_core_lib::Result { + return Self::new(key.into(), value.into(), true); + } + + /// Returns the validated metadata key. + #[must_use] + pub fn key(&self) -> &str { + return self.key.as_str(); + } + + /// Returns whether the value must be treated as secret by Transport. + #[must_use] + pub const fn is_secret(&self) -> bool { + return self.secret; + } + + /// Appends this validated value to an internal Tonic metadata map while preserving its sensitivity marker. + pub(crate) fn append_to(&self, metadata: &mut tonic::metadata::MetadataMap) -> ksp_core_lib::Result<()> { + let key = match tonic::metadata::MetadataKey::::from_bytes(self.key.as_bytes()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata key is invalid") + .with_context("field", "grpc_endpoint.metadata.key"), + ); + }, + }; + let mut value = match tonic::metadata::AsciiMetadataValue::try_from(self.value.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata value is invalid") + .with_context("field", "grpc_endpoint.metadata.value") + .with_context("metadata_key", self.key.as_str()), + ); + }, + }; + value.set_sensitive(self.secret); + metadata.append(key, value); + return std::result::Result::Ok(()); + } + + fn new(key: std::string::String, value: std::string::String, secret: bool) -> ksp_core_lib::Result { + if key.is_empty() + || key.len() > MAX_GRPC_METADATA_KEY_LENGTH_BYTES + || key != key.to_ascii_lowercase() + || key.starts_with("grpc-") + || key.ends_with("-bin") + { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata key violates the KSP ASCII metadata contract") + .with_context("field", "grpc_endpoint.metadata.key"), + ); + } + if tonic::metadata::MetadataKey::::from_bytes(key.as_bytes()).is_err() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata key is invalid") + .with_context("field", "grpc_endpoint.metadata.key"), + ); + } + if value.len() > MAX_GRPC_METADATA_VALUE_LENGTH_BYTES || tonic::metadata::AsciiMetadataValue::try_from(value.as_str()).is_err() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "Yellowstone gRPC metadata value violates the KSP ASCII metadata contract") + .with_context("field", "grpc_endpoint.metadata.value") + .with_context("metadata_key", key.as_str()), + ); + } + return std::result::Result::Ok(Self { key, value, secret }); + } +} + +impl std::fmt::Debug for YellowstoneGrpcMetadataEntry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("YellowstoneGrpcMetadataEntry") + .field("key", &self.key) + .field("secret", &self.secret) + .field("value", &"") + .finish(); + } +} + /// Open provider descriptor used by Yellowstone gRPC endpoint settings. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct YellowstoneGrpcProviderName { @@ -330,6 +441,7 @@ pub struct YellowstoneGrpcEndpointSettings { cluster: crate::YellowstoneGrpcClusterName, url: crate::YellowstoneGrpcEndpointUrl, session: crate::YellowstoneGrpcSessionSettings, + metadata: std::vec::Vec, } impl YellowstoneGrpcEndpointSettings { @@ -343,7 +455,7 @@ impl YellowstoneGrpcEndpointSettings { url: crate::YellowstoneGrpcEndpointUrl, session: crate::YellowstoneGrpcSessionSettings, ) -> Self { - return Self { name: name.into(), enabled, provider, cluster, url, session }; + return Self { name: name.into(), enabled, provider, cluster, url, session, metadata: std::vec::Vec::new() }; } /// Returns the logical endpoint name. @@ -382,6 +494,24 @@ impl YellowstoneGrpcEndpointSettings { return &self.session; } + /// Returns metadata entries in declaration order without exposing their values. + #[must_use] + pub fn metadata(&self) -> &[crate::YellowstoneGrpcMetadataEntry] { + return self.metadata.as_slice(); + } + + /// Replaces request metadata after validating KSP bounds and ASCII metadata rules. + pub fn with_metadata(mut self, metadata: std::vec::Vec) -> ksp_core_lib::Result { + if metadata.len() > MAX_GRPC_METADATA_ENTRY_COUNT { + return grpc_invalid_settings_value("Yellowstone gRPC metadata entry count exceeds the KSP bound", "grpc_endpoint.metadata"); + } + self.metadata = metadata; + if let std::result::Result::Err(error) = self.validate() { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(self); + } + /// Validates this endpoint without performing network I/O. pub fn validate(&self) -> ksp_core_lib::Result<()> { return validate_grpc_endpoint(self, "grpc_endpoint"); @@ -464,6 +594,9 @@ fn validate_grpc_endpoint(endpoint: &crate::YellowstoneGrpcEndpointSettings, fie if let std::result::Result::Err(error) = endpoint.session().validate() { return std::result::Result::Err(error); } + if endpoint.metadata().len() > MAX_GRPC_METADATA_ENTRY_COUNT { + return grpc_invalid_settings("Yellowstone gRPC metadata entry count exceeds the KSP bound", "grpc_endpoint.metadata"); + } ksp_logging_lib::trace!( target: crate::TRACING_TARGET, endpoint_name = endpoint.name(), @@ -523,6 +656,11 @@ fn grpc_invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field)); } +fn grpc_invalid_settings_value(message: &str, field: &str) -> ksp_core_lib::Result { + ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, reason = message, "rejected Yellowstone gRPC transport settings"); + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field)); +} + #[cfg(test)] #[path = "../unit_tests/grpc_settings.rs"] mod tests; diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index 4d9d38c..38e9025 100644 --- a/crates/ksp-onchain-transport-lib/src/lib.rs +++ b/crates/ksp-onchain-transport-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/lib.rs -// version: 35 +// version: 36 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -32,8 +32,9 @@ //! transaction handle and typed `transactionNotification` union into the same actor-owned registry, remote-ID remap, unsubscribe-race handling and //! per-subscription backpressure path. //! `0.2.9-pre.002` opens the Yellowstone gRPC N1 engine foundation with Transport-owned redacted settings, bounded reconnect/channel/message policies, the -//! published Yellowstone protobuf dependency and a lazy Tonic HTTP/2 channel wrapper that exposes no raw Tonic or upstream protobuf types. TLS, metadata and -//! live unary calls remain deliberately deferred to `pre.003`. +//! published Yellowstone protobuf dependency and a lazy Tonic HTTP/2 channel wrapper that exposes no raw Tonic or upstream protobuf types. +//! `0.2.9-pre.003` adds bounded TLS/WebPKI connection establishment, generic redacted ASCII request metadata and the seven standard Yellowstone unary RPCs +//! through KSP-owned DTOs. Streaming `Subscribe` remains deliberately deferred to `pre.004`. mod client; mod constants; @@ -41,6 +42,7 @@ mod error; mod executor; mod grpc_channel; mod grpc_settings; +mod grpc_unary; mod json_rpc; mod pool; mod resilience; @@ -77,6 +79,8 @@ pub use self::client::HttpEndpointSnapshot; pub use self::error::ERROR_CODE_ENDPOINT_SELECTION_FAILED; /// Error code used when a Yellowstone gRPC channel cannot be prepared safely. pub use self::error::ERROR_CODE_GRPC_CHANNEL_FAILED; +/// Error code used when a Yellowstone gRPC endpoint returns a remote status. +pub use self::error::ERROR_CODE_GRPC_STATUS; /// Error code used when an HTTP connection cannot be established. pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED; /// Error code used when an HTTP request fails after connection establishment. @@ -109,7 +113,7 @@ pub use self::error::ERROR_CODE_WS_CONNECTION_FAILED; pub use self::error::ERROR_CODE_WS_PROTOCOL_ERROR; /// Error code used when a WebSocket session is no longer available. pub use self::error::ERROR_CODE_WS_SESSION_CLOSED; -/// Prepared lazy Yellowstone gRPC channel owned by KSP Transport. +/// Yellowstone gRPC channel owned by KSP Transport. pub use self::grpc_channel::YellowstoneGrpcChannel; /// Open cluster or network descriptor used by Yellowstone gRPC endpoint settings. pub use self::grpc_settings::YellowstoneGrpcClusterName; @@ -117,6 +121,8 @@ pub use self::grpc_settings::YellowstoneGrpcClusterName; pub use self::grpc_settings::YellowstoneGrpcEndpointSettings; /// Runtime Yellowstone gRPC endpoint URL with redacted diagnostics. pub use self::grpc_settings::YellowstoneGrpcEndpointUrl; +/// Validated public or secret ASCII metadata attached to Yellowstone gRPC requests. +pub use self::grpc_settings::YellowstoneGrpcMetadataEntry; /// Open provider descriptor used by Yellowstone gRPC endpoint settings. pub use self::grpc_settings::YellowstoneGrpcProviderName; /// Bounded reconnect settings owned by the Yellowstone gRPC runtime. @@ -125,6 +131,22 @@ pub use self::grpc_settings::YellowstoneGrpcReconnectSettings; pub use self::grpc_settings::YellowstoneGrpcSessionSettings; /// Complete runtime settings consumed by the KSP Yellowstone gRPC transport engine. pub use self::grpc_settings::YellowstoneGrpcTransportSettings; +/// Standard Solana Yellowstone unary facade over one KSP-owned physical gRPC channel. +pub use self::grpc_unary::SolanaYellowstoneGrpcUnaryClient; +/// Block height returned by the standard Yellowstone unary surface. +pub use self::grpc_unary::YellowstoneBlockHeight; +/// Result of a standard Yellowstone blockhash-validity check. +pub use self::grpc_unary::YellowstoneBlockhashValidity; +/// Latest blockhash returned by the standard Yellowstone unary surface. +pub use self::grpc_unary::YellowstoneLatestBlockhash; +/// Echo returned by the standard Yellowstone unary Ping RPC. +pub use self::grpc_unary::YellowstonePong; +/// Replay availability advertised by the standard Yellowstone unary surface. +pub use self::grpc_unary::YellowstoneReplayInfo; +/// Current slot returned by the standard Yellowstone unary surface. +pub use self::grpc_unary::YellowstoneSlot; +/// Bounded endpoint version returned by the standard Yellowstone unary surface. +pub use self::grpc_unary::YellowstoneVersionInfo; /// 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. diff --git a/crates/ksp-onchain-transport-lib/src/rpc_common.rs b/crates/ksp-onchain-transport-lib/src/rpc_common.rs index affb13b..cb89d91 100644 --- a/crates/ksp-onchain-transport-lib/src/rpc_common.rs +++ b/crates/ksp-onchain-transport-lib/src/rpc_common.rs @@ -1,7 +1,7 @@ // file: crates/ksp-onchain-transport-lib/src/rpc_common.rs -// version: 6 +// version: 7 -/// Commitment level accepted by typed Solana HTTP and WebSocket adapters. +/// Commitment level accepted by typed Solana HTTP, WebSocket and Yellowstone gRPC adapters. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum SolanaCommitment { /// Query the most recent processed bank. diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index f4c2845..6192613 100644 --- a/crates/ksp-onchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-onchain-transport-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/tests/public_api.rs -// version: 40 +// version: 41 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -789,3 +789,39 @@ async fn public_v0_2_9_pre_002_yellowstone_engine_settings_and_lazy_channel_are_ assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_CHANNEL_FAILED.domain(), "onchain_transport"); assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_CHANNEL_FAILED.code(), "grpc_channel_failed"); } + +#[test] +fn public_v0_2_9_pre_003_yellowstone_metadata_and_seven_unary_contracts_are_available_from_crate_root() { + let public = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::public("x-ksp-public", "fixture"); + assert!(public.is_ok()); + let secret = ksp_onchain_transport_lib::YellowstoneGrpcMetadataEntry::secret("x-ksp-token", "secret"); + assert!(secret.is_ok()); + let replay = ksp_onchain_transport_lib::YellowstoneReplayInfo::new(std::option::Option::Some(1)); + assert_eq!(replay.first_available(), std::option::Option::Some(1)); + let pong = ksp_onchain_transport_lib::YellowstonePong::new(2); + assert_eq!(pong.count(), 2); + let latest = ksp_onchain_transport_lib::YellowstoneLatestBlockhash::new(3, "hash".to_owned(), 4); + assert_eq!(latest.slot(), 3); + assert_eq!(latest.blockhash(), "hash"); + assert_eq!(latest.last_valid_block_height(), 4); + assert_eq!(ksp_onchain_transport_lib::YellowstoneBlockHeight::new(5).block_height(), 5); + assert_eq!(ksp_onchain_transport_lib::YellowstoneSlot::new(6).slot(), 6); + let validity = ksp_onchain_transport_lib::YellowstoneBlockhashValidity::new(7, true); + assert_eq!(validity.slot(), 7); + assert!(validity.valid()); + assert_eq!(ksp_onchain_transport_lib::YellowstoneVersionInfo::new("v".to_owned()).version(), "v"); + let _replay_info = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::subscribe_replay_info; + let _ping = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::ping; + let _latest_blockhash = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::get_latest_blockhash; + let _block_height = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::get_block_height; + let _slot = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::get_slot; + async fn call_is_blockhash_valid( + client: &ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient, + ) -> ksp_core_lib::Result { + return client.is_blockhash_valid("fixture-blockhash", std::option::Option::None).await; + } + let _is_blockhash_valid = call_is_blockhash_valid; + let _version = ksp_onchain_transport_lib::SolanaYellowstoneGrpcUnaryClient::get_version; + assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_STATUS.domain(), "onchain_transport"); + assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_STATUS.code(), "grpc_status"); +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index baa2f82..64732b9 100644 --- a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs +++ b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs -// version: 33 +// version: 34 //! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage. @@ -1008,7 +1008,7 @@ fn release_v0_2_9_pre_002_materializes_minimal_yellowstone_engine_without_provid let crate_root = include_str!("../src/lib.rs"); assert!(root_manifest.contains("tonic = { version = \"^0.14\", default-features = false }")); assert!(root_manifest.contains("yellowstone-grpc-proto = { version = \"^12.6\", default-features = false }")); - assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"channel\"] }")); + assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"channel\"")); assert!(transport_manifest.contains("yellowstone-grpc-proto.workspace = true")); assert!(!transport_manifest.contains("yellowstone-grpc-client")); assert!(!transport_manifest.contains("ksp-config-lib")); @@ -1019,10 +1019,50 @@ fn release_v0_2_9_pre_002_materializes_minimal_yellowstone_engine_without_provid assert!(!settings_source.contains("KSP_SECRET_")); assert!(channel_source.contains("tonic::transport::Endpoint::from_shared")); assert!(channel_source.contains("connect_lazy")); - assert!(!channel_source.contains("tls_config")); - assert!(!channel_source.contains("MetadataMap")); assert!(!channel_source.contains("WsSession")); assert!(!crate_root.contains("pub use tonic")); assert!(!crate_root.contains("pub use yellowstone_grpc_proto")); let _published_wire = std::any::type_name::(); } + +#[test] +fn release_v0_2_9_pre_003_adds_tls_metadata_and_exactly_seven_standard_unary_methods_without_subscribe() { + let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = manifest_directory.parent().and_then(std::path::Path::parent).expect("Transport test must resolve workspace root"); + let root_manifest = std::fs::read_to_string(workspace.join("Cargo.toml")).expect("workspace manifest must be readable"); + let transport_manifest = std::fs::read_to_string(manifest_directory.join("Cargo.toml")).expect("Transport manifest must be readable"); + let settings_source = include_str!("../src/grpc_settings.rs"); + let channel_source = include_str!("../src/grpc_channel.rs"); + let unary_source = include_str!("../src/grpc_unary.rs"); + let crate_root = include_str!("../src/lib.rs"); + assert!(root_manifest.contains("http = { version = \"^1.5\", default-features = false }")); + assert!(root_manifest.contains("tonic-prost = { version = \"^0.14\", default-features = false }")); + assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"channel\", \"tls-aws-lc\", \"tls-webpki-roots\"] }")); + assert!(transport_manifest.contains("tonic = { workspace = true, features = [\"codegen\", \"server\"] }")); + assert!(transport_manifest.contains("tonic-prost.workspace = true")); + assert!(transport_manifest.contains("yellowstone-grpc-proto.workspace = true")); + assert!(transport_manifest.contains("yellowstone-grpc-proto = { workspace = true, features = [\"tonic\"] }")); + assert!(!transport_manifest.contains("yellowstone-grpc-client")); + assert!(settings_source.contains("YellowstoneGrpcMetadataEntry")); + assert!(settings_source.contains("set_sensitive")); + assert!(channel_source.contains("ClientTlsConfig")); + assert!(channel_source.contains("with_webpki_roots")); + assert!(channel_source.contains("pub async fn connect")); + for path in [ + "/geyser.Geyser/SubscribeReplayInfo", + "/geyser.Geyser/Ping", + "/geyser.Geyser/GetLatestBlockhash", + "/geyser.Geyser/GetBlockHeight", + "/geyser.Geyser/GetSlot", + "/geyser.Geyser/IsBlockhashValid", + "/geyser.Geyser/GetVersion", + ] { + assert!(unary_source.contains(path), "missing standard Yellowstone unary path: {path}"); + } + assert!(!unary_source.contains("const PATH_SUBSCRIBE: ")); + assert!(!unary_source.contains("SubscribeDeshred")); + assert!(!unary_source.contains("PublicNode")); + assert!(!crate_root.contains("pub use tonic")); + assert!(!crate_root.contains("pub use yellowstone_grpc_proto")); + let _client = std::any::type_name::(); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs b/crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs index 8dfc6c4..0f34587 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs -// version: 2 +// version: 3 fn endpoint(enabled: bool, value: &str) -> crate::YellowstoneGrpcEndpointSettings { let parsed = crate::YellowstoneGrpcEndpointUrl::parse(value).expect("fixture Yellowstone gRPC URL must parse"); @@ -28,7 +28,7 @@ async fn grpc_channel_prepare_is_lazy_safe_and_keeps_tonic_private() { let rendered = format!("{channel:?}"); assert!(!rendered.contains("GRPC-SECRET-CANARY")); assert!(!rendered.contains("127.0.0.1")); - let _channel_type = std::any::type_name_of_val(&channel._channel); + let _client = channel.standard_unary_client(); let _wire_type = std::any::type_name::(); } @@ -48,3 +48,13 @@ fn grpc_channel_prepare_rejects_disabled_endpoint_before_network_io() { let result = crate::YellowstoneGrpcChannel::prepare(&endpoint); assert!(result.is_err()); } + +#[tokio::test(flavor = "current_thread")] +async fn grpc_channel_prepare_configures_https_without_exposing_url() { + let endpoint = endpoint(true, "https://example.invalid:443/GRPC-SECRET-CANARY"); + let result = crate::YellowstoneGrpcChannel::prepare(&endpoint); + assert!(result.is_ok()); + let rendered = format!("{result:?}"); + assert!(!rendered.contains("GRPC-SECRET-CANARY")); + assert!(!rendered.contains("example.invalid")); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs b/crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs index 213b02b..4a54ce7 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs -// version: 2 +// version: 3 fn endpoint(name: &str, enabled: bool, url: &str, session: crate::YellowstoneGrpcSessionSettings) -> crate::YellowstoneGrpcEndpointSettings { let parsed = crate::YellowstoneGrpcEndpointUrl::parse(url).expect("fixture Yellowstone gRPC URL must parse"); @@ -149,3 +149,50 @@ fn grpc_transport_settings_require_one_enabled_endpoint() { assert!(crate::YellowstoneGrpcTransportSettings::new(std::vec![disabled]).validate().is_err()); assert!(crate::YellowstoneGrpcTransportSettings::new(std::vec![]).validate().is_err()); } + +#[test] +fn grpc_metadata_validates_ascii_bounds_sensitivity_and_redacted_debug() { + let public = crate::YellowstoneGrpcMetadataEntry::public("x-ksp-public", "VISIBLE-CANARY").expect("public metadata must validate"); + let secret = crate::YellowstoneGrpcMetadataEntry::secret("x-ksp-token", "GRPC-SECRET-CANARY").expect("secret metadata must validate"); + assert_eq!(public.key(), "x-ksp-public"); + assert!(!public.is_secret()); + assert!(secret.is_secret()); + let rendered = format!("{public:?} {secret:?}"); + assert!(!rendered.contains("VISIBLE-CANARY")); + assert!(!rendered.contains("GRPC-SECRET-CANARY")); + let mut metadata = tonic::metadata::MetadataMap::new(); + assert!(secret.append_to(&mut metadata).is_ok()); + let appended = metadata.get("x-ksp-token").expect("secret metadata must be appended"); + assert!(appended.is_sensitive()); +} + +#[test] +fn grpc_metadata_rejects_reserved_binary_uppercase_malformed_and_oversized_values() { + assert!(crate::YellowstoneGrpcMetadataEntry::public("grpc-timeout", "1").is_err()); + assert!(crate::YellowstoneGrpcMetadataEntry::public("x-ksp-bin", "1").is_err()); + assert!(crate::YellowstoneGrpcMetadataEntry::public("X-KSP-UPPER", "1").is_err()); + assert!(crate::YellowstoneGrpcMetadataEntry::public("x-ksp-bad", "line\nfeed").is_err()); + assert!(crate::YellowstoneGrpcMetadataEntry::secret("x-ksp-large", "S".repeat(super::MAX_GRPC_METADATA_VALUE_LENGTH_BYTES + 1)).is_err()); +} + +#[test] +fn grpc_endpoint_metadata_count_is_bounded_and_url_tracks_tls_scheme() { + let http = crate::YellowstoneGrpcEndpointUrl::parse("http://127.0.0.1:10000").expect("HTTP URL must parse"); + let https = crate::YellowstoneGrpcEndpointUrl::parse("https://example.invalid:443").expect("HTTPS URL must parse"); + assert!(!http.uses_tls()); + assert!(https.uses_tls()); + let metadata = (0..=super::MAX_GRPC_METADATA_ENTRY_COUNT) + .map(|index| { + return crate::YellowstoneGrpcMetadataEntry::public(format!("x-ksp-{index}"), "value").expect("generated metadata must be valid"); + }) + .collect(); + let endpoint = crate::YellowstoneGrpcEndpointSettings::new( + "metadata-bound", + true, + crate::YellowstoneGrpcProviderName::new("fixture"), + crate::YellowstoneGrpcClusterName::new("devnet"), + http, + crate::YellowstoneGrpcSessionSettings::default(), + ); + assert!(endpoint.with_metadata(metadata).is_err()); +} diff --git a/docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md b/docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md index e03d005..0225eeb 100644 --- a/docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md +++ b/docs/plans/016-V0_2_9_YELLOWSTONE_GRPC_PLAN.md @@ -1,9 +1,9 @@ - + # Plan `0.2.9` — moteur Yellowstone gRPC + standard Solana + PublicNode -> **Statut : `0.2.9-pre.002-fix.002` — second correctif du gate opérateur : `fix.001` est confirmé vert sur fmt/audit/check/Clippy et les 346 unit tests ; seul le canari d’intégration `public_api` appelait encore le channel lazy depuis un test synchrone. `fix.002` place ce canari sous runtime Tokio sans modifier N1 en production. Les tests Transport/workspace doivent être réexécutés avant commit. `0.2.9` reste bornée à un moteur client Yellowstone partagé, une façade Solana Yellowstone standard et une première intégration concrète PublicNode. Seuls OrbitFlare puis Helius LaserStream gRPC sont actuellement planifiés comme releases provider suivantes ; les autres providers restent en TODO/IDEAS sans numéro réservé. Chaque prerelease vise 15–20 minutes de travail effectif et la release complète doit rester clôturable dans une seule session de chat.** +> **Statut : `0.2.9-pre.003` — `pre.002-fix.002` est fermée sur gate opérateur intégralement vert (fmt/audit/check/Clippy, Transport 346 unit + 42 public API + 35 completeness + 4 doctests, dependency canary et workspace complet). `pre.003` ajoute uniquement TLS client, metadata générique redacted, connexion HTTP/2 réelle, fixture Geyser locale et les sept unary standard ; `Subscribe`, PublicNode et Config V3 restent hors tranche. `0.2.9` reste bornée à un moteur client Yellowstone partagé, une façade Solana Yellowstone standard et une première intégration concrète PublicNode. Seuls OrbitFlare puis Helius LaserStream gRPC sont actuellement planifiés comme releases provider suivantes ; les autres providers restent en TODO/IDEAS sans numéro réservé. Chaque prerelease vise 15–20 minutes de travail effectif et la release complète doit rester clôturable dans une seule session de chat.** ## 1. Objet, base et état d'ouverture @@ -298,19 +298,22 @@ https://docs.rs/crate/yellowstone-grpc-proto/latest KSP suit la **Rust stable courante de l'opérateur** et ne documente pas de numéro Rust upstream comme objectif de projet. Le seul gate utile est opérationnel : les dépendances finalement retenues doivent compiler avec la stable courante utilisée par le workspace. -Résultat de matérialisation `pre.002` : +Résultat après matérialisation `pre.003` : ```text -yellowstone-grpc-proto ^12.6 workspace dependency, default-features = false, aucune feature KSP activée -tonic ^0.14 workspace dependency, default-features = false ; Transport active seulement `channel` +yellowstone-grpc-proto ^12.6 runtime : default-features = false, aucune feature `tonic` + dev/test : feature `tonic` uniquement pour GeyserServer de fixture +tonic ^0.14 runtime : channel + tls-aws-lc + tls-webpki-roots + dev/test : codegen + server ajoutés explicitement +http ^1.5 direct KSP pour PathAndQuery unary sans constructeur panic +tonic-prost ^0.14 direct KSP pour ProstCodec ; sa dépendance Tonic désactive les defaults yellowstone-grpc-client absent prost/prost-types aucune dépendance KSP directe -tonic-prost aucune dépendance KSP directe à ce stade proto crate publiée/générée ; aucun .proto copié dans KSP protoc aucun outil système KSP ajouté ; la crate proto publiée utilise son build vendored ``` -Le feature `tonic` optionnel de `yellowstone-grpc-proto` n'est **pas** activé en `pre.002`. KSP n'a besoin à ce stade que des messages Protobuf générés bruts ; le channel physique est construit directement avec Tonic. Cette décision évite de tirer prématurément la surface client/serveur Tonic générée et laisse `pre.003` choisir explicitement les features supplémentaires réellement nécessaires aux unary/TLS/metadata. +La séparation reste intentionnelle : les messages Protobuf officiels sont consommés sans activer le client généré Yellowstone dans le runtime. La feature `tonic` du proto est réservée au graphe dev/test pour matérialiser le serveur Geyser de fixture. KSP utilise le dispatcher `tonic::client::Grpc` + `tonic-prost::ProstCodec` derrière sa façade N2 et conserve ainsi ses propres timeouts, redactions et erreurs. Un minimum Rust déclaré par une dépendance n'est enregistré que s'il devient un **blocage réel** lors de la compilation ; il n'est pas suivi comme métrique de release. @@ -535,19 +538,21 @@ Avantages : **Décision : stratégie cible retenue.** -Matérialisation réalisée en `pre.002` : +Matérialisation après `pre.003` : ```text -yellowstone-grpc-proto ^12.6 root : default-features = false ; Transport : .workspace = true sans feature -tonic ^0.14 root : default-features = false ; Transport : features = ["channel"] +yellowstone-grpc-proto ^12.6 runtime sans feature ; dev/test feature `tonic` pour le serveur de fixture uniquement +tonic ^0.14 runtime features = ["channel", "tls-aws-lc", "tls-webpki-roots"] + dev/test ajoute ["codegen", "server"] +tonic-prost ^0.14 runtime, ProstCodec bas niveau +http ^1.5 runtime, PathAndQuery borné/interne yellowstone-grpc-client absent prost/prost-types pas de dépendance KSP directe -tonic-prost pas de dépendance KSP directe en pre.002 -tokio-stream pas ajouté -futures-util dépendance existante inchangée +tokio-stream pas ajouté directement +futures-util dépendance existante réutilisée par la fixture Stream ``` -Le choix est volontairement plus étroit que le forecast initial : `pre.002` matérialise les messages wire publiés et le channel HTTP/2 lazy, mais **pas encore le client RPC généré, TLS, metadata ou compression**. Les éventuelles features/dependencies supplémentaires doivent être justifiées par `pre.003` et vérifiées par `cargo tree`; elles ne sont pas activées par anticipation. +`pre.003` n'active toujours ni client Yellowstone upstream ni compression. Les sept unary passent par une façade KSP au-dessus du channel N1 ; la surface `GeyserServer` générée n'existe que dans les tests pour prouver le wire exact localement. Le graphe Cargo doit être réinspecté après application parce que les features TLS et les deux dépendances directes `http`/`tonic-prost` changent le graphe runtime. Le premier gate opérateur de `pre.002` confirme l'alignement de versions utile : Tonic 0.14.6 réutilise `http` 1.5, `hyper` 1.11, `hyper-util` 0.1, `tower` 0.5 et `bytes` 1.12 déjà présents ; `yellowstone-grpc-proto` unifie `solana-pubkey` en 4.3.0. Les occurrences Prost 0.14.4 visibles dans `cargo tree --duplicates` correspondent aux contextes runtime/build de la même version, notamment `prost-build`/`tonic-prost-build`, et ne constituent pas une seconde génération de version à corriger. @@ -897,11 +902,11 @@ Prévision courante : pre.001 DONE — audit upstream/service/proto + providers gratuits + licences/deps + architecture + threat model + sizing budget : 15–20 min nominal ; preuve : plan + matrice + stratégie B + forecast recalibré -pre.002 FIX.002 CANDIDATE — moteur Yellowstone : proto/dependencies + settings/errors + channel minimal - budget : 15–20 min ; second gate : fmt/audit/check/Clippy + 346 unit PASS ; public_api sync corrigé sous Tokio ; cargo tree inspecté +pre.002 DONE — moteur Yellowstone : proto/dependencies + settings/errors + channel minimal + budget : 15–20 min ; gate final fix.002 : fmt/audit/check/Clippy + Transport 346/42/35/4 + dependency canary + workspace PASS -pre.003 moteur Yellowstone : TLS/metadata générique + fixture locale + 7 unary RPCs - budget : 15–20 min ; preuve : connect/TLS/timeouts/Status safe + wire unary exact +pre.003 CANDIDATE — moteur TLS/metadata + façade N2 unary + fixture locale + 7 unary RPCs + budget : 15–20 min ; preuve cible : connect/TLS/timeouts/Status safe + metadata redacted + wire unary exact + cargo tree pre.004 standard Solana : Subscribe foundation + maps/commitment/ping/from_slot/data slices/bounds budget : 15–20 min ; preuve : omitted/empty/oneof exact + rejets avant I/O @@ -962,12 +967,17 @@ crates/ksp-onchain-transport-lib/src/grpc_channel.rs crates/ksp-onchain-transport-lib/unit_tests/grpc_settings.rs crates/ksp-onchain-transport-lib/unit_tests/grpc_channel.rs -# pre.003+ +# pre.003 +crates/ksp-onchain-transport-lib/src/grpc_unary.rs +crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs +crates/ksp-onchain-transport-lib/src/grpc_channel.rs +crates/ksp-onchain-transport-lib/src/grpc_settings.rs + +# pre.004+ crates/ksp-onchain-transport-lib/src/grpc_session.rs crates/ksp-onchain-transport-lib/src/grpc_protocol.rs crates/ksp-onchain-transport-lib/src/grpc_subscribe.rs crates/ksp-onchain-transport-lib/src/grpc_updates.rs -crates/ksp-onchain-transport-lib/src/grpc_unary.rs unit_tests/ correspondants tests/public_api.rs tests/release_completeness.rs @@ -1008,6 +1018,7 @@ Après ajout/modification de la stack gRPC : ```bash cargo tree -p ksp-onchain-transport-lib +cargo tree -p ksp-onchain-transport-lib -e features cargo tree -p ksp-onchain-transport-lib --duplicates cargo tree --duplicates ``` @@ -1021,9 +1032,43 @@ prost / prost-types bytes / http / hyper / hyper-util tower rustls / tokio-rustls +features Tonic : channel/TLS runtime ; codegen/server dev ; pas de router/gzip/zstd KSP solana-* transitifs ``` + +## 19.1 Gate opérateur final `pre.002-fix.002` + +Preuve opérateur du `2026-08-24` : + +```text +cargo fmt --all PASS +python3 scripts/audit_rust_workspace_rules.py PASS / clean +cargo check --workspace PASS +cargo clippy --workspace --all-targets PASS +Transport unit PASS 346/346 +Transport public_api PASS 42/42 +Transport release_completeness PASS 35/35 +Transport doctests PASS 4/4 +Core workspace_dependencies PASS 3/3 +cargo test --workspace PASS ; seuls smokes/bench diagnostics explicitement ignored +``` + +`pre.002` est donc **fermée**. Les graphes Cargo fournis au premier gate restent valides pour son dependency set ; `pre.003` doit les relancer parce qu'elle active TLS et ajoute `http`/`tonic-prost`. + +## 19.2 Gate source `pre.003` + +La candidate matérialise : + +```text +N1 : connect() réel + TLS WebPKI/rustls + metadata ASCII publique/secrète redacted +N2 : exactement 7 unary Yellowstone standard via dispatcher Tonic privé +fixture : GeyserServer local dev-only, metadata + commitment + timeout + Status hostile +OUT : Subscribe, SubscribeDeshred, PublicNode, Config V3, reconnect/stream lifecycle +``` + +Le runtime n'active pas la feature `tonic` de `yellowstone-grpc-proto`; cette feature et `tonic codegen/server` sont réservées au graphe dev/test de la fixture. Le gate Cargo opérateur reste requis avant commit. + ## 20. Conditions de clôture `0.2.9` ne devient stable que si : diff --git a/docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md b/docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md index 8865fca..592d17f 100644 --- a/docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md +++ b/docs/validation/012-V0_2_9_YELLOWSTONE_GRPC.md @@ -1,9 +1,9 @@ - + # Validation `0.2.9` — moteur Yellowstone + standard Solana + PublicNode -> **Statut : matrice ouverte par `0.2.9-pre.001`, corrigée par `pre.001-fix.002`, avancée par `pre.002`, puis corrigée par `pre.002-fix.001` et `pre.002-fix.002`. Le second gate opérateur confirme fmt/audit/check/Clippy et les 346 unit tests ; il a isolé le dernier écart dans le canari d'intégration `public_api`, encore synchrone alors que `prepare()` exige désormais un runtime Tokio actif. `fix.002` corrige uniquement ce canari et attend la réexécution des tests Transport/workspace. Le scope distingue moteur Yellowstone, façade Solana standard et première intégration PublicNode. OrbitFlare et Helius sont les seules releases provider suivantes actuellement planifiées ; les autres providers restent en TODO/IDEAS sans numéro réservé.** +> **Statut : `pre.002-fix.002` est fermé sur gate opérateur intégralement vert : fmt/audit/check/Clippy, Transport 346 unit + 42 public API + 35 release completeness + 4 doctests, Core dependency canary et workspace complet. `0.2.9-pre.003` est maintenant candidate : TLS client, metadata générique redacted, connexion réelle et exactement sept unary Yellowstone standard sont matérialisés avec une fixture Geyser locale ; `Subscribe`, PublicNode et Config V3 restent hors tranche. OrbitFlare et Helius sont les seules releases provider suivantes planifiées ; les autres providers restent en TODO/IDEAS sans numéro réservé.** ## 1. Autorités du gate @@ -195,33 +195,34 @@ Preuve cible : fixtures issues du wire Protobuf + cas old/current + malformed/ov ## 7. Unary RPC matrix -| RPC | Request exact | Response exact | État | -|------------------|---------------------------|-----------------------------|------| -| ReplayInfo | empty | `first_available?` | TODO | -| Ping | `count` | `count` | TODO | -| LatestBlockhash | `commitment?` | slot/hash/last_valid_height | TODO | -| BlockHeight | `commitment?` | block_height | TODO | -| Slot | `commitment?` | slot | TODO | -| IsBlockhashValid | blockhash + `commitment?` | slot + bool | TODO | -| Version | empty | version | TODO | +| RPC | Request exact | Response exact | État | +| ---------------- | ------------------------- | --------------------------- | --------- | +| ReplayInfo | empty | `first_available?` | CANDIDATE | +| Ping | `count` | `count` | CANDIDATE | +| LatestBlockhash | `commitment?` | slot/hash/last_valid_height | CANDIDATE | +| BlockHeight | `commitment?` | block_height | CANDIDATE | +| Slot | `commitment?` | slot | CANDIDATE | +| IsBlockhashValid | blockhash + `commitment?` | slot + bool | CANDIDATE | +| Version | empty | version | CANDIDATE | ## 8. Dépendances / licence -| Gate | Décision / matérialisation `pre.002` | Preuve / état | -|--------------------------------|---------------------------------------------------------------|---------------------------------| -| repository default | AGPL-3.0-only | `LICENSING.md` | -| client subtree | Apache-2.0 | `LICENSING.md` | -| proto subtree | Apache-2.0 | `LICENSING.md` | -| strategy A client+proto | rejetée comme default | documenté | -| strategy B proto+Tonic KSP | **retenue et matérialisée** | source PASS / Cargo pending | -| `yellowstone-grpc-proto` | `^12.6`, `default-features = false`, aucune feature KSP | Cargo tree pending | -| `tonic` | `^0.14`, `default-features = false`, feature `channel` locale | Cargo tree pending | -| `yellowstone-grpc-client` | absent | manifest canary ajouté | -| `prost/prost-types` direct KSP | absent | manifest / Cargo tree pending | -| strategy C vendored proto | fallback seulement | aucun vendoring | -| system protoc | aucun outil système KSP ajouté | proto publié/build vendored | -| raw upstream types public | interdit ; aucun reexport Tonic/Yellowstone | public API canary ajouté | -| executable Yellowstone deps | interdit | dependency firewall à revalider | +| Gate | Décision / matérialisation `pre.003` | Preuve / état | +| ------------------------------ | ------------------------------------------------------------------- | ------------------------------- | +| repository default | AGPL-3.0-only | `LICENSING.md` | +| client subtree | Apache-2.0 | `LICENSING.md` | +| proto subtree | Apache-2.0 | `LICENSING.md` | +| strategy A client+proto | rejetée comme default | documenté | +| strategy B proto+Tonic KSP | **retenue ; TLS/unary matérialisés** | source PASS / Cargo pending | +| `yellowstone-grpc-proto` | `^12.6`, `default-features = false`, aucune feature KSP | Cargo tree pending | +| `tonic` | runtime `channel+tls-aws-lc+tls-webpki-roots`; dev `codegen+server` | Cargo tree pending | +| `yellowstone-grpc-client` | absent | manifest canary ajouté | +| `prost/prost-types` direct KSP | absent | manifest / Cargo tree pending | +| `http` / `tonic-prost` direct | `^1.5` / `^0.14`, runtime minimal pour PathAndQuery/ProstCodec | Cargo tree pending | +| strategy C vendored proto | fallback seulement | aucun vendoring | +| system protoc | aucun outil système KSP ajouté | proto publié/build vendored | +| raw upstream types public | interdit ; aucun reexport Tonic/Yellowstone | public API canary ajouté | +| executable Yellowstone deps | interdit | dependency firewall à revalider | Versions observées : @@ -234,29 +235,29 @@ prost/prost-types 0.14.x (0.14.4 latest observé) ## 9. Architecture / ownership -| Invariant | Preuve cible | État `pre.002` | -|--------------------------------------------------------------------------------|--------------------------------|---------------------------------| -| backend gRPC distinct HTTP/WS | source/API canary | IMPLEMENTED / Cargo pending | -| aucun `WsProtocolKind` gRPC | source scan | SOURCE PASS | -| Transport owns gRPC | dependency graph | IMPLEMENTED / tree pending | -| Config -> Transport seulement | ownership test | hérité / final TODO | -| Transport -X-> Config | ownership test | SOURCE PASS / Cargo pending | -| Transport -X-> std::env KSP_* | ownership test | SOURCE PASS | -| Logging façade KSP only | logging ownership | SOURCE PASS | -| no raw Tonic client escape hatch | public API canary | IMPLEMENTED / Cargo pending | -| moteur Yellowstone partagé sans duplication provider | source/API canary | N1 FOUNDATION IMPLEMENTED | -| façade Solana Yellowstone standard distincte du moteur | public API canary | TODO `pre.003+` | -| PublicNode représenté comme provider/capabilities, pas comme nouveau protocole | public API/config canary | TODO `pre.010/011` | -| provider peut réutiliser, restreindre ou étendre N2 sans dupliquer N1 | capability/completeness review | architecture conservée | -| aucune équivalence provider/standard présumée sans preuve | provider matrix/tests | architecture conservée | -| façade provider spécialisée seulement si delta réel | completeness review | TODO provider integration | +| Invariant | Preuve cible | État `pre.003` | +| ------------------------------------------------------------------------------ | ------------------------------ | ------------------------------------- | +| backend gRPC distinct HTTP/WS | source/API canary | IMPLEMENTED / Cargo pending | +| aucun `WsProtocolKind` gRPC | source scan | SOURCE PASS | +| Transport owns gRPC | dependency graph | IMPLEMENTED / tree pending | +| Config -> Transport seulement | ownership test | hérité / final TODO | +| Transport -X-> Config | ownership test | SOURCE PASS / Cargo pending | +| Transport -X-> std::env KSP_* | ownership test | SOURCE PASS | +| Logging façade KSP only | logging ownership | SOURCE PASS | +| no raw Tonic client escape hatch | public API canary | IMPLEMENTED / Cargo pending | +| moteur Yellowstone partagé sans duplication provider | source/API canary | N1 FOUNDATION IMPLEMENTED | +| façade Solana Yellowstone standard distincte du moteur | public API canary | PARTIAL : 7 unary N2 / Subscribe TODO | +| PublicNode représenté comme provider/capabilities, pas comme nouveau protocole | public API/config canary | TODO `pre.010/011` | +| provider peut réutiliser, restreindre ou étendre N2 sans dupliquer N1 | capability/completeness review | architecture conservée | +| aucune équivalence provider/standard présumée sans preuve | provider matrix/tests | architecture conservée | +| façade provider spécialisée seulement si delta réel | completeness review | TODO provider integration | ## 10. Settings, bounds et redaction -État après `pre.002` : +État après `pre.003` : ```text -DONE/source+tests ajoutés endpoint schemes http/https validés ; TLS réel reporté pre.003 +DONE/source+tests ajoutés endpoint schemes http/https validés ; TLS réel matérialisé en pre.003 DONE/source+tests ajoutés endpoint URL, descripteurs et nombre d’endpoints plafonnés DONE/source+tests ajoutés connect timeout > 0 et plafonné DONE/source+tests ajoutés unary timeout > 0 et plafonné @@ -266,7 +267,7 @@ DONE/source+tests ajoutés request/update queue capacities > 0 et plafonnées DONE/source+tests ajoutés reconnect attempt/backoff bornés TODO pre.004 filter-group count TODO pre.004 filter-name length/uniqueness -TODO pre.003 metadata key/value count/size +DONE/source+tests ajoutés metadata ASCII key/value count/size + reserved/bin bounds TODO pre.005+ account/owner/include/exclude/required counts TODO pre.004+ memcmp/data slice bounds TODO pre.005+ Cuckoo dimensions/data bounds @@ -276,11 +277,11 @@ Security canaries : ```text DONE/source+tests ajoutés endpoint URL Debug redacted -TODO pre.003 secret metadata Debug/Display absent -TODO pre.003 Status message/details distants non recopiés aveuglément en context -PARTIAL pre.002 URI Tonic invalide mappée sans recopier l'erreur ; TLS/connect réel pre.003 +DONE/source+tests ajoutés metadata publique/secrète redacted ; secret marqué sensitive +DONE/source+fixture Status message/details/metadata distants exclus des KspError +CANDIDATE pre.003 connect réel + TLS WebPKI configuré ; erreur connect/TLS safe, Cargo pending TODO pre.004+ request/update Debug sans payload arbitraire sensible -PARTIAL pre.002 channel Debug ne contient ni URL ni raw Tonic ; snapshot lifecycle ultérieur +DONE pre.003 source/tests channel/client Debug sans URL, metadata value ni raw Tonic ; lifecycle stream ultérieur ``` ## 11. Lifecycle / backpressure / replay @@ -299,7 +300,7 @@ PARTIAL pre.002 channel Debug ne contient ni URL ni raw Tonic ; snap | reconnect budget | borné | TODO | | resubscribe order | déterministe | TODO | | `from_slot` | utilisé sans promesse lossless | TODO | -| ReplayInfo | informatif | TODO | +| ReplayInfo | informatif | CANDIDATE | | duplicates | observables | TODO | | gaps | observables | TODO | | divergent node history | couverture documentée | TODO | @@ -402,8 +403,8 @@ Les autres providers restent en TODO/IDEAS sans release dédiée. Aucune façade ```text pre.001 DONE audit/sizing/architecture 15–20 min nominal -pre.002 FIX.001 CANDIDATE moteur: deps/settings/errors/channel 15–20 min -pre.003 TODO moteur: TLS/metadata + fixture + 7 unary 15–20 min +pre.002 DONE moteur: deps/settings/errors/channel 15–20 min ; gate final fix.002 PASS +pre.003 CANDIDATE TLS/metadata + fixture + 7 unary standard 15–20 min pre.004 TODO standard: Subscribe common/from_slot/bounds 15–20 min pre.005 TODO standard: accounts + slots 15–20 min pre.006 TODO standard: transactions + transaction_status 15–20 min @@ -510,3 +511,44 @@ Le second échec ne révèle aucune nouvelle faiblesse du moteur N1 : il confirm **Verdict `pre.002-fix.002` : correctif de test d'intégration prêt ; `pre.002` reste ouverte jusqu'à réexécution verte de `cargo test -p ksp-onchain-transport-lib`, `cargo test --workspace` et des gates habituels.** +### Gate final opérateur `pre.002-fix.002` + +| Gate | Résultat final | +| ---------------------------------------- | -------------- | +| `cargo fmt --all` | PASS | +| audit Rust workspace | PASS / clean | +| `cargo check --workspace` | PASS | +| `cargo clippy --workspace --all-targets` | PASS | +| Transport unit | 346/346 PASS | +| Transport `public_api` | 42/42 PASS | +| Transport `release_completeness` | 35/35 PASS | +| Transport doctests | 4/4 PASS | +| Core dependency canary | 3/3 PASS | +| `cargo test --workspace` | PASS | + +**Verdict : `pre.002` fermée.** + +## 18. Gate `pre.003` — candidate + +| Gate / surface | État candidate | +| ------------------------------------------- | -------------- | +| workspace version | `0.2.9-pre.3` | +| runtime proto sans feature `tonic` | SOURCE PASS | +| Tonic runtime channel + AWS-LC + WebPKI TLS | SOURCE PASS | +| Tonic server/codegen uniquement dev/test | SOURCE PASS | +| metadata ASCII publique/secrète redacted | SOURCE PASS | +| connexion réelle bornée | SOURCE PASS | +| 7 unary standard exacts | SOURCE PASS | +| fixture Geyser locale | SOURCE PASS | +| commitment mapping | SOURCE PASS | +| Status hostile sans payload distant | SOURCE PASS | +| timeout unary borné | SOURCE PASS | +| Subscribe / SubscribeDeshred | OUT pre.003 | +| PublicNode / Config V3 | OUT pre.003 | +| audit Rust workspace local | PASS / clean | +| fmt/check/Clippy/tests/Cargo trees | opérateur TODO | + +La fixture active `yellowstone-grpc-proto/tonic` et Tonic `codegen+server` uniquement dans le graphe dev/test. Le runtime utilise les messages Protobuf publiés, `tonic::client::Grpc` et `tonic-prost::ProstCodec` derrière une façade KSP sans raw escape hatch. + +**Verdict `pre.003` : candidate source prête ; fermeture seulement après gate Cargo opérateur complet et réinspection des graphes.** +