Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -437,7 +454,32 @@ struct ContentView: View {
let descriptor = FetchDescriptor<PersistentWallet>(
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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<Network> = []
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
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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
}
}
Loading