From 5ac9676a16a47206a39d624cc8c0649fe2bc4a60 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 19 Aug 2026 10:41:46 +0200 Subject: [PATCH 1/8] fix(sdk): skip seed nodes with deterministically failing TLS probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded seed file records a Platform TLS probe per evonode; 26% of the current mainnet pool presents an expired certificate (or fails the handshake), and every connect to such a node is a guaranteed transport error that costs retry/ban churn — and, when the retry lands on another bad node, multi-cycle sync stalls. Skip Expired/SelfSigned/Untrusted/ NoHandshake seeds when building the default DAPI address list; keep Valid and Unknown, and fall back to the unfiltered list if the filter would empty it. Co-Authored-By: Claude Fable 5 --- packages/rs-sdk/src/sdk.rs | 60 +++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index a9f76afbf5d..07886cd9177 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -95,27 +95,67 @@ 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`, `NoHandshake`) are skipped: every connect to +/// them fails the handshake, so keeping them in rotation only costs +/// retry/ban churn. `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 /// seed list exists for devnet/regtest. fn default_address_list_for_network(network: Network) -> AddressList { + use dash_network_seeds::SslStatus; + if !matches!(network, Network::Mainnet | Network::Testnet) { panic!("default address list is only available for mainnet and testnet"); } - let mut list = AddressList::new(); - for seed in dash_network_seeds::evo_seeds(network) { - let Some(port) = seed.platform_http_port else { - continue; - }; - let url = format!("https://{}:{}", seed.address.ip(), port); - if let Ok(uri) = url.parse::() { - if let Ok(address) = Address::try_from(uri) { - list.add(address); + + let seeds = dash_network_seeds::evo_seeds(network); + + let build = |skip_bad_tls: bool| -> AddressList { + let mut list = AddressList::new(); + for seed in &seeds { + let Some(port) = seed.platform_http_port else { + continue; + }; + if skip_bad_tls { + let ssl = seed.platform.as_ref().map(|p| p.ssl); + if matches!( + ssl, + Some( + SslStatus::Expired + | SslStatus::SelfSigned + | SslStatus::Untrusted + | SslStatus::NoHandshake + ) + ) { + continue; + } + } + let url = format!("https://{}:{}", seed.address.ip(), port); + if let Ok(uri) = url.parse::() { + if let Ok(address) = Address::try_from(uri) { + list.add(address); + } } } + list + }; + + let filtered = build(true); + if filtered.is_empty() { + tracing::warn!( + ?network, + "all seed entries have failing TLS probes; falling back to unfiltered seed list" + ); + return build(false); } - list + filtered } /// Dash Platform SDK From 2694687d1d4dc684f577bfada1fc16be32c93134 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 19 Aug 2026 10:41:57 +0200 Subject: [PATCH 2/8] feat(sdk): network-path diagnostics for shielded sync slowness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slow shielded syncs proved unobservable: the per-chunk fan-out logged nothing, rs-dapi-client's request/ban events matched no file-logging filter, and the trusted provider's blocking quorum refetch had no timing. Add: - per-chunk fetch logs with elapsed_ms in sync_shielded_notes' fetch_chunk (info on success, warn with duration on failure); - rs_dapi_client + rs_sdk_trusted_context_provider directives (debug) in the grpc file-logging bucket and the stdout filter; - timing around the trusted provider's quorum-list refetch on cache miss. All logged values are public network/chain data (node addresses, quorum hashes, chunk indices, durations) — no wallet material. elapsed_ms is None on wasm32 (no Instant there) rather than fabricated. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet-ffi/src/logging.rs | 6 ++-- .../src/provider.rs | 31 +++++++++++++++++- .../shielded/notes_sync/fetch_chunk.rs | 32 ++++++++++++++++--- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/logging.rs b/packages/rs-platform-wallet-ffi/src/logging.rs index 58a6bfc3f1a..f8ddf446db0 100644 --- a/packages/rs-platform-wallet-ffi/src/logging.rs +++ b/packages/rs-platform-wallet-ffi/src/logging.rs @@ -107,7 +107,8 @@ 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=debug,rs_sdk_trusted_context_provider=debug" ))); if fs::write(path.join("build_info.txt"), build_info_string()).is_err() { @@ -157,7 +158,8 @@ 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=debug,rs_sdk_trusted_context_provider=debug" ); 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..bdde50e046e 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -706,14 +706,43 @@ 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::info!( + 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 })? .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: {}", e + ); ContextProviderError::Generic(format!("Failed to find quorum: {}", e)) })?; + tracing::info!( + 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..d5ec16b99f4 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::{info, warn}; /// Fetch a single chunk of encrypted notes from the network. /// @@ -28,21 +28,43 @@ pub async fn fetch_chunk( count: chunk_size as u32, }; - debug!(chunk_start, chunk_size, "fetching shielded notes chunk"); + info!(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), None => (Vec::new(), 0), }; - debug!( + info!( chunk_start, notes_returned = notes.len(), block_height = metadata.height, total_count, + elapsed_ms = ?elapsed_ms(), "shielded notes chunk fetched" ); From 753ba6497ad885dcc98e6756b8d28a2600ee3a9e Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 19 Aug 2026 10:42:47 +0200 Subject: [PATCH 3/8] chore: bump rust-dashcore to bec50270 (refreshed mainnet seed list) Data-only delta over the previous pin 173ffac0: the branch chore/refresh-mainnet-seeds-173ffac swaps dash-network-seeds/seeds/ mainnet.txt for a fresh 2026-08-19 probe (259 valid / 88 expired / 3 no-handshake evonode TLS certs). No API changes. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 17 +++++++++-------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6775bdd0c17..e38c9eb36a3 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" dependencies = [ "bincode", "dashcore-private", @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" [[package]] name = "glob" @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" dependencies = [ "aes", "async-trait", @@ -4125,7 +4125,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=bec50270cadb171f41bc61f63b6589eedc2edf0e#bec50270cadb171f41bc61f63b6589eedc2edf0e" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 5238bf2a982..f963f6bbf80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "bec50270cadb171f41bc61f63b6589eedc2edf0e" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "bec50270cadb171f41bc61f63b6589eedc2edf0e" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "bec50270cadb171f41bc61f63b6589eedc2edf0e" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "bec50270cadb171f41bc61f63b6589eedc2edf0e" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "bec50270cadb171f41bc61f63b6589eedc2edf0e" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "bec50270cadb171f41bc61f63b6589eedc2edf0e" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "bec50270cadb171f41bc61f63b6589eedc2edf0e" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "bec50270cadb171f41bc61f63b6589eedc2edf0e" } tokio-metrics = "0.5" @@ -130,3 +130,4 @@ opt-level = 3 version = "4.2.0-dev.1" rust-version = "1.92" + From 4619b56b5867a5d01cf85cd65d10af63732ecde4 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 19 Aug 2026 12:17:49 +0200 Subject: [PATCH 4/8] fix(sdk): address review feedback on seed TLS filter and diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Treat NoHandshake as deterministic only when the probe's TCP connect succeeded — the prober also stamps it on TCP/budget timeouts, which are transient and belong to runtime banning. - Log quorum refetch failures from the outer block_on result too, not just the inner find_quorum error. - Honor a caller-selected trace level for the rs_dapi_client / rs_sdk_trusted_context_provider diagnostic directives instead of pinning them to debug. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet-ffi/src/logging.rs | 19 +++++++++-- .../src/provider.rs | 11 +++++- packages/rs-sdk/src/sdk.rs | 34 +++++++++++-------- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/logging.rs b/packages/rs-platform-wallet-ffi/src/logging.rs index f8ddf446db0..e75af583bb8 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; @@ -108,7 +121,8 @@ fn enable_file_logging(log_level: &str, path: &Path) -> bool { .with_filter(tracing_subscriber::EnvFilter::new(format!( "dapi_grpc={log_level},tonic={log_level},h2={log_level},\ hyper={log_level},tower={log_level},\ - rs_dapi_client=debug,rs_sdk_trusted_context_provider=debug" + 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() { @@ -159,7 +173,8 @@ fn broad_env_filter(log_level: &str) -> tracing_subscriber::EnvFilter { dash_spv={log_level},key_wallet={log_level},\ dapi_grpc={log_level},h2={log_level},tower={log_level},\ hyper={log_level},tonic={log_level},\ - rs_dapi_client=debug,rs_sdk_trusted_context_provider=debug" + rs_dapi_client={diag},rs_sdk_trusted_context_provider={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 bdde50e046e..90f62ef36e0 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -725,7 +725,16 @@ impl ContextProvider for TrustedHttpContextProvider { 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| { + 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, diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 07886cd9177..6235b1486cc 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -97,9 +97,12 @@ const DEFAULT_REQUEST_SETTINGS: RequestSettings = RequestSettings { /// /// Seeds whose recorded Platform TLS probe shows a certificate that this /// client's rustls stack would deterministically reject (`Expired`, -/// `SelfSigned`, `Untrusted`, `NoHandshake`) are skipped: every connect to -/// them fails the handshake, so keeping them in rotation only costs -/// retry/ban churn. `Valid` and `Unknown` (not probed) are kept. If the +/// `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. @@ -124,17 +127,20 @@ fn default_address_list_for_network(network: Network) -> AddressList { continue; }; if skip_bad_tls { - let ssl = seed.platform.as_ref().map(|p| p.ssl); - if matches!( - ssl, - Some( - SslStatus::Expired - | SslStatus::SelfSigned - | SslStatus::Untrusted - | SslStatus::NoHandshake - ) - ) { - continue; + if let Some(platform) = seed.platform.as_ref() { + let deterministic_bad = match platform.ssl { + SslStatus::Expired | SslStatus::SelfSigned | SslStatus::Untrusted => true, + // Also stamped on TCP timeout / probe-budget expiry, + // which are transient — only trust it when the TCP + // connect itself succeeded. + SslStatus::NoHandshake => { + platform.reachable == dash_network_seeds::Reachability::Ok + } + SslStatus::Valid | SslStatus::Unknown => false, + }; + if deterministic_bad { + continue; + } } } let url = format!("https://{}:{}", seed.address.ip(), port); From 71d5c16d5ac502f58ab8b69dfbbb4083748a1fe2 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 19 Aug 2026 16:03:27 +0200 Subject: [PATCH 5/8] test(sdk): unit-test the seed TLS classification independently of seed data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract seed_tls_deterministically_bad and address_list_from_seeds from default_address_list_for_network, and cover every SslStatus×Reachability combination, the mixed-filtering case, the all-rejected input (the empty-filtered result the caller falls back from), and the missing platform-port skip — none of it depending on the embedded seed snapshot. Co-Authored-By: Claude Fable 5 --- packages/rs-sdk/src/sdk.rs | 184 +++++++++++++++++++++++++++++-------- 1 file changed, 146 insertions(+), 38 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 6235b1486cc..d16f7b25914 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -112,58 +112,64 @@ const DEFAULT_REQUEST_SETTINGS: RequestSettings = RequestSettings { /// Panics on networks other than `Mainnet` and `Testnet` — no upstream /// seed list exists for devnet/regtest. fn default_address_list_for_network(network: Network) -> AddressList { - use dash_network_seeds::SslStatus; - 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 build = |skip_bad_tls: bool| -> AddressList { - let mut list = AddressList::new(); - for seed in &seeds { - let Some(port) = seed.platform_http_port else { - continue; - }; - if skip_bad_tls { - if let Some(platform) = seed.platform.as_ref() { - let deterministic_bad = match platform.ssl { - SslStatus::Expired | SslStatus::SelfSigned | SslStatus::Untrusted => true, - // Also stamped on TCP timeout / probe-budget expiry, - // which are transient — only trust it when the TCP - // connect itself succeeded. - SslStatus::NoHandshake => { - platform.reachable == dash_network_seeds::Reachability::Ok - } - SslStatus::Valid | SslStatus::Unknown => false, - }; - if deterministic_bad { - continue; - } - } - } - let url = format!("https://{}:{}", seed.address.ip(), port); - if let Ok(uri) = url.parse::() { - if let Ok(address) = Address::try_from(uri) { - list.add(address); - } - } - } - list - }; - - let filtered = build(true); + 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 build(false); + 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 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) { + list.add(address); + } + } + } + list +} + /// Dash Platform SDK /// /// This is the main entry point for interacting with Dash Platform. @@ -1386,6 +1392,108 @@ 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. From f0a0df0c26972f52d578f3c5fbca4de2e1e122c3 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 19 Aug 2026 17:04:56 +0200 Subject: [PATCH 6/8] fix(sdk): per-chunk shielded fetch logs at debug, not info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: a library hot path shouldn't emit info-level events per chunk. Downgrade the two fetch_chunk logs to debug (the failure warn stays), and have the file-logging harness opt back in with a targeted dash_sdk::platform::shielded directive at the diagnostic level, the same mechanism used for rs_dapi_client — so crate consumers get a quiet default while the wallet's diagnostic export still captures per-chunk timings. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/logging.rs | 7 +++++-- .../rs-sdk/src/platform/shielded/notes_sync/fetch_chunk.rs | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/logging.rs b/packages/rs-platform-wallet-ffi/src/logging.rs index e75af583bb8..a7acad78948 100644 --- a/packages/rs-platform-wallet-ffi/src/logging.rs +++ b/packages/rs-platform-wallet-ffi/src/logging.rs @@ -76,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() @@ -173,7 +175,8 @@ fn broad_env_filter(log_level: &str) -> tracing_subscriber::EnvFilter { dash_spv={log_level},key_wallet={log_level},\ dapi_grpc={log_level},h2={log_level},tower={log_level},\ hyper={log_level},tonic={log_level},\ - rs_dapi_client={diag},rs_sdk_trusted_context_provider={diag}", + rs_dapi_client={diag},rs_sdk_trusted_context_provider={diag},\ + dash_sdk::platform::shielded={diag}", diag = diag_level(log_level) ); 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 d5ec16b99f4..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::{info, warn}; +use tracing::{debug, warn}; /// Fetch a single chunk of encrypted notes from the network. /// @@ -28,7 +28,7 @@ pub async fn fetch_chunk( count: chunk_size as u32, }; - info!(chunk_start, chunk_size, "fetching shielded notes chunk"); + debug!(chunk_start, chunk_size, "fetching shielded notes chunk"); // `Instant` is unavailable on wasm32; a chunk fetched there logs // `elapsed_ms=None` rather than a fabricated duration. @@ -59,7 +59,7 @@ pub async fn fetch_chunk( None => (Vec::new(), 0), }; - info!( + debug!( chunk_start, notes_returned = notes.len(), block_height = metadata.height, From bedb63766c3ae19b0bc50567fb5ff3dead0d3dc2 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 19 Aug 2026 18:09:14 +0200 Subject: [PATCH 7/8] fix(sdk): quorum refetch diagnostics at debug, not info Same reasoning as the fetch_chunk downgrade: cache-miss refetches can fire per verified request during quorum rotation, so a library should not emit them at info. The failure paths stay at warn; the wallet's file-logging harness already collects this crate from debug up. Co-Authored-By: Claude Fable 5 --- packages/rs-sdk-trusted-context-provider/src/provider.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 90f62ef36e0..482e7a8c39b 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -717,7 +717,7 @@ impl ContextProvider for TrustedHttpContextProvider { #[cfg(target_arch = "wasm32")] let elapsed_ms = || None::; - tracing::info!( + tracing::debug!( quorum_type, quorum_hash = %hex::encode(quorum_hash), "quorum cache miss; blocking refetch of quorum lists" @@ -745,7 +745,7 @@ impl ContextProvider for TrustedHttpContextProvider { ContextProviderError::Generic(format!("Failed to find quorum: {}", e)) })?; - tracing::info!( + tracing::debug!( quorum_type, quorum_hash = %hex::encode(quorum_hash), elapsed_ms = ?elapsed_ms(), From 190362cfd95d454154f2e41cf7bca1c49f83b45f Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 19 Aug 2026 20:43:30 +0200 Subject: [PATCH 8/8] style: cargo fmt Co-Authored-By: Claude Fable 5 --- packages/rs-sdk/src/sdk.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index d16f7b25914..7ba5383cbeb 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1433,7 +1433,11 @@ mod test { Reachability::Error, ]; for reachable in reachabilities { - for ssl in [SslStatus::Expired, SslStatus::SelfSigned, SslStatus::Untrusted] { + 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:?}" @@ -1465,7 +1469,10 @@ mod test { 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( + 3, + Some(status(SslStatus::NoHandshake, Reachability::Timeout)), + ), seed(4, Some(status(SslStatus::NoHandshake, Reachability::Ok))), seed(5, None), ];