Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/rs-platform-wallet-ffi/src/spv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 '/'",
);
}
}
Comment thread
shumkov marked this conversation as resolved.
if (llmq_devnet_size > 0) ^ (llmq_devnet_threshold > 0) {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>` 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:<faucetRPCPort>`) and
Expand Down Expand Up @@ -81,6 +92,14 @@ struct OptionsView: View {
// here redirects the next SDK construction.
@AppStorage("platformQuorumURL") private var devnetQuorumURL: String = ""

// Devnet identity (`-devnet=<name>` in Dash Core). Required by
// `DevnetConfig` so the SPV client embeds `devnet.devnet-<name>`
// 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),
Expand Down Expand Up @@ -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=<name>)",
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-<name>`) 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
}
}
Comment thread
shumkov marked this conversation as resolved.
}
Comment thread
shumkov marked this conversation as resolved.
Comment thread
shumkov marked this conversation as resolved.
} else {
Toggle("Use Custom SPV Peers", isOn: $customSpvPeersEnabled)
.onChange(of: customSpvPeersEnabled) { _, isOn in
Expand Down
Loading