diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 574290f55b0..55ece2d51b3 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -254,6 +254,37 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_start( "devnet_name is only valid on devnet", ); } + // 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 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.is_empty() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "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('/') { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "devnet_name must not contain '/'", + ); + } + } if (llmq_devnet_size > 0) ^ (llmq_devnet_threshold > 0) { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, 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/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/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 3a3b7e09b28..580313de999 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 @@ -23,6 +24,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 @@ -81,6 +92,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), @@ -234,11 +253,76 @@ 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) + + 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) + .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) + // `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 + ) + } + // 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 + } + } + } } else { Toggle("Use Custom SPV Peers", isOn: $customSpvPeersEnabled) .onChange(of: customSpvPeersEnabled) { _, isOn in