From 053676c3dc0b3d25496af67a66e5b52f802f081d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 05:36:50 +0700 Subject: [PATCH 1/6] feat(swift-sdk): auto-start Core SPV sync on app launch Extracts the Sync tab Start button's config assembly (per-network data dir, peer-override modes, devnet name) into a shared CoreSpvLauncher and invokes it from bootstrap once the SDK and the launch network's wallets are loaded, so Core sync no longer needs a manual Start tap. Gated to once per launch, skipped when SPV is already running or the network has no wallets yet. Co-Authored-By: Claude Fable 5 --- .../Core/Services/CoreSpvLauncher.swift | 102 ++++++++++++++++++ .../Core/Views/CoreContentView.swift | 78 +------------- .../SwiftExampleApp/SwiftExampleAppApp.swift | 50 +++++++++ .../SwiftExampleApp/Views/OptionsView.swift | 2 +- 4 files changed, 155 insertions(+), 77 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift 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 00000000000..076e8643194 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift @@ -0,0 +1,102 @@ +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`. Returns immediately — `startSpv` spawns the + /// sync loop on the shared tokio runtime. Rethrows whatever + /// `startSpv` throws; the caller decides whether to surface or log. + static func start( + network: Network, + on walletManager: PlatformWalletManager + ) throws { + let dataDirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + .first! + .appendingPathComponent("SPV") + .appendingPathComponent(network.networkName) + try? FileManager.default.createDirectory(at: dataDirURL, withIntermediateDirectories: true) + + let peers = peerOverride(network: network) + 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 walletManager.startSpv(config: 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. + 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 } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 7d9f307f3e2..af318d10503 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -754,89 +754,15 @@ 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, + try CoreSpvLauncher.start( network: platformState.currentNetwork, - peers: peers, - restrictToConfiguredPeers: restrictToConfiguredPeers, - devnetName: devnetName + on: walletManager ) - 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) - } - 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() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 99d8459348e..6e689d82ea1 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,47 @@ struct SwiftExampleAppApp: App { } } + /// Auto-start Core SPV sync for the launch network. Gated three + /// ways so it's safe to call from `bootstrap`: + /// + /// * **Once per launch** — `didAutoStartCoreSpv` latches so a + /// `retryBootstrap` after a partial first pass can't restart it. + /// * **No double-start** — skips when the client is already + /// running (e.g. a retry after the first pass got it going). + /// * **Wallet-gated** — skips when the active (per-network) + /// manager has no wallets, so first-run onboarding doesn't burn + /// bandwidth syncing headers for a wallet that doesn't exist + /// yet. The active manager is already scoped to the launch + /// network, so `wallets` is the right cheap signal. + /// + /// 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() { + guard !didAutoStartCoreSpv else { return } + guard !walletManager.wallets.isEmpty else { return } + // Latch before the (idempotent) start so a re-entrant call + // can't race a second launch. + didAutoStartCoreSpv = true + + guard !walletManager.spvIsRunning else { return } + do { + try CoreSpvLauncher.start( + network: platformState.currentNetwork, + on: walletManager + ) + SDKLogger.log( + "🟢 Auto-started Core SPV sync for " + + platformState.currentNetwork.displayName, + minimumLevel: .medium + ) + } catch { + SDKLogger.error( + "Auto-start Core SPV sync failed: \(error.localizedDescription)" + ) + } + } + @MainActor private func bootstrap() async { do { @@ -356,6 +402,10 @@ struct SwiftExampleAppApp: App { preWarmOrphanNetworkManagers() rebindWalletScopedServices() + + // Kick off Core SPV sync for the launch network so the + // user doesn't have to tap Start on the Sync tab. + autoStartCoreSpvIfNeeded() } isInitialized = true diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index e562a3d90cd..e1abc6ee605 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -137,7 +137,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) From 72d2e0ec3067b192e5563258d0cdb9012050cbae Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 06:16:08 +0700 Subject: [PATCH 2/6] fix(swift-sdk): resolve SPV peers off the main actor + test auto-start gates The devnet peer discovery blocks on a semaphore for up to 6s; hop it to a detached task so the launch-time auto-start (and the Start button) can no longer freeze the UI. Extract the auto-start gate matrix into pure CoreSpvAutoStart.decision and pin its asymmetric latching invariants with unit tests. Co-Authored-By: Claude Fable 5 --- .../Core/Services/CoreSpvLauncher.swift | 72 ++++++++++++++-- .../Core/Views/CoreContentView.swift | 19 +++-- .../SwiftExampleApp/SwiftExampleAppApp.swift | 47 ++++++----- .../CoreSpvAutoStartTests.swift | 84 +++++++++++++++++++ 4 files changed, 186 insertions(+), 36 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift index 076e8643194..5d9360101eb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift @@ -13,20 +13,28 @@ import SwiftDashSDK @MainActor enum CoreSpvLauncher { /// Build the start config for `network` and start SPV on - /// `walletManager`. Returns immediately — `startSpv` spawns the - /// sync loop on the shared tokio runtime. Rethrows whatever - /// `startSpv` throws; the caller decides whether to surface or log. + /// `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. Rethrows whatever `startSpv` throws; the caller + /// decides whether to surface or log. static func start( network: Network, on walletManager: PlatformWalletManager - ) throws { + ) async throws { let dataDirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) .first! .appendingPathComponent("SPV") .appendingPathComponent(network.networkName) try? FileManager.default.createDirectory(at: dataDirURL, withIntermediateDirectories: true) - let peers = peerOverride(network: network) + // 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 (no `walletManager`) is + // captured, so the detached hop is data-race-free. + let peers = await Task.detached { Self.peerOverride(network: network) }.value let restrictToConfiguredPeers = !peers.isEmpty // Devnet requires a name so `DevnetConfig` can embed @@ -71,7 +79,12 @@ enum CoreSpvLauncher { /// seeds and edits this string. /// 3. **everything else** — empty list, FFI uses the network's /// built-in seed nodes. - private static func peerOverride(network: Network) -> [String] { + /// + /// `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"] @@ -100,3 +113,50 @@ enum CoreSpvLauncher { .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 af318d10503..92f5fedd8ce 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -753,13 +753,18 @@ var body: some View { } private func startSync() { - do { - try CoreSpvLauncher.start( - network: platformState.currentNetwork, - on: walletManager - ) - } catch { - print("❌ Sync failed: \(error)") + // `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. + Task { + do { + try await CoreSpvLauncher.start( + network: platformState.currentNetwork, + on: walletManager + ) + } catch { + print("❌ Sync failed: \(error)") + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 6e689d82ea1..5d86a66c69c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -308,32 +308,33 @@ struct SwiftExampleAppApp: App { } } - /// Auto-start Core SPV sync for the launch network. Gated three - /// ways so it's safe to call from `bootstrap`: + /// 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. /// - /// * **Once per launch** — `didAutoStartCoreSpv` latches so a - /// `retryBootstrap` after a partial first pass can't restart it. - /// * **No double-start** — skips when the client is already - /// running (e.g. a retry after the first pass got it going). - /// * **Wallet-gated** — skips when the active (per-network) - /// manager has no wallets, so first-run onboarding doesn't burn - /// bandwidth syncing headers for a wallet that doesn't exist - /// yet. The active manager is already scoped to the launch - /// network, so `wallets` is the right cheap signal. + /// `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. + /// 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() { - guard !didAutoStartCoreSpv else { return } - guard !walletManager.wallets.isEmpty else { return } - // Latch before the (idempotent) start so a re-entrant call - // can't race a second launch. - didAutoStartCoreSpv = true - - guard !walletManager.spvIsRunning else { return } + private func autoStartCoreSpvIfNeeded() async { + let decision = CoreSpvAutoStart.decision( + alreadyLatched: didAutoStartCoreSpv, + hasWallets: !walletManager.wallets.isEmpty, + spvRunning: walletManager.spvIsRunning + ) + if decision.shouldLatch { didAutoStartCoreSpv = true } + guard decision.shouldStart else { return } + do { - try CoreSpvLauncher.start( + try await CoreSpvLauncher.start( network: platformState.currentNetwork, on: walletManager ) @@ -405,7 +406,7 @@ struct SwiftExampleAppApp: App { // Kick off Core SPV sync for the launch network so the // user doesn't have to tap Start on the Sync tab. - autoStartCoreSpvIfNeeded() + await autoStartCoreSpvIfNeeded() } isInitialized = true diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift new file mode 100644 index 00000000000..8d45e46fcba --- /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) + } +} From c09ca8ea8fe3949ec8c54565ae40cc51d0634a0d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 07:08:36 +0700 Subject: [PATCH 3/6] fix(swift-sdk): bail a pending SPV start when its manager is superseded After the off-main peer resolution, revalidate that the captured wallet manager is still the store's active one before calling startSpv; a network switch (or devnet manager rebuild) mid-await now throws CancellationError, which both call sites treat as benign, instead of reviving sync on the abandoned network. Co-Authored-By: Claude Fable 5 --- .../Core/Services/CoreSpvLauncher.swift | 27 ++++++++++++++++--- .../Core/Views/CoreContentView.swift | 20 ++++++++++++-- .../SwiftExampleApp/SwiftExampleAppApp.swift | 26 +++++++++++++----- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift index 5d9360101eb..e9713dfd80b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift @@ -18,12 +18,26 @@ enum CoreSpvLauncher { /// 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. Rethrows whatever `startSpv` throws; the caller - /// decides whether to surface or log. + /// 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 + on walletManager: PlatformWalletManager, + stillCurrent: @MainActor () -> Bool ) async throws { + // Network-derived; safe to compute pre-await because we bail on a + // `stillCurrent` mismatch below before touching `walletManager` — + // and creating the data dir alone starts nothing. let dataDirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) .first! .appendingPathComponent("SPV") @@ -35,6 +49,13 @@ enum CoreSpvLauncher { // `[String]`; nothing main-actor-bound (no `walletManager`) 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 superseded this + // start during the peer lookup. Manager identity is the strongest + // signal — it also catches a devnet→devnet SDK rebuild where the + // network is unchanged but the manager instance was replaced. + guard stillCurrent() else { throw CancellationError() } + let restrictToConfiguredPeers = !peers.isEmpty // Devnet requires a name so `DevnetConfig` can embed diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 92f5fedd8ce..a6073fe903c 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 @@ -756,12 +760,24 @@ var body: some View { // `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 up front. If the user + // switches networks during the peer lookup, `stillCurrent` reports + // the captured manager is no longer active and the start bails + // rather than reviving sync on the abandoned network. + let network = platformState.currentNetwork + let manager = walletManager + let store = walletManagerStore Task { do { try await CoreSpvLauncher.start( - network: platformState.currentNetwork, - on: walletManager + network: network, + on: manager, + stillCurrent: { 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)") } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 5d86a66c69c..3b68986e60f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -325,22 +325,36 @@ struct SwiftExampleAppApp: App { /// 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: !walletManager.wallets.isEmpty, - spvRunning: walletManager.spvIsRunning + hasWallets: !manager.wallets.isEmpty, + spvRunning: manager.spvIsRunning ) if decision.shouldLatch { didAutoStartCoreSpv = true } guard decision.shouldStart else { return } do { try await CoreSpvLauncher.start( - network: platformState.currentNetwork, - on: walletManager + network: network, + on: manager, + stillCurrent: { store.activeManager === manager } + ) + SDKLogger.log( + "🟢 Auto-started Core SPV sync for " + network.displayName, + minimumLevel: .medium ) + } catch is CancellationError { + // The user switched networks during the peer lookup, so the + // launch network's auto-start window is over. The latch stays + // set (set before the await) — we deliberately do NOT unlatch + // and re-fire for a network the user has left; the now-active + // network's `rebindWalletScopedServices` + its own auto-start + // own its sync. SDKLogger.log( - "🟢 Auto-started Core SPV sync for " - + platformState.currentNetwork.displayName, + "ℹ️ Core SPV auto-start superseded by a network switch", minimumLevel: .medium ) } catch { From c6ddb4a63efe83439a6456e00ddd522f390b94c2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 07:43:02 +0700 Subject: [PATCH 4/6] fix(swift-sdk): invalidate pending SPV starts with a store generation token Manager identity alone missed the stop-to-activate gap (OptionsView stops the old manager synchronously; the store swaps activeManager later from onChange). WalletManagerStore now carries a monotonically increasing spvStartGeneration, bumped at the earliest synchronous point of every supersession path (network picker, docker toggle, devnet rebuild, custom-peers toggle, Pause) plus defensively on any actual activeManager swap in activate(). Start call sites capture the generation before initiating and stillCurrent requires both the generation and manager identity to match. Adds launcher tests via an injected startSpv seam and rewords the auto-start supersession comment. Co-Authored-By: Claude Fable 5 --- .../Core/Services/CoreSpvLauncher.swift | 39 ++++++--- .../Core/Views/CoreContentView.swift | 18 +++-- .../SwiftExampleApp/SwiftExampleAppApp.swift | 20 +++-- .../SwiftExampleApp/Views/OptionsView.swift | 19 +++++ .../SwiftExampleApp/WalletManagerStore.swift | 40 ++++++++++ .../CoreSpvLauncherStartTests.swift | 79 +++++++++++++++++++ 6 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvLauncherStartTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift index e9713dfd80b..b8558ba4f28 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift @@ -34,10 +34,30 @@ enum CoreSpvLauncher { 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 touching `walletManager` — - // and creating the data dir alone starts nothing. + // `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") @@ -46,14 +66,15 @@ enum CoreSpvLauncher { // 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 (no `walletManager`) is - // captured, so the detached hop is data-race-free. + // `[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 superseded this - // start during the peer lookup. Manager identity is the strongest - // signal — it also catches a devnet→devnet SDK rebuild where the - // network is unchanged but the manager instance was replaced. + // 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 @@ -76,7 +97,7 @@ enum CoreSpvLauncher { restrictToConfiguredPeers: restrictToConfiguredPeers, devnetName: devnetName ) - try walletManager.startSpv(config: config) + try startSpv(config) } /// Resolve the SPV peer override for the given network / docker diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index a6073fe903c..12e81f29892 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -761,19 +761,24 @@ var body: some View { // 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 up front. If the user - // switches networks during the peer lookup, `stillCurrent` reports - // the captured manager is no longer active and the start bails - // rather than reviving sync on the abandoned network. + // 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. let network = platformState.currentNetwork let manager = walletManager let store = walletManagerStore + let generation = store.spvStartGeneration Task { do { try await CoreSpvLauncher.start( network: network, on: manager, - stillCurrent: { store.activeManager === manager } + stillCurrent: { + store.spvStartGeneration == generation + && store.activeManager === manager + } ) } catch is CancellationError { // Superseded by a network switch during peer resolution — @@ -785,6 +790,9 @@ var body: some View { } private func pauseSync() { + // Cancel any in-flight start so a fast Start→Pause can't have the + // start resume and revive sync after the user paused. + walletManagerStore.invalidatePendingSpvStarts() try? walletManager.stopSpv() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 3b68986e60f..12a2fcd20fc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -328,6 +328,7 @@ struct SwiftExampleAppApp: App { let network = platformState.currentNetwork let manager = walletManager let store = walletManagerStore + let generation = store.spvStartGeneration let decision = CoreSpvAutoStart.decision( alreadyLatched: didAutoStartCoreSpv, hasWallets: !manager.wallets.isEmpty, @@ -340,19 +341,24 @@ struct SwiftExampleAppApp: App { try await CoreSpvLauncher.start( network: network, on: manager, - stillCurrent: { store.activeManager === manager } + stillCurrent: { + store.spvStartGeneration == generation + && store.activeManager === manager + } ) SDKLogger.log( "🟢 Auto-started Core SPV sync for " + network.displayName, minimumLevel: .medium ) } catch is CancellationError { - // The user switched networks during the peer lookup, so the - // launch network's auto-start window is over. The latch stays - // set (set before the await) — we deliberately do NOT unlatch - // and re-fire for a network the user has left; the now-active - // network's `rebindWalletScopedServices` + its own auto-start - // own its sync. + // 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index e1abc6ee605..ca850be1b15 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 @@ -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 4d647f9ed96..d32a522f6e0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -52,6 +52,35 @@ 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. Call at the earliest + /// synchronous point of any path that stops or replaces a manager + /// (network switch, devnet/SDK rebuild, peer-config change) so a + /// start suspended in the stop→activate gap bails instead of + /// reviving the superseded manager. + func invalidatePendingSpvStarts() { + spvStartGeneration &+= 1 + } + /// Per-network managers. Lazily populated on first activation /// of each network; lookup is O(1). private var managers: [Network: PlatformWalletManager] = [:] @@ -107,6 +136,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 +179,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/CoreSpvLauncherStartTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvLauncherStartTests.swift new file mode 100644 index 00000000000..75eb2ac47fd --- /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) + } +} From d72de1ca2def76bbaefc59066c3d87478aa0c0fc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 08:22:58 +0700 Subject: [PATCH 5/6] fix(swift-sdk): route every user stop through invalidate-then-stopSpv GlobalSyncIndicator's overlay stop button bypassed the pending-start invalidation. Adds WalletManagerStore.stopSpvCancellingPendingStarts as the blessed user-facing stop path (used by the overlay button and pauseSync), documents the bump-before-stop invariant on invalidatePendingSpvStarts, and makes both start entry points bump then capture the generation so the latest start supersedes any still-pending one (collapses the rapid multi-tap window). Co-Authored-By: Claude Fable 5 --- .../SwiftExampleApp/ContentView.swift | 5 +++- .../Core/Views/CoreContentView.swift | 15 ++++++++--- .../SwiftExampleApp/SwiftExampleAppApp.swift | 11 +++++++- .../SwiftExampleApp/WalletManagerStore.swift | 27 +++++++++++++++---- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index fd24c73e4ff..7febdf052ed 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/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 12e81f29892..2a61a4bf1d0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -766,9 +766,16 @@ var body: some View { // 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 { @@ -790,10 +797,10 @@ var body: some View { } private func pauseSync() { - // Cancel any in-flight start so a fast Start→Pause can't have the - // start resume and revive sync after the user paused. - walletManagerStore.invalidatePendingSpvStarts() - 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 12a2fcd20fc..847d5e28586 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -328,7 +328,6 @@ struct SwiftExampleAppApp: App { let network = platformState.currentNetwork let manager = walletManager let store = walletManagerStore - let generation = store.spvStartGeneration let decision = CoreSpvAutoStart.decision( alreadyLatched: didAutoStartCoreSpv, hasWallets: !manager.wallets.isEmpty, @@ -337,6 +336,16 @@ struct SwiftExampleAppApp: App { 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, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index d32a522f6e0..f8fa3edfae2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -72,15 +72,32 @@ final class WalletManagerStore: ObservableObject { /// closures only. private(set) var spvStartGeneration: UInt64 = 0 - /// Invalidate every in-flight SPV start. Call at the earliest - /// synchronous point of any path that stops or replaces a manager - /// (network switch, devnet/SDK rebuild, peer-config change) so a - /// start suspended in the stop→activate gap bails instead of - /// reviving the superseded manager. + /// 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) { + invalidatePendingSpvStarts() + try? manager.stopSpv() + } + /// Per-network managers. Lazily populated on first activation /// of each network; lookup is O(1). private var managers: [Network: PlatformWalletManager] = [:] From 215af6e0d87c04ee1df8070076282c3e9b25e6cf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 08:59:35 +0700 Subject: [PATCH 6/6] test(swift-sdk): pin invalidate-before-stop; unlock UI before auto-start Adds an injected-stop seam on stopSpvCancellingPendingStarts plus a test asserting the generation is already bumped when the stop runs, and moves the launch auto-start after isInitialized so devnet peer discovery can't hold the app on the initializing screen. Co-Authored-By: Claude Fable 5 --- .../SwiftExampleApp/SwiftExampleAppApp.swift | 16 +++++-- .../SwiftExampleApp/WalletManagerStore.swift | 12 ++++- .../WalletManagerStoreStopSpvTests.swift | 45 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreStopSpvTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 847d5e28586..adcfad7c274 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -432,13 +432,21 @@ struct SwiftExampleAppApp: App { preWarmOrphanNetworkManagers() rebindWalletScopedServices() - - // Kick off Core SPV sync for the launch network so the - // user doesn't have to tap Start on the Sync tab. - await autoStartCoreSpvIfNeeded() } + // 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/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index f8fa3edfae2..8719ab54661 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -94,8 +94,18 @@ final class WalletManagerStore: ObservableObject { /// 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() - try? manager.stopSpv() + stop() } /// Per-network managers. Lazily populated on first activation diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreStopSpvTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreStopSpvTests.swift new file mode 100644 index 00000000000..3ededa986cc --- /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" + ) + } +}