diff --git a/Cargo.lock b/Cargo.lock index c244140e627..92026cb5b5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "dash-network", ] @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "async-trait", "chrono", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "anyhow", "base64-compat", @@ -1823,12 +1823,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "bincode", "dashcore-private", @@ -2923,7 +2923,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" [[package]] name = "glob" @@ -4114,7 +4114,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "aes", "async-trait", @@ -4143,7 +4143,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4159,7 +4159,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=a062ccb9887c222bae72ab9f207b16baa3cce358#a062ccb9887c222bae72ab9f207b16baa3cce358" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a97b32c617c8b1fef5185bb806500b66faf8e8c4#a97b32c617c8b1fef5185bb806500b66faf8e8c4" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 13080d2baee..23dcd8d051c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "a062ccb9887c222bae72ab9f207b16baa3cce358" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "a062ccb9887c222bae72ab9f207b16baa3cce358" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "a062ccb9887c222bae72ab9f207b16baa3cce358" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "a062ccb9887c222bae72ab9f207b16baa3cce358" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "a062ccb9887c222bae72ab9f207b16baa3cce358" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "a062ccb9887c222bae72ab9f207b16baa3cce358" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "a062ccb9887c222bae72ab9f207b16baa3cce358" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "a062ccb9887c222bae72ab9f207b16baa3cce358" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "a97b32c617c8b1fef5185bb806500b66faf8e8c4" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "a97b32c617c8b1fef5185bb806500b66faf8e8c4" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "a97b32c617c8b1fef5185bb806500b66faf8e8c4" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "a97b32c617c8b1fef5185bb806500b66faf8e8c4" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "a97b32c617c8b1fef5185bb806500b66faf8e8c4" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "a97b32c617c8b1fef5185bb806500b66faf8e8c4" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "a97b32c617c8b1fef5185bb806500b66faf8e8c4" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "a97b32c617c8b1fef5185bb806500b66faf8e8c4" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/logging.rs b/packages/rs-platform-wallet-ffi/src/logging.rs index 58a6bfc3f1a..a7acad78948 100644 --- a/packages/rs-platform-wallet-ffi/src/logging.rs +++ b/packages/rs-platform-wallet-ffi/src/logging.rs @@ -36,6 +36,19 @@ pub unsafe extern "C" fn platform_wallet_enable_file_logging( enable_file_logging(level_to_directive(level), &path) } +/// Level for the network-diagnostics targets (`rs_dapi_client`, +/// `rs_sdk_trusted_context_provider`): their useful events (per-request +/// execution, address ban/unban, quorum cache misses) sit at `debug`, so +/// they get at least that regardless of the caller's global level — but a +/// caller asking for `trace` still gets `trace`. +fn diag_level(log_level: &str) -> &str { + if log_level == "trace" { + "trace" + } else { + "debug" + } +} + fn enable_file_logging(log_level: &str, path: &Path) -> bool { let Some(f_sdk) = open_file(path.join("dash_sdk").join("run.log")) else { return false; @@ -63,7 +76,9 @@ fn enable_file_logging(log_level: &str, path: &Path) -> bool { .with_writer(Mutex::new(f_sdk)) .with_ansi(false) .with_filter(tracing_subscriber::EnvFilter::new(format!( - "dash_sdk={log_level},rs_sdk_ffi={log_level},rs_sdk_ffi::metrics=off" + "dash_sdk={log_level},rs_sdk_ffi={log_level},rs_sdk_ffi::metrics=off,\ + dash_sdk::platform::shielded={diag}", + diag = diag_level(log_level) ))); let l_sdk_metrics = tracing_subscriber::fmt::layer() @@ -107,7 +122,9 @@ fn enable_file_logging(log_level: &str, path: &Path) -> bool { .with_ansi(false) .with_filter(tracing_subscriber::EnvFilter::new(format!( "dapi_grpc={log_level},tonic={log_level},h2={log_level},\ - hyper={log_level},tower={log_level}" + hyper={log_level},tower={log_level},\ + rs_dapi_client={diag},rs_sdk_trusted_context_provider={diag}", + diag = diag_level(log_level) ))); if fs::write(path.join("build_info.txt"), build_info_string()).is_err() { @@ -157,7 +174,10 @@ fn broad_env_filter(log_level: &str) -> tracing_subscriber::EnvFilter { platform_wallet={log_level},platform_wallet_ffi={log_level},\ dash_spv={log_level},key_wallet={log_level},\ dapi_grpc={log_level},h2={log_level},tower={log_level},\ - hyper={log_level},tonic={log_level}" + hyper={log_level},tonic={log_level},\ + rs_dapi_client={diag},rs_sdk_trusted_context_provider={diag},\ + dash_sdk::platform::shielded={diag}", + diag = diag_level(log_level) ); tracing_subscriber::EnvFilter::try_from_default_env() diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 4bf5cbe3c56..482e7a8c39b 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -706,14 +706,52 @@ impl ContextProvider for TrustedHttpContextProvider { ))); } + // This network refetch blocks the caller (proof verification) and + // re-runs on every retry of the outer request, so record how long + // it takes. `Instant` is unavailable on wasm32; those builds log + // `elapsed_ms=None` rather than a fabricated duration. + #[cfg(not(target_arch = "wasm32"))] + let started = std::time::Instant::now(); + #[cfg(not(target_arch = "wasm32"))] + let elapsed_ms = move || Some(started.elapsed().as_millis() as u64); + #[cfg(target_arch = "wasm32")] + let elapsed_ms = || None::; + + tracing::debug!( + quorum_type, + quorum_hash = %hex::encode(quorum_hash), + "quorum cache miss; blocking refetch of quorum lists" + ); + let this = self.clone(); let quorum = - dash_async::block_on(async move { this.find_quorum(quorum_type, quorum_hash).await })? + dash_async::block_on(async move { this.find_quorum(quorum_type, quorum_hash).await }) .map_err(|e| { - debug!("Error finding quorum: {}", e); + tracing::warn!( + quorum_type, + quorum_hash = %hex::encode(quorum_hash), + elapsed_ms = ?elapsed_ms(), + "quorum refetch failed to execute: {}", e + ); + e + })? + .map_err(|e| { + tracing::warn!( + quorum_type, + quorum_hash = %hex::encode(quorum_hash), + elapsed_ms = ?elapsed_ms(), + "quorum refetch failed: {}", e + ); ContextProviderError::Generic(format!("Failed to find quorum: {}", e)) })?; + tracing::debug!( + quorum_type, + quorum_hash = %hex::encode(quorum_hash), + elapsed_ms = ?elapsed_ms(), + "quorum refetch succeeded" + ); + Self::parse_quorum_public_key(&quorum.key) } diff --git a/packages/rs-sdk/src/platform/shielded/notes_sync/fetch_chunk.rs b/packages/rs-sdk/src/platform/shielded/notes_sync/fetch_chunk.rs index 68afdce0287..756bc80aefe 100644 --- a/packages/rs-sdk/src/platform/shielded/notes_sync/fetch_chunk.rs +++ b/packages/rs-sdk/src/platform/shielded/notes_sync/fetch_chunk.rs @@ -4,7 +4,7 @@ use drive_proof_verifier::types::{ ShieldedEncryptedNote, ShieldedEncryptedNotes, ShieldedEncryptedNotesQuery, }; use rs_dapi_client::RequestSettings; -use tracing::debug; +use tracing::{debug, warn}; /// Fetch a single chunk of encrypted notes from the network. /// @@ -30,8 +30,29 @@ pub async fn fetch_chunk( debug!(chunk_start, chunk_size, "fetching shielded notes chunk"); - let (result, metadata) = - ShieldedEncryptedNotes::fetch_with_metadata(sdk, query, Some(settings)).await?; + // `Instant` is unavailable on wasm32; a chunk fetched there logs + // `elapsed_ms=None` rather than a fabricated duration. + #[cfg(not(target_arch = "wasm32"))] + let started = std::time::Instant::now(); + #[cfg(not(target_arch = "wasm32"))] + let elapsed_ms = move || Some(started.elapsed().as_millis() as u64); + #[cfg(target_arch = "wasm32")] + let elapsed_ms = || None::; + + let fetched = ShieldedEncryptedNotes::fetch_with_metadata(sdk, query, Some(settings)).await; + + let (result, metadata) = match fetched { + Ok(v) => v, + Err(e) => { + warn!( + chunk_start, + elapsed_ms = ?elapsed_ms(), + error = %e, + "shielded notes chunk fetch failed" + ); + return Err(e); + } + }; let (notes, total_count) = match result { Some(ShieldedEncryptedNotes { notes, total_count }) => (notes, total_count), @@ -43,6 +64,7 @@ pub async fn fetch_chunk( notes_returned = notes.len(), block_height = metadata.height, total_count, + elapsed_ms = ?elapsed_ms(), "shielded notes chunk fetched" ); diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index c1498e5506f..43d833e7cad 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -95,6 +95,18 @@ const DEFAULT_REQUEST_SETTINGS: RequestSettings = RequestSettings { /// Malformed upstream entries are silently skipped rather than panicking; /// the DAPI client handles retry/rotation across the remaining addresses. /// +/// Seeds whose recorded Platform TLS probe shows a certificate that this +/// client's rustls stack would deterministically reject (`Expired`, +/// `SelfSigned`, `Untrusted`) are skipped: every connect to them fails the +/// handshake, so keeping them in rotation only costs retry/ban churn. +/// `NoHandshake` is skipped only when the probe's TCP connect succeeded +/// (`reachable == Ok`) — the prober also stamps `NoHandshake` on TCP +/// timeouts and probe-budget expiry, which are transient conditions best +/// left to runtime banning. `Valid` and `Unknown` (not probed) are kept. If the +/// filter would empty the list (e.g. a seed file with all-stale probes), +/// it falls back to the unfiltered set so the client can still bootstrap +/// and let runtime banning sort it out. +/// /// ## Panics /// /// Panics on networks other than `Mainnet` and `Testnet` — no upstream @@ -103,11 +115,51 @@ fn default_address_list_for_network(network: Network) -> AddressList { if !matches!(network, Network::Mainnet | Network::Testnet) { panic!("default address list is only available for mainnet and testnet"); } + + let seeds = dash_network_seeds::evo_seeds(network); + let filtered = address_list_from_seeds(&seeds, true); + if filtered.is_empty() { + tracing::warn!( + ?network, + "all seed entries have failing TLS probes; falling back to unfiltered seed list" + ); + return address_list_from_seeds(&seeds, false); + } + filtered +} + +/// Whether a seed's recorded Platform TLS probe is a failure this client +/// would deterministically reproduce on every connect. `NoHandshake` is +/// also stamped by the prober on TCP timeout / probe-budget expiry, which +/// are transient — it only counts when the probe's TCP connect itself +/// succeeded. An unprobed seed (`None` / `Unknown`) is never rejected. +fn seed_tls_deterministically_bad(platform: Option<&dash_network_seeds::PlatformStatus>) -> bool { + use dash_network_seeds::{Reachability, SslStatus}; + let Some(platform) = platform else { + return false; + }; + match platform.ssl { + SslStatus::Expired | SslStatus::SelfSigned | SslStatus::Untrusted => true, + SslStatus::NoHandshake => platform.reachable == Reachability::Ok, + SslStatus::Valid | SslStatus::Unknown => false, + } +} + +/// Build an [`AddressList`] of `https://:` entries +/// from `seeds`, optionally skipping seeds whose TLS probe is a +/// deterministic failure (see [`seed_tls_deterministically_bad`]). +fn address_list_from_seeds( + seeds: &[dash_network_seeds::MasternodeSeed], + skip_bad_tls: bool, +) -> AddressList { let mut list = AddressList::new(); - for seed in dash_network_seeds::evo_seeds(network) { + for seed in seeds { let Some(port) = seed.platform_http_port else { continue; }; + if skip_bad_tls && seed_tls_deterministically_bad(seed.platform.as_ref()) { + continue; + } let url = format!("https://{}:{}", seed.address.ip(), port); if let Ok(uri) = url.parse::() { if let Ok(address) = Address::try_from(uri) { @@ -1340,6 +1392,115 @@ mod test { } } + mod seed_tls_filter { + use super::super::{address_list_from_seeds, seed_tls_deterministically_bad}; + use dash_network_seeds::{ + CoreStatus, MasternodeSeed, MasternodeType, PlatformStatus, Reachability, SslStatus, + }; + + /// `host` disambiguates seeds — [`AddressList`] dedupes by URI, so + /// every test seed needs a distinct IP. + fn seed(host: u8, platform: Option) -> MasternodeSeed { + MasternodeSeed { + address: format!("203.0.113.{host}:9999").parse().unwrap(), + mn_type: MasternodeType::Evo, + platform_http_port: Some(443), + core: CoreStatus::default(), + platform, + } + } + + fn status(ssl: SslStatus, reachable: Reachability) -> PlatformStatus { + PlatformStatus { + reachable, + ssl, + ..PlatformStatus::default() + } + } + + /// Every `SslStatus` × probe-reachability combination, against the + /// contract: cert-level verdicts (`Expired`/`SelfSigned`/`Untrusted`) + /// are deterministic regardless of reachability; `NoHandshake` is + /// deterministic only when the probe's TCP connect succeeded; + /// `Valid`/`Unknown`/unprobed are never rejected. + #[test] + fn classification_covers_every_status_combination() { + let reachabilities = [ + Reachability::Unknown, + Reachability::Ok, + Reachability::Timeout, + Reachability::Refused, + Reachability::Error, + ]; + for reachable in reachabilities { + for ssl in [ + SslStatus::Expired, + SslStatus::SelfSigned, + SslStatus::Untrusted, + ] { + assert!( + seed_tls_deterministically_bad(Some(&status(ssl, reachable))), + "{ssl:?} must be rejected regardless of {reachable:?}" + ); + } + for ssl in [SslStatus::Valid, SslStatus::Unknown] { + assert!( + !seed_tls_deterministically_bad(Some(&status(ssl, reachable))), + "{ssl:?} must never be rejected ({reachable:?})" + ); + } + assert_eq!( + seed_tls_deterministically_bad(Some(&status( + SslStatus::NoHandshake, + reachable + ))), + reachable == Reachability::Ok, + "NoHandshake must be rejected only when TCP connect succeeded ({reachable:?})" + ); + } + assert!( + !seed_tls_deterministically_bad(None), + "an unprobed seed must never be rejected" + ); + } + + #[test] + fn filter_drops_only_deterministic_failures() { + let seeds = vec![ + seed(1, Some(status(SslStatus::Valid, Reachability::Ok))), + seed(2, Some(status(SslStatus::Expired, Reachability::Ok))), + seed( + 3, + Some(status(SslStatus::NoHandshake, Reachability::Timeout)), + ), + seed(4, Some(status(SslStatus::NoHandshake, Reachability::Ok))), + seed(5, None), + ]; + assert_eq!(address_list_from_seeds(&seeds, true).len(), 3); + assert_eq!(address_list_from_seeds(&seeds, false).len(), 5); + } + + /// The all-rejected input exercises the empty-filter result the + /// caller falls back from; the fallback itself must retain the + /// full set. + #[test] + fn all_rejected_input_yields_empty_filtered_and_full_unfiltered() { + let seeds = vec![ + seed(1, Some(status(SslStatus::Expired, Reachability::Ok))), + seed(2, Some(status(SslStatus::Untrusted, Reachability::Timeout))), + ]; + assert!(address_list_from_seeds(&seeds, true).is_empty()); + assert_eq!(address_list_from_seeds(&seeds, false).len(), 2); + } + + #[test] + fn seed_without_platform_port_is_always_skipped() { + let mut no_port = seed(1, Some(status(SslStatus::Valid, Reachability::Ok))); + no_port.platform_http_port = None; + assert!(address_list_from_seeds(&[no_port], false).is_empty()); + } + } + /// Smoke signal: the upstream seed lists are far larger than 10 entries on /// both networks. If parsing drops most of them we want a loud test /// failure rather than silently shipping a near-empty bootstrap list.