diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift index e66ac939d..7d0171555 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/AssetLockRecoveryService.swift @@ -86,7 +86,7 @@ struct AssetLockRecoveryService { default: throw RecoveryError.unsupportedRoute } - ShieldedTxLookup.shared.refresh() + await ShieldedTxLookup.shared.refresh(reason: "asset-lock-recovery-completed") Self.logger.info("πŸ” LOCK-RETRY :: completed type=\(fundingTypeRaw, privacy: .public)") } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index b834b5376..ea55a663b 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -556,7 +556,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { signer: signer) applyUpdatedBalances(updated, context: container.mainContext) - ShieldedTxLookup.shared.refresh() + await ShieldedTxLookup.shared.refresh(reason: "platform-fund-completed") Task { await self.syncNow() } } @@ -580,7 +580,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { signer: signer) applyUpdatedBalances(updated, context: container.mainContext) - ShieldedTxLookup.shared.refresh() + await ShieldedTxLookup.shared.refresh(reason: "platform-fund-resume-completed") Task { await self.syncNow() } } @@ -770,7 +770,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { let manager: PlatformWalletManager let resolvedWallet: ManagedPlatformWallet do { - (manager, resolvedWallet) = try SwiftDashSDKHost.shared.start(network: network) + (manager, resolvedWallet) = try await SwiftDashSDKHost.shared.start(network: network) } catch { Self.logger.error("πŸ›°οΈ PLATFORM-ADDR :: host.start failed: \(String(describing: error), privacy: .public)") lastError = error.localizedDescription @@ -888,7 +888,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { self.addressWalletStartupError = addressWalletError self.lastError = addressWalletError - subscribeToManager(manager: manager, walletId: resolvedWallet.walletId) + await subscribeToManager(manager: manager, walletId: resolvedWallet.walletId) refreshDerivedAddresses() // Hand the shielded diagnostics monitor the new manager generation. @@ -1047,7 +1047,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { // MARK: - Combine subscriptions - private func subscribeToManager(manager: PlatformWalletManager, walletId: Data) { + private func subscribeToManager(manager: PlatformWalletManager, walletId: Data) async { shieldedMonitoringStartedAt = Date() lastFullShieldedSyncAt = nil @@ -1075,12 +1075,10 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { latestObservedShieldedBalance = result.balance lastFullShieldedSyncAt = Date() } - // Seed the shielded funding-tx β†’ locked-amount map (for the home tx - // list's "Shielded transfer" rows) from whatever is already - // persisted, then refresh it after each completed shielded sync pass - // below β€” a new "to Shielded" transfer's asset lock lands around the - // same time its note shows up in the balance. - ShieldedTxLookup.shared.refresh() + // Install the completion subscription before awaiting the initial + // worker-context read. `await` yields the main actor, so doing the + // initial read first could let a fast first shielded pass publish in + // the gap between the seed check above and this subscription. shieldedEventCancellable = manager.$lastShieldedSyncEvent .receive(on: RunLoop.main) .sink { [weak self] event in @@ -1090,9 +1088,18 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { !result.cooldownSkip else { return } self.lastFullShieldedSyncAt = Date() self.handleShieldedBalanceResult(result, manager: manager) - ShieldedTxLookup.shared.refresh() + Task { @MainActor in + await ShieldedTxLookup.shared.refresh(reason: "shielded-sync-completed") + } } + // Seed the shielded funding-tx β†’ locked-amount map (for the home tx + // list's "Shielded transfer" rows) from whatever is already + // persisted. Each completed pass above refreshes it again β€” a new + // "to Shielded" transfer's asset lock lands around the same time its + // note shows up in the balance. + await ShieldedTxLookup.shared.refresh(reason: "manager-subscribe") + // A suspended app cannot run the SDK's 60-second timer. Force one pass // after returning to the foreground when the last real scan is no // longer fresh. This is what makes an external same-seed spend appear @@ -1507,13 +1514,14 @@ extension PlatformAddressSyncCoordinator { /// shielded variant is funding type 5 /// (`FundingType.assetLockShieldedAddressTopUp`). /// -/// Snapshot design: `refresh()` (main actor, SwiftData `mainContext`) -/// rebuilds an immutable `[txid: ShieldedLockInfo]` map (amount + status + -/// vout); `amountDuffs(forTxidHex:)` / `info(forTxidHex:)` read it under a -/// lock so the (possibly background) transaction-list builder in -/// `Transaction` can call in from any thread without touching SwiftData. -/// Refreshed by `PlatformAddressSyncCoordinator` on wallet start and on each -/// completed shielded sync pass. +/// Snapshot design: `refresh()` captures the active `ModelContainer` on the +/// main actor, then rebuilds an immutable `[txid: ShieldedLockInfo]` map +/// (amount + status + vout) in a private, worker-thread `ModelContext`. +/// `amountDuffs(forTxidHex:)` / `info(forTxidHex:)` read the finished map under +/// a lock, so the (possibly background) transaction-list builder in +/// `Transaction` never touches SwiftData. Refreshed by +/// `PlatformAddressSyncCoordinator` on wallet start and on each completed +/// shielded sync pass. final class ShieldedTxLookup { static let shared = ShieldedTxLookup() @@ -1548,10 +1556,26 @@ final class ShieldedTxLookup { let fundingTypeRaw: Int } + /// Value-only result crossing from the detached SwiftData reader back to + /// the main actor. Persistent models and `ModelContext` never cross that + /// boundary. + private struct SnapshotBuildResult: Sendable { + let map: [String: ShieldedLockInfo]? + let assetLockCount: Int + let typesSeen: [Int] + let uncoveredTxids: [String] + let errorDescription: String? + let workerRanOffMain: Bool + } + private let lock = NSLock() /// txid (display-order hex, lowercased) β†’ locked amount + status + vout. private var infoByTxid: [String: ShieldedLockInfo] = [:] + /// Main-actor generation prevents a slow read from an old container (or + /// an earlier refresh of the same container) from replacing newer state. + @MainActor private var refreshGeneration: UInt64 = 0 + private init() {} /// Locked shielded amount (duffs) for an L1 funding txid, or `nil` if the @@ -1599,32 +1623,86 @@ final class ShieldedTxLookup { } /// Rebuild the snapshot from the active container's shielded asset-lock - /// rows. Main actor: reads the SwiftData `mainContext`, matching the rest - /// of the wallet's SDK access. A nil container (no wallet running) clears - /// the snapshot; a transient fetch error leaves the previous one in place. + /// rows. Only container capture and final publication run on the main + /// actor; all SQLite reads use a private `ModelContext` in a detached task. + /// A nil container (no wallet running) clears the snapshot; a transient + /// fetch error leaves the previous one in place. @MainActor - func refresh() { + func refresh(reason: String = "explicit") async { + refreshGeneration &+= 1 + let generation = refreshGeneration + let refreshID = String(UUID().uuidString.prefix(8)) + guard let container = SwiftDashSDKHost.shared.modelContainer else { store([:]) + DWLogger.log( + "πŸ›‘οΈ SHIELD-TX-REFRESH [\(refreshID)] skipped reason=\(reason) β€” no model container") + return + } + + let started = CFAbsoluteTimeGetCurrent() + DWLogger.log( + "πŸ›‘οΈ SHIELD-TX-REFRESH [\(refreshID)] start reason=\(reason) generation=\(generation)") + + let result = await Task.detached(priority: .utility) { + Self.buildSnapshot(in: container) + }.value + let elapsedMS = Int((CFAbsoluteTimeGetCurrent() - started) * 1000) + + guard generation == refreshGeneration, + SwiftDashSDKHost.shared.modelContainer === container else { + DWLogger.log( + "πŸ›‘οΈ SHIELD-TX-REFRESH [\(refreshID)] stale after \(elapsedMS)ms reason=\(reason) offMain=\(result.workerRanOffMain)") return } + + if let errorDescription = result.errorDescription { + Self.logger.error( + "πŸ›‘οΈ SHIELD-TX :: refresh failed: \(errorDescription, privacy: .public)") + DWLogger.log( + "πŸ›‘οΈ SHIELD-TX-REFRESH [\(refreshID)] failed in \(elapsedMS)ms reason=\(reason) offMain=\(result.workerRanOffMain): \(errorDescription)") + return + } + + guard let map = result.map else { return } + store(map) + Self.logger.info( + "πŸ›‘οΈ SHIELD-TX :: snapshot \(map.count, privacy: .public) funding tx(s) (shielded + platform)") + DWLogger.log( + "πŸ›‘οΈ SHIELD-TX-REFRESH [\(refreshID)] finish in \(elapsedMS)ms reason=\(reason) offMain=\(result.workerRanOffMain) rows=\(result.assetLockCount) snapshot=\(map.count) uncovered=\(result.uncoveredTxids.count)") + + if !result.uncoveredTxids.isEmpty { + Self.logger.error( + "πŸ›‘οΈ SHIELD-TX :: \(result.uncoveredTxids.count, privacy: .public) asset-lock tx(s) have no PersistentAssetLock row (SDK reconstruction gap?): \(result.uncoveredTxids.joined(separator: ","), privacy: .public)") + } + // Diagnostic: if asset locks exist but none matched the shielded + // funding type, surface the types actually present so a single test + // run reveals whether the discriminant assumption is wrong. + if map.isEmpty && result.assetLockCount > 0 { + Self.logger.info( + "πŸ›‘οΈ SHIELD-TX :: \(result.assetLockCount, privacy: .public) asset lock(s) present, none funding-type \(Self.shieldedFundingType, privacy: .public); types=\(result.typesSeen, privacy: .public)") + } + } + + /// Worker-thread SwiftData pass. Asset locks are a tiny table; fetch all + /// and filter in Swift rather than fighting `#Predicate` local-capture + /// rules. The coverage query preserves the former launch diagnostic while + /// keeping its SQLite work off the main actor as well. + private static func buildSnapshot(in container: ModelContainer) -> SnapshotBuildResult { + let workerRanOffMain = !Thread.isMainThread do { - // Asset locks are a tiny table; fetch all and filter in Swift - // rather than fighting `#Predicate` local-capture rules. - let rows = try container.mainContext.fetch(FetchDescriptor()) + let context = ModelContext(container) + context.autosaveEnabled = false + let rows = try context.fetch(FetchDescriptor()) var map: [String: ShieldedLockInfo] = [:] - let trackedTypes = Array(Self.identityFundingTypes) + [Self.platformFundingType, Self.shieldedFundingType] + let trackedTypes = Array(identityFundingTypes) + [platformFundingType, shieldedFundingType] for row in rows where trackedTypes.contains(row.fundingTypeRaw) && row.amountDuffs > 0 { // outPointHex == ":"; key on the txid, - // parse the vout after the colon. One shielded asset-lock row - // per funding txid in practice; if one ever recurs, prefer the - // most informative status: consumed (4, consumption known) - // over recovered-from-chain (5, consumption unknown) over the - // live pending window (0…3). + // parse the vout after the colon. If one recurs, prefer the + // most informative status: consumed (4) over recovered (5) + // over the live pending window (0…3). guard let colon = row.outPointHex.firstIndex(of: ":") else { continue } let txid = row.outPointHex[..= rank(info.statusRaw) { continue } map[txid] = info } - logUnclassifiedAssetLocks(coveredTxids: Set(map.keys), context: container.mainContext) - store(map) - Self.logger.info("πŸ›‘οΈ SHIELD-TX :: snapshot \(map.count, privacy: .public) funding tx(s) (shielded + platform)") - // Diagnostic: if asset locks exist but none matched the shielded - // funding type, surface the types actually present so a single - // test run reveals whether the discriminant assumption is wrong. - if map.isEmpty && !rows.isEmpty { - let typesSeen = Set(rows.map { $0.fundingTypeRaw }).sorted() - Self.logger.info("πŸ›‘οΈ SHIELD-TX :: \(rows.count, privacy: .public) asset lock(s) present, none funding-type \(Self.shieldedFundingType, privacy: .public); types=\(typesSeen, privacy: .public)") - } + + // Coverage diagnostic. Every wallet-own asset-lock funding tx is + // expected to have a store row; uncovered rows indicate an SDK + // reconstruction gap and render as "Internal Transfer β€” 0 DASH". + let assetLockKind = TransactionTypeKind.assetLock.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.transactionTypeKind == assetLockKind }) + let coveredTxids = Set(map.keys) + let uncoveredTxids = try context.fetch(descriptor) + .map { Transaction.displayHex($0.txid).lowercased() } + .filter { !coveredTxids.contains($0) } + + return SnapshotBuildResult( + map: map, + assetLockCount: rows.count, + typesSeen: Set(rows.map { $0.fundingTypeRaw }).sorted(), + uncoveredTxids: uncoveredTxids, + errorDescription: nil, + workerRanOffMain: workerRanOffMain) } catch { - Self.logger.error("πŸ›‘οΈ SHIELD-TX :: refresh failed: \(String(describing: error), privacy: .public)") + return SnapshotBuildResult( + map: nil, + assetLockCount: 0, + typesSeen: [], + uncoveredTxids: [], + errorDescription: String(describing: error), + workerRanOffMain: workerRanOffMain) } } - /// Coverage diagnostic. Every wallet-own asset-lock funding tx is - /// expected to have a store row: recorded live at build time, or β€” - /// after a wipe & recover β€” rewritten by the SDK's restore-scan - /// reconstruction (platform #4342; verified on a restored testnet - /// wallet 2026-08-09: 9/9 funding txs classified. The rows currently - /// arrive at `statusRaw` 1/3 rather than the intended 5 β€” an SDK-side - /// enrichment gap tracked for a platform follow-up). - /// An asset-lock tx with no row therefore indicates a reconstruction - /// gap (it renders "Internal Transfer β€” 0 DASH"); log it so a single - /// test run surfaces the txid. This replaced an app-side fallback that - /// re-parsed raw tx bytes into synthetic map entries β€” dead weight once - /// the SDK rows exist, since store-backed entries always beat it. - @MainActor - private func logUnclassifiedAssetLocks(coveredTxids: Set, context: ModelContext) { - let assetLockKind = TransactionTypeKind.assetLock.rawValue - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.transactionTypeKind == assetLockKind }) - guard let rows = try? context.fetch(descriptor), !rows.isEmpty else { return } - let uncovered = rows - .map { Transaction.displayHex($0.txid).lowercased() } - .filter { !coveredTxids.contains($0) } - guard !uncovered.isEmpty else { return } - Self.logger.error("πŸ›‘οΈ SHIELD-TX :: \(uncovered.count, privacy: .public) asset-lock tx(s) have no PersistentAssetLock row (SDK reconstruction gap?): \(uncovered.joined(separator: ","), privacy: .public)") - } - private func store(_ map: [String: ShieldedLockInfo]) { lock.lock() infoByTxid = map diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift index 9cf9303fe..1004d8685 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift @@ -18,8 +18,10 @@ // tears down and rebuilds. // - `createOrImportWallet(mnemonic:network:isImported:)` is the only path // that creates wallet rows and stores the mnemonic in WalletStorage. -// - `stop()` releases the manager handle. Wipe-time persisted-row cleanup is -// owned by `PlatformAddressSyncCoordinator` before BLAST stops. +// - `stopAsync()` shuts the manager down off-main (blocking native +// teardown on the SDK's destroy queue) and only then releases the +// references. Wipe-time persisted-row cleanup is owned by +// `PlatformAddressSyncCoordinator` before BLAST stops. // // Subsystems coordinate ordering through `SwiftDashSDKWalletRuntime`: // start = host.start β†’ SPV.start β†’ BLAST.start. Stop = BLAST.stop β†’ @@ -438,9 +440,9 @@ final class SwiftDashSDKHost { /// Start the host for `network`. Idempotent: re-entering with the same /// network leaves the live manager + wallet alone. Different network - /// triggers a clean rebuild via `stop()` first. + /// triggers a clean rebuild via `stopAsync()` first. @discardableResult - func start(network: Network) throws -> (manager: PlatformWalletManager, wallet: ManagedPlatformWallet) { + func start(network: Network) async throws -> (manager: PlatformWalletManager, wallet: ManagedPlatformWallet) { if let existingManager = manager, let existingWallet = wallet, runningNetwork == network { @@ -448,8 +450,9 @@ final class SwiftDashSDKHost { } Self.logger.info("πŸͺΊ HOST :: starting for \(network.rawValue, privacy: .public)") + DWLogger.log("HOST starting for \(network.rawValue)") - let handles = try buildRuntime(for: network) + let handles = try await buildRuntime(for: network) let resolvedWallet: ManagedPlatformWallet Self.logger.info("πŸͺΊ HOST :: stage 4/4 restoring wallet for \(network.rawValue, privacy: .public)") do { @@ -461,19 +464,26 @@ final class SwiftDashSDKHost { // reinstall+Keep only worked when the KeyMigrator's async re-import // happened to win the race against this load. guard let recovered = recoverPersistedWallet(handles: handles) else { + // The freshly built manager was never published β€” tear it + // down deterministically instead of leaving it to the + // deinit fallback. + await handles.manager.shutdown() throw HostError.walletNotFound(network) } resolvedWallet = recovered } catch let error as HostError { + await handles.manager.shutdown() throw error } catch { Self.logger.error("πŸͺΊ HOST :: wallet bootstrap failed: \(String(describing: error), privacy: .public)") + await handles.manager.shutdown() throw HostError.walletBootstrapFailed(error) } publish(handles: handles, wallet: resolvedWallet) Self.logger.info("πŸͺΊ HOST :: stage 4/4 wallet restored for \(network.rawValue, privacy: .public)") Self.logger.info("πŸͺΊ HOST :: started for \(network.rawValue, privacy: .public)") + DWLogger.log("HOST started for \(network.rawValue)") return (handles.manager, resolvedWallet) } @@ -502,7 +512,7 @@ final class SwiftDashSDKHost { Self.logger.info("πŸͺΊ HOST :: creating managed wallet for \(network.rawValue, privacy: .public)") - let handles = try buildRuntime(for: network) + let handles = try await buildRuntime(for: network) let createdWallet: ManagedPlatformWallet do { createdWallet = try createAndPersist( @@ -527,24 +537,36 @@ final class SwiftDashSDKHost { currentNetwork: network) for targetNetwork in missingNetworks where targetNetwork != network { - let targetManager = try managerForStoredWalletOperation( + // Always temporary here: `buildRuntime` just cleared the + // published runtime, so `managerForStoredWalletOperation` + // can never hand back a live manager β€” but keep the + // guard so this call site stays correct if that changes. + let (targetManager, isTemporary) = try managerForStoredWalletOperation( network: targetNetwork) - _ = try createAndPersist( - mnemonic: mnemonic, - manager: targetManager, - network: targetNetwork, - birthHeight: isImported - ? Self.importedWalletBirthHeight(for: targetNetwork) - : nil) + do { + _ = try createAndPersist( + mnemonic: mnemonic, + manager: targetManager, + network: targetNetwork, + birthHeight: isImported + ? Self.importedWalletBirthHeight(for: targetNetwork) + : nil) + } catch { + if isTemporary { await targetManager.shutdown() } + throw error + } + if isTemporary { await targetManager.shutdown() } Self.logger.info( "πŸͺΊ HOST :: provisioned onboarding wallet for \(targetNetwork.rawValue, privacy: .public)") } } } catch { // `createOrImportWallet` owns a freshly-built (not yet published) - // runtime, so tear it down on failure. `createAndPersist` has - // already rolled back any provisional mnemonic it wrote. - stop() + // runtime, so tear it down on failure β€” the manager was never + // assigned to `self.manager`, so it must be shut down directly. + // `createAndPersist` has already rolled back any provisional + // mnemonic it wrote. + await handles.manager.shutdown() throw error } @@ -638,19 +660,31 @@ final class SwiftDashSDKHost { let createsCurrentNetwork = networksToCreate.contains(network) for targetNetwork in networksToCreate { - let targetManager = targetNetwork == network - ? manager - : try managerForStoredWalletOperation(network: targetNetwork) - _ = try createAndPersist( - mnemonic: mnemonic, - manager: targetManager, - network: targetNetwork, - // Same semantics as `createOrImportWallet`: imports scan - // from each network's import floor, freshly generated - // wallets from that network's tip. - birthHeight: isImported - ? Self.importedWalletBirthHeight(for: targetNetwork) - : nil) + let targetManager: PlatformWalletManager + let isTemporary: Bool + if targetNetwork == network { + targetManager = manager + isTemporary = false + } else { + (targetManager, isTemporary) = try managerForStoredWalletOperation( + network: targetNetwork) + } + do { + _ = try createAndPersist( + mnemonic: mnemonic, + manager: targetManager, + network: targetNetwork, + // Same semantics as `createOrImportWallet`: imports scan + // from each network's import floor, freshly generated + // wallets from that network's tip. + birthHeight: isImported + ? Self.importedWalletBirthHeight(for: targetNetwork) + : nil) + } catch { + if isTemporary { await targetManager.shutdown() } + throw error + } + if isTemporary { await targetManager.shutdown() } Self.logger.info( "πŸͺΊ HOST :: added managed wallet for \(targetNetwork.rawValue, privacy: .public) (additive)") } @@ -745,16 +779,57 @@ final class SwiftDashSDKHost { } } - /// Tear down the host's active references. The per-network + /// Tear down the host's active references, running the manager's blocking + /// native teardown OFF the main thread and returning only when it has + /// completed (`nil` when no manager was running). The per-network /// `ModelContainer` remains process-cached so a later runtime rebuild does /// not open a second container over the same SQLite store. /// + /// Order matters: + /// 1. `contactCryptoDrainWatch` is cancelled AND awaited first β€” the task + /// holds the manager strongly and calls `unlockWalletFromKeychain` + /// (FFI), so it must be provably finished before the manager's handle + /// is taken. No self-deadlock: this method and the task share the + /// main actor, but `await value` suspends (freeing the actor) and the + /// task's `for await …values` / `Task.sleep` both honor cancellation, + /// so the wait is short and deterministic. + /// 2. `manager.shutdown()` takes the FFI handle exactly once and runs the + /// five sync stops + destroy on the SDK's dedicated destroy queue. + /// 3. Only then are the host references dropped β€” their deinits find a + /// NULL handle and do no FFI. + /// /// Persisted-row cleanup on wipe is owned by `PlatformAddressSyncCoordinator` /// β€” it must happen BEFORE BLAST's tokio task winds down so in-flight /// `walletNetwork(walletId:)` callbacks early-exit on an empty fetch. /// The host is torn down last (after BLAST + SPV stops), so the /// invariant doesn't hold here. - func stop() { + @discardableResult + func stopAsync() async -> PlatformWalletShutdownMetrics? { + let drainWatch = contactCryptoDrainWatch + contactCryptoDrainWatch = nil + drainWatch?.cancel() + await drainWatch?.value + + let metrics = await manager?.shutdown() + if let metrics { + let stepSummary = metrics.steps + .map { "\($0.name)=\($0.milliseconds)ms(code \($0.ffiCode))" } + .joined(separator: " ") + // DWLogger on purpose (os_log doesn't reach diagnostic exports): + // this line is the field telemetry for how often the native + // teardown hits its wedged-pass worst case. + DWLogger.log( + "HOST shutdown: total=\(metrics.totalMilliseconds)ms offMain=\(metrics.ranOffMainThread) \(stepSummary)") + } + + clearRuntimeReferences() + return metrics + } + + /// Drop the host's references AFTER the manager teardown has completed. + /// Split out of `stopAsync` so the shutdown-first ordering is the only + /// public shape; never call this with a still-configured manager. + private func clearRuntimeReferences() { manager = nil wallet = nil sdk = nil @@ -762,13 +837,14 @@ final class SwiftDashSDKHost { runningNetwork = nil Self.logger.info("πŸͺΊ HOST :: stopped") + DWLogger.log("HOST stopped") } // MARK: - Runtime bootstrap - private func buildRuntime(for network: Network) throws -> RuntimeHandles { + private func buildRuntime(for network: Network) async throws -> RuntimeHandles { if manager != nil { - stop() + await stopAsync() } return try makeRuntime(for: network) @@ -789,9 +865,16 @@ final class SwiftDashSDKHost { let newSDK: SDK do { let platformVersion = Self.platformVersion(for: network) + // Timed because it is main-thread work: SDK creation prefetches + // quorums over the network (~1-2s observed). Known stage-1 + // limitation β€” the switch overlay covers it; the measurement is + // the data for deciding whether to move it off-main later. + let started = CFAbsoluteTimeGetCurrent() newSDK = try SDK(network: network, platformVersion: platformVersion) + let ms = Int((CFAbsoluteTimeGetCurrent() - started) * 1000) Self.logger.info( "πŸͺΊ HOST :: stage 1/4 SDK created for \(network.rawValue, privacy: .public), protocol \(platformVersion == 0 ? "auto-detect" : "pinned v\(platformVersion)", privacy: .public)") + DWLogger.log("HOST stage 1/4 SDK created for \(network.rawValue) in \(ms)ms") } catch { Self.logger.error("πŸͺΊ HOST :: SDK init failed: \(String(describing: error), privacy: .public)") throw HostError.sdkInitFailed(error) @@ -815,6 +898,7 @@ final class SwiftDashSDKHost { Self.logger.info("πŸͺΊ HOST :: stage 3/4 configuring manager for \(network.rawValue, privacy: .public)") try newManager.configure(sdk: newSDK, modelContainer: container) Self.logger.info("πŸͺΊ HOST :: stage 3/4 manager configured for \(network.rawValue, privacy: .public)") + DWLogger.log("HOST stage 3/4 manager configured for \(network.rawValue)") } catch { Self.logger.error("πŸͺΊ HOST :: configure failed: \(String(describing: error), privacy: .public)") throw HostError.configureFailed(error) @@ -829,25 +913,32 @@ final class SwiftDashSDKHost { /// Manager bound to `network` for full-device wipe. /// - /// The live manager is reused for its network. The other network gets a - /// detached manager over the process-cached `ModelContainer`, avoiding a - /// second open of the same SQLite store and leaving the published runtime - /// unchanged until the wipe commits. - func managerForWipe(network: Network) throws -> PlatformWalletManager { + /// The live manager is reused for its network (`isTemporary == false` β€” + /// the caller must NOT shut it down). The other network gets a detached + /// manager over the process-cached `ModelContainer` + /// (`isTemporary == true` β€” the caller owns its lifecycle and must + /// `await manager.shutdown()` when done), avoiding a second open of the + /// same SQLite store and leaving the published runtime unchanged until + /// the wipe commits. + func managerForWipe(network: Network) throws -> (manager: PlatformWalletManager, isTemporary: Bool) { try managerForStoredWalletOperation(network: network) } /// Returns a manager over the network's persisted store without changing /// the published runtime. Shared by full-device wipe and explicit - /// cross-network wallet provisioning. - private func managerForStoredWalletOperation(network: Network) throws -> PlatformWalletManager { + /// cross-network wallet provisioning. `isTemporary` tells the caller + /// whether it owns the manager's teardown (`await manager.shutdown()` + /// after use) or borrowed the live published one (hands off). + private func managerForStoredWalletOperation( + network: Network + ) throws -> (manager: PlatformWalletManager, isTemporary: Bool) { if runningNetwork == network, let manager { - return manager + return (manager, false) } let handles = try makeRuntime(for: network) _ = try handles.manager.loadFromPersistor() - return handles.manager + return (handles.manager, true) } private func loadPersistedWallet( diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift index 8702934be..d1d31e6a8 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift @@ -233,7 +233,7 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { let manager: PlatformWalletManager let wallet: ManagedPlatformWallet do { - (manager, wallet) = try host.start(network: network) + (manager, wallet) = try await host.start(network: network) } catch { Self.logger.error("πŸ›°οΈ SPVCOORD :: host.start failed: \(String(describing: error), privacy: .public)") return .failure(StartError.walletImport(error)) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift index f19c49c42..17632eefc 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift @@ -144,6 +144,77 @@ final class SwiftDashSDKWalletRuntime: NSObject { restartCoreSPV() } + // MARK: - Runtime network switching + + /// Interactive network switch: the single owner of the runtime rebuild. + /// + /// Strict sequence β€” `stop old runtime β†’ await native teardown off-main β†’ + /// start new runtime β†’ ready` β€” driven by exactly ONE `refresh` on the + /// serial lifecycle chain. The `DWCurrentNetworkDidChange` post carries a + /// `.managedSwitch` source so the runtime observer skips its own + /// lifecycle reaction (UI listeners like DWRootModel behave as always); + /// external key writers keep the observer-driven path. + /// + /// A no-op requires an actually-ready runtime (`isRuntimeReady`), not + /// just a matching persisted key β€” a matching key over a dead runtime + /// runs the full rebuild (self-heal). + /// + /// MUST NOT be called from within a lifecycle operation on + /// `lifecycleQueue` (`refresh`/`fullReset` bodies): it awaits its own + /// enqueued op, so a call from inside the chain would self-await and + /// deadlock the queue. UI entry points and coordinators outside the + /// chain are the intended callers. + @MainActor + func switchNetwork(to kind: WalletEnvironment.NetworkKind) async throws { + guard kind != .devnet else { throw SwitchError.unsupportedNetwork } + if case .switching = NetworkTransitionState.shared.phase { + throw SwitchError.switchInProgress + } + let targetNetwork: Network = kind == .mainnet ? .mainnet : .testnet + if WalletEnvironment.networkKind == kind, isRuntimeReady(for: targetNetwork) { + Self.logger.info("🧭 RUNTIME :: switchNetwork β€” already on \(String(describing: kind), privacy: .public) with a ready runtime; no-op") + return + } + + let transitionID = String(UUID().uuidString.prefix(8)) + let from = WalletEnvironment.networkKind + let started = CFAbsoluteTimeGetCurrent() + NetworkTransitionState.shared.begin(from: from, to: kind) + // Thread stamp deliberately absent: this method is MainActor-bound + // (always main); the off-main proof for the blocking teardown is the + // shutdown metrics' `ranOffMainThread` in the HOST shutdown line. + DWLogger.log("πŸ”€ NETSWITCH [\(transitionID)] start \(from) β†’ \(kind)") + + // Owned here for managed switches (the observer skips them): silence + // and zero the published balance mirrors before anything else renders + // the old network's funds as the new one's. + SwiftDashSDKSPVCoordinator.shared.prepareForNetworkSwitch() + PlatformAddressSyncCoordinator.shared.prepareForNetworkSwitch() + + // Skip the key write when it already matches (a dead runtime on the + // right network heals via the refresh alone); `switchToNetwork` + // would early-return without posting in that case anyway. + if WalletEnvironment.networkKind != kind { + WalletEnvironment.switchToNetwork(kind, source: .managedSwitch(transitionID: transitionID)) + } + + await enqueueAwaitable { [weak self] in + await self?.refresh(trigger: .networkDidChange) + }.value + + let ms = Int((CFAbsoluteTimeGetCurrent() - started) * 1000) + if isRuntimeReady(for: targetNetwork) { + NetworkTransitionState.shared.finish() + DWLogger.log("πŸ”€ NETSWITCH [\(transitionID)] ready in \(ms)ms") + } else { + let detail = SwiftDashSDKSPVCoordinator.shared.lastError + ?? PlatformAddressSyncCoordinator.shared.lastError + NetworkTransitionState.shared.fail(target: kind, message: detail) + DWLogger.log("πŸ”€ NETSWITCH [\(transitionID)] FAILED after \(ms)ms: \(detail ?? "runtime did not become ready")") + throw SwitchError.startFailed(detail) + } + } + // MARK: - Runtime wallet switching /// Switch the active wallet (same network) to `walletId` at runtime. @@ -359,7 +430,10 @@ final class SwiftDashSDKWalletRuntime: NSObject { } await SwiftDashSDKSPVCoordinator.shared.stopAsync(lastError: lastError) SwiftDashSDKWalletState.shared.clearAllState() - SwiftDashSDKHost.shared.stop() + // Blocking native teardown runs off-main inside stopAsync; this only + // suspends. The shutdown metrics are logged by the host (DWLogger) + // so switch telemetry survives into diagnostic exports. + await SwiftDashSDKHost.shared.stopAsync() currentNetwork = nil if forWipe { DWCurrentUserIdentityInfo.shared.resetForWalletRemoval() @@ -401,19 +475,30 @@ final class SwiftDashSDKWalletRuntime: NSObject { // elide the rebind. return false case .startIfReady, .networkDidChange, .platformSyncRearm: - // External callers (PlatformSyncStatusScreen, StorageExplorerUnavailableView, - // the Platform send path) can mutate BLAST without touching - // `currentNetwork`, so consult the live coordinator state too β€” otherwise - // the runtime would skip a refresh after an out-of-band BLAST stop and - // leave the user without the BLAST sync they triggered. - let blast = PlatformAddressSyncCoordinator.shared - return currentNetwork == network - && blast.isRunning - && blast.runningNetwork == network - && SwiftDashSDKSPVCoordinator.shared.isRunning + return isRuntimeReady(for: network) } } + /// Whether the runtime is fully bound and running for `network`: host has + /// a bound wallet, SPV runs, and BLAST runs on that same network. The one + /// readiness predicate shared by refresh elision (`shouldSkipRefresh`) + /// and `switchNetwork(to:)`'s no-op / ready checks β€” a persisted network + /// key alone never counts as "ready". + /// + /// Consulting live coordinator state matters beyond switches too: + /// external callers (PlatformSyncStatusScreen, StorageExplorerUnavailableView, + /// the Platform send path) can mutate BLAST without touching + /// `currentNetwork`, and skipping a refresh after an out-of-band BLAST + /// stop would leave the user without the sync they triggered. + func isRuntimeReady(for network: Network) -> Bool { + let blast = PlatformAddressSyncCoordinator.shared + return currentNetwork == network + && SwiftDashSDKHost.shared.wallet != nil + && blast.isRunning + && blast.runningNetwork == network + && SwiftDashSDKSPVCoordinator.shared.isRunning + } + /// Internal (was private): reused by CrowdNode's TransactionObserver row /// scanner to render decoded addresses for the active network β€” reuse, not /// a copy, per the repo's no-copy-then-adapt guardrail. @@ -466,7 +551,14 @@ final class SwiftDashSDKWalletRuntime: NSObject { forName: NSNotification.Name.DWCurrentNetworkDidChange, object: nil, queue: nil - ) { _ in + ) { note in + // A managed switch (`switchNetwork(to:)`) owns BOTH the mirror + // zeroing and the single lifecycle refresh β€” reacting here would + // double-drive the lifecycle and, after a failed switch, retry + // the rebuild behind the transition state machine's back. + // External writers (recovery, sole-network selection) still get + // the full observer behavior below. + guard !WalletEnvironment.isManagedSwitchNotification(note) else { return } Task { @MainActor in // The home screen's funds are three published mirrors: the core // balance plus BLAST's Platform and Shielded totals. `refresh` @@ -502,8 +594,9 @@ final class SwiftDashSDKWalletRuntime: NSObject { } } - /// Failure modes of `switchWallet(to:)`. Surfaced to the caller rather - /// than logged-and-swallowed so a UI switch flow can report why it failed. + /// Failure modes of `switchWallet(to:)` / `switchNetwork(to:)`. Surfaced + /// to the caller rather than logged-and-swallowed so a UI switch flow can + /// report why it failed. enum SwitchError: LocalizedError { /// The current network isn't SDK-supported (devnet/unsupported). case unsupportedNetwork @@ -512,6 +605,13 @@ final class SwiftDashSDKWalletRuntime: NSObject { /// The stop/clear/load/start sequence ran but the host did not bind the /// requested wallet (e.g. its rows failed to load). case bindFailed + /// A network switch is already in flight; the transition state + /// machine admits one at a time. + case switchInProgress + /// The switch's teardown+rebuild ran but the destination runtime did + /// not come up ready; carries the coordinators' last error when one + /// was recorded. + case startFailed(String?) var errorDescription: String? { switch self { @@ -521,7 +621,54 @@ final class SwiftDashSDKWalletRuntime: NSObject { return "Cannot switch wallet: no wallet with that id is stored on this network." case .bindFailed: return "Switching wallet failed: the new wallet could not be loaded." + case .switchInProgress: + return "A network switch is already in progress." + case .startFailed(let detail): + let base = "Switching networks failed: the runtime did not start." + guard let detail, !detail.isEmpty else { return base } + return base + " (\(detail))" } } } } + +// MARK: - Network transition state + +/// Central published state of an interactive network switch, driven solely by +/// `SwiftDashSDKWalletRuntime.switchNetwork(to:)`. The full-screen switch +/// overlay observes it; wallet operations are hard-blocked independently (the +/// old manager's handle is taken at shutdown, so every FFI entry fails fast +/// until the new runtime binds). +/// +/// Non-interactive network writers (recovery, sole-network selection) do not +/// set `.switching` β€” deliberately, matching their pre-existing silent +/// behavior. +@MainActor +final class NetworkTransitionState: ObservableObject { + enum Phase: Equatable { + case idle + case switching(from: WalletEnvironment.NetworkKind, to: WalletEnvironment.NetworkKind) + /// The switch failed after the old runtime was already torn down β€” + /// the app may have no working manager, so the overlay stays up, + /// blocking, offering Retry toward `target`. + case failed(target: WalletEnvironment.NetworkKind, message: String?) + } + + static let shared = NetworkTransitionState() + + @Published private(set) var phase: Phase = .idle + + private init() {} + + func begin(from: WalletEnvironment.NetworkKind, to: WalletEnvironment.NetworkKind) { + phase = .switching(from: from, to: to) + } + + func finish() { + phase = .idle + } + + func fail(target: WalletEnvironment.NetworkKind, message: String?) { + phase = .failed(target: target, message: message) + } +} diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift index c760f7e2c..459b7d99d 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift @@ -402,7 +402,7 @@ final class SwiftDashSDKWalletWiper: NSObject { for network in networks { do { - let manager = try host.managerForWipe(network: network) + let (manager, isTemporary) = try host.managerForWipe(network: network) var walletIds = Set(manager.wallets.keys) walletIds.formUnion(storedWalletIdsByNetwork[network] ?? []) @@ -420,6 +420,15 @@ final class SwiftDashSDKWalletWiper: NSObject { logDeletionFailure(error, walletId: walletId, network: network) } } + // A detached per-network manager is owned by this loop: + // shut it down deterministically before the next network + // (or the post-wipe rebuild) can touch the same + // process-cached ModelContainer. The live published + // manager (isTemporary == false) is the runtime's to + // tear down. + if isTemporary { + await manager.shutdown() + } } catch { result.recordFailure() logger.error( @@ -489,28 +498,63 @@ final class SwiftDashSDKWalletWiper: NSObject { networks.append(current) } - var deletions: [(network: Network, walletId: Data, manager: PlatformWalletManager)] = [] - for network in networks { - guard let walletId = walletIds[network] else { continue } - let manager = try host.managerForWipe(network: network) - if storedWalletIds.contains(walletId) || manager.wallets[walletId] != nil { - deletions.append((network, walletId, manager)) + struct PendingDeletion { + let network: Network + let walletId: Data + let manager: PlatformWalletManager + let isTemporary: Bool + } + + var deletions: [PendingDeletion] = [] + + // Detached managers are owned by this function; shut them down + // deterministically on both the success and every failure path (a + // manager-preparation or deletion throw would otherwise leave + // teardown to the fire-and-forget deinit fallback, racing any + // follow-up rebuild over the same process-cached ModelContainer). + func shutDownTemporaryManagers() async { + for deletion in deletions where deletion.isTemporary { + await deletion.manager.shutdown() } + deletions.removeAll() } - for deletion in deletions { - try deleteWalletFromSDK( - deletion.walletId, - deleteWallet: { walletId in - try deletion.manager.deleteWallet(walletId: walletId) - }) - - let kind: WalletEnvironment.NetworkKind = - deletion.network == .mainnet ? .mainnet : .testnet - if WalletEnvironment.activeWalletId(for: kind) == deletion.walletId { - WalletEnvironment.setActiveWalletId(nil, for: kind) + do { + for network in networks { + guard let walletId = walletIds[network] else { continue } + let (manager, isTemporary) = try host.managerForWipe(network: network) + if storedWalletIds.contains(walletId) || manager.wallets[walletId] != nil { + deletions.append(PendingDeletion( + network: network, + walletId: walletId, + manager: manager, + isTemporary: isTemporary)) + } else if isTemporary { + // Built a detached manager only to find nothing to delete on + // this network β€” shut it down now rather than leaving it to + // the deinit fallback. + await manager.shutdown() + } + } + + for deletion in deletions { + try deleteWalletFromSDK( + deletion.walletId, + deleteWallet: { walletId in + try deletion.manager.deleteWallet(walletId: walletId) + }) + + let kind: WalletEnvironment.NetworkKind = + deletion.network == .mainnet ? .mainnet : .testnet + if WalletEnvironment.activeWalletId(for: kind) == deletion.walletId { + WalletEnvironment.setActiveWalletId(nil, for: kind) + } } + } catch { + await shutDownTemporaryManagers() + throw error } + await shutDownTemporaryManagers() let remaining = Set(try storage.listWalletIdsWithMnemonic()) guard walletIds.values.allSatisfy({ !remaining.contains($0) }) else { diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift index 86d133028..cfd1781a7 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UnconfirmedTransactionRemover.swift @@ -299,7 +299,7 @@ struct UnconfirmedTransactionRemover { } // App caches that mirror the deleted rows. - ShieldedTxLookup.shared.refresh() + await ShieldedTxLookup.shared.refresh(reason: "transaction-removal-completed") return rescanArmed } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift index 62f4b17e3..4b58c4544 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift @@ -76,16 +76,46 @@ public final class WalletEnvironment: NSObject { } } + /// Origin of a network switch, carried in the change notification's + /// `userInfo` so `SwiftDashSDKWalletRuntime`'s observer can tell a + /// managed switch (the runtime's `switchNetwork(to:)` owns the mirror + /// zeroing and the lifecycle refresh itself) from an external write + /// (recovery, sole-network selection), which still gets the full + /// observer behavior. Other listeners (DWRootModel, HomeViewModel, + /// CrowdNode, …) ignore `userInfo` and are unaffected. + public enum NetworkSwitchSource { + case external + case managedSwitch(transitionID: String) + } + + /// `userInfo` keys of `DWCurrentNetworkDidChangeNotification`. + static let networkChangeSourceKey = "DWNetworkChangeSource" + static let networkChangeTransitionIDKey = "DWNetworkChangeTransitionID" + private static let managedSwitchSourceValue = "managed-switch" + + /// Whether this `DWCurrentNetworkDidChange` notification was posted by a + /// managed switch (`switchNetwork(to:)`), i.e. the runtime observer must + /// NOT drive the lifecycle for it. + nonisolated static func isManagedSwitchNotification(_ note: Notification) -> Bool { + note.userInfo?[networkChangeSourceKey] as? String == managedSwitchSourceValue + } + /// Switches the persisted network selection. Returns `true` when the app /// is on `kind` afterwards (including the already-there no-op), `false` /// for `.devnet` (no SDK network exists for it). /// /// Posting `DWCurrentNetworkDidChangeNotification` is what actually moves /// the app: the SDK wallet runtime restarts SPV for the new network and - /// DWRootModel rebuilds the home stack. + /// DWRootModel rebuilds the home stack. A `.managedSwitch` source marks + /// the notification so the runtime observer skips its lifecycle reaction + /// (the caller owns exactly one refresh); every other listener behaves + /// identically for both sources. @MainActor @discardableResult - public static func switchToNetwork(_ kind: NetworkKind) -> Bool { + public static func switchToNetwork( + _ kind: NetworkKind, + source: NetworkSwitchSource = .external + ) -> Bool { guard kind != networkKind else { return true } guard kind != .devnet else { return false } @@ -101,7 +131,17 @@ public final class WalletEnvironment: NSObject { DWGlobalOptions.sharedInstance().dashpayRegistrationCompleted = false UserDefaults.standard.set(kind.rawValue, forKey: currentChainTypeKey) - NotificationCenter.default.post(name: NSNotification.Name.DWCurrentNetworkDidChange, object: nil) + var userInfo: [AnyHashable: Any]? + if case .managedSwitch(let transitionID) = source { + userInfo = [ + networkChangeSourceKey: managedSwitchSourceValue, + networkChangeTransitionIDKey: transitionID, + ] + } + NotificationCenter.default.post( + name: NSNotification.Name.DWCurrentNetworkDidChange, + object: nil, + userInfo: userInfo) return true } diff --git a/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift b/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift index 534c83d0a..5b1110147 100644 --- a/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift +++ b/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift @@ -39,12 +39,14 @@ extension HomeViewController: DWLocalCurrencyViewControllerDelegate { case .importPrivateKey: break case .switchToTestnet: - Task { - await WalletEnvironment.switchToNetwork(.testnet) + Task { @MainActor in + NetworkSwitchOverlayPresenter.shared.ensureActive() + try? await SwiftDashSDKWalletRuntime.shared.switchNetwork(to: .testnet) } case .switchToMainnet: - Task { - await WalletEnvironment.switchToNetwork(.mainnet) + Task { @MainActor in + NetworkSwitchOverlayPresenter.shared.ensureActive() + try? await SwiftDashSDKWalletRuntime.shared.switchNetwork(to: .mainnet) } case .reportAnIssue: break diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 0d41dbeef..b580279a9 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -437,7 +437,7 @@ class HomeViewModel: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] _ in Task { @MainActor in - ShieldedTxLookup.shared.refresh() + await ShieldedTxLookup.shared.refresh(reason: "transaction-projection-changed") self?.txReloadRequests.send() } } diff --git a/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift b/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift index 4c50d1bc7..807714258 100644 --- a/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift @@ -192,11 +192,26 @@ class SettingsMenuViewModel: ObservableObject { // MARK: - Network Switching func switchToMainnet() async -> Bool { - await WalletEnvironment.switchToNetwork(.mainnet) + await switchNetwork(to: .mainnet) } func switchToTestnet() async -> Bool { - await WalletEnvironment.switchToNetwork(.testnet) + await switchNetwork(to: .testnet) + } + + /// Route through the runtime's managed switch: strict teardown β†’ rebuild + /// with the blocking overlay window up for the whole transition. A thrown + /// failure leaves the overlay in its `.failed` phase (Retry lives there), + /// so this only reports the outcome to the settings screen. + private func switchNetwork(to kind: WalletEnvironment.NetworkKind) async -> Bool { + NetworkSwitchOverlayPresenter.shared.ensureActive() + do { + try await SwiftDashSDKWalletRuntime.shared.switchNetwork(to: kind) + return true + } catch { + DWLogger.log("SettingsMenuViewModel: network switch failed: \(error)") + return false + } } // MARK: - CSV Report Generation diff --git a/DashWallet/Sources/UI/Menu/Settings/SettingsScreen.swift b/DashWallet/Sources/UI/Menu/Settings/SettingsScreen.swift index f167128d9..7f807675d 100644 --- a/DashWallet/Sources/UI/Menu/Settings/SettingsScreen.swift +++ b/DashWallet/Sources/UI/Menu/Settings/SettingsScreen.swift @@ -256,3 +256,175 @@ extension AboutDashHostingViewController: MFMailComposeViewControllerDelegate { controller.dismiss(animated: true) } } + +// MARK: - Network switch overlay (app-wide) +// +// Colocated with the settings screen (the user-facing switch trigger) per the +// repo's append-to-existing-file convention, but app-scoped: the presenter +// owns a dedicated UIWindow, not a child of any screen. + +/// Full-screen blocking overlay for an in-flight network switch, hosted in +/// its OWN `UIWindow`. +/// +/// A network switch rebuilds the root UI (`AppDelegate` reassigns +/// `window.rootViewController`; `DWAppRootViewController` swaps its child on +/// `DWCurrentNetworkDidChange`), so any overlay mounted as a child view +/// controller would be torn down mid-switch. A separate window at +/// `.alert + 1` survives every root swap; it is created lazily when the +/// transition enters `.switching` and dropped only on `.idle`. The `.failed` +/// phase keeps the window up β€” the old runtime is already torn down at that +/// point, so the app may have no working manager and the only ways forward +/// are Retry (or force-quit). +@MainActor +final class NetworkSwitchOverlayPresenter { + static let shared = NetworkSwitchOverlayPresenter() + + private var overlayWindow: UIWindow? + private var phaseCancellable: AnyCancellable? + + private init() {} + + /// Idempotent activation: every switch entry point calls this before + /// starting the switch; the first call subscribes to the transition + /// state for the rest of the process lifetime. If a caller forgets, the + /// switch still works β€” only the overlay is missing. + func ensureActive() { + guard phaseCancellable == nil else { return } + phaseCancellable = NetworkTransitionState.shared.$phase + .sink { phase in + Task { @MainActor in + NetworkSwitchOverlayPresenter.shared.apply(phase) + } + } + } + + private func apply(_ phase: NetworkTransitionState.Phase) { + switch phase { + case .idle: + overlayWindow?.isHidden = true + overlayWindow = nil + case .switching, .failed: + presentIfNeeded() + } + } + + private func presentIfNeeded() { + guard overlayWindow == nil else { return } + let scene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + + let window = scene.map { UIWindow(windowScene: $0) } ?? UIWindow(frame: UIScreen.main.bounds) + window.windowLevel = .alert + 1 + window.rootViewController = UIHostingController(rootView: NetworkSwitchOverlayView()) + window.rootViewController?.view.backgroundColor = .clear + window.backgroundColor = .clear + window.isHidden = false + overlayWindow = window + } +} + +/// Content of the network-switch overlay window. Blocks all interaction while +/// `.switching` (spinner + destination) and while `.failed` (error + Retry). +/// Deliberately does NOT wait for peers or chain sync β€” the runtime flips to +/// `.idle` the moment the destination runtime is bound and its services +/// started. +@MainActor +final class NetworkSwitchOverlayViewModel: ObservableObject { + @Published private(set) var phase: NetworkTransitionState.Phase + + private var phaseCancellable: AnyCancellable? + + init() { + let transitionState = NetworkTransitionState.shared + phase = transitionState.phase + phaseCancellable = transitionState.$phase + .sink { [weak self] phase in + self?.phase = phase + } + } + + func retrySwitch(to target: WalletEnvironment.NetworkKind) { + Task { + try? await SwiftDashSDKWalletRuntime.shared.switchNetwork(to: target) + } + } +} + +struct NetworkSwitchOverlayView: View { + @StateObject private var viewModel = NetworkSwitchOverlayViewModel() + + var body: some View { + ZStack { + Color.black.opacity(0.65).ignoresSafeArea() + + switch viewModel.phase { + case .idle: + EmptyView() + case let .switching(_, to): + card { + // Explicit SwiftUI qualifier: the app has its own UIKit + // `ProgressView` (UI/Views/ProgressView.swift) shadowing it. + SwiftUI.ProgressView() + .controlSize(.large) + Text(String( + format: NSLocalizedString("Switching to %@…", comment: "Network switch overlay"), + Self.displayName(of: to))) + .font(.headline) + Text(NSLocalizedString( + "Preparing the wallet on the selected network…", + comment: "Network switch overlay")) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + case let .failed(target, message): + card { + Image(systemName: "exclamationmark.triangle.fill") + .font(.largeTitle) + .foregroundColor(.orange) + Text(String( + format: NSLocalizedString("Switching to %@ failed", comment: "Network switch overlay"), + Self.displayName(of: target))) + .font(.headline) + if let message, !message.isEmpty { + Text(message) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + Button { + viewModel.retrySwitch(to: target) + } label: { + Text(NSLocalizedString("Retry", comment: "")) + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + } + } + } + } + + @ViewBuilder + private func card(@ViewBuilder content: () -> some View) -> some View { + VStack(spacing: 16) { + content() + } + .padding(24) + .frame(maxWidth: 320) + .background(Color(UIColor.systemBackground)) + .cornerRadius(16) + .padding(32) + } + + private static func displayName(of kind: WalletEnvironment.NetworkKind) -> String { + switch kind { + case .mainnet: return "Mainnet" + case .testnet: return "Testnet" + case .devnet: return "Devnet" + } + } +} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift index 4f27594da..85f3bc451 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift @@ -742,7 +742,7 @@ struct ShieldedRecoverySheet: View { // history row's snapshot was captured. The resume FFI builds the // full proof before Platform reports "already consumed", so without // this guard a just-completed transfer would dead-end after ~30s. - ShieldedTxLookup.shared.refresh() + await ShieldedTxLookup.shared.refresh(reason: "shield-transfer-recovery-preflight") let displayTxid = op.txidWire.reversed().map { String(format: "%02x", $0) }.joined() let statusRaw = ShieldedTxLookup.shared.info(forTxidHex: displayTxid)?.statusRaw if statusRaw == 4 { @@ -760,7 +760,7 @@ struct ShieldedRecoverySheet: View { // the history row flips pending β†’ completed even before the next // scheduled shielded sync pass lands. if case .success = coordinator.phase { - ShieldedTxLookup.shared.refresh() + await ShieldedTxLookup.shared.refresh(reason: "shield-transfer-recovery-completed") } } } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index ab540beec..c09317e4c 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -563,7 +563,7 @@ final class ShieldedTransferCoordinator: ObservableObject { stopAssetLockPolling() Self.logger.info("πŸ›‘οΈ SHIELD-TX :: asset-lock route completed") phase = .success - ShieldedTxLookup.shared.refresh() + await ShieldedTxLookup.shared.refresh(reason: "shield-transfer-completed") NotificationCenter.default.post( name: .swiftDashSDKTransactionProjectionDidChange, object: nil) @@ -638,7 +638,7 @@ final class ShieldedTransferCoordinator: ObservableObject { // `performShield`). No intermediate signal exists for this opaque call. phase = .broadcasting phase = terminalPhase - ShieldedTxLookup.shared.refresh() + await ShieldedTxLookup.shared.refresh(reason: "shield-transfer-resume-completed") NotificationCenter.default.post( name: .swiftDashSDKTransactionProjectionDidChange, object: nil)