From 104547ef514b3e73e0e73b6e9bd118058b87849d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 19 May 2026 05:27:31 -0500 Subject: [PATCH 1/2] fix(swift-sdk): wait for rebuilt SDK before wallet activation --- .../SwiftExampleApp/SwiftExampleAppApp.swift | 73 ++++++++++++------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 8e4b088b99..8d1b66a30e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -52,6 +52,7 @@ struct SwiftExampleAppApp: App { @State private var isInitialized = false @State private var bootstrapError: Error? @State private var bootstrapTask: Task? + @State private var pendingWalletManagerNetwork: Network? /// Resolver that backs the platform-wallet-ffi `MnemonicResolverHandle` /// for shielded wallet binding. Reuses the default `WalletStorage` @@ -131,46 +132,66 @@ struct SwiftExampleAppApp: App { })) { _, _ in rebindWalletScopedServices() } - // Network switch: activate the per-network manager - // first (the store lazy-creates one configured with - // a fresh SDK if this is the first time we see this - // network), then rebind the wallet-scoped services - // against it. Order matters — `rebindWalletScopedServices` - // reads `walletManager.firstWallet`, which has to - // resolve to the new network's manager before it - // runs. + // Network switch: remember the requested network when + // the picker changes, but do not activate the wallet + // manager yet. `currentNetwork` publishes before + // AppState's async SDK rebuild finishes, so activating + // here would cache a manager configured with the + // previous network's SDK. .onChange(of: platformState.currentNetwork) { _, newNetwork in - activateManager(for: newNetwork) - rebindWalletScopedServices() + pendingWalletManagerNetwork = newNetwork + } + // Once AppState reports the SDK rebuild is complete, + // activate only the pending network-switch manager. + // Same-network SDK rebuilds (for example Docker/local + // endpoint toggles) are intentionally ignored here + // because WalletManagerStore caches managers by + // Network and cannot safely reconfigure an existing + // manager for a different SDK/backend. + .onChange(of: platformState.isSwitchingNetwork) { _, isSwitching in + guard !isSwitching, + let pendingNetwork = pendingWalletManagerNetwork, + pendingNetwork == platformState.currentNetwork + else { return } + if activateManager(for: pendingNetwork) { + pendingWalletManagerNetwork = nil + rebindWalletScopedServices() + } } } } /// Lazy-create + cache a `PlatformWalletManager` for `network`, - /// configured against a freshly-built per-network SDK. No-ops on - /// the already-active network. Called from bootstrap and from - /// `currentNetwork.onChange`. - /// - /// The SDK is built locally rather than read from `platformState.sdk` - /// because the SwiftUI `.onChange` handler that calls this fires - /// synchronously the moment `currentNetwork` changes, while - /// `AppState.switchNetwork` rebuilds `platformState.sdk` - /// asynchronously — at this instant the shared SDK still points at - /// the previous network. `PlatformWalletManager` is network-locked - /// to its configure-time SDK for its lifetime and the cache is - /// never invalidated, so capturing the stale reference would - /// permanently bind the new network's manager to the previous - /// network's backend. Mirrors `WalletManagerStore.backgroundManager(for:)`. + /// configured against the SDK currently published by `AppState`. + /// No-ops on the already-active network. Called from bootstrap + /// and after a pending network change observes `isSwitchingNetwork` + /// drop, which guarantees the SDK has already been rebuilt for + /// the selected network. @MainActor - private func activateManager(for network: Network) { + private func activateManager(for network: Network) -> Bool { + guard let sdk = platformState.sdk else { + SDKLogger.error( + "Cannot activate wallet manager for \(network.displayName): " + + "no SDK available (still bootstrapping?)" + ) + return false + } + guard sdk.network == network else { + SDKLogger.error( + "Cannot activate wallet manager for \(network.displayName): " + + "SDK is still bound to \(sdk.network.displayName)" + ) + return false + } do { - let sdk = try SDK(network: network) try walletManagerStore.activate(network: network, sdk: sdk) + return true } catch { SDKLogger.error( "Failed to activate wallet manager for " + "\(network.displayName): \(error.localizedDescription)" ) + return false } } From ef947610b643581167fc4b0b2f92b11187afffb3 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 19 May 2026 06:38:17 -0500 Subject: [PATCH 2/2] fix(swift-example-app): rebuild wallet manager for sdk switches --- .../SwiftExampleApp/AppState.swift | 4 ++ .../SwiftExampleApp/ContentView.swift | 14 +++++ .../SwiftExampleApp/SwiftExampleAppApp.swift | 53 +++++++++++-------- .../SwiftExampleApp/WalletManagerStore.swift | 25 ++++++--- 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index cc298a9f0e..be67d5e8da 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -25,6 +25,8 @@ class AppState: ObservableObject { /// this counter and the spawned task only clears the flag when its /// captured id still matches. private var networkSwitchRequestID: UInt64 = 0 + @Published private(set) var sdkRebuildRequestID: UInt64 = 0 + @Published private(set) var readySDKRequestID: UInt64 = 0 @Published var currentNetwork: Network { didSet { @@ -53,6 +55,7 @@ class AppState: ObservableObject { private func beginNetworkSwitch() { networkSwitchRequestID &+= 1 let requestID = networkSwitchRequestID + sdkRebuildRequestID = requestID isSwitchingNetwork = true Task { await switchNetwork(to: currentNetwork, requestID: requestID) @@ -162,6 +165,7 @@ class AppState: ObservableObject { // Load known contracts into the SDK's trusted provider await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) guard isCurrent(requestID) else { return } + readySDKRequestID = requestID isLoading = false } catch { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index fa561d2ea2..54f1ac9d26 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -119,12 +119,26 @@ struct ContentView: View { } .tag(RootTab.settings) } + .disabled(platformState.isSwitchingNetwork) .overlay(alignment: .top) { let state = walletManager.spvProgress.overallState if state == .syncing || state == .waitingForConnections { GlobalSyncIndicator(showDetails: selectedTab == .sync && appUIState.showWalletsSyncDetails) } } + .overlay { + if platformState.isSwitchingNetwork { + ZStack { + Color.black.opacity(0.12) + .ignoresSafeArea() + ProgressView("Switching to \(platformState.currentNetwork.displayName)...") + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + } .onAppear { checkForOrphanMnemonic() } .onChange(of: persistentWallets.count) { _, _ in checkForOrphanMnemonic() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 8d1b66a30e..9c749e455b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -19,6 +19,11 @@ final class AppUIState: ObservableObject { @Published var showWalletsSyncDetails: Bool = true } +private struct PendingWalletManagerActivation: Equatable { + let requestID: UInt64 + let network: Network +} + @main struct SwiftExampleAppApp: App { // SwiftData container — shared across services and views. @@ -52,7 +57,7 @@ struct SwiftExampleAppApp: App { @State private var isInitialized = false @State private var bootstrapError: Error? @State private var bootstrapTask: Task? - @State private var pendingWalletManagerNetwork: Network? + @State private var pendingWalletManagerActivation: PendingWalletManagerActivation? /// Resolver that backs the platform-wallet-ffi `MnemonicResolverHandle` /// for shielded wallet binding. Reuses the default `WalletStorage` @@ -132,32 +137,36 @@ struct SwiftExampleAppApp: App { })) { _, _ in rebindWalletScopedServices() } - // Network switch: remember the requested network when - // the picker changes, but do not activate the wallet - // manager yet. `currentNetwork` publishes before - // AppState's async SDK rebuild finishes, so activating - // here would cache a manager configured with the - // previous network's SDK. - .onChange(of: platformState.currentNetwork) { _, newNetwork in - pendingWalletManagerNetwork = newNetwork + // Remember every SDK rebuild request, including + // same-network ones (for example regtest endpoint + // flips), but do not activate a wallet manager until + // AppState publishes the matching rebuilt SDK. + .onChange(of: platformState.sdkRebuildRequestID) { _, requestID in + guard requestID != 0 else { return } + pendingWalletManagerActivation = PendingWalletManagerActivation( + requestID: requestID, + network: platformState.currentNetwork + ) } - // Once AppState reports the SDK rebuild is complete, - // activate only the pending network-switch manager. - // Same-network SDK rebuilds (for example Docker/local - // endpoint toggles) are intentionally ignored here - // because WalletManagerStore caches managers by - // Network and cannot safely reconfigure an existing - // manager for a different SDK/backend. - .onChange(of: platformState.isSwitchingNetwork) { _, isSwitching in - guard !isSwitching, - let pendingNetwork = pendingWalletManagerNetwork, - pendingNetwork == platformState.currentNetwork + // Activate once the rebuilt SDK that corresponds to + // the pending request has been published. + .onChange(of: platformState.readySDKRequestID) { _, readyRequestID in + guard let pending = pendingWalletManagerActivation, + readyRequestID == pending.requestID, + pending.network == platformState.currentNetwork else { return } - if activateManager(for: pendingNetwork) { - pendingWalletManagerNetwork = nil + if activateManager(for: pending.network) { + pendingWalletManagerActivation = nil rebindWalletScopedServices() } } + .onChange(of: platformState.isSwitchingNetwork) { _, isSwitching in + guard !isSwitching, + let pending = pendingWalletManagerActivation, + pending.requestID != platformState.readySDKRequestID + else { return } + pendingWalletManagerActivation = nil + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index b615bed75e..7f01ae0058 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -39,6 +39,11 @@ import SwiftDashSDK /// dev-focused example app where flipping is common. @MainActor final class WalletManagerStore: ObservableObject { + private struct ManagerEntry { + let sdkIdentity: ObjectIdentifier + let manager: PlatformWalletManager + } + /// Currently-active manager. Reassigned when the user switches /// networks; SwiftUI re-injects this into the env object so /// every `@EnvironmentObject var walletManager: @@ -54,7 +59,7 @@ final class WalletManagerStore: ObservableObject { /// Per-network managers. Lazily populated on first activation /// of each network; lookup is O(1). - private var managers: [Network: PlatformWalletManager] = [:] + private var managers: [Network: ManagerEntry] = [:] /// SwiftData container shared across every manager. Each /// manager's persistence handler narrows its `loadWalletList` @@ -85,9 +90,10 @@ final class WalletManagerStore: ObservableObject { /// 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 makeActive && existing !== activeManager { - activeManager = existing + let sdkIdentity = ObjectIdentifier(sdk) + if let existing = managers[network], existing.sdkIdentity == sdkIdentity { + if makeActive && existing.manager !== activeManager { + activeManager = existing.manager } return } @@ -107,7 +113,10 @@ final class WalletManagerStore: ObservableObject { + "\(network.displayName): \(error.localizedDescription)" ) } - managers[network] = manager + managers[network] = ManagerEntry( + sdkIdentity: sdkIdentity, + manager: manager + ) if makeActive { activeManager = manager } @@ -118,7 +127,7 @@ final class WalletManagerStore: ObservableObject { /// Memory Explorer) that want to inspect a specific network's /// state without forcing it active. func manager(for network: Network) -> PlatformWalletManager? { - managers[network] + managers[network]?.manager } /// Get-or-build the manager for `network` without changing the @@ -135,11 +144,11 @@ final class WalletManagerStore: ObservableObject { /// the testnet manager lands as a testnet row). func backgroundManager(for network: Network) throws -> PlatformWalletManager { if let existing = managers[network] { - return existing + return existing.manager } let sdk = try SDK(network: network) try activate(network: network, sdk: sdk, makeActive: false) - guard let manager = managers[network] else { + guard let manager = managers[network]?.manager else { throw PlatformWalletError.invalidParameter( "Failed to materialize manager for \(network.displayName)" )