From cea465e6dee380a428b9e7910f3af5cd6557f476 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 00:06:45 +0200 Subject: [PATCH 01/17] fix(sdk): wallet-flow network fixes for SwiftExampleApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent wallet-flow bugs surfaced while testing on non-active networks: - SDK.swift: the devnet-only `platformQuorumURL` override was applied to every network, leaking a non-https http quorum URL into mainnet/testnet SDK builds. Rust's trusted context provider rejects that, so mainnet/testnet wallet creation silently failed. Gate the override behind `useOverrideAddresses` so mainnet/testnet use the SDK's automatic (canonical) quorum endpoints. - CreateWalletView.swift: on create failure the error alert is bound to CreateWalletView, but the pushed SeedBackupView sat on top of it with its submit button stuck disabled — the user got no feedback. Pop the backup screen in the catch block so the alert is visible. - SearchWalletsForIdentitiesView.swift / IdentitiesContentView.swift: the Re-scan for Identities picker used an unfiltered @Query and listed wallets from every network. Filter to the active network (query-all + computed-filter, matching WalletsContentView) and re-inject platformState into the sheet. Co-Authored-By: Claude Opus 4.8 --- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 14 +- .../Core/Views/CreateWalletView.swift | 126 ++++++++++-------- .../Core/Views/IdentitiesContentView.swift | 18 ++- .../SearchWalletsForIdentitiesView.swift | 21 ++- 4 files changed, 111 insertions(+), 68 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 29b0eefd736..6697607769e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -287,15 +287,19 @@ public final class SDK: @unchecked Sendable { // that toggle is off, the Rust side picks the canonical seed // addresses for the network. // - // `quorum_url` is forwarded whenever the UserDefaults override is - // set, regardless of network — supports custom mainnet/testnet - // shards and any future deployment that needs a non-default - // endpoint. + // `quorum_url` is gated identically: applied for devnet/regtest and + // under `useDockerSetup`, but NOT for plain mainnet/testnet. The + // `platformQuorumURL` UserDefault is only ever populated by the + // devnet-only Quorum URL field in Options, so forwarding it to a + // mainnet/testnet build leaked a devnet (often http) endpoint into a + // network whose Rust provider requires https — refusing to build the + // SDK. With the gate off, mainnet/testnet use the canonical quorum + // endpoints automatically. let result: DashSDKResult let useOverrideAddresses = network == .regtest || network == .devnet || UserDefaults.standard.bool(forKey: "useDockerSetup") - let overrideQuorumURL: String? = Self.platformQuorumURL + let overrideQuorumURL: String? = useOverrideAddresses ? Self.platformQuorumURL : nil // Resolve the DAPI address list. Two paths: // diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index 874691c55ff..e57881c7740 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -6,6 +6,7 @@ struct CreateWalletView: View { @Environment(\.dismiss) var dismiss @Environment(\.modelContext) private var modelContext @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var walletManagerStore: WalletManagerStore @EnvironmentObject var platformState: AppState @State private var walletLabel: String = "" @@ -313,7 +314,6 @@ struct CreateWalletView: View { print("PIN length: \(walletPin.count)") print("Import option enabled: \(showImportOption)") - // Determine primary network to create the wallet in (SDK enforces unique wallet per mnemonic) let selectedNetworks: [Network] = [ createForMainnet ? Network.mainnet : nil, createForTestnet ? Network.testnet : nil, @@ -321,86 +321,95 @@ struct CreateWalletView: View { (createForRegtest && shouldShowRegtest) ? Network.regtest : nil, ].compactMap { $0 } - guard let platformNetwork = selectedNetworks.first else { + guard !selectedNetworks.isEmpty else { struct MissingNetwork: LocalizedError { var errorDescription: String? { "No network selected" } } throw MissingNetwork() } - // Create exactly one wallet via PlatformWalletManager. - // The Rust-side wallet creation emits - // `persistWalletMetadata` + `setWalletName`, which - // the persister callback translates into a - // `PersistentWallet` SwiftData row — no separate - // HDWallet mirror to maintain. We only have to - // patch `isImported` after-the-fact because that - // flag is UI-cosmetic and the persister doesn't - // know about it. + // Create the wallet in EVERY ticked network. Each + // network has its own `PlatformWalletManager` (the + // Rust manager is network-locked at construction and + // stamps its own network onto the wallet), so routing + // through the active manager alone would ignore the + // passed `network`. `backgroundManager(for:)` returns + // the warm cached manager for the active network and + // builds one on demand for the others. `walletId` is + // network-independent, so the Keychain mnemonic + + // metadata are written once and the `isImported` flag + // is stamped on every per-network row. try await MainActor.run { - let managed = try walletManager.createWallet( - mnemonic: mnemonicPhrase, - network: platformNetwork, - name: walletLabel - ) + var createdWalletId: Data? + for net in selectedNetworks { + do { + let mgr = try walletManagerStore.backgroundManager(for: net) + let managed = try mgr.createWallet( + mnemonic: mnemonicPhrase, + network: net, + name: walletLabel + ) + createdWalletId = managed.walletId + } catch { + // An "already exists" throw for one network + // (wallet was previously created there) is + // non-fatal — keep creating the others. + SDKLogger.error( + "Wallet creation skipped for \(net.displayName): \(error.localizedDescription)" + ) + } + } + + guard let walletId = createdWalletId else { + struct AllNetworksFailed: LocalizedError { + var errorDescription: String? { + "Wallet could not be created on any selected network." + } + } + throw AllNetworksFailed() + } + // Persist the mnemonic in the iOS Keychain keyed - // by walletId so multiple wallets coexist and the - // recovery flow can enumerate all of them on - // launch. Best-effort — failure here doesn't - // block wallet creation. + // by walletId (network-independent) so the + // recovery flow can enumerate wallets on launch. + // Best-effort — failure here doesn't block. let storage = WalletStorage() do { - try storage.storeMnemonic( - mnemonicPhrase, - for: managed.walletId - ) + try storage.storeMnemonic(mnemonicPhrase, for: walletId) } catch { SDKLogger.error( "Failed to persist mnemonic to keychain: \(error.localizedDescription)" ) } - // Stamp the `isImported` flag on the - // just-created PersistentWallet row. The - // persister callback runs synchronously from - // `walletManager.createWallet` via the - // background context; SwiftData's - // `autosaveEnabled = true` on that context - // propagates the row into the main context - // before this fetch runs. If the row somehow - // isn't there yet, the flag stays `false` - // (the default on `PersistentWallet`) — a - // cosmetic miss, not a correctness issue. - let walletIdMatch = managed.walletId + + // Stamp `isImported` on every per-network row for + // this walletId. The persister callbacks run + // synchronously from `createWallet` via the + // background contexts; autosave propagates the + // rows into the main context before this fetch. let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletIdMatch } + predicate: PersistentWallet.predicate(walletId: walletId) ) - let row = try? modelContext.fetch(descriptor).first - if let row = row { + let rows = (try? modelContext.fetch(descriptor)) ?? [] + for row in rows { row.isImported = showImportOption + } + if !rows.isEmpty { try? modelContext.save() } - // Mirror the user-typed name + the networks the - // user explicitly ticked + the SPV-tip-derived - // birth height into the keychain alongside the - // mnemonic. Read back by the orphan-mnemonic - // recovery flow so a wipe + reinstall restores - // the original label / networks / birth height - // instead of resurrecting the wallet on testnet - // with a synthetic genesis. - // - // `selectedNetworks` carries every network the - // user ticked even though `walletManager` only - // currently consumes the first; persisting the - // full list now means the multi-network TODO on - // the Rust side won't need a metadata migration. + + // Mirror the name + ticked networks + birth height + // into the keychain alongside the mnemonic so an + // orphan-recovery after a wipe restores the + // original label / networks / birth height. do { let metadata = WalletKeychainMetadata( name: walletLabel, walletDescription: nil, networks: selectedNetworks.map { $0.networkName }, - birthHeight: row?.birthHeight + birthHeight: rows.first?.birthHeight ) - try storage.setMetadata(metadata, for: managed.walletId) + try storage.setMetadata(metadata, for: walletId) } catch { SDKLogger.error( "Failed to persist wallet metadata to keychain: \(error.localizedDescription)" @@ -409,7 +418,7 @@ struct CreateWalletView: View { dismiss() } - print("=== WALLET CREATION SUCCESS - Created 1 wallet for \(platformNetwork.displayName) ===") + print("=== WALLET CREATION SUCCESS - networks: \(selectedNetworks.map { $0.displayName }) ===") } catch { print("=== WALLET CREATION ERROR ===") print("Error: \(error)") @@ -417,6 +426,11 @@ struct CreateWalletView: View { await MainActor.run { self.error = error isCreating = false + // Pop the pushed `SeedBackupView` so the error alert + // (bound to this view) is actually visible — otherwise + // the backup screen sits on top with its submit button + // stuck disabled and no feedback. + showBackupScreen = false } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 11829df4b6d..f6de6a47fa3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -13,8 +13,12 @@ struct IdentitiesContentView: View { @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext - @Query(sort: \PersistentIdentity.identityIndex) - private var identities: [PersistentIdentity] + /// Active network the parent threads in. Scopes the identities + /// query below so rows from another network don't leak into the + /// list after a network switch — same pattern as + /// `ContractsTabView`. + private let network: Network + @Query private var identities: [PersistentIdentity] /// All tracked asset locks across wallets. Filtered into /// "resumable" rows (status >= `InstantSendLocked` AND no /// `PersistentIdentity` at the same `(walletId, identityIndex)` @@ -41,6 +45,15 @@ struct IdentitiesContentView: View { /// when the sheet dismisses (SwiftUI nils the binding for us). @State private var resumingAssetLock: PersistentAssetLock? + init(network: Network) { + self.network = network + _identities = Query( + filter: PersistentIdentity.predicate(network: network), + sort: \PersistentIdentity.identityIndex, + order: .forward + ) + } + var body: some View { List { pendingRegistrationsSection @@ -181,6 +194,7 @@ struct IdentitiesContentView: View { } .sheet(isPresented: $showingSearchWallets) { SearchWalletsForIdentitiesView() + .environmentObject(platformState) } .refreshable { await platformBalanceSyncService.performSync() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift index bdf78340f81..b30bdecf72c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift @@ -20,13 +20,24 @@ import SwiftData struct SearchWalletsForIdentitiesView: View { @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var platformState: AppState @Environment(\.dismiss) private var dismiss - /// Wallet list for the picker. Sorted by `createdAt` to match - /// the ordering `CreateIdentityView` uses, so the "first wallet" - /// that's preselected is deterministic and consistent with the - /// rest of the app. - @Query(sort: \PersistentWallet.createdAt) private var hdWallets: [PersistentWallet] + /// Every persisted wallet, across all networks. Sorted by + /// `createdAt` to match the ordering `CreateIdentityView` uses. + /// Filtered down to the active network by `hdWallets` — the + /// store keeps one row per (walletId, network), so without the + /// filter the picker would list other networks' rows too. + @Query(sort: \PersistentWallet.createdAt) private var allWallets: [PersistentWallet] + + /// Wallets on the currently-selected network — the only ones the + /// picker should offer, since the scan runs against the active + /// network's wallet manager. Mirrors the `networkRaw`-filter + /// pattern used by `WalletsContentView` / `IdentitiesContentView`. + private var hdWallets: [PersistentWallet] { + let raw = platformState.currentNetwork.rawValue + return allWallets.filter { $0.networkRaw == raw } + } /// User-selected wallet id. Initialized to the first wallet on /// appear; always preselected even when the list only has one From ee59225190b20728f52f6d913f36b069ec4170ad Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 00:08:15 +0200 Subject: [PATCH 02/17] fix(sdk): per-network wallet persistence and add-to-network support Supporting per-network wallet infrastructure that the create-wallet and rescan fixes build on: - PersistentWallet: make rows unique per (walletId, network) and add `predicate(walletId:)` / `predicate(walletId:network:)` helpers so the same mnemonic can live on multiple networks without colliding. - PlatformWalletPersistenceHandler / PlatformWalletManager: scope persister wallet lookups to the manager's network so a per-network manager only restores its own rows. - WalletDetailView: implement the per-network "+" add-to-network action so an existing wallet can be added to another network. - IdentitiesContentView: take an explicit `network` and scope its identities @Query to it; IdentitiesView passes the active network. - ContentView: thread the active network through. Co-Authored-By: Claude Opus 4.8 --- .../Persistence/Models/PersistentWallet.swift | 19 +++++- .../PlatformWalletManager.swift | 18 ++++-- .../PlatformWalletPersistenceHandler.swift | 42 ++++++++++-- .../SwiftExampleApp/ContentView.swift | 6 +- .../Core/Views/WalletDetailView.swift | 64 +++++++++++++------ .../Views/IdentitiesView.swift | 54 ++++++++++++++++ 6 files changed, 170 insertions(+), 33 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 4b237821a2e..2e19d2f4198 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -18,9 +18,14 @@ public final class PersistentWallet { /// "is there a wallet on this chain yet" lookups) don't degrade /// to a table scan. #Index([\.networkRaw]) + #Unique([\.walletId, \.networkRaw]) - /// 32-byte wallet ID (SHA256 of root public key). - @Attribute(.unique) public var walletId: Data + /// 32-byte wallet ID (SHA256 of root public key). Not unique on + /// its own — the same seed yields the same `walletId` on every + /// network, so a wallet that exists on multiple chains has one + /// row per network. Uniqueness is the composite + /// `(walletId, networkRaw)` declared above. + public var walletId: Data /// Network this wallet belongs to. `nil` means "not yet known" — /// the row was created by a changeset before `persistWalletMetadata` /// filled the network in. Views treat `nil` as unknown. @@ -140,4 +145,14 @@ extension PersistentWallet { public static func predicate(walletId: Data) -> Predicate { #Predicate { $0.walletId == walletId } } + + public static func predicate( + walletId: Data, + network: Network + ) -> Predicate { + let networkRaw = network.rawValue + return #Predicate { + $0.walletId == walletId && $0.networkRaw == networkRaw + } + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 0540dd48d8a..f8c398f3947 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -619,10 +619,20 @@ public class PlatformWalletManager: ObservableObject { try persistenceHandler.deleteWalletData(walletId: walletId) - let storage = WalletStorage() - // Delete metadata first so the mnemonic remains available for retry. - try storage.deleteMetadata(for: walletId) - try storage.deleteMnemonic(for: walletId) + // The mnemonic + metadata blobs in the Keychain are keyed by + // `walletId` alone and shared by every network the wallet + // lives on (same seed → same id on all chains). Only purge + // them once this was the wallet's LAST remaining + // `PersistentWallet` row — otherwise deleting the wallet from + // one network would orphan it on the others by destroying the + // recovery phrase they still rely on. + let remaining = try persistenceHandler.walletRowCountAcrossNetworks(walletId: walletId) + if remaining == 0 { + let storage = WalletStorage() + // Delete metadata first so the mnemonic remains available for retry. + try storage.deleteMetadata(for: walletId) + try storage.deleteMnemonic(for: walletId) + } } // MARK: - Per-wallet lookup diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index a64c724dd20..e50faa0bded 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -401,12 +401,12 @@ public class PlatformWalletPersistenceHandler { /// stale post-deletion callbacks can't resurrect a wiped wallet. private func ensureWalletRecord(walletId: Data) -> PersistentWallet { let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } + predicate: walletRecordPredicate(walletId: walletId) ) if let existing = try? backgroundContext.fetch(descriptor).first { return existing } - let record = PersistentWallet(walletId: walletId, network: nil) + let record = PersistentWallet(walletId: walletId, network: self.network) backgroundContext.insert(record) return record } @@ -415,11 +415,27 @@ public class PlatformWalletPersistenceHandler { /// when no row exists. private func findWalletRecord(walletId: Data) -> PersistentWallet? { let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } + predicate: walletRecordPredicate(walletId: walletId) ) return try? backgroundContext.fetch(descriptor).first } + /// Predicate matching the `PersistentWallet` row owned by THIS + /// handler. A handler is constructed per-network, so when + /// `self.network` is set we scope to `(walletId, networkRaw)` — + /// otherwise the mainnet handler would find and overwrite the + /// devnet row (and vice versa) now that the same `walletId` can + /// have one row per network. When `self.network` is `nil` (the + /// advanced `configure(sdkPointer:network:nil)` path) we fall + /// back to walletId-only matching to preserve that behaviour. + private func walletRecordPredicate(walletId: Data) -> Predicate { + if let network = self.network { + let networkRaw = network.rawValue + return #Predicate { $0.walletId == walletId && $0.networkRaw == networkRaw } + } + return #Predicate { $0.walletId == walletId } + } + /// Look up a `PersistentWallet` to hang on /// `PersistentIdentity.wallet`. Non-creating — returns `nil` if /// no row exists (an identity may arrive before its owning @@ -429,7 +445,7 @@ public class PlatformWalletPersistenceHandler { private func fetchWalletForLink(walletId: Data?) -> PersistentWallet? { guard let walletId else { return nil } let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } + predicate: walletRecordPredicate(walletId: walletId) ) return try? backgroundContext.fetch(descriptor).first } @@ -2594,11 +2610,25 @@ public class PlatformWalletPersistenceHandler { } } - public func identityIdsForWallet(walletId: Data) throws -> [Data] { + /// Count `PersistentWallet` rows for `walletId` across ALL + /// networks (deliberately ignores `self.network`). The mnemonic / + /// metadata in the Keychain are shared by every network's row, so + /// `deleteWallet` consults this after wiping its own network's row + /// to decide whether the shared Keychain material can be purged. + public func walletRowCountAcrossNetworks(walletId: Data) throws -> Int { try onQueue { let descriptor = FetchDescriptor( predicate: PersistentWallet.predicate(walletId: walletId) ) + return try backgroundContext.fetchCount(descriptor) + } + } + + public func identityIdsForWallet(walletId: Data) throws -> [Data] { + try onQueue { + let descriptor = FetchDescriptor( + predicate: walletRecordPredicate(walletId: walletId) + ) guard let walletRow = try backgroundContext.fetch(descriptor).first else { return [] } @@ -2611,7 +2641,7 @@ public class PlatformWalletPersistenceHandler { try onQueue { do { let walletDescriptor = FetchDescriptor( - predicate: PersistentWallet.predicate(walletId: walletId) + predicate: walletRecordPredicate(walletId: walletId) ) let walletRow = try backgroundContext.fetch(walletDescriptor).first let walletNetwork = walletRow?.network diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 1142abffe49..5febd575118 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -90,7 +90,7 @@ struct ContentView: View { .tag(RootTab.wallets) // Tab 3: Identities - IdentitiesTabView() + IdentitiesTabView(network: platformState.currentNetwork) .tabItem { Label("Identities", systemImage: "person.crop.circle") } @@ -638,9 +638,11 @@ struct WalletsTabView: View { } struct IdentitiesTabView: View { + let network: Network + var body: some View { NavigationStack { - IdentitiesContentView() + IdentitiesContentView(network: network) } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 5339b4e1a9e..57f56038989 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -229,6 +229,7 @@ struct WalletInfoView: View { @Environment(\.dismiss) var dismiss @Environment(\.modelContext) var modelContext @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var walletManagerStore: WalletManagerStore let wallet: PersistentWallet var onWalletDeleted: () -> Void = {} @@ -628,16 +629,20 @@ struct WalletInfoView: View { } private func loadNetworkStates() { - switch wallet.network ?? .testnet { - case .mainnet: - mainnetEnabled = true - case .testnet: - testnetEnabled = true - case .regtest: - regtestEnabled = true - case .devnet: - devnetEnabled = true - } + // A wallet now has one `PersistentWallet` row per network it + // lives on (same `walletId`, distinct `networkRaw`). Reflect + // the actual set of rows rather than the single `wallet.network` + // this view was opened with. + let walletId = wallet.walletId + let descriptor = FetchDescriptor( + predicate: PersistentWallet.predicate(walletId: walletId) + ) + let rows = (try? modelContext.fetch(descriptor)) ?? [wallet] + let networks = Set(rows.compactMap { $0.network }) + mainnetEnabled = networks.contains(.mainnet) + testnetEnabled = networks.contains(.testnet) + regtestEnabled = networks.contains(.regtest) + devnetEnabled = networks.contains(.devnet) } private func loadAccountCounts() { @@ -715,18 +720,39 @@ struct WalletInfoView: View { isUpdatingNetworks = true defer { isUpdatingNetworks = false } - // TODO(platform-wallet): Proper multi-network wallet support once the - // Rust side exposes add-network. For now we only refresh UI state. + // Add the existing wallet to another network by re-creating it + // from the stored mnemonic in that network's manager. The + // `walletId` is network-independent, so this just stamps a new + // per-network `PersistentWallet` row via the persister + // callback — same sanctioned path the create + orphan-recovery + // flows use. Reusing `createWallet(mnemonic:)` keeps all + // derivation on the Rust side (no Swift orchestration). + let mnemonic: String do { - try modelContext.save() - loadNetworkStates() - loadAccountCounts() + mnemonic = try WalletStorage().retrieveMnemonic(for: wallet.walletId) } catch { - await MainActor.run { - errorMessage = "Failed to enable network: \(error.localizedDescription)" - showError = true - } + errorMessage = "This wallet's recovery phrase isn't stored on this device, so it can't be added to another network." + showError = true + return } + + do { + let mgr = try walletManagerStore.backgroundManager(for: network) + _ = try mgr.createWallet( + mnemonic: mnemonic, + network: network, + name: wallet.name ?? wallet.label + ) + } catch { + // An "already exists" throw means the wallet is already on + // this network — treat as a no-op and just refresh below. + SDKLogger.error( + "enableNetwork(\(network.displayName)) create returned: \(error.localizedDescription)" + ) + } + + loadNetworkStates() + loadAccountCounts() } private func deleteWallet() async { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift index c08b024f1af..e716ecc5614 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift @@ -23,6 +23,24 @@ struct IdentityRow: View { return String(format: "%.2f DASH", dashAmount) } + private var hasAnyPrivateKey: Bool { + if identity.votingPrivateKeyIdentifier != nil + || identity.ownerPrivateKeyIdentifier != nil + || identity.payoutPrivateKeyIdentifier != nil { + return true + } + let km = KeychainManager.shared + for publicKey in identity.identityPublicKeys { + if km.hasPrivateKey(identityId: identity.identityId, keyIndex: Int32(publicKey.id)) { + return true + } + if km.hasIdentityPrivateKey(publicKeyHex: publicKey.data.toHexString()) { + return true + } + } + return false + } + var body: some View { NavigationLink(destination: IdentityDetailView(identityId: identity.identityId)) { VStack(alignment: .leading, spacing: 4) { @@ -64,6 +82,23 @@ struct IdentityRow: View { .lineLimit(1) .truncationMode(.middle) + let identityType = identity.identityTypeEnum + if identityType != .user || !hasAnyPrivateKey || identity.wallet == nil { + HStack(spacing: 6) { + if identityType == .masternode { + IdentityBadge(text: "Masternode", icon: "server.rack", color: .purple) + } else if identityType == .evonode { + IdentityBadge(text: "Evonode", icon: "server.rack", color: .indigo) + } + if !hasAnyPrivateKey { + IdentityBadge(text: "No Keys", icon: "key.slash", color: .red) + } + if identity.wallet == nil { + IdentityBadge(text: "No Wallet", icon: "wallet.pass", color: .orange) + } + } + } + if identity.isLocal { HStack { Image(systemName: "location") @@ -160,3 +195,22 @@ struct IdentityRow: View { } } } + +private struct IdentityBadge: View { + let text: String + let icon: String + let color: Color + + var body: some View { + HStack(spacing: 3) { + Image(systemName: icon) + Text(text) + } + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15)) + .foregroundColor(color) + .cornerRadius(4) + } +} From 207b4f855a5546ef3275637778a00cb39066fe57 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 00:33:04 +0200 Subject: [PATCH 03/17] =?UTF-8?q?fix(sdk):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20network-scope=20deletes,=20surface=20errors,=20real=20key=20?= =?UTF-8?q?check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review on #3772: - PlatformWalletPersistenceHandler.walletNetwork(walletId:): use walletRecordPredicate so the network resolved for sync-state / identity / token writes and key derivation is THIS manager's network, not an arbitrary sibling row when the same seed lives on two networks. - deleteWalletData: the txo / pending-input / asset-lock child tables are keyed by the network-independent walletId and carry no network column, so they're shared across networks. Only wipe them when the row being deleted is the wallet's LAST remaining per-network row; otherwise deleting one network erased a sibling network's cached state. The wallet row itself stays network-scoped. - WalletDetailView.enableNetwork: only swallow an "already exists" throw (a genuine no-op); surface any other failure via the error alert instead of silently doing nothing. - IdentitiesView.hasAnyPrivateKey: a stored *PrivateKeyIdentifier only means an identifier string was persisted — the Keychain item can be missing. Confirm actual presence via hasSpecialKey(identifier:) before counting it, so the "No Keys" badge isn't wrongly hidden. Co-Authored-By: Claude Opus 4.8 --- .../PlatformWalletPersistenceHandler.swift | 73 +++++++++++++------ .../Core/Views/WalletDetailView.swift | 15 +++- .../Views/IdentitiesView.swift | 19 +++-- 3 files changed, 76 insertions(+), 31 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e50faa0bded..e3cb1d98610 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2719,32 +2719,51 @@ public class PlatformWalletPersistenceHandler { try backgroundContext.save() } - let txoDescriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } + // The txo / pending-input / asset-lock tables are keyed + // by the network-independent walletId (same mnemonic → + // same id on every network) and carry no network column, + // so their rows are shared by every network this wallet + // lives on. Only wipe them when this is the wallet's LAST + // remaining per-network row — otherwise deleting the + // wallet from one network would erase a sibling network's + // cached UTXOs / pending inputs / asset-lock state. + // (The walletRow itself, deleted below, IS network-scoped + // via `walletRecordPredicate`.) Counted before walletRow + // is removed, so `<= 1` means "this is the last one". + let siblingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } ) - for row in try backgroundContext.fetch(txoDescriptor) { - backgroundContext.delete(row) - } + let isLastNetworkRow = + ((try? backgroundContext.fetchCount(siblingDescriptor)) ?? 0) <= 1 - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } - ) - for row in try backgroundContext.fetch(pendingDescriptor) { - backgroundContext.delete(row) - } + if isLastNetworkRow { + let txoDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try backgroundContext.fetch(txoDescriptor) { + backgroundContext.delete(row) + } - // `loadCachedAssetLocksOnQueue` rehydrates these rows on - // the wallet-load path back into the Rust-side - // `unused_asset_locks` map so an in-flight registration - // can resume across an app kill. Without this cleanup, - // delete-then-reimport of the same wallet would - // resurrect stale Pending / Resumable asset-lock state - // that the user thought they had wiped. - let assetLockDescriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } - ) - for row in try backgroundContext.fetch(assetLockDescriptor) { - backgroundContext.delete(row) + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try backgroundContext.fetch(pendingDescriptor) { + backgroundContext.delete(row) + } + + // `loadCachedAssetLocksOnQueue` rehydrates these rows on + // the wallet-load path back into the Rust-side + // `unused_asset_locks` map so an in-flight registration + // can resume across an app kill. Without this cleanup, + // delete-then-reimport of the same wallet would + // resurrect stale Pending / Resumable asset-lock state + // that the user thought they had wiped. + let assetLockDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try backgroundContext.fetch(assetLockDescriptor) { + backgroundContext.delete(row) + } } if let walletRow = walletRow { @@ -4059,8 +4078,14 @@ public class PlatformWalletPersistenceHandler { /// `PersistentWallet` row. Returns `nil` if the wallet row /// doesn't exist or its network hasn't been resolved yet. private func walletNetwork(walletId: Data) -> Network? { + // Scope to this handler's network when one is set so a mnemonic + // that lives on multiple networks resolves to the row for THIS + // manager's network — not an arbitrary sibling row that would + // mis-stamp persisted sync state / identity / token writes and + // feed the wrong coin type into key derivation. Falls back to + // walletId-only when no network is set (legacy / no-container). let descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } + predicate: walletRecordPredicate(walletId: walletId) ) guard let wallet = try? backgroundContext.fetch(descriptor).first else { return nil diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 57f56038989..e9f01ec08ff 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -744,10 +744,21 @@ struct WalletInfoView: View { name: wallet.name ?? wallet.label ) } catch { + let description = error.localizedDescription // An "already exists" throw means the wallet is already on - // this network — treat as a no-op and just refresh below. + // this network — a genuine no-op, so fall through to refresh. + // Any other failure (SDK build error, Rust-side error, etc.) + // must surface to the user instead of silently doing nothing. + if description.range(of: "already exists", options: .caseInsensitive) == nil { + SDKLogger.error( + "enableNetwork(\(network.displayName)) failed: \(description)" + ) + errorMessage = "Failed to add \(network.displayName): \(description)" + showError = true + return + } SDKLogger.error( - "enableNetwork(\(network.displayName)) create returned: \(error.localizedDescription)" + "enableNetwork(\(network.displayName)) create returned: \(description)" ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift index e716ecc5614..52e00b16f94 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift @@ -24,12 +24,21 @@ struct IdentityRow: View { } private var hasAnyPrivateKey: Bool { - if identity.votingPrivateKeyIdentifier != nil - || identity.ownerPrivateKeyIdentifier != nil - || identity.payoutPrivateKeyIdentifier != nil { - return true - } let km = KeychainManager.shared + // A stored identifier only proves an identifier string was + // persisted on the row — the backing Keychain item can still be + // missing (wiped, never written, restored on another device). + // Confirm the key actually exists before counting it, otherwise + // the "No Keys" badge gets hidden for identities that have none. + for identifier in [ + identity.votingPrivateKeyIdentifier, + identity.ownerPrivateKeyIdentifier, + identity.payoutPrivateKeyIdentifier, + ] { + if let identifier, km.hasSpecialKey(identifier: identifier) { + return true + } + } for publicKey in identity.identityPublicKeys { if km.hasPrivateKey(identityId: identity.identityId, keyIndex: Int32(publicKey.id)) { return true From 65807e7f907e40812ecb250e1c25b7d8aa84f71b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 00:37:18 +0200 Subject: [PATCH 04/17] fix(sdk): drop identifier shortcut in hasAnyPrivateKey (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit's alternative suggestion on #3772: a stored *PrivateKeyIdentifier only means an identifier string was persisted — the backing Keychain item can be missing. Remove the non-nil-identifier shortcut and rely solely on the existing concrete key-presence checks (hasPrivateKey / hasIdentityPrivateKey) so the 'No Keys' badge reflects actual Keychain state. Co-Authored-By: Claude Opus 4.8 --- .../Views/IdentitiesView.swift | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift index 52e00b16f94..b1463daab61 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift @@ -24,21 +24,12 @@ struct IdentityRow: View { } private var hasAnyPrivateKey: Bool { + // A stored *PrivateKeyIdentifier only proves an identifier string + // was persisted on the row — the backing Keychain item can still + // be missing (wiped, never written, restored on another device). + // Rely solely on concrete key-presence checks against the + // Keychain so the "No Keys" badge reflects what's actually there. let km = KeychainManager.shared - // A stored identifier only proves an identifier string was - // persisted on the row — the backing Keychain item can still be - // missing (wiped, never written, restored on another device). - // Confirm the key actually exists before counting it, otherwise - // the "No Keys" badge gets hidden for identities that have none. - for identifier in [ - identity.votingPrivateKeyIdentifier, - identity.ownerPrivateKeyIdentifier, - identity.payoutPrivateKeyIdentifier, - ] { - if let identifier, km.hasSpecialKey(identifier: identifier) { - return true - } - } for publicKey in identity.identityPublicKeys { if km.hasPrivateKey(identityId: identity.identityId, keyIndex: Int32(publicKey.id)) { return true From 076c0a0a14588df607c83d7e0122b84b83a5104f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 00:56:12 +0200 Subject: [PATCH 05/17] fix(sdk): network-scope deriveAndStoreIdentityKey wallet lookup (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thepastaclaw review on #3772: deriveAndStoreIdentityKey resolved the wallet's network with the bare PersistentWallet.predicate(walletId:), which can pick a sibling network's row now that the same walletId can have one row per network — feeding the wrong coin_type into KeyDerivation.getIdentityAuthenticationPath and writing key bytes unusable for the on-chain identity. Use walletRecordPredicate so the lookup is scoped to this handler's network, matching every other PersistentWallet fetch on the handler (the lone remaining bare lookup, walletRowCountAcrossNetworks, is deliberately cross-network). Co-Authored-By: Claude Opus 4.8 --- .../PlatformWallet/PlatformWalletPersistenceHandler.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e3cb1d98610..78f59462fe0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1811,9 +1811,13 @@ public class PlatformWalletPersistenceHandler { // 1. Resolve the wallet's network from SwiftData. We need it // to feed `KeyDerivation.getIdentityAuthenticationPath` // so the path chooses the right `coin_type` (mainnet vs - // testnet). + // testnet). Scope to THIS handler's network via + // `walletRecordPredicate` — the same `walletId` can now have + // a row per network, and a bare walletId-only fetch could + // resolve to a sibling network's row and derive the key on + // the wrong chain (unusable on-chain). let walletDescriptor = FetchDescriptor( - predicate: PersistentWallet.predicate(walletId: walletId) + predicate: walletRecordPredicate(walletId: walletId) ) guard let persistentWallet = try? backgroundContext.fetch(walletDescriptor).first From cee4b2cbb2b8e563832752b461d2199cf9a2b1fe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 01:01:52 +0200 Subject: [PATCH 06/17] fix(sdk): surface partial-failure in multi-network wallet create (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thepastaclaw review on #3772: the multi-network create loop caught every createWallet error and treated it like the benign "already exists" case, so a real failure on one network (e.g. the devnet quorum URL leaking into mainnet/testnet manager construction — the very bug this PR fixes) was masked: as long as one network succeeded the sheet dismissed as success and keychain metadata claimed every ticked network was enabled. Mirror the WalletDetailView.enableNetwork idiom: - only "already exists" counts a network as present (benign no-op); - genuine failures are collected and surfaced via the error alert; - keychain metadata records only the networks the wallet actually has a row on (presentNetworks), not every ticked network; - a partial create (wallet exists but not on all ticked networks) throws a descriptive error instead of silently dismissing as success. Co-Authored-By: Claude Opus 4.8 --- .../Core/Views/CreateWalletView.swift | 72 +++++++++++++++---- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index e57881c7740..086cdde3f9f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -341,6 +341,17 @@ struct CreateWalletView: View { // is stamped on every per-network row. try await MainActor.run { var createdWalletId: Data? + // Networks the wallet now actually has a row on — + // either freshly created or pre-existing ("already + // exists"). Only these get stamped into the keychain + // metadata; recording a network that failed would + // make orphan-recovery believe a row exists where it + // doesn't. + var presentNetworks: [Network] = [] + // Real (non-"already exists") failures, surfaced to + // the user so a partial create isn't reported as + // success. + var failures: [(network: Network, message: String)] = [] for net in selectedNetworks { do { let mgr = try walletManagerStore.backgroundManager(for: net) @@ -350,23 +361,38 @@ struct CreateWalletView: View { name: walletLabel ) createdWalletId = managed.walletId + presentNetworks.append(net) } catch { - // An "already exists" throw for one network - // (wallet was previously created there) is - // non-fatal — keep creating the others. - SDKLogger.error( - "Wallet creation skipped for \(net.displayName): \(error.localizedDescription)" - ) + let message = error.localizedDescription + // An "already exists" throw means the wallet + // is already on this network — benign, the + // row is present, so count it. Any other + // error is a genuine failure to record. + if message.range(of: "already exists", options: .caseInsensitive) != nil { + presentNetworks.append(net) + SDKLogger.error( + "Wallet already present on \(net.displayName); continuing" + ) + } else { + failures.append((net, message)) + SDKLogger.error( + "Wallet creation failed for \(net.displayName): \(message)" + ) + } } } guard let walletId = createdWalletId else { struct AllNetworksFailed: LocalizedError { + let detail: String var errorDescription: String? { - "Wallet could not be created on any selected network." + "Wallet could not be created on any selected network.\n\(detail)" } } - throw AllNetworksFailed() + let detail = failures + .map { "\($0.network.displayName): \($0.message)" } + .joined(separator: "\n") + throw AllNetworksFailed(detail: detail) } // Persist the mnemonic in the iOS Keychain keyed @@ -398,15 +424,17 @@ struct CreateWalletView: View { try? modelContext.save() } - // Mirror the name + ticked networks + birth height - // into the keychain alongside the mnemonic so an - // orphan-recovery after a wipe restores the - // original label / networks / birth height. + // Mirror the name + birth height + the networks the + // wallet ACTUALLY has a row on (not every ticked + // network) into the keychain alongside the mnemonic + // so an orphan-recovery after a wipe restores the + // original label / networks / birth height without + // claiming networks that failed to create. do { let metadata = WalletKeychainMetadata( name: walletLabel, walletDescription: nil, - networks: selectedNetworks.map { $0.networkName }, + networks: presentNetworks.map { $0.networkName }, birthHeight: rows.first?.birthHeight ) try storage.setMetadata(metadata, for: walletId) @@ -415,6 +443,24 @@ struct CreateWalletView: View { "Failed to persist wallet metadata to keychain: \(error.localizedDescription)" ) } + + // If some (but not all) networks failed, the wallet + // exists — but the user must know it wasn't added + // everywhere they ticked. Surface the partial + // failure instead of silently dismissing as success. + if !failures.isEmpty { + struct PartialCreate: LocalizedError { + let detail: String + var errorDescription: String? { + "Wallet created, but not on every selected network:\n\(detail)" + } + } + let detail = failures + .map { "\($0.network.displayName): \($0.message)" } + .joined(separator: "\n") + throw PartialCreate(detail: detail) + } + dismiss() } From 815751a93f6b6a037e5f3b484209e73d95d8ae08 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 09:58:46 +0200 Subject: [PATCH 07/17] fix(sdk): correct two review-fix regressions (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the prior review-round fixes, both caught by CodeRabbit / thepastaclaw on #3772: - deleteWalletData: `isLastNetworkRow` was computed from the sibling count alone, so a handler asked to delete a wallet it doesn't own (walletRow == nil) but whose sibling network still has a row (count == 1) would read as "last row" and wipe the shared txo / pending-input / asset-lock tables out from under the other network. Gate the last-row check on `walletRow != nil`. - CreateWalletView multi-network create: when every selected network reported "already exists", `createdWalletId` stayed nil (only set on the fresh-create branch) while `presentNetworks` was populated, so the guard threw "Wallet could not be created on any selected network" with an empty detail — contradicting the benign "already exists" handling. Re-importing an already-present mnemonic is now a no-op dismiss; the failure path only fires when there's a genuine (non-"already exists") failure and nothing succeeded. Co-Authored-By: Claude Opus 4.8 --- .../PlatformWalletPersistenceHandler.swift | 22 ++++++++++++++----- .../Core/Views/CreateWalletView.swift | 13 +++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 78f59462fe0..832308ad6c8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2734,11 +2734,23 @@ public class PlatformWalletPersistenceHandler { // (The walletRow itself, deleted below, IS network-scoped // via `walletRecordPredicate`.) Counted before walletRow // is removed, so `<= 1` means "this is the last one". - let siblingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } - ) - let isLastNetworkRow = - ((try? backgroundContext.fetchCount(siblingDescriptor)) ?? 0) <= 1 + // Guard on `walletRow != nil`: if this handler doesn't + // own a row for `walletId` (asked to delete a wallet it + // doesn't have), a sibling network's row can still make + // the cross-network count 1 — which would wrongly read + // as "last row" and wipe the shared child tables out + // from under that other network. No owned row → never + // treat it as the last one. + let isLastNetworkRow: Bool + if walletRow != nil { + let siblingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + isLastNetworkRow = + ((try? backgroundContext.fetchCount(siblingDescriptor)) ?? 0) <= 1 + } else { + isLastNetworkRow = false + } if isLastNetworkRow { let txoDescriptor = FetchDescriptor( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index 086cdde3f9f..edb9516fcd0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -383,6 +383,19 @@ struct CreateWalletView: View { } guard let walletId = createdWalletId else { + // No wallet was freshly created. Two cases: + if failures.isEmpty { + // Every selected network reported "already + // exists" — the wallet is present on all of + // them and its mnemonic/metadata were stored + // at the original creation. Re-importing is a + // benign no-op; dismiss without a misleading + // "could not be created" error. + dismiss() + return + } + // At least one network had a real failure and + // none succeeded — surface the failure detail. struct AllNetworksFailed: LocalizedError { let detail: String var errorDescription: String? { From 14d353eb67549a6b3655d36e4d26ab699e8e1ccd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 15:39:09 +0200 Subject: [PATCH 08/17] fix(sdk): network-scoped walletId closes cross-network cache leak (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the network-scoped wallet id from rust-dashcore #793 (merged to dev as 3d0d5dcd) to structurally fix the carried-forward blocking finding #2: child caches keyed by the network-INDEPENDENT walletId (PersistentTxo / PersistentPendingInput / PersistentAssetLock / PersistentPlatformAddress / PersistentShieldedSyncState) cross-fed state between a single mnemonic's per-network wallets. With #793 the wallet id is network-scoped BY DEFAULT at construction (`Wallet::from_mnemonic` / `from_seed` fold a domain-tagged, wire-stable network byte into the digest), so the same mnemonic now yields a DISTINCT id per network. Every walletId-keyed structure becomes network-correct by construction — no networkRaw columns needed on the child models, and the leak is eliminated at the source. - Cargo.toml / Cargo.lock: pin all 8 rust-dashcore crates to 3d0d5dcd (the squash-merge of #793 on dev). - rs-platform-wallet: no behavior change needed — construction already stamps the scoped id. `register_wallet` carries an explanatory comment. Added two unit tests proving same-mnemonic → 4 pairwise- distinct ids across networks, and stable per-(seed,network). - CreateWalletView: the multi-network create now persists the mnemonic + metadata + `isImported` per FRESHLY-CREATED network's scoped walletId (each is independently recoverable), instead of once under a single shared id; metadata records just that network. All-already- exist is a benign dismiss. - WalletDetailView.enableNetwork: stores the mnemonic under the newly-enabled network's scoped walletId so that wallet's own keychain lookups resolve. - PlatformWalletManager.deleteWallet: comment updated for the scoped-id model (the walletRowCountAcrossNetworks guard stays correct). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 44 +++--- Cargo.toml | 16 +- .../src/manager/wallet_lifecycle.rs | 76 +++++++++ .../PlatformWalletManager.swift | 16 +- .../Core/Views/CreateWalletView.swift | 145 ++++++++++-------- .../Core/Views/WalletDetailView.swift | 27 +++- 6 files changed, 216 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d778bf17520..3d7b6ab6988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1132,7 +1132,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "bincode", "bincode_derive", @@ -1561,7 +1561,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "dash-network", ] @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "async-trait", "chrono", @@ -1667,7 +1667,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "anyhow", "base64-compat", @@ -1693,12 +1693,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" [[package]] name = "dashcore-rpc" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "dashcore-rpc-json", "hex", @@ -1711,7 +1711,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "bincode", "dashcore", @@ -1726,7 +1726,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "bincode", "dashcore-private", @@ -2264,7 +2264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2639,7 +2639,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" [[package]] name = "glob" @@ -3552,7 +3552,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3783,7 +3783,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "aes", "async-trait", @@ -3811,7 +3811,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "cbindgen 0.29.2", "dash-network", @@ -3827,7 +3827,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3d0d5dcd4ad64e2199a726651bca7f8ffac123e6#3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" dependencies = [ "async-trait", "bincode", @@ -4304,7 +4304,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5316,7 +5316,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6038,7 +6038,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6051,7 +6051,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6110,7 +6110,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6950,7 +6950,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8352,7 +8352,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 786b2d40b36..3ab272b8827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,14 +49,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" } # Optimize heavy crypto crates even in dev/test builds so that # Halo 2 proof generation and verification run at near-release speed. diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index ca8d5051b39..cf8f7c257a5 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -128,6 +128,20 @@ impl PlatformWalletManager

{ wallet: Wallet, birth_height_override: Option, ) -> Result, PlatformWalletError> { + // NOTE: the wallet id is NETWORK-SCOPED by construction. + // `Wallet::from_mnemonic` / `from_seed_bytes` now stamp a + // network-scoped id (key-wallet folds a domain-tagged, + // wire-stable network byte into the digest), so the same + // mnemonic yields a DISTINCT id per network. That makes every + // downstream `walletId`-keyed structure network-correct by + // construction — no per-network disambiguation needed in the + // persistence layer, and network-blind child tables (UTXOs, + // asset locks, platform addresses) can no longer cross-feed + // between a mnemonic's per-network wallets. The watch-only + // restore path (`Wallet::new_external_signable`) reuses the + // persisted id verbatim, so it stays self-consistent across + // launches. + // Birth height resolution: explicit override wins; otherwise // fall back to SPV's confirmed header tip (default for fresh // wallets — they only need to see funding from now on); 0 if @@ -439,3 +453,65 @@ impl PlatformWalletManager

{ Ok(removed) } } + +#[cfg(test)] +mod scoped_wallet_id_tests { + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + use key_wallet::Network; + + // Canonical all-`abandon` BIP-39 test vector. Deterministic, so the + // ids below are reproducible across runs. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + fn wallet_id_for(network: Network) -> [u8; 32] { + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let wallet = + Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) + .expect("wallet construction"); + // This is the id the manager keys on (insert_wallet returns it, + // the create FFI hands it to Swift) — exercises the same + // construction path `create_wallet_from_mnemonic` uses. + wallet.wallet_id + } + + /// The same mnemonic must yield a DISTINCT wallet id on each network. + /// This is the property the whole per-network persistence model now + /// relies on (rust-dashcore #793: network-scoped id by default). + #[test] + fn same_mnemonic_yields_distinct_ids_per_network() { + let mainnet = wallet_id_for(Network::Mainnet); + let testnet = wallet_id_for(Network::Testnet); + let devnet = wallet_id_for(Network::Devnet); + let regtest = wallet_id_for(Network::Regtest); + + let all = [mainnet, testnet, devnet, regtest]; + for i in 0..all.len() { + for j in (i + 1)..all.len() { + assert_ne!( + all[i], all[j], + "wallet ids for two different networks must differ \ + (index {i} vs {j}) — scoped-id regression" + ); + } + } + } + + /// Re-deriving the same (mnemonic, network) must be stable, otherwise + /// the watch-only restore path (which reuses the persisted id) would + /// drift across launches. + #[test] + fn same_mnemonic_same_network_is_stable() { + assert_eq!( + wallet_id_for(Network::Testnet), + wallet_id_for(Network::Testnet) + ); + assert_eq!( + wallet_id_for(Network::Mainnet), + wallet_id_for(Network::Mainnet) + ); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index f8c398f3947..507ef109981 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -620,12 +620,16 @@ public class PlatformWalletManager: ObservableObject { try persistenceHandler.deleteWalletData(walletId: walletId) // The mnemonic + metadata blobs in the Keychain are keyed by - // `walletId` alone and shared by every network the wallet - // lives on (same seed → same id on all chains). Only purge - // them once this was the wallet's LAST remaining - // `PersistentWallet` row — otherwise deleting the wallet from - // one network would orphan it on the others by destroying the - // recovery phrase they still rely on. + // `walletId`. With network-scoped wallet ids the same mnemonic + // maps to a DIFFERENT id per network, so a given id is owned by + // exactly one network's wallet and carries its own mnemonic + // copy — purging it can't orphan a sibling network (those live + // under their own distinct ids). The `walletRowCountAcrossNetworks + // == 0` check is therefore expected to be true right after + // `deleteWalletData` removes this id's lone row; it is retained + // as a defensive guard (and to stay correct should the id model + // ever change) so we never delete the phrase while any row for + // this exact id still exists. let remaining = try persistenceHandler.walletRowCountAcrossNetworks(walletId: walletId) if remaining == 0 { let storage = WalletStorage() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index edb9516fcd0..465c7aa0c44 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -335,19 +335,19 @@ struct CreateWalletView: View { // through the active manager alone would ignore the // passed `network`. `backgroundManager(for:)` returns // the warm cached manager for the active network and - // builds one on demand for the others. `walletId` is - // network-independent, so the Keychain mnemonic + - // metadata are written once and the `isImported` flag - // is stamped on every per-network row. + // builds one on demand for the others. The `walletId` + // is now network-scoped — the same mnemonic produces a + // DIFFERENT id per network — so the Keychain mnemonic + + // metadata must be written under EACH freshly-created + // network's id, and `isImported` stamped on each id's + // row. try await MainActor.run { - var createdWalletId: Data? - // Networks the wallet now actually has a row on — - // either freshly created or pre-existing ("already - // exists"). Only these get stamped into the keychain - // metadata; recording a network that failed would - // make orphan-recovery believe a row exists where it - // doesn't. - var presentNetworks: [Network] = [] + // Per-network results for networks the wallet was + // FRESHLY created on this pass — each carries the + // scoped `walletId` Rust returned, which is the + // Keychain key its mnemonic / metadata / `isImported` + // writes hang off of. + var createdWallets: [(network: Network, walletId: Data)] = [] // Real (non-"already exists") failures, surfaced to // the user so a partial create isn't reported as // success. @@ -360,16 +360,20 @@ struct CreateWalletView: View { network: net, name: walletLabel ) - createdWalletId = managed.walletId - presentNetworks.append(net) + createdWallets.append((net, managed.walletId)) } catch { let message = error.localizedDescription // An "already exists" throw means the wallet - // is already on this network — benign, the - // row is present, so count it. Any other - // error is a genuine failure to record. + // is already on this network — benign. We do + // NOT resolve the existing scoped walletId to + // re-store the mnemonic: a wallet that already + // exists on this network had its mnemonic + + // metadata stored under that scoped id at its + // original creation, so there is nothing to + // write. It is also not counted as a freshly- + // created wallet. Any other error is a genuine + // failure. if message.range(of: "already exists", options: .caseInsensitive) != nil { - presentNetworks.append(net) SDKLogger.error( "Wallet already present on \(net.displayName); continuing" ) @@ -382,15 +386,16 @@ struct CreateWalletView: View { } } - guard let walletId = createdWalletId else { + guard !createdWallets.isEmpty else { // No wallet was freshly created. Two cases: if failures.isEmpty { // Every selected network reported "already // exists" — the wallet is present on all of - // them and its mnemonic/metadata were stored - // at the original creation. Re-importing is a - // benign no-op; dismiss without a misleading - // "could not be created" error. + // them and its per-network mnemonic/metadata + // were stored at the original creation. + // Re-importing is a benign no-op; dismiss + // without a misleading "could not be created" + // error. dismiss() return } @@ -408,53 +413,61 @@ struct CreateWalletView: View { throw AllNetworksFailed(detail: detail) } - // Persist the mnemonic in the iOS Keychain keyed - // by walletId (network-independent) so the - // recovery flow can enumerate wallets on launch. - // Best-effort — failure here doesn't block. + // For EACH freshly-created network, persist that + // network's scoped walletId independently: store the + // mnemonic in the iOS Keychain keyed by that id (so + // the recovery flow can enumerate it on launch), stamp + // `isImported` on its row, and mirror the wallet + // metadata under that id. Each scoped wallet is + // independently recoverable, so its metadata records + // just THAT network. All writes are best-effort — + // failures are logged, not fatal. let storage = WalletStorage() - do { - try storage.storeMnemonic(mnemonicPhrase, for: walletId) - } catch { - SDKLogger.error( - "Failed to persist mnemonic to keychain: \(error.localizedDescription)" - ) - } + for created in createdWallets { + let walletId = created.walletId - // Stamp `isImported` on every per-network row for - // this walletId. The persister callbacks run - // synchronously from `createWallet` via the - // background contexts; autosave propagates the - // rows into the main context before this fetch. - let descriptor = FetchDescriptor( - predicate: PersistentWallet.predicate(walletId: walletId) - ) - let rows = (try? modelContext.fetch(descriptor)) ?? [] - for row in rows { - row.isImported = showImportOption - } - if !rows.isEmpty { - try? modelContext.save() - } + do { + try storage.storeMnemonic(mnemonicPhrase, for: walletId) + } catch { + SDKLogger.error( + "Failed to persist mnemonic to keychain for \(created.network.displayName): \(error.localizedDescription)" + ) + } - // Mirror the name + birth height + the networks the - // wallet ACTUALLY has a row on (not every ticked - // network) into the keychain alongside the mnemonic - // so an orphan-recovery after a wipe restores the - // original label / networks / birth height without - // claiming networks that failed to create. - do { - let metadata = WalletKeychainMetadata( - name: walletLabel, - walletDescription: nil, - networks: presentNetworks.map { $0.networkName }, - birthHeight: rows.first?.birthHeight - ) - try storage.setMetadata(metadata, for: walletId) - } catch { - SDKLogger.error( - "Failed to persist wallet metadata to keychain: \(error.localizedDescription)" + // Stamp `isImported` on the per-network row for + // this scoped walletId. The persister callbacks + // run synchronously from `createWallet` via the + // background contexts; autosave propagates the + // rows into the main context before this fetch. + let descriptor = FetchDescriptor( + predicate: PersistentWallet.predicate(walletId: walletId) ) + let rows = (try? modelContext.fetch(descriptor)) ?? [] + for row in rows { + row.isImported = showImportOption + } + if !rows.isEmpty { + try? modelContext.save() + } + + // Mirror name + birth height + just THIS network + // into the keychain alongside the mnemonic so an + // orphan-recovery after a wipe restores the + // original label / network / birth height for this + // scoped wallet. + do { + let metadata = WalletKeychainMetadata( + name: walletLabel, + walletDescription: nil, + networks: [created.network.networkName], + birthHeight: rows.first?.birthHeight + ) + try storage.setMetadata(metadata, for: walletId) + } catch { + SDKLogger.error( + "Failed to persist wallet metadata to keychain for \(created.network.displayName): \(error.localizedDescription)" + ) + } } // If some (but not all) networks failed, the wallet diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index e9f01ec08ff..3202820bc85 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -722,11 +722,15 @@ struct WalletInfoView: View { // Add the existing wallet to another network by re-creating it // from the stored mnemonic in that network's manager. The - // `walletId` is network-independent, so this just stamps a new - // per-network `PersistentWallet` row via the persister - // callback — same sanctioned path the create + orphan-recovery - // flows use. Reusing `createWallet(mnemonic:)` keeps all - // derivation on the Rust side (no Swift orchestration). + // `walletId` is now network-scoped — the same mnemonic produces + // a DIFFERENT id on the target network — so the freshly-created + // wallet gets its OWN scoped id, and its mnemonic must be stored + // under that new id (the source wallet's keychain entry is keyed + // by the source network's id and won't be found when the new + // network's wallet looks itself up). Reusing + // `createWallet(mnemonic:)` keeps all derivation on the Rust side + // (no Swift orchestration); the keychain write below is the + // sanctioned Swift-owned persist step. let mnemonic: String do { mnemonic = try WalletStorage().retrieveMnemonic(for: wallet.walletId) @@ -738,11 +742,22 @@ struct WalletInfoView: View { do { let mgr = try walletManagerStore.backgroundManager(for: network) - _ = try mgr.createWallet( + let created = try mgr.createWallet( mnemonic: mnemonic, network: network, name: wallet.name ?? wallet.label ) + // Persist the mnemonic under the newly-enabled network's + // scoped walletId so that wallet is independently recoverable + // and its own keychain lookups resolve. Best-effort — a + // failure here doesn't undo the successful create. + do { + try WalletStorage().storeMnemonic(mnemonic, for: created.walletId) + } catch { + SDKLogger.error( + "Failed to persist mnemonic to keychain for \(network.displayName): \(error.localizedDescription)" + ) + } } catch { let description = error.localizedDescription // An "already exists" throw means the wallet is already on From 5e76921eefee3fa08ed7018477d7bfaef3761da4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 15:50:11 +0200 Subject: [PATCH 09/17] test(sdk): cover all four networks in scoped-walletId stability test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit nitpick on #3772: same_mnemonic_same_network_is_stable only asserted Mainnet/Testnet. Loop over all four Network variants (adds Devnet/Regtest) so the per-(seed, network) stability invariant — which the watch-only restore path depends on — is covered for every supported network. Co-Authored-By: Claude Opus 4.8 --- .../src/manager/wallet_lifecycle.rs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index cf8f7c257a5..3729b531170 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -505,13 +505,17 @@ mod scoped_wallet_id_tests { /// drift across launches. #[test] fn same_mnemonic_same_network_is_stable() { - assert_eq!( - wallet_id_for(Network::Testnet), - wallet_id_for(Network::Testnet) - ); - assert_eq!( - wallet_id_for(Network::Mainnet), - wallet_id_for(Network::Mainnet) - ); + for network in [ + Network::Mainnet, + Network::Testnet, + Network::Devnet, + Network::Regtest, + ] { + assert_eq!( + wallet_id_for(network), + wallet_id_for(network), + "wallet id must be stable across re-derivation for {network:?}" + ); + } } } From 7f142107e6e2a6457adf779459e51cc6bde2419b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 22:11:54 +0200 Subject: [PATCH 10/17] fix(sdk): add walletGroupId so Wallet Info finds sibling-network wallets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blocking regressions from the network-scoped-walletId change (thepastaclaw review on #3772): 1. WalletDetailView.loadNetworkStates discovered a wallet's sibling- network rows by matching `walletId`. Under scoped ids the same mnemonic has a DIFFERENT id per network, so the fetch only ever returned the row the view was opened on — the "Networks" section showed a multi-network wallet as living on one network. Fix: introduce `walletGroupId`, the network-INDEPENDENT id (the `None`-scoped digest = SHA256(rootPubKey || chainCode), i.e. what walletId was before scoping). It's computed in Rust at register_wallet and threaded through the wallet-metadata changeset → FFI callback → Swift persister → a new PersistentWallet.walletGroupId column. loadNetworkStates now groups sibling rows by it. Every network's wallet for one seed shares the same group id, while their scoped walletIds differ. 2. WalletDetailView.enableNetwork stored the mnemonic under the new scoped walletId but never wrote its WalletKeychainMetadata, so an orphan-recovered wallet could fall back to the active network and recreate on the wrong chain. Now writes the same metadata blob CreateWalletView does, keyed by the new id + target network. Layers touched: - changeset.rs: WalletMetadataEntry gains wallet_group_id: [u8; 32]. - wallet_lifecycle.rs: register_wallet computes it via compute_wallet_id_from_root_extended_pub_key(root, None) (falls back to the scoped id for keyless watch-only/external wallets). Added a unit test proving the group id is network-independent and differs from the scoped id. - rs-platform-wallet-ffi/persistence.rs: on_persist_wallet_metadata_fn gains a wallet_group_id pointer param. - PersistentWallet.swift: walletGroupId column + index + predicate. - PlatformWalletPersistenceHandler.swift: marshal + persist it. - WalletDetailView.swift: group by walletGroupId; write metadata in enableNetwork. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/persistence.rs | 19 ++++-- .../src/changeset/changeset.rs | 16 ++++- .../src/manager/wallet_lifecycle.rs | 58 +++++++++++++++++++ .../Persistence/Models/PersistentWallet.swift | 38 +++++++++--- .../PlatformWalletPersistenceHandler.swift | 25 ++++++-- .../Core/Views/WalletDetailView.swift | 55 ++++++++++++++---- 6 files changed, 180 insertions(+), 31 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 5a860e3bed9..e27ee9b6abb 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -177,11 +177,18 @@ pub struct PersistenceCallbacks { ), >, /// Called once per registration round with the wallet's - /// network tag + birth height. `network` uses the same - /// discriminant as `WalletRestoreEntryFFI.network` (0 = Mainnet, - /// 1 = Testnet, 2 = Devnet, 3 = Regtest). `birth_height` is the - /// best estimate of the block at which the wallet started; zero - /// means "scan from genesis / unknown". + /// network tag, network-independent group id + birth height. + /// `network` uses the same discriminant as + /// `WalletRestoreEntryFFI.network` (0 = Mainnet, 1 = Testnet, + /// 2 = Devnet, 3 = Regtest). `wallet_group_id` points to 32 + /// readable bytes (same shape as `wallet_id`) — the + /// NETWORK-INDEPENDENT id shared by every network's wallet derived + /// from the same seed, so a consumer can group a seed's + /// sibling-network rows by it (the per-network `wallet_id` differs + /// per network for the same seed). For watch-only / + /// external-signable wallets it equals `wallet_id` (a group of + /// one). `birth_height` is the best estimate of the block at which + /// the wallet started; zero means "scan from genesis / unknown". /// /// Returns 0 on success. A non-zero return flips the round's /// `success` flag to `false` so [`Self::on_changeset_end_fn`] @@ -191,6 +198,7 @@ pub struct PersistenceCallbacks { context: *mut c_void, wallet_id: *const u8, network: FFINetwork, + wallet_group_id: *const u8, birth_height: u32, ) -> i32, >, @@ -570,6 +578,7 @@ impl PlatformWalletPersistence for FFIPersister { self.callbacks.context, wallet_id.as_ptr(), meta.network.into(), + meta.wallet_group_id.as_ptr(), meta.birth_height, ) }; diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 00a9e39706b..4da5cea45bb 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -794,8 +794,9 @@ impl Merge for TokenBalanceChangeSet { /// Per-wallet metadata captured at registration. Carries fields not /// derivable from the xpub alone: which network the wallet is bound -/// to and the birth-height best estimate (the SPV tip at create time; -/// 0 means "scan from genesis / unknown"). +/// to, the network-independent group id that ties a seed's per-network +/// wallets together, and the birth-height best estimate (the SPV tip +/// at create time; 0 means "scan from genesis / unknown"). /// /// The shape sits on [`PlatformWalletChangeSet`] as /// `Option` because the round emits at most one @@ -811,6 +812,17 @@ impl Merge for TokenBalanceChangeSet { pub struct WalletMetadataEntry { /// Network the wallet is bound to. pub network: Network, + /// Network-INDEPENDENT 32-byte id shared by every network's wallet + /// derived from the same seed. Computed as + /// `Wallet::compute_wallet_id_from_root_extended_pub_key(root, None)` + /// — `SHA256(root_public_key || root_chain_code)` with no network + /// byte folded in. Distinct from the per-network [`Self::network`]- + /// scoped `wallet_id` the changeset is keyed on: that id differs per + /// network for the same seed, this one is the same across all of + /// them, so consumers can group a seed's sibling-network rows by it. + /// For watch-only / external-signable wallets (which carry no root + /// key) this falls back to the scoped `wallet_id` — a group of one. + pub wallet_group_id: [u8; 32], /// Best estimate of the chain tip at creation time. `0` means /// "scan from genesis / unknown". pub birth_height: u32, diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 3729b531170..494a1257b41 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -220,6 +220,20 @@ impl PlatformWalletManager

{ address_snapshots.push((account_type, vec![(pool.pool_type, infos)])); } + // Network-INDEPENDENT group id, snapshotted BEFORE `wallet` is + // moved into `insert_wallet` below. The per-network `wallet_id` + // differs per network for the same seed (see the scoping note + // above); this digest deliberately omits the network byte + // (`None`), so every network's wallet for one seed shares it and + // the persister can group a seed's sibling-network rows by it. + // Watch-only / external-signable wallets carry no root key, so + // there's nothing to hash — fall back to the scoped `wallet_id` + // (a group of one). + let wallet_group_id = wallet + .root_extended_pub_key_cow() + .map(|root| Wallet::compute_wallet_id_from_root_extended_pub_key(&root, None)) + .unwrap_or(wallet.wallet_id); + let platform_info = PlatformWalletInfo { core_wallet: wallet_info, balance: Arc::clone(&balance), @@ -259,6 +273,7 @@ impl PlatformWalletManager

{ let mut registration_changeset = PlatformWalletChangeSet { wallet_metadata: Some(WalletMetadataEntry { network: self.sdk.network, + wallet_group_id, birth_height, }), account_registrations: account_specs @@ -478,6 +493,22 @@ mod scoped_wallet_id_tests { wallet.wallet_id } + /// The network-INDEPENDENT group id `register_wallet` computes and + /// persists onto every per-network row, so the iOS Wallet Info + /// "Networks" section can group a seed's sibling-network wallets. + /// Mirrors the `register_wallet` derivation exactly. + fn wallet_group_id_for(network: Network) -> [u8; 32] { + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let wallet = + Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) + .expect("wallet construction"); + wallet + .root_extended_pub_key_cow() + .map(|root| Wallet::compute_wallet_id_from_root_extended_pub_key(&root, None)) + .unwrap_or(wallet.wallet_id) + } + /// The same mnemonic must yield a DISTINCT wallet id on each network. /// This is the property the whole per-network persistence model now /// relies on (rust-dashcore #793: network-scoped id by default). @@ -518,4 +549,31 @@ mod scoped_wallet_id_tests { ); } } + + /// The group id must be network-INDEPENDENT: the same seed yields + /// the SAME group id on every network (this is what lets the Wallet + /// Info "Networks" section discover a seed's sibling-network rows + /// now that the scoped `walletId` differs per network). It must also + /// differ from the scoped id, or grouping would collapse back into + /// the per-network id and find nothing. + #[test] + fn group_id_is_network_independent_and_differs_from_scoped_id() { + let g_main = wallet_group_id_for(Network::Mainnet); + let g_test = wallet_group_id_for(Network::Testnet); + let g_dev = wallet_group_id_for(Network::Devnet); + let g_reg = wallet_group_id_for(Network::Regtest); + + // Same seed → identical group id across every network. + assert_eq!(g_main, g_test, "group id must not depend on network"); + assert_eq!(g_main, g_dev, "group id must not depend on network"); + assert_eq!(g_main, g_reg, "group id must not depend on network"); + + // …but the group id is NOT the scoped id (else grouping siblings + // by it would degenerate to the per-network id and miss them). + assert_ne!( + g_main, + wallet_id_for(Network::Mainnet), + "group id must differ from the network-scoped id" + ); + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 2e19d2f4198..a72c23dc745 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -16,16 +16,30 @@ public final class PersistentWallet { /// Index `networkRaw` so per-network wallet scans (used everywhere /// from the network-scoped storage explorer to the per-network /// "is there a wallet on this chain yet" lookups) don't degrade - /// to a table scan. - #Index([\.networkRaw]) + /// to a table scan. Also index `walletGroupId` so the Wallet Info + /// "Networks" lookup — which fetches every sibling-network row for + /// a seed by its group id — stays a keyed scan. + #Index([\.networkRaw], [\.walletGroupId]) #Unique([\.walletId, \.networkRaw]) - /// 32-byte wallet ID (SHA256 of root public key). Not unique on - /// its own — the same seed yields the same `walletId` on every - /// network, so a wallet that exists on multiple chains has one - /// row per network. Uniqueness is the composite - /// `(walletId, networkRaw)` declared above. + /// 32-byte NETWORK-SCOPED wallet ID. Since the network-scoping + /// change the same seed yields a DISTINCT `walletId` per network + /// (a domain-tagged network byte is folded into the digest), so a + /// wallet that exists on multiple chains has one row per network, + /// each with its own id. Uniqueness is the composite + /// `(walletId, networkRaw)` declared above. To gather a seed's + /// sibling-network rows, group by `walletGroupId` (which is the + /// same across networks), not by this id. public var walletId: Data + /// 32-byte NETWORK-INDEPENDENT group id shared by every network's + /// wallet derived from the same seed (Rust computes it as the + /// no-network digest of the root key). Distinct from `walletId`, + /// which is network-scoped. Used to group a seed's sibling-network + /// rows in the Wallet Info "Networks" section. Defaults to empty + /// for rows written before this column existed (pre-release, no + /// migration); consumers treat empty as "legacy — this single row + /// only". + public var walletGroupId: Data = Data() /// Network this wallet belongs to. `nil` means "not yet known" — /// the row was created by a changeset before `persistWalletMetadata` /// filled the network in. Views treat `nil` as unknown. @@ -97,6 +111,7 @@ public final class PersistentWallet { public init( walletId: Data, + walletGroupId: Data = Data(), network: Network? = nil, name: String? = nil, walletDescription: String? = nil, @@ -105,6 +120,7 @@ public final class PersistentWallet { isImported: Bool = false ) { self.walletId = walletId + self.walletGroupId = walletGroupId self.networkRaw = network?.rawValue self.name = name self.walletDescription = walletDescription @@ -146,6 +162,14 @@ extension PersistentWallet { #Predicate { $0.walletId == walletId } } + /// Fetch every sibling-network row for one seed by its + /// network-independent group id. See `walletGroupId`. + public static func predicate( + walletGroupId: Data + ) -> Predicate { + #Predicate { $0.walletGroupId == walletGroupId } + } + public static func predicate( walletId: Data, network: Network diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 832308ad6c8..750ca691de4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2586,14 +2586,26 @@ public class PlatformWalletPersistenceHandler { private var shieldedSyncStateLoadAllocations: [UnsafeRawPointer: ShieldedSyncStateLoadAllocation] = [:] - /// Set network + birth height on the `PersistentWallet` row. Fires - /// once at wallet registration with values the Rust side can - /// contribute but Swift can't easily recompute (network is on the - /// manager's SDK; birth height is SPV's confirmed tip at creation). - func persistWalletMetadata(walletId: Data, network: Network, birthHeight: UInt32) { + /// Set network, group id + birth height on the `PersistentWallet` + /// row. Fires once at wallet registration with values the Rust side + /// can contribute but Swift can't easily recompute (network is on + /// the manager's SDK; the group id is the network-independent digest + /// Rust derives from the root key; birth height is SPV's confirmed + /// tip at creation). `walletGroupId` ties this row to its + /// sibling-network rows for the same seed; it is left empty only if + /// Rust handed back no bytes. + func persistWalletMetadata( + walletId: Data, + network: Network, + walletGroupId: Data, + birthHeight: UInt32 + ) { onQueue { let wallet = ensureWalletRecord(walletId: walletId) wallet.network = network + if !walletGroupId.isEmpty { + wallet.walletGroupId = walletGroupId + } wallet.birthHeight = birthHeight wallet.lastUpdated = Date() if !self.inChangeset { try? backgroundContext.save() } @@ -5037,6 +5049,7 @@ private func persistWalletMetadataCallback( context: UnsafeMutableRawPointer?, walletIdPtr: UnsafePointer?, network: FFINetwork, + walletGroupIdPtr: UnsafePointer?, birthHeight: UInt32 ) -> Int32 { guard let context = context, @@ -5047,9 +5060,11 @@ private func persistWalletMetadataCallback( .fromOpaque(context) .takeUnretainedValue() let walletId = Data(bytes: walletIdPtr, count: 32) + let walletGroupId = walletGroupIdPtr.map { Data(bytes: $0, count: 32) } ?? Data() handler.persistWalletMetadata( walletId: walletId, network: Network(ffiNetwork: network), + walletGroupId: walletGroupId, birthHeight: birthHeight ) return 0 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 3202820bc85..d2e294bb2a2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -630,14 +630,24 @@ struct WalletInfoView: View { private func loadNetworkStates() { // A wallet now has one `PersistentWallet` row per network it - // lives on (same `walletId`, distinct `networkRaw`). Reflect - // the actual set of rows rather than the single `wallet.network` - // this view was opened with. - let walletId = wallet.walletId - let descriptor = FetchDescriptor( - predicate: PersistentWallet.predicate(walletId: walletId) - ) - let rows = (try? modelContext.fetch(descriptor)) ?? [wallet] + // lives on. Since network-scoping, those rows have DISTINCT + // `walletId`s (the network byte is folded into the digest), so + // they can't be matched by `walletId` anymore. They share a + // network-independent `walletGroupId` instead — group by that + // to reflect the actual set of rows rather than the single + // `wallet.network` this view was opened with. + let groupId = wallet.walletGroupId + let rows: [PersistentWallet] + if groupId.isEmpty { + // Legacy row written before the group-id column existed — + // fall back to this single row. + rows = [wallet] + } else { + let descriptor = FetchDescriptor( + predicate: PersistentWallet.predicate(walletGroupId: groupId) + ) + rows = (try? modelContext.fetch(descriptor)) ?? [wallet] + } let networks = Set(rows.compactMap { $0.network }) mainnetEnabled = networks.contains(.mainnet) testnetEnabled = networks.contains(.testnet) @@ -747,17 +757,38 @@ struct WalletInfoView: View { network: network, name: wallet.name ?? wallet.label ) - // Persist the mnemonic under the newly-enabled network's - // scoped walletId so that wallet is independently recoverable - // and its own keychain lookups resolve. Best-effort — a + // Persist the mnemonic AND the per-wallet metadata under the + // newly-enabled network's scoped walletId so that wallet is + // independently recoverable and its own keychain lookups + // resolve. The metadata is load-bearing for orphan-recovery + // and the post-launch warmup: `ContentView.recoverWallet` + // and the bootstrap pre-warm pick the restore network from + // `metadata.resolvedNetworks`, so without it a wiped wallet + // falls back to whatever network is active and could be + // recreated on the wrong chain. Mirror the same blob shape + // `CreateWalletView` writes per network. Best-effort — a // failure here doesn't undo the successful create. + let storage = WalletStorage() do { - try WalletStorage().storeMnemonic(mnemonic, for: created.walletId) + try storage.storeMnemonic(mnemonic, for: created.walletId) } catch { SDKLogger.error( "Failed to persist mnemonic to keychain for \(network.displayName): \(error.localizedDescription)" ) } + do { + let metadata = WalletKeychainMetadata( + name: wallet.name ?? wallet.label, + walletDescription: wallet.walletDescription, + networks: [network.networkName], + birthHeight: wallet.birthHeight + ) + try storage.setMetadata(metadata, for: created.walletId) + } catch { + SDKLogger.error( + "Failed to persist wallet metadata to keychain for \(network.displayName): \(error.localizedDescription)" + ) + } } catch { let description = error.localizedDescription // An "already exists" throw means the wallet is already on From 92b6b28004d08183c313f5a5956b4d7ab6692089 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 1 Jun 2026 22:43:17 +0200 Subject: [PATCH 11/17] refactor(sdk): tighten PersistentWallet uniqueness to walletId alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With network-scoped walletIds the network is folded into the id digest, so `walletId` is globally unique on its own — a given id maps to exactly one (seed, network) pair. The `(walletId, networkRaw)` composite was a leftover from the pre-scoping model, where one seed shared a single id across networks and `networkRaw` was the only distinguishing column. Drop it to `#Unique([\.walletId])` so the constraint states the actual invariant. Pre-release: dev stores rebuild, no migration. Comment updated to explain the history. Co-Authored-By: Claude Opus 4.8 --- .../Persistence/Models/PersistentWallet.swift | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index a72c23dc745..ac4d8a1fc4a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -20,16 +20,20 @@ public final class PersistentWallet { /// "Networks" lookup — which fetches every sibling-network row for /// a seed by its group id — stays a keyed scan. #Index([\.networkRaw], [\.walletGroupId]) - #Unique([\.walletId, \.networkRaw]) + #Unique([\.walletId]) - /// 32-byte NETWORK-SCOPED wallet ID. Since the network-scoping - /// change the same seed yields a DISTINCT `walletId` per network - /// (a domain-tagged network byte is folded into the digest), so a - /// wallet that exists on multiple chains has one row per network, - /// each with its own id. Uniqueness is the composite - /// `(walletId, networkRaw)` declared above. To gather a seed's - /// sibling-network rows, group by `walletGroupId` (which is the - /// same across networks), not by this id. + /// 32-byte NETWORK-SCOPED wallet ID, and the row's primary + /// uniqueness key. Since the network-scoping change the same seed + /// yields a DISTINCT `walletId` per network (a domain-tagged network + /// byte is folded into the digest), so a wallet that exists on + /// multiple chains has one row per network, each with its own id — + /// the network is already baked into the id, so `walletId` alone is + /// globally unique (an earlier `(walletId, networkRaw)` composite + /// was a leftover from the pre-scoping model, where one seed shared + /// a single id across networks and `networkRaw` was the only + /// distinguishing column). To gather a seed's sibling-network rows, + /// group by `walletGroupId` (which is the same across networks), + /// not by this id. public var walletId: Data /// 32-byte NETWORK-INDEPENDENT group id shared by every network's /// wallet derived from the same seed (Rust computes it as the From 4bdc6042676e021a7aa7714726f7824d3873c4db Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 7 Jun 2026 02:09:15 +0200 Subject: [PATCH 12/17] refactor(sdk): drop dead predicate(walletId:network:) overload With network-scoped walletIds, `walletId` is globally unique and the model is declared `#Unique([\.walletId])`, so the `networkRaw` clause in `predicate(walletId:network:)` can never narrow the result beyond the walletId match. The overload had no remaining callers (sibling-network grouping goes through `predicate(walletGroupId:)`), so keeping it only preserved the obsolete pre-scoping mental model that `(walletId, networkRaw)` is the disambiguating key. Remove it. Co-Authored-By: Claude Opus 4.8 --- .../Persistence/Models/PersistentWallet.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index ac4d8a1fc4a..e900ce0e332 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -173,14 +173,4 @@ extension PersistentWallet { ) -> Predicate { #Predicate { $0.walletGroupId == walletGroupId } } - - public static func predicate( - walletId: Data, - network: Network - ) -> Predicate { - let networkRaw = network.rawValue - return #Predicate { - $0.walletId == walletId && $0.networkRaw == networkRaw - } - } } From dc55e6d06302ebf97b210d100057889897501a7c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 7 Jun 2026 02:28:50 +0200 Subject: [PATCH 13/17] fix(sdk): use target-network birthHeight in enableNetwork metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enableNetwork wrote the add-to-network keychain metadata with `birthHeight: wallet.birthHeight`, but `wallet` is the SOURCE-network row the detail screen was opened on — birth heights are chain-block numbers, so the source network's tip is unrelated to the target's. On orphan recovery, ContentView.recoverWallet trusts the stored value verbatim, so the target-network wallet would rescan from a wrong height (and if the source height exceeds the target tip, skip its entire history). Read the birth height from the freshly-created target-network row (`created.walletId`), the same way CreateWalletView sources it — the persister stamps the correct value on that row synchronously during createWallet. Co-Authored-By: Claude Opus 4.8 --- .../Core/Views/WalletDetailView.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index d2e294bb2a2..289e7579c94 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -777,11 +777,25 @@ struct WalletInfoView: View { ) } do { + // Birth height is a chain-block number, so it must come + // from the TARGET network's freshly-created row — NOT the + // source `wallet`, whose `birthHeight` belongs to the + // network this detail screen was opened on. The persister + // stamps the right value on the new row synchronously + // during `createWallet`; read it back (same shape + // `CreateWalletView` uses) so orphan-recovery rescans the + // target chain from the correct height. + let createdId = created.walletId + let createdRow = try? modelContext.fetch( + FetchDescriptor( + predicate: PersistentWallet.predicate(walletId: createdId) + ) + ).first let metadata = WalletKeychainMetadata( name: wallet.name ?? wallet.label, walletDescription: wallet.walletDescription, networks: [network.networkName], - birthHeight: wallet.birthHeight + birthHeight: createdRow?.birthHeight ) try storage.setMetadata(metadata, for: created.walletId) } catch { From 4cf30a442301fc68c6af43d6c39c738c625a68bb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 10:47:04 +0200 Subject: [PATCH 14/17] fix(swift): scope resumable asset locks to active network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Resumable Registrations list ran its identity/in-flight anti-join over every tracked PersistentAssetLock regardless of network, so locks belonging to wallets on another network surfaced as resumable rows after a network switch. Thread the active Network into ResumableRegistrationsList and restrict allAssetLocks to wallets on that network before the anti-join, joining through walletId to PersistentWallet.networkRaw (PersistentAssetLock has no networkRaw column of its own) — the same network-scoping pivot CoreContentView already uses. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index f6de6a47fa3..777966637c9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -271,6 +271,7 @@ struct IdentitiesContentView: View { private var resumableRegistrationsSection: some View { ResumableRegistrationsList( coordinator: walletManager.registrationCoordinator, + network: network, allAssetLocks: allAssetLocks, allWallets: allWallets, allIdentities: identities, @@ -436,6 +437,10 @@ struct IdentitiesContentView: View { /// dismiss UX on `.failed` rows. private struct ResumableRegistrationsList: View { @ObservedObject var coordinator: RegistrationCoordinator + /// Active network. Scopes `allAssetLocks` to wallets on this + /// network before the anti-join so locks from another network + /// don't leak into the list after a network switch. + let network: Network let allAssetLocks: [PersistentAssetLock] let allWallets: [PersistentWallet] let allIdentities: [PersistentIdentity] @@ -487,11 +492,27 @@ private struct ResumableRegistrationsList: View { } ) return IdentitiesContentView.crossWalletResumableLocks( - in: allAssetLocks, + in: networkScopedAssetLocks, usedSlots: identitySlots.union(activeSlots) ) } + /// `allAssetLocks` restricted to wallets on the active network. + /// `PersistentAssetLock` carries no `networkRaw` column itself; + /// the canonical join is through `walletId` to the parent + /// `PersistentWallet.networkRaw` — same pivot `CoreContentView` + /// uses. Without this, locks from another network would surface + /// as resumable rows after a network switch. + private var networkScopedAssetLocks: [PersistentAssetLock] { + let raw = network.rawValue + let walletIdsOnNetwork = Set( + allWallets.lazy + .filter { $0.networkRaw == raw } + .map(\.walletId) + ) + return allAssetLocks.filter { walletIdsOnNetwork.contains($0.walletId) } + } + private func walletDisplayLabel(for walletId: Data) -> String { if let wallet = allWallets.first(where: { $0.walletId == walletId }) { return wallet.label From 1c0fb979f05d53eee759a9f9ae0c58f318312ea4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 11:13:46 +0200 Subject: [PATCH 15/17] fix(sdk): classify duplicate-wallet errors by typed FFI code CreateWalletView's multi-network create loop and WalletDetailView's enableNetwork both classified a duplicate-wallet failure as benign by substring-matching error.localizedDescription for "already exists", coupling Swift control flow to the Rust Display text. PlatformWalletError::WalletAlreadyExists already existed but the FFI flattened it to ErrorUnknown, so only the string survived. Add a dedicated PlatformWalletFFIResultCode::ErrorWalletAlreadyExists (= 15), map the variant to it (Display still carried as the message), mirror it in Swift as PlatformWalletError.walletAlreadyExists, and branch on the typed case at both sites instead of matching display text. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-platform-wallet-ffi/src/error.rs | 36 +++++++++++++++++++ .../PlatformWallet/PlatformWalletResult.swift | 6 ++++ .../Core/Views/CreateWalletView.swift | 10 +++--- .../Core/Views/WalletDetailView.swift | 15 ++++---- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 9fb32fba017..8e7e4100220 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -89,6 +89,14 @@ pub enum PlatformWalletFFIResultCode { /// receive address, consolidate sub-min balances, or fall back to /// `InputSelection::Explicit`. ErrorNoSelectableInputs = 14, + /// Maps `PlatformWalletError::WalletAlreadyExists`. Callers that create + /// a wallet across multiple networks (or enable an additional network on + /// an existing wallet) treat this as a benign "already present" no-op + /// rather than a hard failure — the wallet's mnemonic/metadata were + /// stored under its scoped id at original creation, so there is nothing + /// to re-persist. The typed Display rendering still survives as the + /// result message for logging/detail. + ErrorWalletAlreadyExists = 15, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -180,6 +188,9 @@ impl From for PlatformWalletFFIResult { | PlatformWalletError::OnlyDustInputs { .. } => { PlatformWalletFFIResultCode::ErrorNoSelectableInputs } + PlatformWalletError::WalletAlreadyExists(..) => { + PlatformWalletFFIResultCode::ErrorWalletAlreadyExists + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -458,6 +469,31 @@ mod tests { } } + /// `WalletAlreadyExists` maps to the dedicated + /// `ErrorWalletAlreadyExists` FFI code rather than flattening to + /// `ErrorUnknown`, so multi-network wallet create/enable callers can + /// branch on the typed code instead of substring-matching the Display + /// text. The typed Display rendering still survives as the message. + #[test] + fn wallet_already_exists_maps_to_dedicated_code() { + let err = PlatformWalletError::WalletAlreadyExists("wallet 0xdeadbeef".to_string()); + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletAlreadyExists, + "WalletAlreadyExists should map to ErrorWalletAlreadyExists (rendered: {rendered})" + ); + assert!(!result.message.is_null()); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!( + msg, rendered, + "Display payload must survive the FFI boundary verbatim" + ); + } + /// Other wallet-error variants without a dedicated FFI arm still /// fall through to `ErrorUnknown` while carrying the typed /// Display rendering as the message. Pin this so the catch-all diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 1d974b71916..942ef996dd4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -20,6 +20,7 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorUtf8Conversion = 12 case errorArithmeticOverflow = 13 case errorNoSelectableInputs = 14 + case errorWalletAlreadyExists = 15 case notFound = 98 case errorUnknown = 99 @@ -55,6 +56,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorArithmeticOverflow case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_NO_SELECTABLE_INPUTS: self = .errorNoSelectableInputs + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_WALLET_ALREADY_EXISTS: + self = .errorWalletAlreadyExists case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -132,6 +135,7 @@ public enum PlatformWalletError: LocalizedError { case memoryAllocation(String) case arithmeticOverflow(String) case noSelectableInputs(String) + case walletAlreadyExists(String) case notFound(String) case unknown(String) @@ -145,6 +149,7 @@ public enum PlatformWalletError: LocalizedError { .identityNotFound(let m), .contactNotFound(let m), .utf8Conversion(let m), .serialization(let m), .deserialization(let m), .memoryAllocation(let m), .arithmeticOverflow(let m), .noSelectableInputs(let m), + .walletAlreadyExists(let m), .notFound(let m), .unknown(let m): return m } @@ -171,6 +176,7 @@ public enum PlatformWalletError: LocalizedError { case .errorUtf8Conversion: self = .utf8Conversion(detail) case .errorArithmeticOverflow: self = .arithmeticOverflow(detail) case .errorNoSelectableInputs: self = .noSelectableInputs(detail) + case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index 465c7aa0c44..34fb48b2c46 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -362,10 +362,9 @@ struct CreateWalletView: View { ) createdWallets.append((net, managed.walletId)) } catch { - let message = error.localizedDescription - // An "already exists" throw means the wallet - // is already on this network — benign. We do - // NOT resolve the existing scoped walletId to + // A typed `walletAlreadyExists` throw means the + // wallet is already on this network — benign. We + // do NOT resolve the existing scoped walletId to // re-store the mnemonic: a wallet that already // exists on this network had its mnemonic + // metadata stored under that scoped id at its @@ -373,11 +372,12 @@ struct CreateWalletView: View { // write. It is also not counted as a freshly- // created wallet. Any other error is a genuine // failure. - if message.range(of: "already exists", options: .caseInsensitive) != nil { + if case PlatformWalletError.walletAlreadyExists = error { SDKLogger.error( "Wallet already present on \(net.displayName); continuing" ) } else { + let message = error.localizedDescription failures.append((net, message)) SDKLogger.error( "Wallet creation failed for \(net.displayName): \(message)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 289e7579c94..9c235d34141 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -804,12 +804,13 @@ struct WalletInfoView: View { ) } } catch { - let description = error.localizedDescription - // An "already exists" throw means the wallet is already on - // this network — a genuine no-op, so fall through to refresh. - // Any other failure (SDK build error, Rust-side error, etc.) - // must surface to the user instead of silently doing nothing. - if description.range(of: "already exists", options: .caseInsensitive) == nil { + // A typed `walletAlreadyExists` throw means the wallet is + // already on this network — a genuine no-op, so fall through to + // refresh. Any other failure (SDK build error, Rust-side error, + // etc.) must surface to the user instead of silently doing + // nothing. + guard case PlatformWalletError.walletAlreadyExists = error else { + let description = error.localizedDescription SDKLogger.error( "enableNetwork(\(network.displayName)) failed: \(description)" ) @@ -818,7 +819,7 @@ struct WalletInfoView: View { return } SDKLogger.error( - "enableNetwork(\(network.displayName)) create returned: \(description)" + "enableNetwork(\(network.displayName)) create returned benign already-exists" ) } From 21391d55e04394b39e6ef2c92d01438b9a5c9956 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 12:05:27 +0200 Subject: [PATCH 16/17] fix(sdk): emit WalletAlreadyExists on duplicate create; scope restorableWalletIds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit added a typed ErrorWalletAlreadyExists FFI code and made the Swift call sites branch on PlatformWalletError.walletAlreadyExists, but register_wallet blanket-wrapped every WalletManager::insert_wallet failure into PlatformWalletError::WalletCreation. The duplicate variant (key_wallet_manager::WalletError::WalletExists) was therefore never mapped to WalletAlreadyExists, the FFI never emitted code 15, and the Swift typed checks never matched — breaking the benign 'already on this network' no-op end-to-end. Map WalletExists to WalletAlreadyExists explicitly. Add a producer-level test (register the same wallet twice) so the create path is covered, not just the FFI mapper in isolation. Also scope restorableWalletIds() to the handler's bound network to mirror loadWalletList(); the unfiltered fetch made a per-network manager request sibling-network wallet handles right after a network-scoped restore, polluting lastError with spurious get-wallet failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/manager/wallet_lifecycle.rs | 125 +++++++++++++++++- .../PlatformWalletPersistenceHandler.swift | 18 ++- 2 files changed, 137 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 494a1257b41..118dbc690c0 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -241,14 +241,23 @@ impl PlatformWalletManager

{ tracked_asset_locks: std::collections::BTreeMap::new(), }; - // Insert into WalletManager. + // Insert into WalletManager. A duplicate (same network-scoped + // wallet id already registered) surfaces as the typed + // `WalletAlreadyExists` so the create FFI / Swift call sites can + // treat re-registering an existing wallet as a benign no-op + // instead of substring-matching the error text. Everything else + // stays `WalletCreation`. let wallet_id = { let mut wm = self.wallet_manager.write().await; wm.insert_wallet(wallet, platform_info).map_err(|e| { - PlatformWalletError::WalletCreation(format!( - "Failed to register wallet in WalletManager: {}", - e - )) + if matches!(e, key_wallet_manager::WalletError::WalletExists(_)) { + PlatformWalletError::WalletAlreadyExists(e.to_string()) + } else { + PlatformWalletError::WalletCreation(format!( + "Failed to register wallet in WalletManager: {}", + e + )) + } })? }; @@ -577,3 +586,109 @@ mod scoped_wallet_id_tests { ); } } + +#[cfg(test)] +mod register_wallet_duplicate_tests { + use std::sync::Arc; + + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::error::PlatformWalletError; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWalletManager; + + // Canonical all-`abandon` BIP-39 test vector. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + /// No-op persister: lifecycle tests don't need the real persistence + /// pipeline, just a handle satisfying the constructor. + struct NoopPersister; + + impl PlatformWalletPersistence for NoopPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + /// Build a manager wired to a no-op persister over a mock SDK. The + /// duplicate-create path under test never reaches the network: the + /// first `create` returns `Ok` (its only network touch — best-effort + /// `identity().sync()` — is logged-and-ignored), and the second + /// fails at `WalletManager::insert_wallet` before any query. + fn make_manager() -> Arc> { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(NoopPersister); + let event_handler: Arc = Arc::new(NoopEventHandler); + Arc::new(PlatformWalletManager::new(sdk, persister, event_handler)) + } + + /// Registering the SAME wallet (same mnemonic/seed + network) twice + /// must surface the typed `WalletAlreadyExists` on the second call — + /// NOT `WalletCreation`. This exercises the real producer path + /// (`register_wallet` → `WalletManager::insert_wallet` → + /// `WalletError::WalletExists` mapping) end-to-end; the prior + /// isolated FFI-mapper test missed that nothing ever constructed + /// `WalletAlreadyExists` on the create path. + #[tokio::test] + async fn duplicate_register_wallet_returns_wallet_already_exists() { + let manager = make_manager(); + + let network = Network::Testnet; + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let seed_bytes = mnemonic.to_seed(""); + + // First registration succeeds. `Some(0)` skips the SPV-tip + // birth-height lookup so the test never consults SPV. + manager + .create_wallet_from_seed_bytes( + network, + seed_bytes, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("first create should succeed"); + + // Second registration of the identical (seed, network) wallet + // collides on the network-scoped wallet id inside + // `WalletManager::insert_wallet`. + let err = manager + .create_wallet_from_seed_bytes( + network, + seed_bytes, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect_err("second create of the same wallet must fail"); + + assert!( + matches!(err, PlatformWalletError::WalletAlreadyExists(_)), + "duplicate create must map to WalletAlreadyExists, got: {err:?}" + ); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 750ca691de4..8d55e43f461 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3941,9 +3941,25 @@ public class PlatformWalletPersistenceHandler { /// `PlatformWalletManager.loadFromPersistor` after the FFI call /// succeeds so it can fetch a Swift-side handle for each wallet /// Rust just reconstructed. + /// + /// Network-scoped to match `loadWalletList()`: when `network` is + /// non-nil the fetch is filtered to the handler's bound network so + /// `loadFromPersistor` only requests handles for the wallets the FFI + /// just reconstructed on this network — not sibling-network rows, + /// whose handle lookups would miss and pollute `lastError`. When + /// `network` is nil (legacy callers) we fall back to the unfiltered + /// cross-network fetch, matching `loadWalletList()`. public func restorableWalletIds() -> [Data] { onQueue { - let descriptor = FetchDescriptor() + let descriptor: FetchDescriptor + if let network = self.network { + let raw = network.rawValue + descriptor = FetchDescriptor( + predicate: #Predicate { $0.networkRaw == raw } + ) + } else { + descriptor = FetchDescriptor() + } guard let wallets = try? backgroundContext.fetch(descriptor) else { return [] } From 24344b079a29e25a43a3977d073024988195940f Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 9 Jun 2026 14:31:12 +0200 Subject: [PATCH 17/17] fix(swift): group legacy wallets with per-network siblings in Wallet Info A pre-existing wallet (empty walletGroupId) kept showing other networks as addable even after one was added, because loadNetworkStates collapsed legacy rows to a single row. It now finds siblings by the legacy row's walletId (the group id siblings stamp), and enableNetwork backfills the legacy row's walletGroupId so grouping is symmetric across launches. Also adds a progress overlay while a network is being added, with a one-frame yield so it paints before the synchronous @MainActor create blocks the main thread. Co-Authored-By: Claude Opus 4.8 --- .../Core/Views/WalletDetailView.swift | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 9c235d34141..917cf4a87bd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -584,6 +584,27 @@ struct WalletInfoView: View { } } } + // Progress overlay shown while `enableNetwork` runs + // (`isUpdatingNetworks`) so the add-to-network create isn't silent. + .overlay { + if isUpdatingNetworks { + ZStack { + Color.black.opacity(0.25) + .ignoresSafeArea() + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text("Adding to network…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(24) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: isUpdatingNetworks) } /// Prompt the user via biometric / passcode, then pull the @@ -639,9 +660,14 @@ struct WalletInfoView: View { let groupId = wallet.walletGroupId let rows: [PersistentWallet] if groupId.isEmpty { - // Legacy row written before the group-id column existed — - // fall back to this single row. - rows = [wallet] + // Legacy row (no group id): its `walletId` is the + // network-independent digest that siblings stamp as their + // `walletGroupId`, so find siblings by it instead of + // collapsing to this single row. + let siblings = (try? modelContext.fetch(FetchDescriptor( + predicate: PersistentWallet.predicate(walletGroupId: wallet.walletId) + ))) ?? [] + rows = [wallet] + siblings } else { let descriptor = FetchDescriptor( predicate: PersistentWallet.predicate(walletGroupId: groupId) @@ -730,6 +756,11 @@ struct WalletInfoView: View { isUpdatingNetworks = true defer { isUpdatingNetworks = false } + // `createWallet` below is a synchronous @MainActor FFI call that + // blocks the main thread, so without yielding first SwiftUI never + // paints the overlay. Let it render one frame before we block. + try? await Task.sleep(nanoseconds: 50_000_000) // ~50ms, one frame + // Add the existing wallet to another network by re-creating it // from the stored mnemonic in that network's manager. The // `walletId` is now network-scoped — the same mnemonic produces @@ -823,6 +854,14 @@ struct WalletInfoView: View { ) } + // Backfill a legacy row's group id (= its walletId) so it groups + // with the sibling just created — in both directions and across + // launches. Idempotent: only fires while empty. + if wallet.walletGroupId.isEmpty { + wallet.walletGroupId = wallet.walletId + try? modelContext.save() + } + loadNetworkStates() loadAccountCounts() }