diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 5f9ef815963..320f381da08 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -159,9 +159,19 @@ public final class SDK: @unchecked Sendable { config.request_timeout_ms = 8000 // 8 seconds // Create SDK with trusted setup — Rust side auto-detects local/regtest - // and uses the quorum sidecar at localhost:22444 instead of remote endpoints + // and uses the quorum sidecar at localhost:22444 instead of remote endpoints. + // + // Regtest has no remote DAPI defaults on the Rust side, so it + // *must* be constructed with a local DAPI address regardless of + // the user-facing `useDockerSetup` toggle. Without this, building + // a regtest SDK from a context where the toggle has been + // auto-disabled (e.g. orphan-mnemonic recovery routing wallets to + // their original network from a non-regtest active state) fails + // with `DAPI addresses not available for network: Regtest` and + // the recovery loop stalls. let result: DashSDKResult - let forceLocal = UserDefaults.standard.bool(forKey: "useDockerSetup") + let forceLocal = network == .regtest + || UserDefaults.standard.bool(forKey: "useDockerSetup") if forceLocal { let localAddresses = Self.platformDAPIAddresses result = localAddresses.withCString { addressesCStr -> DashSDKResult in diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 9528791342d..c0dd4e84a83 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -13,6 +13,7 @@ struct ContentView: View { let onRetry: () -> Void @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var walletManagerStore: WalletManagerStore @EnvironmentObject var appUIState: AppUIState @EnvironmentObject var platformState: AppState @Environment(\.modelContext) private var modelContext @@ -427,8 +428,24 @@ struct ContentView: View { entry.network ?? metadata?.resolvedNetworks.first ?? platformState.currentNetwork let restoredBirthHeight = metadata?.birthHeight + // Route recovery to the manager for the wallet's original + // network — not the active manager. The Rust side stamps + // `wallet.network = self.sdk.network` at registration time, + // so creating a regtest wallet through the testnet manager + // would persist the row as testnet. `backgroundManager` + // lazy-builds the per-network manager (and its SDK) without + // changing the user's currently visible network. + let targetManager: PlatformWalletManager do { - let managed = try walletManager.createWallet( + targetManager = try walletManagerStore.backgroundManager(for: restoredNetwork) + } catch { + recoveryError = "Failed to prepare \(restoredNetwork.displayName) manager " + + "for \"\(entry.displayName)\": \(error.localizedDescription)" + return false + } + + do { + let managed = try targetManager.createWallet( mnemonic: mnemonic, network: restoredNetwork, name: restoredName @@ -437,7 +454,32 @@ struct ContentView: View { let descriptor = FetchDescriptor( predicate: #Predicate { $0.walletId == walletIdMatch } ) - if let row = try? modelContext.fetch(descriptor).first { + + // Cross-context propagation: the row we just created + // landed in the target manager's background context, + // not in the main context that drives every `@Query` + // consumer in the UI. SwiftData merges sibling-context + // saves through `NSPersistentStoreRemoteChange` + // notifications, but that pipeline is asynchronous — + // an immediate `fetch` on the main context may still + // miss the row, in which case the post-recovery + // `isImported` / metadata flush is skipped *and* no + // main-context save fires, so `@Query` observers + // never re-evaluate. The wallet then "doesn't appear" + // until the next app launch when the main context is + // built fresh against disk. Retry a few times with a + // short yield between attempts so the merge has a + // chance to settle before we move on. + var row: PersistentWallet? = nil + for attempt in 0..<10 { + row = try? modelContext.fetch(descriptor).first + if row != nil { break } + if attempt < 9 { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } + + if let row { row.isImported = true if row.walletDescription == nil { row.walletDescription = restoredDescription diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 05c619dd754..616d0966e56 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -107,6 +107,7 @@ struct SwiftExampleAppApp: App { // PlatformWalletManager` consumers see the right // network's manager without any view changes. .environmentObject(walletManager) + .environmentObject(walletManagerStore) .environmentObject(shieldedService) .environmentObject(platformBalanceSyncService) .environmentObject(transitionState) @@ -264,6 +265,20 @@ struct SwiftExampleAppApp: App { ) } + // Pre-warm per-network managers for any orphan + // mnemonic whose original network differs from + // the active one, so the orphan-recovery flow + // doesn't have to lazy-build them mid-session. + // SwiftData's @Query observers in the main + // context don't always reflect rows persisted + // through a `backgroundContext` that was + // created mid-session — pre-warming here means + // those backgrounds are wired up alongside the + // main context at launch and the recovered + // wallet appears in its correct tab on the same + // run instead of only after a relaunch. + preWarmOrphanNetworkManagers() + rebindWalletScopedServices() } @@ -291,4 +306,43 @@ struct SwiftExampleAppApp: App { } return ["127.0.0.1"] } + + /// Materialize a `PlatformWalletManager` for every network that + /// has an orphan keychain mnemonic, except the already-active + /// one. Used during bootstrap so the orphan-recovery flow has + /// pre-warmed managers when the user authorizes recovery — + /// avoids a SwiftData edge case where a mid-session-created + /// background `ModelContext` doesn't propagate writes back to + /// the launch-time main context's `@Query` observers. + @MainActor + private func preWarmOrphanNetworkManagers() { + let storage = WalletStorage() + let keychainIds = (try? storage.listWalletIdsWithMnemonic()) ?? [] + guard !keychainIds.isEmpty else { return } + + var orphanNetworks: Set = [] + for walletId in keychainIds { + guard let metadata = (try? storage.metadata(for: walletId)) ?? nil, + let resolved = metadata.resolvedNetworks.first + else { continue } + orphanNetworks.insert(resolved) + } + + let active = platformState.currentNetwork + for network in orphanNetworks where network != active { + do { + _ = try walletManagerStore.backgroundManager(for: network) + SDKLogger.log( + "🔥 Pre-warmed wallet manager for \(network.displayName) " + + "(orphan recovery target)", + minimumLevel: .medium + ) + } catch { + SDKLogger.error( + "Failed to pre-warm \(network.displayName) manager: " + + error.localizedDescription + ) + } + } + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index 28d8d32626a..b615bed75ea 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -78,9 +78,15 @@ final class WalletManagerStore: ObservableObject { /// Failures during a fresh manager's `configure` / /// `loadFromPersistor` propagate to the caller — the cache /// stays untouched in that case so a later retry can succeed. - func activate(network: Network, sdk: SDK) throws { + /// + /// `makeActive == false` materializes the manager into the cache + /// without swapping `activeManager`. Used by background flows + /// (e.g. orphan recovery routing wallets to their original + /// network) that need a manager for a non-active network without + /// triggering a user-visible network switch. + func activate(network: Network, sdk: SDK, makeActive: Bool = true) throws { if let existing = managers[network] { - if existing !== activeManager { + if makeActive && existing !== activeManager { activeManager = existing } return @@ -102,7 +108,9 @@ final class WalletManagerStore: ObservableObject { ) } managers[network] = manager - activeManager = manager + if makeActive { + activeManager = manager + } } /// Manager for `network` if one has been activated this session; @@ -112,4 +120,30 @@ final class WalletManagerStore: ObservableObject { func manager(for network: Network) -> PlatformWalletManager? { managers[network] } + + /// Get-or-build the manager for `network` without changing the + /// currently active one. Builds a fresh `SDK` for that network + /// on demand the first time it's requested. + /// + /// Used by orphan-mnemonic recovery, which needs to route each + /// rebuilt wallet to the manager bound to the network the + /// wallet was originally created on — otherwise wallets bound + /// to non-active networks at creation time get re-derived and + /// persisted under the active network's manager (the Rust + /// manager stamps `wallet.network = self.sdk.network` at + /// registration time, so a regtest mnemonic recovered through + /// the testnet manager lands as a testnet row). + func backgroundManager(for network: Network) throws -> PlatformWalletManager { + if let existing = managers[network] { + return existing + } + let sdk = try SDK(network: network) + try activate(network: network, sdk: sdk, makeActive: false) + guard let manager = managers[network] else { + throw PlatformWalletError.invalidParameter( + "Failed to materialize manager for \(network.displayName)" + ) + } + return manager + } }