From afb925b7df807606438451b0cd96894d19fa2ca7 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 16:01:57 +0700 Subject: [PATCH 1/6] fix(platform-wallet-ffi): reject empty / whitespace / slash devnet_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this guard the FFI accepts an empty or whitespace-only C string for `devnet_name` and constructs `DevnetConfig::new("")` (or `" "`), which produces a `(devnet.devnet-)` user agent that Dash Core devnet peers silently drop — the exact failure mode the explicit `devnet_name` knob exists to eliminate. Mirrors `dash_spv::DevnetConfig::validate` (empty + `/`) and additionally trims whitespace, so the rejection is synchronous on the FFI return rather than asynchronous from `spawn_in_background`. The Swift example caller in CoreContentView already filters empties/whitespace to `nil`, but this C ABI is the chokepoint for other consumers without a pre-filter (other language bindings, integration tests). --- packages/rs-platform-wallet-ffi/src/spv.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 574290f55b0..bdeffb7a857 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -254,6 +254,28 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_start( "devnet_name is only valid on devnet", ); } + // Reject empty / whitespace-only / `/`-containing names + // synchronously here rather than letting `DevnetConfig::validate` + // surface them asynchronously from `spawn_in_background`. Mirrors + // `DevnetConfig::validate` (which only checks empty + `/`) and + // additionally rejects whitespace-only names so callers without a + // pre-filter (other language bindings, integration tests) can't + // produce a `(devnet.devnet- )` user agent that Dash Core peers + // silently drop. + if let Some(name) = devnet_name_str.as_deref() { + if name.trim().is_empty() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "devnet_name must not be empty or whitespace-only", + ); + } + if name.contains('/') { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "devnet_name must not contain '/'", + ); + } + } if (llmq_devnet_size > 0) ^ (llmq_devnet_threshold > 0) { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, From 6fd73b9f799ea6b996a09da143794bd8106605d2 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 16:02:01 +0700 Subject: [PATCH 2/6] feat(swift-example-app): enable SPV on devnet Wires the devnet identity through the example app so users can run the SPV client against a `dashd -devnet=` peer set: - OptionsView: new "Devnet Name" text field under the existing Quorum URL field, backed by `platformDevnetName` UserDefaults. - CoreContentView.startSync: reads the saved name (trimming whitespace, treating empty as nil) and passes it to `PlatformSpvStartConfig.devnetName` when the active network is devnet. Without this the FFI rejects the start with "devnet_name is required when network=Devnet". --- .../Core/Views/CoreContentView.swift | 14 ++++++++++- .../SwiftExampleApp/Views/OptionsView.swift | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 61dcd0bbda9..3b53ab451fb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -584,11 +584,23 @@ var body: some View { let peers = spvPeerOverride() let restrictToConfiguredPeers = !peers.isEmpty + // Devnet requires a name so `DevnetConfig` can embed + // `devnet.devnet-` in the SPV user agent (Dash + // Core devnet peers drop inbound handshakes without it). + // Read from the same UserDefaults key OptionsView writes. + let devnetName: String? = platformState.currentNetwork == .devnet + ? UserDefaults.standard.string(forKey: "platformDevnetName").flatMap { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } + : nil + let config = PlatformSpvStartConfig( dataDir: dataDirURL.path, network: platformState.currentNetwork, peers: peers, - restrictToConfiguredPeers: restrictToConfiguredPeers + restrictToConfiguredPeers: restrictToConfiguredPeers, + devnetName: devnetName ) try walletManager.startSpv(config: config) } catch { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 3a3b7e09b28..8cd3c40ca84 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -81,6 +81,14 @@ struct OptionsView: View { // here redirects the next SDK construction. @AppStorage("platformQuorumURL") private var devnetQuorumURL: String = "" + // Devnet identity (`-devnet=` in Dash Core). Required by + // `DevnetConfig` so the SPV client embeds `devnet.devnet-` + // in its user agent — Dash Core devnet peers drop inbound + // handshakes that don't carry the name. Read by SPV start in + // CoreContentView (`startSync`); editing here applies on the + // next SPV start. + @AppStorage("platformDevnetName") private var devnetName: String = "" + /// Default localhost peer string for a given network. Used to /// pre-populate the peers text field when the user enables the /// custom-SPV toggle. The FFI drops bare-IP entries (no port), @@ -237,6 +245,22 @@ struct OptionsView: View { Text("SPV Peers + DAPI nodes are auto-discovered from {Quorum URL}/masternodes. Changes apply on the next SDK build (switch network or relaunch).") .font(.caption2) .foregroundColor(.secondary) + + Text("Devnet Name") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + TextField( + "e.g. paloma (matches dashd -devnet=)", + text: $devnetName + ) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Text("Required to start SPV on devnet. The name is embedded in the SPV user agent (`devnet.devnet-`) so Dash Core devnet peers accept the handshake. Applies on the next SPV start.") + .font(.caption2) + .foregroundColor(.secondary) } .padding(.top, 4) } else { From 857d289f09506a312a5ecb5d601685355922c83c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 18:25:56 +0700 Subject: [PATCH 3/6] fix(swift-example-app): rebuild SDK on devnet field edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `appState.switchNetwork(to: .devnet)` is the only path that rereads `platformQuorumURL` + `platformDevnetName` and runs `SDK.init` with the new values. Without an explicit trigger, editing either field in Options leaves the running SDK pinned to the stale values from the last network switch — observed in UAT as "I filled both in but the SDK didn't change until I bounced to testnet and back." Snapshot both values when the devnet section appears, compare on disappear, and call `stopSpv()` + `switchNetwork(to: .devnet)` if either actually changed. Bound to onDisappear rather than per-field onChange to avoid rebuilding the SDK on every keystroke. --- .../SwiftExampleApp/Views/OptionsView.swift | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 8cd3c40ca84..8ca93ce2ee2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -23,6 +23,16 @@ struct OptionsView: View { UserDefaults.standard.string(forKey: "faucetRPCPassword") ?? "" @State private var faucetValidation: FaucetValidationStatus = .idle + /// Snapshot of `devnetQuorumURL` / `devnetName` at the moment the + /// devnet section appeared. Captured so the `.onDisappear` rebuild + /// only fires when the user actually edited a value — without this + /// every Options-tab dismissal would tear down and rebuild the SDK, + /// even on read-only visits. Set to `nil` outside the devnet branch + /// so a mainnet/testnet visit can't accidentally trigger a rebuild + /// on dismissal. + @State private var devnetQuorumURLSnapshot: String? = nil + @State private var devnetNameSnapshot: String? = nil + /// Driven by the 0.5s-debounced `.task(id: faucetPassword)`. /// Runs a single cheap `getblockcount` JSON-RPC against the /// dashmate-managed Core (`127.0.0.1:`) and @@ -242,7 +252,7 @@ struct OptionsView: View { .autocorrectionDisabled() .keyboardType(.URL) - Text("SPV Peers + DAPI nodes are auto-discovered from {Quorum URL}/masternodes. Changes apply on the next SDK build (switch network or relaunch).") + Text("Required, alongside Devnet Name. SPV Peers + DAPI nodes are auto-discovered from {Quorum URL}/masternodes. The SDK rebuilds automatically when you leave Options.") .font(.caption2) .foregroundColor(.secondary) @@ -263,6 +273,29 @@ struct OptionsView: View { .foregroundColor(.secondary) } .padding(.top, 4) + .onAppear { + devnetQuorumURLSnapshot = devnetQuorumURL + devnetNameSnapshot = devnetName + } + .onDisappear { + // Rebuild the SDK once on close if either + // devnet field actually changed. Avoids + // per-keystroke churn (TextField onChange + // would fire every character) while still + // saving the user from having to manually + // bounce to testnet and back to pick up + // edits. + let quorumChanged = devnetQuorumURLSnapshot != devnetQuorumURL + let nameChanged = devnetNameSnapshot != devnetName + devnetQuorumURLSnapshot = nil + devnetNameSnapshot = nil + guard quorumChanged || nameChanged else { return } + guard appState.currentNetwork == .devnet else { return } + try? walletManager.stopSpv() + Task { + await appState.switchNetwork(to: .devnet) + } + } } else { Toggle("Use Custom SPV Peers", isOn: $customSpvPeersEnabled) .onChange(of: customSpvPeersEnabled) { _, isOn in From 8707438a32cb4adecefbf65622dddbfbe5836c67 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 18:43:29 +0700 Subject: [PATCH 4/6] fix(platform-wallet-ffi): reject any whitespace in devnet_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `name.trim().is_empty()` only catches names that are entirely whitespace — " paloma " (leading/trailing spaces, a common copy-paste artifact from `dashd -devnet=…` lines) slipped through and got interpolated verbatim into the user agent (`(devnet.devnet- paloma )/`), exactly the malformed substring the check was supposed to prevent. Replace the trim check with `name.chars().any(char::is_whitespace)` so any whitespace fails synchronously at the FFI boundary, matching how `/` is rejected. Strict rejection rather than auto-trimming keeps the rule deterministic and avoids the Unicode-whitespace asymmetry between Rust's `str::trim` and Swift's `CharacterSet.whitespaces`. --- packages/rs-platform-wallet-ffi/src/spv.rs | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index bdeffb7a857..55ece2d51b3 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -254,19 +254,28 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_start( "devnet_name is only valid on devnet", ); } - // Reject empty / whitespace-only / `/`-containing names - // synchronously here rather than letting `DevnetConfig::validate` - // surface them asynchronously from `spawn_in_background`. Mirrors + // Reject empty / any-whitespace / `/`-containing names synchronously + // here rather than letting `DevnetConfig::validate` surface them + // asynchronously from `spawn_in_background`. Mirrors // `DevnetConfig::validate` (which only checks empty + `/`) and - // additionally rejects whitespace-only names so callers without a - // pre-filter (other language bindings, integration tests) can't - // produce a `(devnet.devnet- )` user agent that Dash Core peers - // silently drop. + // additionally rejects any whitespace — leading, trailing, or + // interior — so callers without a pre-filter (other language + // bindings, integration tests) can't produce a malformed + // `(devnet.devnet- foo )` user agent that Dash Core peers silently + // drop. Rejecting (rather than auto-trimming) keeps the rule + // deterministic and avoids the Unicode-whitespace asymmetry + // between `str::trim` and Swift's `CharacterSet.whitespaces`. if let Some(name) = devnet_name_str.as_deref() { - if name.trim().is_empty() { + if name.is_empty() { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - "devnet_name must not be empty or whitespace-only", + "devnet_name must not be empty", + ); + } + if name.chars().any(char::is_whitespace) { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "devnet_name must not contain whitespace", ); } if name.contains('/') { From 8dd375c66ef2fd361cb9038ef03142c265482a19 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 18:51:27 +0700 Subject: [PATCH 5/6] fix(swift-example-app): re-activate wallet manager after SDK rebuild The previous `onDisappear` rebuilt `appState.sdk` via `switchNetwork` but never refreshed the cached `PlatformWalletManager`. The app-level `.onChange(of: currentNetwork)` observer doesn't fire when the value stays `.devnet`, so `WalletManagerStore.activate(...)` was never called and the manager kept its locked-in `sdk.handle` clone. SPV start was fine (CoreContentView re-reads UserDefaults on every start), but any manager-routed work (BLAST sync, platform queries) kept talking to the old DAPI / quorum endpoints until the user manually bounced networks. Call `walletManagerStore.activate(network: .devnet, sdk:)` on the main actor right after `switchNetwork` returns. The store's existing stale-handle check (WalletManagerStore.swift:106-127) drops the cached manager and rebuilds against the new SDK clone. --- .../SwiftExampleApp/Views/OptionsView.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 8ca93ce2ee2..cb2efe19c43 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -4,6 +4,7 @@ import SwiftDashSDK struct OptionsView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var walletManagerStore: WalletManagerStore @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService @EnvironmentObject var shieldedService: ShieldedService @State private var showingDataManagement = false @@ -294,6 +295,23 @@ struct OptionsView: View { try? walletManager.stopSpv() Task { await appState.switchNetwork(to: .devnet) + // `switchNetwork` rebuilds `appState.sdk` but + // doesn't refresh per-network managers (the + // app-level `.onChange(of: currentNetwork)` + // observer doesn't fire when the value stays + // `.devnet`). Re-`activate` here so the + // store's stale-handle check fires and the + // cached `PlatformWalletManager` rebuilds + // against the new SDK clone — otherwise + // wallet-manager-routed work keeps talking + // to the old DAPI / quorum endpoints. + await MainActor.run { + if let sdk = appState.sdk { + try? walletManagerStore.activate( + network: .devnet, sdk: sdk + ) + } + } } } } else { From 0f1ba4c5a8e2d07986f5ee016487f06346b2d2ca Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 19:21:34 +0700 Subject: [PATCH 6/6] fix(swift-example-app): rebind wallet-scoped services on devnet rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even with `walletManagerStore.activate(...)` swapping the cached `PlatformWalletManager`, `PlatformBalanceSyncService.configure(...)` and `ShieldedService.bind(...)` still retained strong references to the old manager — neither of SwiftExampleAppApp's existing observers fires for a devnet→devnet rebuild (current network stays `.devnet`; wallet ID set is identical after persistor rehydration), so `rebindWalletScopedServices()` never ran. Result: BLAST sync and shielded operations kept routing through the stale SDK clone's DAPI / quorum endpoints until the user manually bounced networks. Add an explicit `walletScopedServicesRebindTick` counter on `AppState`, increment it from `OptionsView.onDisappear` after `activate(...)` returns, and observe it in SwiftExampleAppApp with `.onChange` that calls `rebindWalletScopedServices()`. Closes the last devnet→devnet edge case the on-disappear refresh missed. --- .../SwiftExampleApp/SwiftExampleApp/AppState.swift | 11 +++++++++++ .../SwiftExampleApp/SwiftExampleAppApp.swift | 12 ++++++++++++ .../SwiftExampleApp/Views/OptionsView.swift | 9 +++++++++ 3 files changed, 32 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index b56e46c4175..71320f832ed 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -20,6 +20,17 @@ class AppState: ObservableObject { @Published var dataStatistics: (identities: Int, documents: Int, contracts: Int, tokenBalances: Int)? + /// Monotonic tick incremented when a wallet-scoped service rebind + /// is needed but neither of the standard triggers + /// (`currentNetwork.onChange`, `wallets.keys.onChange`) will fire. + /// Concretely: a devnet→devnet SDK rebuild from OptionsView swaps + /// the cached `PlatformWalletManager` but leaves the network and + /// wallet ID set unchanged, so `PlatformBalanceSyncService` and + /// `ShieldedService` keep their references to the old manager. + /// SwiftExampleAppApp observes this tick to re-run + /// `rebindWalletScopedServices()` in that edge case. + @Published var walletScopedServicesRebindTick: Int = 0 + @Published var useDockerSetup: Bool { didSet { UserDefaults.standard.set(useDockerSetup, forKey: "useDockerSetup") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 17c29990d44..dc3e663432d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -150,6 +150,18 @@ struct SwiftExampleAppApp: App { activateManager(for: newNetwork) rebindWalletScopedServices() } + // Devnet→devnet rebuild from OptionsView: when the user + // edits the quorum URL / devnet name the SDK is rebuilt + // and `WalletManagerStore.activate` swaps the cached + // `PlatformWalletManager`, but neither of the two + // observers above fires (network stays `.devnet`; + // wallet ID set stays identical after persistor reload). + // PlatformBalanceSyncService and ShieldedService would + // keep retaining the old manager. Listen for the explicit + // tick OptionsView publishes after the activate completes. + .onChange(of: platformState.walletScopedServicesRebindTick) { _, _ in + rebindWalletScopedServices() + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index cb2efe19c43..580313de999 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -311,6 +311,15 @@ struct OptionsView: View { network: .devnet, sdk: sdk ) } + // Drive `rebindWalletScopedServices` + // via the App scene's observer. + // Neither `currentNetwork` nor the + // wallet ID set changes here, so + // PlatformBalanceSyncService and + // ShieldedService would otherwise + // keep referencing the now-stale + // manager. + appState.walletScopedServicesRebindTick &+= 1 } } }