diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index fd24c73e4f..7febdf052e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -618,6 +618,9 @@ struct GlobalSyncIndicatorOverlay: View { struct GlobalSyncIndicator: View { @EnvironmentObject var walletManager: PlatformWalletManager + /// Used so the stop button cancels any in-flight SPV start before + /// stopping — see `stopSpvCancellingPendingStarts`. + @EnvironmentObject var walletManagerStore: WalletManagerStore let showDetails: Bool // Helpers @@ -648,7 +651,7 @@ struct GlobalSyncIndicator: View { .font(.caption) Spacer() Button(action: { - try? walletManager.stopSpv() + walletManagerStore.stopSpvCancellingPendingStarts(walletManager) }) { Image(systemName: "xmark.circle.fill") .font(.caption) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift new file mode 100644 index 0000000000..b8558ba4f2 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift @@ -0,0 +1,204 @@ +import Foundation +import SwiftDashSDK + +/// Single source of truth for starting Core SPV sync. Assembles the +/// per-network start config (data dir, peer override, devnet name) and +/// starts the SPV client, so the Sync tab's Start button +/// (`CoreContentView.startSync`) and the launch-time auto-start in +/// `SwiftExampleAppApp.bootstrap` drive identical logic. +/// +/// `@MainActor`-isolated because it reads/writes the `@MainActor` +/// `PlatformWalletManager`. Both call sites already run on the main +/// actor (a SwiftUI button action and the `@MainActor` bootstrap). +@MainActor +enum CoreSpvLauncher { + /// Build the start config for `network` and start SPV on + /// `walletManager`. `startSpv` itself returns immediately (it spawns + /// the sync loop on the shared tokio runtime), but resolving the + /// peer override is done off the main actor first: on devnet + /// `peerOverride` blocks on a `DispatchSemaphore` up to ~6 s inside + /// `SDK.discoverActiveMasternodes`, which would freeze the UI on a + /// cold launch. + /// + /// `stillCurrent` is re-checked on the main actor **after** that + /// (multi-second) peer resolution and immediately before `startSpv`: + /// if the user switched networks while we were suspended, the + /// captured `walletManager` is no longer the active one (its network + /// was stopped and `WalletManagerStore` swapped in a different + /// per-network instance), and starting it here would revive sync on + /// an abandoned network with no UI to stop it. On a stale start we + /// throw `CancellationError` so callers can distinguish supersession + /// from a real `startSpv` failure. Otherwise rethrows whatever + /// `startSpv` throws; the caller decides whether to surface or log. + static func start( + network: Network, + on walletManager: PlatformWalletManager, + stillCurrent: @MainActor () -> Bool + ) async throws { + try await start( + network: network, + stillCurrent: stillCurrent, + startSpv: { config in try walletManager.startSpv(config: config) } + ) + } + + /// Testable core: assembles the config and drives the whole + /// supersession guard, but the terminal `startSpv` step is injected + /// rather than calling `PlatformWalletManager` directly. Lets a unit + /// test assert the guard bails (throws `CancellationError`, injected + /// closure never runs) on a stale start and passes the expected + /// config through on a fresh one — no real, network-locked wallet + /// manager required. The public overload above supplies the real + /// `startSpv`. + static func start( + network: Network, + stillCurrent: @MainActor () -> Bool, + startSpv: @MainActor (PlatformSpvStartConfig) throws -> Void + ) async throws { + // Network-derived; safe to compute pre-await because we bail on a + // `stillCurrent` mismatch below before invoking `startSpv` — and + // creating the data dir alone starts nothing. + let dataDirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + .first! + .appendingPathComponent("SPV") + .appendingPathComponent(network.networkName) + try? FileManager.default.createDirectory(at: dataDirURL, withIntermediateDirectories: true) + + // Resolve peers off the main actor — the devnet branch can block + // for seconds. `network` is `Sendable` and the result is + // `[String]`; nothing main-actor-bound is captured, so the + // detached hop is data-race-free. + let peers = await Task.detached { Self.peerOverride(network: network) }.value + + // Back on the main actor: bail if a network switch / SDK rebuild + // superseded this start during the peer lookup. Call sites pair a + // generation token with manager identity (`store.spvStartGeneration + // == captured && store.activeManager === manager`) so the + // stop→activate gap is covered, not just the post-swap state. + guard stillCurrent() else { throw CancellationError() } + + 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? = network == .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: network, + peers: peers, + restrictToConfiguredPeers: restrictToConfiguredPeers, + devnetName: devnetName + ) + try startSpv(config) + } + + /// Resolve the SPV peer override for the given network / docker + /// combo. + /// + /// Three modes coexist on top of the same `useLocalhostCore` / + /// `localCorePeers` `UserDefaults` keys, which used to bleed into + /// each other when the user reconfigured between sessions: + /// + /// 1. **regtest + docker** — connect to dashmate's `local_seed` + /// Core P2P port. The default 3-node setup maps the seed to + /// `127.0.0.1:20301` (`getLocalConfigFactory.js` base 20001 + /// + `setupLocalPresetTaskFactory.js` `+ i*100` with seed + /// at index = `nodeCount`, typically 3). Anything sitting + /// in `localCorePeers` from a previous testnet / mainnet + /// "custom peers" session is ignored — the UI doesn't show + /// that knob on regtest+docker so a stale value is always + /// bleed-through, never user intent. + /// 2. **non-regtest + custom peers** — honor `localCorePeers` + /// verbatim. The OptionsView "Use Custom SPV Peers" toggle + /// seeds and edits this string. + /// 3. **everything else** — empty list, FFI uses the network's + /// built-in seed nodes. + /// + /// `nonisolated` so `start` can resolve it on a detached task: it + /// touches only `UserDefaults` and the (nonisolated) blocking + /// `SDK.discoverActiveMasternodes` static, both safe off the main + /// actor. + nonisolated private static func peerOverride(network: Network) -> [String] { + let useDocker = UserDefaults.standard.bool(forKey: "useDockerSetup") + if network == .regtest && useDocker { + return ["127.0.0.1:20301"] + } + // Devnet: auto-discover SPV peers from the quorum-list + // service's `/masternodes` endpoint. Each masternode reports + // its own `address` field (`ip:CoreP2PPort`) — use the + // verbatim values rather than guessing the canonical 29999 + // port (paloma reports 20001 per masternode, for example). + // No manual SPV input on devnet — the quorum URL is the + // single source of truth (see `OptionsView`'s devnet branch). + if network == .devnet { + guard + let quorum = UserDefaults.standard.string(forKey: "platformQuorumURL"), + !quorum.isEmpty, + let active = SDK.discoverActiveMasternodes(quorumBase: quorum) + else { return [] } + return active.map(\.spvPeer) + } + let useLocalCore = UserDefaults.standard.bool(forKey: "useLocalhostCore") + guard useLocalCore else { return [] } + let raw = UserDefaults.standard.string(forKey: "localCorePeers") ?? "" + return raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } +} + +/// Pure decision for the launch-time Core SPV auto-start gate, split out +/// from `SwiftExampleAppApp.autoStartCoreSpvIfNeeded` so the gate matrix +/// is unit-testable without a configured wallet manager. Encodes two +/// non-obvious rules the caller relies on: +/// +/// * a **no-wallets** launch does NOT latch (leaving the door open for +/// a future call once wallets exist), whereas +/// * an **already-running** client DOES latch (the auto-start is +/// considered "handled" — we don't want to keep re-checking). +enum CoreSpvAutoStart { + enum Decision: Equatable { + /// Auto-start already ran this launch — do nothing. + case skipAlreadyLatched + /// No wallets on the active network — do nothing, don't latch. + case skipNoWallets + /// A client is already running — latch, but don't restart it. + case latchOnly + /// Start SPV and latch. + case startAndLatch + + /// Whether the caller should set its once-per-launch latch. + var shouldLatch: Bool { + switch self { + case .latchOnly, .startAndLatch: return true + case .skipAlreadyLatched, .skipNoWallets: return false + } + } + + /// Whether the caller should actually start the SPV client. + var shouldStart: Bool { self == .startAndLatch } + } + + /// Resolve the gate. Order matters: the latch short-circuits first, + /// then the wallet gate (which must not latch), then the + /// already-running gate (which latches without starting). + static func decision( + alreadyLatched: Bool, + hasWallets: Bool, + spvRunning: Bool + ) -> Decision { + if alreadyLatched { return .skipAlreadyLatched } + if !hasWallets { return .skipNoWallets } + if spvRunning { return .latchOnly } + return .startAndLatch + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 7d9f307f3e..2a61a4bf1d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -4,6 +4,10 @@ import SwiftData struct CoreContentView: View { @EnvironmentObject var walletManager: PlatformWalletManager + /// Used only to revalidate that the Start we kicked off is still for + /// the active per-network manager after the async peer resolution — + /// see `startSync`. + @EnvironmentObject var walletManagerStore: WalletManagerStore @EnvironmentObject var platformState: AppState @EnvironmentObject var appUIState: AppUIState @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService @@ -753,92 +757,50 @@ var body: some View { } private func startSync() { - do { - let dataDirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - .first! - .appendingPathComponent("SPV") - .appendingPathComponent(platformState.currentNetwork.networkName) - try? FileManager.default.createDirectory(at: dataDirURL, withIntermediateDirectories: true) - - 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, - devnetName: devnetName - ) - try walletManager.startSpv(config: config) - } catch { - print("❌ Sync failed: \(error)") - } - } - - /// Resolve the SPV peer override for the current network / - /// docker combo. - /// - /// Three modes coexist on top of the same `useLocalhostCore` / - /// `localCorePeers` `UserDefaults` keys, which used to bleed into - /// each other when the user reconfigured between sessions: - /// - /// 1. **regtest + docker** — connect to dashmate's `local_seed` - /// Core P2P port. The default 3-node setup maps the seed to - /// `127.0.0.1:20301` (`getLocalConfigFactory.js` base 20001 - /// + `setupLocalPresetTaskFactory.js` `+ i*100` with seed - /// at index = `nodeCount`, typically 3). Anything sitting - /// in `localCorePeers` from a previous testnet / mainnet - /// "custom peers" session is ignored — the UI doesn't show - /// that knob on regtest+docker so a stale value is always - /// bleed-through, never user intent. - /// 2. **non-regtest + custom peers** — honor `localCorePeers` - /// verbatim. The OptionsView "Use Custom SPV Peers" toggle - /// seeds and edits this string. - /// 3. **everything else** — empty list, FFI uses the network's - /// built-in seed nodes. - private func spvPeerOverride() -> [String] { - let useDocker = UserDefaults.standard.bool(forKey: "useDockerSetup") - if platformState.currentNetwork == .regtest && useDocker { - return ["127.0.0.1:20301"] - } - // Devnet: auto-discover SPV peers from the quorum-list - // service's `/masternodes` endpoint. Each masternode reports - // its own `address` field (`ip:CoreP2PPort`) — use the - // verbatim values rather than guessing the canonical 29999 - // port (paloma reports 20001 per masternode, for example). - // No manual SPV input on devnet — the quorum URL is the - // single source of truth (see `OptionsView`'s devnet branch). - if platformState.currentNetwork == .devnet { - guard - let quorum = UserDefaults.standard.string(forKey: "platformQuorumURL"), - !quorum.isEmpty, - let active = SDK.discoverActiveMasternodes(quorumBase: quorum) - else { return [] } - return active.map(\.spvPeer) + // `CoreSpvLauncher.start` resolves peers off the main actor (the + // devnet branch can block for seconds), so it's async — drive it + // from a main-actor `Task` so the button tap stays non-blocking. + // + // Capture the target manager/network/store + generation token up + // front. If a network switch / SDK rebuild supersedes this start + // during the peer lookup, `stillCurrent` reports a generation + // mismatch (or a swapped-out manager) and the start bails rather + // than reviving sync on the abandoned network. + // + // Bump the generation FIRST so rapid Start taps (before the 1 Hz + // poll flips `spvIsRunning`) collapse to "latest wins": each tap + // supersedes any prior still-pending start. Capture our own fresh + // generation immediately after — synchronously, before any await — + // so we don't cancel ourselves. + let network = platformState.currentNetwork + let manager = walletManager + let store = walletManagerStore + store.invalidatePendingSpvStarts() + let generation = store.spvStartGeneration + Task { + do { + try await CoreSpvLauncher.start( + network: network, + on: manager, + stillCurrent: { + store.spvStartGeneration == generation + && store.activeManager === manager + } + ) + } catch is CancellationError { + // Superseded by a network switch during peer resolution — + // the now-active manager owns its own sync. + } catch { + print("❌ Sync failed: \(error)") + } } - let useLocalCore = UserDefaults.standard.bool(forKey: "useLocalhostCore") - guard useLocalCore else { return [] } - let raw = UserDefaults.standard.string(forKey: "localCorePeers") ?? "" - return raw - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } } private func pauseSync() { - try? walletManager.stopSpv() + // Cancel any in-flight start (so a fast Start→Pause can't resume + // and revive sync after the pause) then stop, via the blessed + // helper. + walletManagerStore.stopSpvCancellingPendingStarts(walletManager) } private func clearSyncData() { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 99d8459348..adcfad7c27 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -59,6 +59,11 @@ struct SwiftExampleAppApp: App { @State private var bootstrapError: Error? @State private var bootstrapTask: Task? + /// Guards the launch-time Core SPV auto-start so it fires at most + /// once per process, even if `bootstrap()` re-runs via + /// `retryBootstrap`. + @State private var didAutoStartCoreSpv = false + /// Resolver that backs the platform-wallet-ffi `MnemonicResolverHandle` /// for shielded wallet binding. Reuses the default `WalletStorage` /// keychain access — same shape as the identity-key signing path. @@ -303,6 +308,77 @@ struct SwiftExampleAppApp: App { } } + /// Auto-start Core SPV sync for the launch network. The gate matrix + /// (once-per-launch latch / wallet-gated / no-double-start) lives in + /// the pure `CoreSpvAutoStart.decision` so it can be unit-tested; see + /// its doc for why a no-wallets launch doesn't latch but an + /// already-running client does. + /// + /// `CoreSpvLauncher.start` is `async` (it resolves peers off the main + /// actor), so we **latch before the first `await`**: the latch write + /// happens synchronously on the main actor, closing the window where + /// a concurrent `bootstrap` (e.g. `retryBootstrap` overlapping the + /// original `.task`) could pass the gate during the off-main peer + /// resolution and double-start. + /// + /// Best-effort: a start failure is logged, never fatal — it must not + /// propagate into `bootstrap`'s catch and trip the error UI. + @MainActor + private func autoStartCoreSpvIfNeeded() async { + let network = platformState.currentNetwork + let manager = walletManager + let store = walletManagerStore + let decision = CoreSpvAutoStart.decision( + alreadyLatched: didAutoStartCoreSpv, + hasWallets: !manager.wallets.isEmpty, + spvRunning: manager.spvIsRunning + ) + if decision.shouldLatch { didAutoStartCoreSpv = true } + guard decision.shouldStart else { return } + + // Symmetry with the Start button's "latest wins": supersede any + // prior in-flight start, then capture our own fresh generation + // synchronously before the await. The latch above already prevents + // auto-start from arming twice, so this is belt-and-suspenders — + // done only on the shouldStart path so a bail (e.g. already + // running) never bumps the generation and cancels a legitimate + // pending start. + store.invalidatePendingSpvStarts() + let generation = store.spvStartGeneration + + do { + try await CoreSpvLauncher.start( + network: network, + on: manager, + stillCurrent: { + store.spvStartGeneration == generation + && store.activeManager === manager + } + ) + SDKLogger.log( + "🟢 Auto-started Core SPV sync for " + network.displayName, + minimumLevel: .medium + ) + } catch is CancellationError { + // A network switch / SDK rebuild superseded this start during + // the peer lookup. The latch stays set (set before the await); + // we deliberately do NOT unlatch. Auto-start is a + // once-per-launch bootstrap step, so it does NOT re-fire for + // the newly-selected network — the user starts that network's + // Core SPV from the Sync tab's Start button. (The + // platform/shielded/DashPay loops for the new network are + // handled separately by `rebindWalletScopedServices`.) + SDKLogger.log( + "ℹ️ Core SPV auto-start superseded by a network switch", + minimumLevel: .medium + ) + } catch { + SDKLogger.error( + "Auto-start Core SPV sync failed: \(error.localizedDescription)" + ) + } + } + @MainActor private func bootstrap() async { do { @@ -358,7 +434,19 @@ struct SwiftExampleAppApp: App { rebindWalletScopedServices() } + // Unlock the UI first, then kick off Core SPV sync for the + // launch network so the user doesn't have to tap Start on the + // Sync tab. Auto-start runs AFTER the unlock because it's + // best-effort (all errors handled internally, so it can't trip + // `bootstrapError`) and its devnet peer discovery can block + // ~6 s off the main actor — no reason to make the first render + // wait on it. It self-gates on wallet presence, so it no-ops + // when the SDK path above didn't activate a manager. A user who + // races a manual Start the instant the UI unlocks is safe: both + // starts bump-then-capture the generation token, so whichever + // bumps last wins and the other bails (never a double-start). isInitialized = true + await autoStartCoreSpvIfNeeded() } catch { bootstrapError = error } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index e562a3d90c..ca850be1b1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -127,6 +127,12 @@ struct OptionsView: View { get: { appState.currentNetwork }, set: { newNetwork in if newNetwork != appState.currentNetwork { + // Earliest synchronous point of the switch: + // cancel any in-flight SPV start before the + // async body stops the old manager, so a + // start resuming in the stop→activate gap + // can't revive it. + walletManagerStore.invalidatePendingSpvStarts() isSwitchingNetwork = true Task { // Auto-disable Docker when leaving Local @@ -137,7 +143,7 @@ struct OptionsView: View { // Devnet's SPV peers come from // `{platformQuorumURL}/masternodes` // — no UserDefaults state to seed - // here. See `CoreContentView.spvPeerOverride` + // here. See `CoreSpvLauncher.peerOverride` // for the devnet branch. // Update platform state (which will trigger SDK switch) @@ -183,6 +189,11 @@ struct OptionsView: View { if appState.currentNetwork == .regtest { Toggle("Use Docker Setup", isOn: $appState.useDockerSetup) .onChange(of: appState.useDockerSetup) { _, _ in + // Toggling Docker rebuilds the SDK and flips + // the peer override; cancel any in-flight + // start so it can't proceed with stale peer + // config against the rebuilt SDK. + walletManagerStore.invalidatePendingSpvStarts() isSwitchingNetwork = true Task { await appState.switchNetwork(to: appState.currentNetwork) @@ -291,6 +302,10 @@ struct OptionsView: View { devnetNameSnapshot = nil guard quorumChanged || nameChanged else { return } guard appState.currentNetwork == .devnet else { return } + // Widest supersession gap (stop → await SDK + // rebuild → activate). Invalidate first so a + // start resuming anywhere in it bails. + walletManagerStore.invalidatePendingSpvStarts() try? walletManager.stopSpv() Task { await appState.switchNetwork(to: .devnet) @@ -333,6 +348,10 @@ struct OptionsView: View { if isOn && (customSpvPeers.isEmpty || !customSpvPeers.contains(":")) { customSpvPeers = defaultSpvPeers(for: appState.currentNetwork) } + // Cancel any in-flight start (it captured the + // pre-toggle peer config) before stopping, so + // the next start picks up the new peers. + walletManagerStore.invalidatePendingSpvStarts() // Stop SPV so the next start picks up the // new peer config in CoreContentView. try? walletManager.stopSpv() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index 4d647f9ed9..8719ab5466 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -52,6 +52,62 @@ final class WalletManagerStore: ObservableObject { /// gating the pre-refactor single-manager flow used). @Published private(set) var activeManager: PlatformWalletManager + /// Monotonic token bumped whenever a network switch / SDK rebuild + /// supersedes any in-flight `CoreSpvLauncher.start`. + /// + /// A start captures this synchronously before it begins and + /// re-checks it (alongside manager identity) after its off-main + /// peer resolution; a mismatch means the start is stale and must + /// not revive a stopped or replaced manager. Identity alone is not + /// enough because of the stop→activate gap: OptionsView's picker + /// stops the old manager synchronously but the store only swaps + /// `activeManager` later (from the app-level `onChange`), and the + /// devnet rebuild widens that gap with an `await` on the SDK + /// rebuild. This token is invalidated at the *earliest* synchronous + /// point of each such path, closing the window. See the guard in + /// `CoreSpvLauncher.start`. + /// + /// Not `@Published` — nothing renders off it; it's read + /// synchronously by start call sites and their revalidation + /// closures only. + private(set) var spvStartGeneration: UInt64 = 0 + + /// Invalidate every in-flight SPV start. + /// + /// Invariant: **every user-visible or programmatic stop / replace of + /// the active manager's SPV must bump the generation first.** Call + /// this at the earliest synchronous point of any such path (network + /// switch, devnet/SDK rebuild, peer-config change, a Stop/Pause + /// button) so a start suspended in the stop→activate gap bails + /// instead of reviving the superseded manager. User-facing stop + /// buttons should route through `stopSpvCancellingPendingStarts(_:)` + /// so they can't forget; supersession flows (see `OptionsView`) + /// invalidate explicitly at their flow's earliest sync point. + func invalidatePendingSpvStarts() { + spvStartGeneration &+= 1 + } + + /// Stop `manager`'s SPV client after cancelling any in-flight + /// `CoreSpvLauncher.start`. The blessed path for every user-visible + /// or programmatic SPV stop of the active manager: it bumps the + /// generation first, so a start suspended in peer resolution can't + /// resume and revive sync after the stop. Best-effort stop (`try?`), + /// matching the raw call sites it replaces. + func stopSpvCancellingPendingStarts(_ manager: PlatformWalletManager) { + stopSpvCancellingPendingStarts(stop: { try? manager.stopSpv() }) + } + + /// Testable core of `stopSpvCancellingPendingStarts(_:)`: the actual + /// stop is injected so a unit test can assert the generation was + /// already bumped by the time `stop` runs (pinning the + /// invalidate-before-stop ordering) without a real, network-locked + /// `PlatformWalletManager`. The public overload supplies the real + /// `try? manager.stopSpv()`. + func stopSpvCancellingPendingStarts(stop: () -> Void) { + invalidatePendingSpvStarts() + stop() + } + /// Per-network managers. Lazily populated on first activation /// of each network; lookup is O(1). private var managers: [Network: PlatformWalletManager] = [:] @@ -107,6 +163,12 @@ final class WalletManagerStore: ObservableObject { let cachedHandle = managerSdkHandles[network] if cachedHandle == sdk.handle { if makeActive && existing !== activeManager { + // Swapping to a different warm manager supersedes any + // in-flight SPV start against the outgoing one. + // Defensive backstop so every present/future activate + // path is covered even if a caller forgets to + // invalidate at its own supersession point. + invalidatePendingSpvStarts() activeManager = existing } return @@ -144,6 +206,11 @@ final class WalletManagerStore: ObservableObject { managers[network] = manager managerSdkHandles[network] = sdk.handle if makeActive { + // A freshly (re)built active manager supersedes any in-flight + // SPV start against the previous one (defensive backstop; the + // supersession call sites also invalidate at their earliest + // synchronous point). + invalidatePendingSpvStarts() activeManager = manager } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift new file mode 100644 index 0000000000..8d45e46fcb --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import SwiftExampleApp + +/// Pins the launch-time Core SPV auto-start gate matrix — the pure +/// decision extracted from `SwiftExampleAppApp.autoStartCoreSpvIfNeeded`. +/// The interesting invariants are the two asymmetric side effects: a +/// no-wallets launch must NOT latch (so a later call can still start +/// once wallets exist), while an already-running client MUST latch (the +/// auto-start is considered handled). +final class CoreSpvAutoStartTests: XCTestCase { + + // MARK: - Latch gate wins first + + func testAlreadyLatchedSkipsRegardlessOfOtherInputs() { + // Even with wallets present and SPV stopped (the "would start" + // combination), a set latch short-circuits to a no-op. + let decision = CoreSpvAutoStart.decision( + alreadyLatched: true, + hasWallets: true, + spvRunning: false + ) + XCTAssertEqual(decision, .skipAlreadyLatched) + XCTAssertFalse(decision.shouldStart) + // Nothing to re-latch — the latch is already set. + XCTAssertFalse(decision.shouldLatch) + } + + // MARK: - No-wallets gate does not latch + + func testNoWalletsSkipsWithoutLatching() { + let decision = CoreSpvAutoStart.decision( + alreadyLatched: false, + hasWallets: false, + spvRunning: false + ) + XCTAssertEqual(decision, .skipNoWallets) + XCTAssertFalse(decision.shouldStart) + // Key invariant: do NOT latch, so auto-start stays eligible + // if wallets appear later this launch. + XCTAssertFalse(decision.shouldLatch) + } + + func testNoWalletsPrecedesRunningGateAndStillDoesNotLatch() { + // The wallet gate is checked before the running gate: with no + // wallets we skip-without-latch even though a running client + // would otherwise map to `.latchOnly`. + let decision = CoreSpvAutoStart.decision( + alreadyLatched: false, + hasWallets: false, + spvRunning: true + ) + XCTAssertEqual(decision, .skipNoWallets) + XCTAssertFalse(decision.shouldLatch) + XCTAssertFalse(decision.shouldStart) + } + + // MARK: - Already-running gate latches without starting + + func testAlreadyRunningLatchesButDoesNotStart() { + let decision = CoreSpvAutoStart.decision( + alreadyLatched: false, + hasWallets: true, + spvRunning: true + ) + XCTAssertEqual(decision, .latchOnly) + // Latch (auto-start handled) but never double-start a running + // client. + XCTAssertTrue(decision.shouldLatch) + XCTAssertFalse(decision.shouldStart) + } + + // MARK: - The only start path + + func testWalletsPresentAndStoppedStartsAndLatches() { + let decision = CoreSpvAutoStart.decision( + alreadyLatched: false, + hasWallets: true, + spvRunning: false + ) + XCTAssertEqual(decision, .startAndLatch) + XCTAssertTrue(decision.shouldStart) + XCTAssertTrue(decision.shouldLatch) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvLauncherStartTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvLauncherStartTests.swift new file mode 100644 index 0000000000..75eb2ac47f --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvLauncherStartTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +/// Pins the supersession guard in `CoreSpvLauncher.start`: a start that +/// is superseded during its off-main peer resolution must throw +/// `CancellationError` and must NOT reach `startSpv`, so it can't revive +/// a stopped/replaced manager. Exercised through the injectable +/// `startSpv` seam so no real (network-locked) `PlatformWalletManager` +/// is needed. +/// +/// `.testnet` is used throughout so `peerOverride` takes its +/// non-blocking branch (no `SDK.discoverActiveMasternodes` round-trip): +/// with the default `UserDefaults` (`useLocalhostCore`/`useDockerSetup` +/// unset) it returns an empty peer list synchronously. +@MainActor +final class CoreSpvLauncherStartTests: XCTestCase { + + func testStaleStartThrowsCancellationAndNeverCallsStartSpv() async { + var startSpvInvoked = false + do { + try await CoreSpvLauncher.start( + network: .testnet, + stillCurrent: { false }, + startSpv: { _ in startSpvInvoked = true } + ) + XCTFail("Expected CancellationError for a superseded start") + } catch is CancellationError { + // Expected. + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + XCTAssertFalse( + startSpvInvoked, + "startSpv must not run once the start is superseded" + ) + } + + func testFreshStartInvokesStartSpvWithRequestedNetwork() async throws { + var capturedConfig: PlatformSpvStartConfig? + try await CoreSpvLauncher.start( + network: .testnet, + stillCurrent: { true }, + startSpv: { config in capturedConfig = config } + ) + XCTAssertEqual( + capturedConfig?.network, + .testnet, + "A current start must reach startSpv with the requested network" + ) + } + + /// Mirrors the call-site closure shape (`spvStartGeneration == + /// captured`): when the generation moves after the start was armed, + /// the guard bails even though nothing else changed. + func testGenerationMismatchBailsWithoutStarting() async { + final class GenerationBox { var value: UInt64 = 0 } + let box = GenerationBox() + let captured = box.value + // A supersession bumps the generation while the start is in + // flight (armed with `captured`). + box.value &+= 1 + + var startSpvInvoked = false + do { + try await CoreSpvLauncher.start( + network: .testnet, + stillCurrent: { box.value == captured }, + startSpv: { _ in startSpvInvoked = true } + ) + XCTFail("Expected CancellationError for a generation mismatch") + } catch is CancellationError { + // Expected. + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + XCTAssertFalse(startSpvInvoked) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreStopSpvTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreStopSpvTests.swift new file mode 100644 index 0000000000..3ededa986c --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreStopSpvTests.swift @@ -0,0 +1,45 @@ +import SwiftData +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +/// Pins the ordering invariant of `stopSpvCancellingPendingStarts`: the +/// pending-start generation must already be bumped by the time the stop +/// actually runs, so a start suspended in peer resolution can't resume +/// and revive sync after the stop. Uses the injected-`stop` seam so no +/// real (network-locked) `PlatformWalletManager` is exercised — +/// `WalletManagerStore.init` is cheap (`PlatformWalletManager()` is +/// `public init() {}`), so bare construction against an in-memory +/// container is side-effect-free. +@MainActor +final class WalletManagerStoreStopSpvTests: XCTestCase { + + func testStopSpvCancellingPendingStartsBumpsGenerationBeforeStopping() throws { + let container = try DashModelContainer.createInMemory() + let store = WalletManagerStore(modelContainer: container) + + let before = store.spvStartGeneration + var stopInvocations = 0 + var generationWhenStopRan: UInt64? + + store.stopSpvCancellingPendingStarts(stop: { + stopInvocations += 1 + // Read the live generation from inside the stop closure: if + // invalidation is (correctly) ordered first, it already reads + // the bumped value. + generationWhenStopRan = store.spvStartGeneration + }) + + XCTAssertEqual(stopInvocations, 1, "stop must be invoked exactly once") + XCTAssertEqual( + generationWhenStopRan, + before &+ 1, + "generation must already be bumped by the time stop runs" + ) + XCTAssertEqual( + store.spvStartGeneration, + before &+ 1, + "generation is bumped exactly once" + ) + } +}