diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 22f405757..e3a8052e2 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -543,6 +543,10 @@ 752F81992E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */; }; 752F819A2E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81962E30F55E00ADA76D /* SecurityMenuScreen.swift */; }; 752F819B2E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */; }; + 7DA5000000000000000A0011 /* WalletsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DA5000000000000000A0001 /* WalletsScreen.swift */; }; + 7DA5000000000000000A0012 /* WalletsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DA5000000000000000A0001 /* WalletsScreen.swift */; }; + 7DA5000000000000000A0021 /* WalletsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DA5000000000000000A0002 /* WalletsViewModel.swift */; }; + 7DA5000000000000000A0022 /* WalletsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DA5000000000000000A0002 /* WalletsViewModel.swift */; }; 752F81A92E323FDB00ADA76D /* ToolsMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81A82E323FD000ADA76D /* ToolsMenuViewModel.swift */; }; 752F81AA2E323FDB00ADA76D /* ToolsMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752F81A82E323FD000ADA76D /* ToolsMenuViewModel.swift */; }; 75303FE52AE7B70500870D8B /* CrowdNode.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 75303FE42AE7B70500870D8B /* CrowdNode.storyboard */; }; @@ -2509,6 +2513,8 @@ 752D03A82E2F758B00B88784 /* MainMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuViewModel.swift; sourceTree = ""; }; 752D03B12E2F7B3C00B88784 /* MainMenuViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuViewControllerDelegate.swift; sourceTree = ""; }; 752F81962E30F55E00ADA76D /* SecurityMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityMenuScreen.swift; sourceTree = ""; }; + 7DA5000000000000000A0001 /* WalletsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WalletsScreen.swift; path = Wallets/WalletsScreen.swift; sourceTree = ""; }; + 7DA5000000000000000A0002 /* WalletsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WalletsViewModel.swift; path = Wallets/WalletsViewModel.swift; sourceTree = ""; }; 752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityMenuViewModel.swift; sourceTree = ""; }; 752F81A82E323FD000ADA76D /* ToolsMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolsMenuViewModel.swift; sourceTree = ""; }; 75303FE42AE7B70500870D8B /* CrowdNode.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = CrowdNode.storyboard; sourceTree = ""; }; @@ -4194,6 +4200,8 @@ children = ( 2A7F3B18238C643D00DEA3EF /* Advanced Security */, 2A10EB3F2358D29500C38B61 /* ResetWalletInfo */, + 7DA5000000000000000A0001 /* WalletsScreen.swift */, + 7DA5000000000000000A0002 /* WalletsViewModel.swift */, 752F81962E30F55E00ADA76D /* SecurityMenuScreen.swift */, 752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */, ); @@ -8927,6 +8935,8 @@ 472D13E3299E23B7006903F1 /* BalanceNotifier.swift in Sources */, 752F819A2E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */, 752F819B2E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */, + 7DA5000000000000000A0011 /* WalletsScreen.swift in Sources */, + 7DA5000000000000000A0021 /* WalletsViewModel.swift in Sources */, 2AD1CE6422D9127600C99324 /* DWSeedWordModel.m in Sources */, 7592AA7C2B9B08C000417F9E /* SupportedTopperPaymentMethods.swift in Sources */, 75AA33CC2BF9C82700F12465 /* ModalDialog.swift in Sources */, @@ -9571,6 +9581,8 @@ C9D2C7C42A320AA000D15901 /* CurrencyExchanger_Objc.m in Sources */, 752F81982E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */, 752F81992E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */, + 7DA5000000000000000A0012 /* WalletsScreen.swift in Sources */, + 7DA5000000000000000A0022 /* WalletsViewModel.swift in Sources */, 7573C2E12B01103900F4C347 /* VotingFilterItemSelectableCell.swift in Sources */, C943B32B2A408CED00AF23C5 /* DWAvatarExternalLoadingView.m in Sources */, C943B5972A40EDDA00AF23C5 /* DWConfirmUsernameViewController.m in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index 33ad0ce86..4f221c82c 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -106,6 +106,7 @@ final class SwiftDashSDKContactsService: ObservableObject { private let authorizer = DWIdentityAuthorizer() private var saveObserverCancellable: AnyCancellable? + private var activeWalletCancellable: AnyCancellable? /// Last run of the payments projection (see /// `refreshPaymentsProjection`). Throttles the piggyback call in @@ -124,6 +125,20 @@ final class SwiftDashSDKContactsService: ObservableObject { .sink { [weak self] _ in self?.refresh() } + // The ownerId (current identity id) changes with the active wallet, so + // a runtime wallet switch invalidates every published snapshot. Rebuild + // against the new wallet's ownerId. `refresh` reads the ownerId from + // `DWCurrentUserIdentityInfo`, whose cache the same notification also + // invalidates β€” but NotificationCenter delivery order between the two + // observers isn't guaranteed, so force the identity snapshot fresh here + // (idempotent revision bump) before reading it. + activeWalletCancellable = NotificationCenter.default + .publisher(for: SwiftDashSDKWalletState.activeWalletDidChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + DWCurrentUserIdentityInfo.shared.refreshFromSDK() + self?.refresh() + } refresh() } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift index aba043774..8657d4eec 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift @@ -118,6 +118,15 @@ public final class DWCurrentUserIdentityInfo: NSObject { selector: #selector(handleInvalidationNotification(_:)), name: DWIdentityRegistrationBridge.stateChangedNotification, object: nil) + // A runtime wallet switch rebinds the host to a different wallet whose + // identity/username is entirely different (or absent). Invalidate so + // the next read rebuilds the snapshot from the new wallet's + // `PersistentIdentity` rows instead of serving the old wallet's cache. + center.addObserver( + self, + selector: #selector(handleInvalidationNotification(_:)), + name: SwiftDashSDKWalletState.activeWalletDidChangeNotification, + object: nil) } // MARK: - Obj-C / Swift read API diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift index 717ddf9bc..ead0ea945 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift @@ -229,9 +229,15 @@ final class SwiftDashSDKHost { return (handles.manager, resolvedWallet) } - /// Create or import a wallet as the active managed platform wallet. This is - /// the only path that writes new wallet identity into SwiftData and stores - /// its mnemonic in `WalletStorage`. + /// Create or import a wallet as the SOLE active managed platform wallet. + /// This is the fresh-install / recover path: it rebuilds the runtime from + /// scratch (`buildRuntime` tears down any running manager), creates the + /// wallet, stores its mnemonic, pins it active in the registry, and + /// publishes it as bound. Onboarding's first wallet uses this. + /// + /// For adding a wallet ALONGSIDE existing ones without rebinding the + /// active wallet, use `addWallet(mnemonic:)` instead β€” this path replaces + /// the running runtime and is not additive. @discardableResult func createOrImportWallet( mnemonic: String, @@ -247,9 +253,102 @@ final class SwiftDashSDKHost { let handles = try buildRuntime(for: network) let createdWallet: ManagedPlatformWallet do { - createdWallet = try handles.manager.createWallet( + createdWallet = try createAndPersist( mnemonic: mnemonic, + manager: handles.manager, network: handles.network, + modelContainer: handles.modelContainer) + } catch { + // `createOrImportWallet` owns a freshly-built (not yet published) + // runtime, so tear it down on failure. `createAndPersist` has + // already rolled back the wallet row + mnemonic it wrote. + stop() + throw error + } + + if let kind = registryNetworkKind(for: network) { + WalletEnvironment.setActiveWalletId(createdWallet.walletId, for: kind) + } + publish(handles: handles, wallet: createdWallet) + + let origin = isImported ? "imported" : "created" + Self.logger.info("πŸͺΊ HOST :: \(origin, privacy: .public) managed wallet for \(network.rawValue, privacy: .public)") + return createdWallet + } + + /// Outcome of `addWallet(mnemonic:)`. + enum AddWalletResult { + /// The wallet was created and its mnemonic persisted; the running + /// runtime is unchanged (the caller switches to it explicitly). + case added(walletId: Data) + /// A wallet deriving this walletId already has a persisted mnemonic on + /// this device β€” nothing was written. The caller offers switching to it. + case alreadyExists(walletId: Data) + } + + /// Add a wallet from `mnemonic` ADDITIVELY: create it in the already-running + /// manager and persist its mnemonic, WITHOUT tearing down the runtime, + /// touching the active-wallet registry, or rebinding the published active + /// wallet. The caller (Wallets screen "Add Wallet") switches to the new + /// wallet afterward via `SwiftDashSDKWalletRuntime.switchWallet`. + /// + /// Requires a running host (a bound active wallet already exists β€” adding + /// is only reachable from the Wallets screen). Returns `.alreadyExists` + /// without writing anything when a mnemonic for the derived walletId is + /// already persisted (`manager.createWallet` is idempotent by walletId, so + /// re-adding would silently no-op β€” the caller surfaces this instead). + /// + /// Shares the create-then-persist-with-rollback body with + /// `createOrImportWallet` (`createAndPersist`); differs only in that it + /// uses the LIVE manager and does not publish or set-active. + @discardableResult + func addWallet(mnemonic: String) throws -> AddWalletResult { + guard !mnemonic.isEmpty, Mnemonic.validate(mnemonic) else { + throw HostError.invalidMnemonic + } + guard let manager = manager, + let modelContainer = modelContainer, + let network = runningNetwork else { + throw HostError.walletNotFound(runningNetwork ?? .mainnet) + } + + // Idempotence guard: `createWallet` is keyed by the deterministic + // walletId, so adding an already-present wallet would no-op. Detect it + // from the persisted-mnemonic set (the switchable-wallet source of + // truth) and report `.alreadyExists` rather than a fabricated success. + let derivedId = try Wallet(mnemonic: mnemonic, network: network).id + if Self.persistedMnemonics().contains(where: { $0.walletId == derivedId }) { + Self.logger.info("πŸͺΊ HOST :: addWallet β€” walletId already persisted; not re-adding") + return .alreadyExists(walletId: derivedId) + } + + let createdWallet = try createAndPersist( + mnemonic: mnemonic, + manager: manager, + network: network, + modelContainer: modelContainer) + + Self.logger.info("πŸͺΊ HOST :: added managed wallet for \(network.rawValue, privacy: .public) (additive)") + return .added(walletId: createdWallet.walletId) + } + + /// Create a wallet in `manager` and persist its mnemonic in `WalletStorage`, + /// verifying the round-trip. On any failure, rolls back both the wallet row + /// (from `modelContainer`) and the mnemonic, then rethrows a typed + /// `HostError`. Does NOT touch the registry, publish, or stop the host β€” + /// runtime bookkeeping is the caller's (so this body is shared by the + /// rebuild path `createOrImportWallet` and the additive `addWallet`). + private func createAndPersist( + mnemonic: String, + manager: PlatformWalletManager, + network: Network, + modelContainer: ModelContainer + ) throws -> ManagedPlatformWallet { + let createdWallet: ManagedPlatformWallet + do { + createdWallet = try manager.createWallet( + mnemonic: mnemonic, + network: network, name: "dashwallet", createDefaultAccounts: true) } catch { @@ -267,18 +366,16 @@ final class SwiftDashSDKHost { } catch { Self.logger.error("πŸͺΊ HOST :: mnemonic persistence failed: \(String(describing: error), privacy: .public)") try? storage.deleteMnemonic(for: createdWallet.walletId) - deletePersistedWallet(walletId: createdWallet.walletId, in: handles.modelContainer) - stop() + deletePersistedWallet(walletId: createdWallet.walletId, in: modelContainer) + // Also drop the just-created wallet from the live manager so an + // additive add doesn't leave an orphan in `manager.wallets`. + try? manager.deleteWallet(walletId: createdWallet.walletId) if let hostError = error as? HostError { throw hostError } throw HostError.mnemonicPersistenceFailed(error) } - publish(handles: handles, wallet: createdWallet) - - let origin = isImported ? "imported" : "created" - Self.logger.info("πŸͺΊ HOST :: \(origin, privacy: .public) managed wallet for \(network.rawValue, privacy: .public)") return createdWallet } @@ -360,14 +457,53 @@ final class SwiftDashSDKHost { network: Network ) throws -> ManagedPlatformWallet { let restored = try manager.loadFromPersistor() - if let first = manager.firstWallet { + if let resolved = resolveActiveWallet(in: manager, network: network) { Self.logger.info("πŸͺΊ HOST :: reusing persisted wallet; restored=\(restored.count, privacy: .public)") - return first + return resolved } throw HostError.walletNotFound(network) } + /// `WalletEnvironment.NetworkKind` for the SDK `Network` β€” the app-side + /// key the active-wallet registry is scoped by. Only `.mainnet` / + /// `.testnet` reach the registry; `.devnet`/`.regtest` don't run a + /// persisted wallet (`buildRuntime` rejects `.regtest`), so they map to + /// `nil` and the resolver falls back to `firstWallet` without touching + /// the registry. + private func registryNetworkKind(for network: Network) -> WalletEnvironment.NetworkKind? { + switch network { + case .mainnet: return .mainnet + case .testnet: return .testnet + default: return nil + } + } + + /// Resolve which loaded wallet is active for `network`: the persisted + /// `WalletEnvironment.activeWalletId` when it names a wallet the manager + /// currently holds, otherwise `firstWallet` (unset registry, or the + /// recorded wallet is gone). The resolved walletId is written back to the + /// registry so it's concrete after the first launch β€” including the + /// fallback, which pins the arbitrary-but-deterministic `firstWallet` as + /// the active choice going forward. Returns `nil` only when the manager + /// holds no wallets at all. + private func resolveActiveWallet( + in manager: PlatformWalletManager, + network: Network + ) -> ManagedPlatformWallet? { + let kind = registryNetworkKind(for: network) + if let kind, + let activeId = WalletEnvironment.activeWalletId(for: kind), + let active = manager.wallets[activeId] { + return active + } + guard let fallback = manager.firstWallet else { return nil } + if let kind { + WalletEnvironment.setActiveWalletId(fallback.walletId, for: kind) + } + return fallback + } + /// `walletNotFound` retry: re-create wallet rows from the keychain /// mnemonics (`persistedMnemonics`). One attempt per entry, no retry loop β€” /// `createWallet` is idempotent by walletId (`Wallet(mnemonic:network:).id`), @@ -401,9 +537,9 @@ final class SwiftDashSDKHost { } } - guard let first = handles.manager.firstWallet else { return nil } + guard let resolved = resolveActiveWallet(in: handles.manager, network: handles.network) else { return nil } Self.logger.info("πŸͺΊ HOST :: recovered persisted wallet from keychain mnemonic(s); entries=\(entries.count, privacy: .public)") - return first + return resolved } private func publish(handles: RuntimeHandles, wallet resolvedWallet: ManagedPlatformWallet) { diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKReceiveAddressReader.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKReceiveAddressReader.swift index 8307c3471..20daad856 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKReceiveAddressReader.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKReceiveAddressReader.swift @@ -67,9 +67,10 @@ final class SwiftDashSDKReceiveAddressReader: NSObject { @objc static func isAddressUsed(_ address: String) -> Bool { onMain { - guard let container = SwiftDashSDKHost.shared.modelContainer else { return false } + guard let container = SwiftDashSDKHost.shared.modelContainer, + let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { return false } var descriptor = FetchDescriptor( - predicate: #Predicate { $0.address == address }) + predicate: #Predicate { $0.address == address && $0.walletId == walletId }) descriptor.fetchLimit = 1 let rows = (try? container.mainContext.fetch(descriptor)) ?? [] return !rows.isEmpty @@ -85,8 +86,23 @@ final class SwiftDashSDKReceiveAddressReader: NSObject { @objc(receivedTotalExcludingAddress:) static func receivedTotal(excludingAddress address: String) -> UInt64 { onMain { - guard let container = SwiftDashSDKHost.shared.modelContainer else { return 0 } - let descriptor = FetchDescriptor() + guard let container = SwiftDashSDKHost.shared.modelContainer, + let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { return 0 } + // Scope to the active wallet via the TXO join: `PersistentTransaction` + // carries no walletId, so gather the wallet's txids from its + // walletId-scoped TXO rows (producing + spending sides) and sum + // only those transactions' own outputs. + let txoDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + guard let txos = try? container.mainContext.fetch(txoDescriptor) else { return 0 } + var txids = Set() + for txo in txos { + if let producing = txo.transaction { txids.insert(producing.txid) } + if let spending = txo.spendingTransaction { txids.insert(spending.txid) } + } + guard !txids.isEmpty else { return 0 } + let descriptor = FetchDescriptor( + predicate: #Predicate { txids.contains($0.txid) }) guard let rows = try? container.mainContext.fetch(descriptor) else { return 0 } var total: UInt64 = 0 diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift index dbf239cb8..efde23896 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift @@ -85,6 +85,89 @@ final class SwiftDashSDKWalletRuntime: NSObject { dispatchOnPipeline { shared.enqueueFullReset(lastError: nil, forWipe: true) } } + // MARK: - Runtime wallet switching + + /// Switch the active wallet (same network) to `walletId` at runtime. + /// + /// Reuses the exact stop β†’ clear β†’ load β†’ start sequence a network switch + /// runs (`refresh`), the only difference being that the network is + /// unchanged and the active-wallet registry is repointed first: after + /// `WalletEnvironment.setActiveWalletId`, the host's Phase-0 + /// `resolveActiveWallet` binds `walletId` when it rebuilds. On success the + /// new wallet is bound and its balance seeded, and + /// `activeWalletDidChangeNotification` has been posted. + /// + /// Validation runs synchronously on the main actor before any teardown: + /// the target must be a persisted wallet on the current network (its + /// mnemonic present in `WalletStorage`), or the call throws without + /// touching the running runtime. Switching to the already-active wallet is + /// a success no-op. + @MainActor + func switchWallet(to walletId: Data) async throws { + let network = try validateSwitchTarget(walletId) + + let kind = registryNetworkKind(for: network) + if WalletEnvironment.activeWalletId(for: kind) == walletId, + SwiftDashSDKHost.shared.wallet?.walletId == walletId { + Self.logger.info("🧭 RUNTIME :: switchWallet β€” target already active; no-op") + return + } + + WalletEnvironment.setActiveWalletId(walletId, for: kind) + + // Same stop/clear/load/start sequence as a network switch, enqueued on + // the serial lifecycle chain so it can't interleave with a concurrent + // refresh/wipe. `.walletDidChange` never elides the refresh. + await enqueueAwaitable { [weak self] in + await self?.refresh(trigger: .walletDidChange) + }.value + + guard SwiftDashSDKHost.shared.wallet?.walletId == walletId else { + throw SwitchError.bindFailed + } + + NotificationCenter.default.post( + name: SwiftDashSDKWalletState.activeWalletDidChangeNotification, + object: nil) + Self.logger.info("🧭 RUNTIME :: switchWallet β€” bound new active wallet and posted change") + } + + /// Validate that `walletId` is a switchable target on the current network: + /// the network must be SDK-supported and a mnemonic for `walletId` must be + /// persisted in `WalletStorage` β€” the same keychain surface the host loads + /// and recovers wallets from. Returns the resolved `Network` for the + /// caller's registry write. Throws (leaving the runtime untouched) on an + /// unsupported network or an unknown/missing walletId. + @MainActor + private func validateSwitchTarget(_ walletId: Data) throws -> Network { + let network: Network + switch resolveCurrentNetwork() { + case .failure: + throw SwitchError.unsupportedNetwork + case .success(let resolved): + network = resolved + } + + let persistedIds = SwiftDashSDKHost.persistedMnemonics().map { $0.walletId } + guard persistedIds.contains(walletId) else { + throw SwitchError.unknownWallet + } + return network + } + + /// `WalletEnvironment.NetworkKind` for the SDK `Network`. Only + /// `.mainnet`/`.testnet` reach a persisted wallet (the runtime fails fast + /// on every other network before this is called), so the switch path never + /// sees a network without a registry key; the `default` maps to `.testnet` + /// defensively to keep the return non-optional. + private func registryNetworkKind(for network: Network) -> WalletEnvironment.NetworkKind { + switch network { + case .mainnet: return .mainnet + case .testnet: return .testnet + default: return .testnet + } + } + /// Push one ordered step from any thread into the MainActor lifecycle. /// `entryQueue` serializes Task creation; the MainActor then processes /// the enqueue calls in the order their Tasks were created. The block @@ -105,11 +188,23 @@ final class SwiftDashSDKWalletRuntime: NSObject { /// succession (e.g. `stop()` then `startIfReady()` from the diagnostic /// Restart button) are processed strictly in order. private func enqueue(_ op: @escaping @MainActor () async -> Void) { + currentLifecycleTask = enqueueAwaitable(op) + } + + /// Same serial-chain append as `enqueue`, but returns the appended task so + /// an awaiting caller (`switchWallet`) can block until its own op β€” and + /// every op enqueued before it β€” has completed. Preserves the FIFO + /// ordering `enqueue` provides: the returned task awaits the previous + /// lifecycle task before running. + @discardableResult + private func enqueueAwaitable(_ op: @escaping @MainActor () async -> Void) -> Task { let previous = currentLifecycleTask - currentLifecycleTask = Task { @MainActor in + let task = Task { @MainActor in await previous?.value await op() } + currentLifecycleTask = task + return task } private func enqueueRefresh(trigger: RefreshTrigger) { @@ -187,7 +282,11 @@ final class SwiftDashSDKWalletRuntime: NSObject { private func shouldSkipRefresh(for network: Network, trigger: RefreshTrigger) -> Bool { switch trigger { - case .walletMaterialChanged: + case .walletMaterialChanged, .walletDidChange: + // A runtime wallet switch always rebuilds β€” the active-wallet + // registry was repointed to a different wallet on the SAME + // network, so `currentNetwork == network` would otherwise wrongly + // elide the rebind. return false case .startIfReady, .networkDidChange: // External callers (PlatformSyncStatusScreen, StorageExplorerUnavailableView, @@ -255,6 +354,7 @@ final class SwiftDashSDKWalletRuntime: NSObject { case startIfReady case networkDidChange case walletMaterialChanged + case walletDidChange } enum RuntimeError: LocalizedError { @@ -267,4 +367,27 @@ final class SwiftDashSDKWalletRuntime: NSObject { } } } + + /// Failure modes of `switchWallet(to:)`. Surfaced to the caller rather + /// than logged-and-swallowed so a UI switch flow can report why it failed. + enum SwitchError: LocalizedError { + /// The current network isn't SDK-supported (devnet/unsupported). + case unsupportedNetwork + /// No mnemonic is persisted in `WalletStorage` for the target walletId. + case unknownWallet + /// The stop/clear/load/start sequence ran but the host did not bind the + /// requested wallet (e.g. its rows failed to load). + case bindFailed + + var errorDescription: String? { + switch self { + case .unsupportedNetwork: + return "Cannot switch wallet: the current network is not supported." + case .unknownWallet: + return "Cannot switch wallet: no wallet with that id is stored on this network." + case .bindFailed: + return "Switching wallet failed: the new wallet could not be loaded." + } + } + } } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift index bac4cfe47..f6db76d39 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletState.swift @@ -132,6 +132,20 @@ public final class SwiftDashSDKWalletState: NSObject, ObservableObject { @objc public static let balanceDidChangeNotification = NSNotification.Name("DWSwiftDashSDKWalletStateBalanceDidChange") + /// Notification posted on the main queue AFTER the active wallet is + /// switched at runtime (`SwiftDashSDKWalletRuntime.switchWallet`) β€” the + /// host has bound the new wallet and its balance state has been seeded. + /// Consumers that cache per-wallet state keyed off the host's active + /// wallet (identity snapshot, DashPay contacts, tx list, DashPay tab + /// gating) observe this to invalidate and reload for the new wallet. + /// + /// A distinct name (guardrail #5): it is NOT a re-emission of any DashSync + /// `DS*` name nor of `balanceDidChangeNotification` β€” a balance change and + /// an active-wallet change are different events, and the switch drives the + /// balance notification separately (via `clearAllState` + the SPV re-seed). + @objc public static let activeWalletDidChangeNotification = + NSNotification.Name("DWSwiftDashSDKWalletStateActiveWalletDidChange") + /// Obj-C-friendly accessor for the current total balance in satoshis. /// Returns 0 when no balance is published yet (e.g. before SPV first /// emits a balance event for an imported wallet, or after `clearBalance`). diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift index c75654b35..d335a272d 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift @@ -90,6 +90,11 @@ final class SwiftDashSDKWalletWiper: NSObject { // background queue with no @MainActor hop (unlike deleteWalletsFromSDK). CoinJoinRecovery.shared.resetForWipe() CoinJoinWithdrawalStore.shared.resetForWipe() + // CrowdNode state is per-wallet-keyed; the wipe destroys every wallet, so + // clear every wallet's keys (the CrowdNode singleton's own + // `DWWillWipeWallet` observer only resets the ACTIVE wallet's keys). Also + // UserDefaults-only, safe from this background queue. + CrowdNodeDefaults.shared.resetForWipe() // Enumerate every wallet that still has stored material BEFORE any // deletion runs. Both the SDK wipe and the mnemonic safety-net below @@ -115,16 +120,21 @@ final class SwiftDashSDKWalletWiper: NSObject { // example app's `WalletDetailView.deleteWallet()`. Must run BEFORE the // teardown: the manager is dropped in `host.stop()`, and the // `PersistentWallet` row (needed for the identity/account cascade) is - // deleted by `fullReset(forWipe:)`. + // deleted by `fullReset(forWipe:)`. Each per-wallet delete also removes + // that wallet's Keychain mnemonic (the safety-net step lives inside + // `deleteWalletFromSDK`), so no separate mnemonic-delete loop follows. deleteWalletsFromSDK(walletIds) + // Clear the per-network active-wallet registry. The wipe removes ALL + // wallets (mnemonics are network-agnostic β€” one keychain entry backs a + // wallet on every network), so every network's recorded active id now + // points at nothing. Phase 0's `resolveActiveWallet` fallback would + // mask a stale id, but clearing it keeps the registry honest β€” a + // wallet created afterwards resolves as `firstWallet` and re-pins + // itself rather than briefly matching a dead id. UserDefaults-only, so + // safe from this background queue. + WalletEnvironment.setActiveWalletId(nil, for: .mainnet) + WalletEnvironment.setActiveWalletId(nil, for: .testnet) - // Safety net: ensure the seed is gone even if `deleteWallet` threw - // before reaching its own mnemonic-delete step (e.g. the wallet was - // never registered in the Rust manager). Idempotent β€” no-op on an - // already-deleted mnemonic. - for walletId in walletIds { - try? storage.deleteMnemonic(for: walletId) - } logger.info("wiped \(walletIds.count) wallet(s) from SwiftDashSDK") // Tear down the app-owned runtime now that all wallet material is @@ -142,16 +152,8 @@ final class SwiftDashSDKWalletWiper: NSObject { private static func deleteWalletsFromSDK(_ walletIds: [Data]) { let work = { MainActor.assumeIsolated { - guard let manager = SwiftDashSDKHost.shared.manager else { - logger.info("no live PlatformWalletManager; skipping SDK deleteWallet") - return - } for walletId in walletIds { - do { - try manager.deleteWallet(walletId: walletId) - } catch { - logger.error("deleteWallet failed: \(String(describing: error), privacy: .public)") - } + deleteWalletFromSDK(walletId) } } } @@ -161,4 +163,45 @@ final class SwiftDashSDKWalletWiper: NSObject { DispatchQueue.main.sync { work() } } } + + /// Full per-wallet SwiftDashSDK deletion of a single wallet: the Rust + /// manager state + this wallet's SwiftData rows (via + /// `PlatformWalletManager.deleteWallet(walletId:)`) and its Keychain + /// mnemonic (via `WalletStorage().deleteMnemonic(for:)`). Both steps are + /// idempotent β€” a no-op on an already-deleted wallet β€” and their failures + /// are logged, not thrown, so a partial failure of one step doesn't block + /// the other. + /// + /// The single per-wallet deletion primitive shared by the full wipe + /// (`deleteWalletsFromSDK`) and the Wallets screen's per-wallet Remove + /// flow (`WalletsViewModel`) β€” one body, not a copy on each side + /// (guardrail #1). Callers own their own registry/runtime bookkeeping + /// (the wipe tears the runtime down; the remove flow rebinds via + /// `switchWallet` before deleting the active wallet). + @MainActor + static func deleteWalletFromSDK(_ walletId: Data) { + if let manager = SwiftDashSDKHost.shared.manager { + do { + try manager.deleteWallet(walletId: walletId) + } catch { + logger.error("deleteWallet failed: \(String(describing: error), privacy: .public)") + } + } else { + logger.info("no live PlatformWalletManager; skipping SDK deleteWallet") + } + // Safety net: ensure the seed is gone even if `deleteWallet` threw + // before reaching its own mnemonic-delete step. Idempotent. + try? WalletStorage().deleteMnemonic(for: walletId) + + // Clear this wallet's per-wallet app-side state that lives outside the + // SDK/SwiftData/Keychain teardown above: CrowdNode account state and the + // CoinJoin withdrawal tag set are UserDefaults, keyed by walletId hex. + // Done here (not in the UI) so BOTH the per-wallet Remove flow and the + // full wipe's per-wallet loop clear them β€” one shared deletion primitive + // (guardrail #1). Hex must match `WalletEnvironment.activeWalletIdHex` + // (lowercase, %02x). Idempotent; UserDefaults-only, so thread-agnostic. + let walletIdHex = walletId.map { String(format: "%02x", $0) }.joined() + CrowdNodeDefaults.shared.clearPerWalletKeys(forWalletIdHex: walletIdHex) + CoinJoinWithdrawalStore.shared.clearForWallet(walletIdHex: walletIdHex) + } } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift index d7fd18f02..c548b2274 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift @@ -84,6 +84,57 @@ public final class WalletEnvironment: NSObject { SwiftDashSDKHost.hasPersistedSDKWallet() } + // MARK: - Active-wallet registry + + /// UserDefaults key holding the raw walletId `Data` chosen as active on + /// `network`. One key per network β€” the app tracks a distinct active + /// wallet on mainnet and testnet (the same posture as the per-network + /// SwiftData store `SwiftDashSDKHost.buildModelContainer` builds). A + /// missing key means "unset" β€” no wallet has been resolved on this + /// network yet, and `SwiftDashSDKHost` falls back to `firstWallet`. + private static func activeWalletIdKey(for network: NetworkKind) -> String { + "DW_ACTIVE_WALLET_ID_\(network.rawValue)" + } + + /// The walletId last resolved as active for `network`, or `nil` when + /// unset. Written by `SwiftDashSDKHost` whenever it binds a wallet + /// (including the `firstWallet` fallback and after `createOrImportWallet`), + /// so the registry becomes concrete after first launch. The stored value + /// is the raw 32-byte walletId `Data`. + public static func activeWalletId(for network: NetworkKind) -> Data? { + UserDefaults.standard.data(forKey: activeWalletIdKey(for: network)) + } + + /// Persist (or clear, when `id` is `nil`) the active walletId for + /// `network`. Sole writer of the per-network active-wallet key. + public static func setActiveWalletId(_ id: Data?, for network: NetworkKind) { + let defaults = UserDefaults.standard + let key = activeWalletIdKey(for: network) + if let id { + defaults.set(id, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + /// The active walletId for the app's CURRENT network, hex-encoded, or nil + /// when no wallet is resolved yet (fresh install, or between wipe and first + /// create). ObjC-facing so `DWGlobalOptions` can scope its per-wallet + /// UserDefaults keys (backup / has-balance) by the active wallet without + /// importing SwiftDashSDK. Resolves through the same per-network registry + /// the Swift side reads (`activeWalletId(for:)`) β€” one place owns the + /// registry. `devnet`/unsupported network β‡’ nil. + @objc public static var activeWalletIdHex: NSString? { + let kind: NetworkKind + switch networkKind { + case .mainnet: kind = .mainnet + case .testnet: kind = .testnet + case .devnet: return nil + } + guard let id = activeWalletId(for: kind) else { return nil } + return id.map { String(format: "%02x", $0) }.joined() as NSString + } + /// App-level wallet existence. MIGRATION-WINDOW UNION: SDK presence OR /// DashSync `chain.hasAWallet` β€” DashSync-only wallets exist transiently /// (the recover flow's async SDK import; migrator-deferred multi-wallet / diff --git a/DashWallet/Sources/Models/CoinJoin/CoinJoinWithdrawalStore.swift b/DashWallet/Sources/Models/CoinJoin/CoinJoinWithdrawalStore.swift index 3279ef68f..ab524a046 100644 --- a/DashWallet/Sources/Models/CoinJoin/CoinJoinWithdrawalStore.swift +++ b/DashWallet/Sources/Models/CoinJoin/CoinJoinWithdrawalStore.swift @@ -25,18 +25,45 @@ final class CoinJoinWithdrawalStore { private let defaults = UserDefaults.standard private let lock = NSLock() - /// Single global set: txids are globally unique, so no per-network scoping - /// is needed (and we avoid touching DashSync's main-thread-affine - /// `currentChain` from the background grouping queue). - private let key = "coinJoinWithdrawal.v1.txids" + /// Legacy (pre-multi-wallet) global key. Kept as the migration source and as + /// the fallback when no wallet is active; the effective key is scoped per + /// wallet (`_`) so a swept txid recorded under + /// wallet A isn't tagged as A's withdrawal while wallet B is active. Though + /// txids are globally unique, the sweep belongs to one wallet's UTXOs, so the + /// tag set is per wallet. `WalletEnvironment.activeWalletIdHex` reads only + /// UserDefaults, so it stays off DashSync's main-thread-affine `currentChain` + /// (safe from the background grouping queue). + private let legacyKey = "coinJoinWithdrawal.v1.txids" + /// Cache keyed by the resolved (per-wallet) key, so a wallet switch between + /// accesses naturally re-reads the new wallet's set instead of serving the + /// previous wallet's cached txids. + private var cacheKey: String? private var cache: Set? private init() {} + /// The active wallet's per-wallet key, or the legacy global key when no + /// wallet is active. Seeds the per-wallet key once from the legacy value so a + /// pre-multi-wallet install's recorded sweeps carry over to its wallet. + private func resolvedKey() -> String { + guard let walletIdHex = WalletEnvironment.activeWalletIdHex as String?, + !walletIdHex.isEmpty else { + return legacyKey + } + let key = "\(legacyKey)_\(walletIdHex)" + if defaults.object(forKey: key) == nil, + let legacyValue = defaults.array(forKey: legacyKey) { + defaults.set(legacyValue, forKey: key) + } + return key + } + private func loaded() -> Set { - if let cache { return cache } + let key = resolvedKey() + if cacheKey == key, let cache { return cache } let stored = (defaults.array(forKey: key) as? [Data]) ?? [] let set = Set(stored) + cacheKey = key cache = set return set } @@ -48,7 +75,7 @@ final class CoinJoinWithdrawalStore { guard !set.contains(txid) else { return } set.insert(txid) cache = set - defaults.set(Array(set), forKey: key) + defaults.set(Array(set), forKey: resolvedKey()) } /// Whether `txid` (WIRE order, e.g. `Transaction.txHashData`) is a recorded @@ -58,14 +85,34 @@ final class CoinJoinWithdrawalStore { return loaded().contains(txid) } - /// Clear the recorded sweep txids on a wallet wipe so the global tag set - /// does not carry a previous wallet's sweeps into a restored one (and does - /// not grow unbounded across wipes). Also resets the in-memory cache on the - /// long-lived `shared` singleton, so stale txids don't linger until the next - /// process launch. Thread-safe. - func resetForWipe() { + /// Clear a SINGLE wallet's recorded sweep txids (used by the per-wallet + /// Remove flow β€” the removed wallet may not be the active one, so address it + /// by explicit `walletIdHex`). Thread-safe. Invalidates the in-memory cache + /// if it currently holds that wallet's set. + func clearForWallet(walletIdHex: String) { + guard !walletIdHex.isEmpty else { return } lock.lock(); defer { lock.unlock() } + let key = "\(legacyKey)_\(walletIdHex)" defaults.removeObject(forKey: key) - cache = [] + if cacheKey == key { + cacheKey = nil + cache = nil + } + } + + /// Clear ALL recorded sweep txids on a full wallet wipe (which destroys every + /// wallet): every wallet's per-wallet set (enumerated by prefix, since the + /// walletIds are gone by wipe time) plus the dormant legacy key. Also resets + /// the in-memory cache on the long-lived `shared` singleton so stale txids + /// don't linger until the next process launch. Thread-safe. + func resetForWipe() { + lock.lock(); defer { lock.unlock() } + for key in defaults.dictionaryRepresentation().keys { + if key == legacyKey || key.hasPrefix("\(legacyKey)_") { + defaults.removeObject(forKey: key) + } + } + cacheKey = nil + cache = nil } } diff --git a/DashWallet/Sources/Models/CrowdNode/CrowdNode+UserDefaults.swift b/DashWallet/Sources/Models/CrowdNode/CrowdNode+UserDefaults.swift index bfad12759..723960d70 100644 --- a/DashWallet/Sources/Models/CrowdNode/CrowdNode+UserDefaults.swift +++ b/DashWallet/Sources/Models/CrowdNode/CrowdNode+UserDefaults.swift @@ -17,14 +17,25 @@ import Foundation +// Genuinely app-global keys: user-level UX-education flags shown once ever, and +// CrowdNode service parameters (withdrawal limits, fee) that are refreshed from +// the CrowdNode API and identical for every account. These describe the app/user +// or the service, not one wallet's CrowdNode relationship β€” they stay global. private let kInfoShown = "crowdNodeInfoShownKey" -private let kLastKnownBalance = "lastKnownCrowdNodeBalanceKey" private let kWithdrawalLimitPerTx = "crowdNodeWithdrawalLimitPerTxKey" private let kWithdrawalLimitPerHour = "crowdNodeWithdrawalLimitPerHourKey" private let kWithdrawalLimitPerDay = "crowdNodeWithdrawalLimitPerDayKey" private let kWithdrawalLimitsInfoShown = "crowdNodeWithdrawalLimitsInfoShownKey" +private let kFeePercentage = "feePercentageKey" + +// Per-wallet keys: everything below describes ONE wallet's CrowdNode account +// (its funding/primary address, that account's signup/online state, its last +// known balance, its pending confirmation notification, its email-signing +// message id, its last withdrawal block). These are the LEGACY key names β€” kept +// as the migration source and as the fallback when no wallet is active β€” routed +// through `perWalletKey(_:)` so the effective key is `_`. +private let kLastKnownBalance = "lastKnownCrowdNodeBalanceKey" private let kOnlineAccountState = "сrowdNodeOnlineAccountStateKey" -private let kOnlineAccountAddress = "crowdNodeOnlineAccountAddressKey" private let kCrowdNodeAccountAddress = "crowdNodeAccountAddressKey" private let kCrowdNodePrimaryAddress = "crowdNodePrimaryAddressKey" private let kConfirmationDialogShown = "crowdNodeConfirmationDialogShownKey" @@ -32,19 +43,95 @@ private let kOnlineInfoShown = "crowdNodeOnlineInfoShownKey" private let kSignedEmailMessageId = "crowdNodeSignedEmailMessageId" private let kShouldShowConfirmedNotification = "shouldShowConfirmedNotification" private let kLastWithdrawalBlock = "lastWithdrawalBlockKey" -private let kFeePercentage = "feePercentageKey" + +/// The set of legacy keys that are scoped per wallet. Enumerated by `resetForWipe` +/// (which must clear the per-wallet variant for EVERY wallet) and used by +/// `CrowdNodeDefaults.perWalletKeysToClear(forWalletIdHex:)` to build the exact +/// per-wallet keys to remove when a single wallet is deleted. +private let kPerWalletKeys = [ + kLastKnownBalance, + kOnlineAccountState, + kCrowdNodeAccountAddress, + kCrowdNodePrimaryAddress, + kConfirmationDialogShown, + kOnlineInfoShown, + kSignedEmailMessageId, + kShouldShowConfirmedNotification, + kLastWithdrawalBlock, +] // MARK: - CrowdNodeDefaults class CrowdNodeDefaults { public static let shared: CrowdNodeDefaults = .init() + // MARK: - Per-wallet key scoping + // + // CrowdNode state describes ONE wallet's CrowdNode account (bound to a + // funding address in a specific wallet). With multi-wallet switching, an + // app-global key would show wallet A's CrowdNode balance/account under + // wallet B. The per-wallet keys above are scoped by the active walletId, + // mirroring the mechanism `DWGlobalOptions` established for its per-wallet + // flags: effective key = `_`, resolved live on + // every access; seeded once from the legacy key on first read for a wallet; + // legacy key kept dormant and used as the fallback when no wallet is active + // yet (e.g. before the SDK binds a wallet). The active id comes from + // `WalletEnvironment.activeWalletIdHex`. + + /// The active wallet's per-wallet key for `legacyKey`, or `nil` when no + /// wallet is active yet (fresh install / post-wipe window) β€” callers fall + /// back to the legacy global key in that case. + private func perWalletKey(_ legacyKey: String) -> String? { + guard let walletIdHex = WalletEnvironment.activeWalletIdHex as String?, + !walletIdHex.isEmpty else { + return nil + } + return "\(legacyKey)_\(walletIdHex)" + } + + /// Resolve the effective UserDefaults key for a per-wallet `legacyKey`, + /// seeding the per-wallet key once from the legacy value on first read so an + /// existing single-wallet install keeps its CrowdNode state. Returns the + /// legacy key itself when no wallet is active (fallback). + private func resolvedKey(_ legacyKey: String) -> String { + let defaults = UserDefaults.standard + guard let key = perWalletKey(legacyKey) else { return legacyKey } + if defaults.object(forKey: key) == nil, + let legacyValue = defaults.object(forKey: legacyKey) { + // First access for this wallet: seed from the legacy global value so + // a pre-multi-wallet install's CrowdNode account carries over. + defaults.set(legacyValue, forKey: key) + } + return key + } + + /// Drop the in-memory caches so the next read resolves the now-active + /// wallet's per-wallet keys. Called on `activeWalletDidChange` β€” the + /// persisted per-wallet values are untouched. + func invalidateCache() { + _accountAddress = nil + _infoShown = nil + _lastKnownBalance = nil + _crowdNodeWithdrawalLimitPerTx = nil + _crowdNodeWithdrawalLimitPerHour = nil + _crowdNodeWithdrawalLimitPerDay = nil + _feePercentage = nil + _withdrawalLimitsInfoShown = nil + _savedOnlineAccountState = nil + _crowdNodePrimaryAddress = nil + _confirmationDialogShown = nil + _onlineInfoShown = nil + _shouldShowConfirmedNotification = nil + _signedEmailMessageId = nil + _lastWithdrawalBlock = nil + } + private var _accountAddress: String? = nil var accountAddress: String? { - get { _accountAddress ?? UserDefaults.standard.value(forKey: kCrowdNodeAccountAddress) as? String } + get { _accountAddress ?? UserDefaults.standard.value(forKey: resolvedKey(kCrowdNodeAccountAddress)) as? String } set(value) { _accountAddress = value - UserDefaults.standard.set(value, forKey: kCrowdNodeAccountAddress) + UserDefaults.standard.set(value, forKey: resolvedKey(kCrowdNodeAccountAddress)) } } @@ -59,10 +146,10 @@ class CrowdNodeDefaults { private var _lastKnownBalance: UInt64? = nil var lastKnownBalance: UInt64 { - get { _lastKnownBalance ?? UserDefaults.standard.value(forKey: kLastKnownBalance) as? UInt64 ?? 0 } + get { _lastKnownBalance ?? UserDefaults.standard.value(forKey: resolvedKey(kLastKnownBalance)) as? UInt64 ?? 0 } set(value) { _lastKnownBalance = value - UserDefaults.standard.set(value, forKey: kLastKnownBalance) + UserDefaults.standard.set(value, forKey: resolvedKey(kLastKnownBalance)) } } @@ -113,68 +200,73 @@ class CrowdNodeDefaults { private var _savedOnlineAccountState: CrowdNode.OnlineAccountState? = nil var savedOnlineAccountState: CrowdNode.OnlineAccountState { - get { _savedOnlineAccountState ?? CrowdNode.OnlineAccountState(rawValue: UserDefaults.standard.integer(forKey: kOnlineAccountState)) ?? .none } + get { _savedOnlineAccountState ?? CrowdNode.OnlineAccountState(rawValue: UserDefaults.standard.integer(forKey: resolvedKey(kOnlineAccountState))) ?? .none } set(value) { _savedOnlineAccountState = value - UserDefaults.standard.set(value.rawValue, forKey: kOnlineAccountState) + UserDefaults.standard.set(value.rawValue, forKey: resolvedKey(kOnlineAccountState)) } } private var _crowdNodePrimaryAddress: String? = nil var crowdNodePrimaryAddress: String? { - get { _crowdNodePrimaryAddress ?? UserDefaults.standard.value(forKey: kCrowdNodePrimaryAddress) as? String } + get { _crowdNodePrimaryAddress ?? UserDefaults.standard.value(forKey: resolvedKey(kCrowdNodePrimaryAddress)) as? String } set(value) { _crowdNodePrimaryAddress = value - UserDefaults.standard.set(value, forKey: kCrowdNodePrimaryAddress) + UserDefaults.standard.set(value, forKey: resolvedKey(kCrowdNodePrimaryAddress)) } } private var _confirmationDialogShown: Bool? = nil var confirmationDialogShown: Bool { - get { _confirmationDialogShown ?? UserDefaults.standard.bool(forKey: kConfirmationDialogShown) } + get { _confirmationDialogShown ?? UserDefaults.standard.bool(forKey: resolvedKey(kConfirmationDialogShown)) } set(value) { _confirmationDialogShown = value - UserDefaults.standard.set(value, forKey: kConfirmationDialogShown) + UserDefaults.standard.set(value, forKey: resolvedKey(kConfirmationDialogShown)) } } private var _onlineInfoShown: Bool? = nil var onlineInfoShown: Bool { - get { _onlineInfoShown ?? UserDefaults.standard.bool(forKey: kOnlineInfoShown) } + get { _onlineInfoShown ?? UserDefaults.standard.bool(forKey: resolvedKey(kOnlineInfoShown)) } set(value) { _onlineInfoShown = value - UserDefaults.standard.set(value, forKey: kOnlineInfoShown) + UserDefaults.standard.set(value, forKey: resolvedKey(kOnlineInfoShown)) } } private var _shouldShowConfirmedNotification: Bool? = nil var shouldShowConfirmedNotification: Bool { - get { _shouldShowConfirmedNotification ?? UserDefaults.standard.bool(forKey: kShouldShowConfirmedNotification) } + get { _shouldShowConfirmedNotification ?? UserDefaults.standard.bool(forKey: resolvedKey(kShouldShowConfirmedNotification)) } set(value) { _shouldShowConfirmedNotification = value - UserDefaults.standard.set(value, forKey: kShouldShowConfirmedNotification) + UserDefaults.standard.set(value, forKey: resolvedKey(kShouldShowConfirmedNotification)) } } private var _signedEmailMessageId: Int? = nil var signedEmailMessageId: Int { - get { _signedEmailMessageId ?? UserDefaults.standard.value(forKey: kSignedEmailMessageId) as? Int ?? -1 } + get { _signedEmailMessageId ?? UserDefaults.standard.value(forKey: resolvedKey(kSignedEmailMessageId)) as? Int ?? -1 } set(value) { _signedEmailMessageId = value - UserDefaults.standard.set(value, forKey: kSignedEmailMessageId) + UserDefaults.standard.set(value, forKey: resolvedKey(kSignedEmailMessageId)) } } - + private var _lastWithdrawalBlock: UInt32? = nil var lastWithdrawalBlock: UInt32 { - get { _lastWithdrawalBlock ?? UserDefaults.standard.value(forKey: kLastWithdrawalBlock) as? UInt32 ?? 0 } + get { _lastWithdrawalBlock ?? UserDefaults.standard.value(forKey: resolvedKey(kLastWithdrawalBlock)) as? UInt32 ?? 0 } set(value) { _lastWithdrawalBlock = value - UserDefaults.standard.set(value, forKey: kLastWithdrawalBlock) + UserDefaults.standard.set(value, forKey: resolvedKey(kLastWithdrawalBlock)) } } + /// Reset the ACTIVE wallet's CrowdNode state (plus the two global education + /// flags). Writes through the per-wallet-scoped properties, so it clears the + /// active wallet's keys only β€” used by `CrowdNode.reset()` on + /// network-change / alien-address detection, where only the current wallet's + /// relationship is being torn down. func resetUserDefaults() { infoShown = false lastKnownBalance = 0 @@ -187,4 +279,41 @@ class CrowdNodeDefaults { signedEmailMessageId = -1 lastWithdrawalBlock = 0 } + + /// Clear a SINGLE wallet's per-wallet CrowdNode keys (the removed wallet may + /// not be the active one, so this addresses keys by explicit `walletIdHex` + /// rather than through the active-wallet-scoped properties). Invoked from the + /// shared per-wallet deletion primitive when a wallet is removed. Leaves the + /// legacy keys and the global education/limit flags intact. + func clearPerWalletKeys(forWalletIdHex walletIdHex: String) { + guard !walletIdHex.isEmpty else { return } + let defaults = UserDefaults.standard + for legacyKey in kPerWalletKeys { + defaults.removeObject(forKey: "\(legacyKey)_\(walletIdHex)") + } + } + + /// Clear CrowdNode state for a full wallet wipe (which destroys EVERY + /// wallet). Removes the per-wallet variant of each per-wallet key for every + /// wallet β€” enumerated by prefix over UserDefaults, since the walletIds are + /// gone by wipe time β€” plus the dormant legacy keys, plus the global + /// education flags. Also drops the in-memory cache on the long-lived + /// `shared` singleton so stale values don't linger until the next launch. + func resetForWipe() { + let defaults = UserDefaults.standard + for key in defaults.dictionaryRepresentation().keys { + if kPerWalletKeys.contains(where: { key.hasPrefix("\($0)_") }) { + defaults.removeObject(forKey: key) + } + } + // Dormant legacy (pre-multi-wallet) per-wallet keys. + for legacyKey in kPerWalletKeys { + defaults.removeObject(forKey: legacyKey) + } + // Global education flags (limits/fee are re-fetched from the API, so + // they're left to refresh on next use). + defaults.removeObject(forKey: kInfoShown) + defaults.removeObject(forKey: kWithdrawalLimitsInfoShown) + invalidateCache() + } } diff --git a/DashWallet/Sources/Models/CrowdNode/CrowdNode.swift b/DashWallet/Sources/Models/CrowdNode/CrowdNode.swift index 3fceb8fe8..a439dd69a 100644 --- a/DashWallet/Sources/Models/CrowdNode/CrowdNode.swift +++ b/DashWallet/Sources/Models/CrowdNode/CrowdNode.swift @@ -142,6 +142,18 @@ public final class CrowdNode { .sink { [weak self] _ in self?.reset() } .store(in: &cancellableBag) + // A runtime wallet switch rebinds every per-wallet fact: CrowdNode state + // is scoped by the active walletId (see CrowdNodeDefaults), and this + // singleton caches the previous wallet's account/balance/signup state in + // memory (plus CrowdNodeDefaults' own `_`-backed value cache). Reload + // against the now-active wallet so wallet A's CrowdNode balance/account + // never shows under wallet B. Mirrors SwiftDashSDKContactsService's + // observer of the same notification. + NotificationCenter.default.publisher(for: SwiftDashSDKWalletState.activeWalletDidChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.handleActiveWalletChanged() } + .store(in: &cancellableBag) + $onlineAccountState .receive(on: DispatchQueue.main) .sink { [weak self] state in @@ -297,6 +309,28 @@ extension CrowdNode { balance = 0 prefs.resetUserDefaults() } + + /// React to a runtime wallet switch: drop the in-memory state cached for the + /// previous wallet and re-restore from the now-active wallet's per-wallet + /// defaults. Unlike `reset()`, this does NOT call `prefs.resetUserDefaults()` + /// β€” that would erase the newly-active wallet's persisted CrowdNode state. + /// It only invalidates `CrowdNodeDefaults`' in-memory value cache (so the + /// next read resolves the new wallet's keys) and re-runs `restoreState()`. + private func handleActiveWalletChanged() { + DWLogger.log("CrowdNode reloading for active wallet change") + prefs.invalidateCache() + // Clear the previous wallet's published/in-memory state without touching + // persisted defaults, then re-open the restore path (its `signUpState > + // .notStarted` guard would otherwise short-circuit the reload). + signUpState = .notInitiated + onlineAccountState = .none + linkingApiAddress = nil + primaryAddress = nil + apiError = nil + balance = 0 + isOnlineStateRestored = false + restoreState() + } private func checkAPY() { // Estimated figure at the current SPV tip; recomputing is cheap. diff --git a/DashWallet/Sources/Models/CrowdNode/Services/TransactionObserver.swift b/DashWallet/Sources/Models/CrowdNode/Services/TransactionObserver.swift index ad1ab3a01..8c960ec51 100644 --- a/DashWallet/Sources/Models/CrowdNode/Services/TransactionObserver.swift +++ b/DashWallet/Sources/Models/CrowdNode/Services/TransactionObserver.swift @@ -115,18 +115,34 @@ public final class TransactionObserver { fetchLimit: Int?, firstSeenAtOrAfter: UInt64? ) -> [ObservedTransaction] { - guard let container = SwiftDashSDKHost.shared.modelContainer else { - logger.info("πŸ…Ύ OBSERVER :: no model container yet β€” empty scan") + guard let container = SwiftDashSDKHost.shared.modelContainer, + let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { + logger.info("πŸ…Ύ OBSERVER :: no model container / active wallet yet β€” empty scan") return [] } guard case .success(let network) = SwiftDashSDKWalletRuntime.shared.resolveCurrentNetwork() else { logger.error("πŸ…Ύ OBSERVER :: unsupported network β€” empty scan") return [] } + // Scope to the active wallet via the TXO join: gather the wallet's + // txids from its walletId-scoped TXO rows (CrowdNode responses land + // in the active wallet's own addresses), then match transactions in + // that set. `PersistentTransaction` carries no walletId of its own. + let txoDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + let txos = (try? container.mainContext.fetch(txoDescriptor)) ?? [] + var txids = Set() + for txo in txos { + if let producing = txo.transaction { txids.insert(producing.txid) } + if let spending = txo.spendingTransaction { txids.insert(spending.txid) } + } + guard !txids.isEmpty else { return [] } var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.firstSeen, order: .reverse)]) if let floor = firstSeenAtOrAfter { - descriptor.predicate = #Predicate { $0.firstSeen >= floor } + descriptor.predicate = #Predicate { txids.contains($0.txid) && $0.firstSeen >= floor } + } else { + descriptor.predicate = #Predicate { txids.contains($0.txid) } } if let fetchLimit { descriptor.fetchLimit = fetchLimit diff --git a/DashWallet/Sources/Models/DWGlobalOptions.m b/DashWallet/Sources/Models/DWGlobalOptions.m index 637cd8721..a2fab93eb 100644 --- a/DashWallet/Sources/Models/DWGlobalOptions.m +++ b/DashWallet/Sources/Models/DWGlobalOptions.m @@ -16,6 +16,7 @@ // #import "DWGlobalOptions.h" +#import "dashwallet-Swift.h" #import NS_ASSUME_NONNULL_BEGIN @@ -25,9 +26,20 @@ static NSString *const LOCKSCREEN_DISABLED_KEY = @"org.dash.wallet.lockscreen-disabled"; static NSString *const SPENDING_CONFIRMATION_DISABLED_KEY = @"org.dash.wallet.spending-confirmation-disabled"; +// Legacy app-global UserDefaults keys for the two per-wallet flags. Written by +// the DSDynamicOptions default registration (`DW_GLOB_`); still read +// as the migration source and as the fallback when no wallet is active yet +// (onboarding sets these before a wallet id exists). Not deleted β€” kept dormant. +static NSString *const LEGACY_WALLET_NEEDS_BACKUP_KEY = @"DW_GLOB_walletNeedsBackup"; +static NSString *const LEGACY_USER_HAS_BALANCE_KEY = @"DW_GLOB_userHasBalance"; + +// Per-wallet key prefixes: the effective key is ``, +// scoped by the active wallet (`DWWalletEnvironment.activeWalletIdHex`). +static NSString *const PER_WALLET_NEEDS_BACKUP_PREFIX = @"DW_WALLET_NEEDS_BACKUP_"; +static NSString *const PER_WALLET_HAS_BALANCE_PREFIX = @"DW_WALLET_HAS_BALANCE_"; + @implementation DWGlobalOptions -@dynamic walletNeedsBackup; @dynamic balanceChangedDate; @dynamic walletBackupReminderWasShown; @dynamic biometricAuthConfigured; @@ -128,6 +140,82 @@ - (void)setSpendingConfirmationDisabled:(BOOL)spendingConfirmationDisabled { setKeychainInt(spendingConfirmationDisabled ? 1 : 0, SPENDING_CONFIRMATION_DISABLED_KEY, NO); } +#pragma mark - Per-wallet flags (walletNeedsBackup / userHasBalance) + +// These two flags describe a per-wallet fact (does THIS wallet need a backup; +// does THIS wallet hold a balance), not an app-global preference. They are +// scoped by the active walletId so switching wallets shows the correct state. +// +// Storage: `` in UserDefaults, resolved live on +// every access (DSDynamicOptions reads UserDefaults live; there is no in-memory +// cache to invalidate on wallet switch β€” the next read simply resolves the new +// active wallet's key). One-time migration: on first read for a wallet whose +// per-wallet key is absent, seed it from the legacy app-global key's effective +// value, so an existing single-wallet install keeps its backup/balance state. +// The legacy key stays put (dormant) and remains the fallback while no wallet +// is active yet β€” onboarding sets these flags before a wallet id is resolved. + +/// The active wallet's per-wallet key for `prefix`, or nil when no wallet is +/// active yet (fresh install / post-wipe window) β€” callers fall back to the +/// legacy global key in that case. +- (nullable NSString *)perWalletKeyWithPrefix:(NSString *)prefix { + NSString *walletIdHex = (NSString *)DWWalletEnvironment.activeWalletIdHex; + if (walletIdHex.length == 0) { + return nil; + } + return [prefix stringByAppendingString:walletIdHex]; +} + +/// Read a per-wallet BOOL flag. With no active wallet, reads the legacy global +/// key directly. With an active wallet, reads the per-wallet key β€” seeding it +/// once from the legacy key's effective value when it has never been written. +- (BOOL)perWalletBoolForPrefix:(NSString *)prefix legacyKey:(NSString *)legacyKey { + NSUserDefaults *defaults = self.userDefaults; + NSString *key = [self perWalletKeyWithPrefix:prefix]; + if (key == nil) { + return [defaults boolForKey:legacyKey]; + } + if ([defaults objectForKey:key] == nil) { + // First read for this wallet: seed from the legacy global value (which + // carries the registered default, e.g. walletNeedsBackup = YES). + BOOL seeded = [defaults boolForKey:legacyKey]; + [defaults setBool:seeded forKey:key]; + return seeded; + } + return [defaults boolForKey:key]; +} + +/// Write a per-wallet BOOL flag. With no active wallet, writes the legacy +/// global key (so onboarding's pre-wallet writes are preserved and later +/// seeded into the per-wallet key on first read once a wallet is active). +- (void)setPerWalletBool:(BOOL)value forPrefix:(NSString *)prefix legacyKey:(NSString *)legacyKey { + NSUserDefaults *defaults = self.userDefaults; + NSString *key = [self perWalletKeyWithPrefix:prefix]; + [defaults setBool:value forKey:(key ?: legacyKey)]; +} + +- (BOOL)walletNeedsBackup { + return [self perWalletBoolForPrefix:PER_WALLET_NEEDS_BACKUP_PREFIX + legacyKey:LEGACY_WALLET_NEEDS_BACKUP_KEY]; +} + +- (void)setWalletNeedsBackup:(BOOL)walletNeedsBackup { + [self setPerWalletBool:walletNeedsBackup + forPrefix:PER_WALLET_NEEDS_BACKUP_PREFIX + legacyKey:LEGACY_WALLET_NEEDS_BACKUP_KEY]; +} + +- (BOOL)userHasBalance { + return [self perWalletBoolForPrefix:PER_WALLET_HAS_BALANCE_PREFIX + legacyKey:LEGACY_USER_HAS_BALANCE_KEY]; +} + +- (void)setUserHasBalance:(BOOL)userHasBalance { + [self setPerWalletBool:userHasBalance + forPrefix:PER_WALLET_HAS_BALANCE_PREFIX + legacyKey:LEGACY_USER_HAS_BALANCE_KEY]; +} + - (void)setActivationDateForReclassifyYourTransactionsFlowIfNeeded:(NSDate *)date { if (self.dateReclassifyYourTransactionsFlowActivated == nil) { self.dateReclassifyYourTransactionsFlowActivated = date; diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 94ca95eca..5f9d0dd5e 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -133,6 +133,20 @@ class HomeViewModel: ObservableObject { self?.clearCachedData() } .store(in: &cancellableBag) + + // A runtime wallet switch rebinds the host to a different wallet on the + // SAME network. The cached tx items belong to the old wallet, so treat + // it exactly like a network switch: drop the caches and reload the new + // wallet's tx list (SwiftDashSDKWalletSource reads the host's now-active + // wallet). The balance-driven reloads don't cover this β€” the balance + // notifications the switch posts can carry the same total, and their + // observers don't clear the stale per-hash cache. + NotificationCenter.default.publisher(for: SwiftDashSDKWalletState.activeWalletDidChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.clearCachedData() + } + .store(in: &cancellableBag) } /// Clears all cached transaction data when switching networks @@ -813,7 +827,8 @@ class SwiftDashSDKWalletSource: TransactionSource { @MainActor private static func fetchOnMain(txid: Data) -> Transaction? { - guard let container = SwiftDashSDKHost.shared.modelContainer else { + guard let container = SwiftDashSDKHost.shared.modelContainer, + let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { return nil } var descriptor = FetchDescriptor( @@ -822,17 +837,53 @@ class SwiftDashSDKWalletSource: TransactionSource { guard let row = (try? container.mainContext.fetch(descriptor))?.first else { return nil } + // Membership check: a tx the active wallet doesn't participate in + // (no TXO row denorm'd to its walletId, via either the producing + // `transaction` or the spending `spendingTransaction`) is not its + // transaction and reads as absent. + guard row.outputs.contains(where: { $0.walletId == walletId }) + || row.inputs.contains(where: { $0.walletId == walletId }) else { + return nil + } let tx = Transaction(persistentTransaction: row) tx.sdkCoinJoinMixing = isCoinJoinMixingTx(row) return tx } + /// Every txid the active wallet participates in, recovered by the + /// TXO join `PersistentTransaction` deliberately can't express itself + /// (it has no walletId β€” one row is shared across wallets; see the + /// model doc). A wallet's TXO rows carry its walletId denorm; each row + /// names the wallet's transactions on both sides β€” the producing + /// `transaction` (funds in) and the `spendingTransaction` (funds out). + /// One walletId-scoped fetch per reload (not per transaction), so the + /// timeline stays a single indexed scan on large wallets. + @MainActor + private static func activeWalletTxids( + in container: ModelContainer, + walletId: Data + ) -> Set { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + guard let txos = try? container.mainContext.fetch(descriptor) else { return [] } + var txids = Set() + for txo in txos { + if let producing = txo.transaction { txids.insert(producing.txid) } + if let spending = txo.spendingTransaction { txids.insert(spending.txid) } + } + return txids + } + @MainActor private static func fetchAndWrapOnMain() -> [Transaction] { - guard let container = SwiftDashSDKHost.shared.modelContainer else { + guard let container = SwiftDashSDKHost.shared.modelContainer, + let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { return [] } + let txids = activeWalletTxids(in: container, walletId: walletId) + guard !txids.isEmpty else { return [] } let descriptor = FetchDescriptor( + predicate: #Predicate { txids.contains($0.txid) }, sortBy: [SortDescriptor(\.firstSeen, order: .reverse)]) let rows: [PersistentTransaction] do { diff --git a/DashWallet/Sources/UI/Main/MainTabbarController.swift b/DashWallet/Sources/UI/Main/MainTabbarController.swift index d398874b9..b74b395af 100644 --- a/DashWallet/Sources/UI/Main/MainTabbarController.swift +++ b/DashWallet/Sources/UI/Main/MainTabbarController.swift @@ -128,6 +128,19 @@ class MainTabbarController: UITabBarController { self?.reconfigureDashPayTabsIfNeeded() } .store(in: &cancellableBag) + + // A runtime wallet switch may cross the identity boundary in EITHER + // direction: the registration-status observer above only ADDS tabs + // (`reconfigureDashPayTabsIfNeeded` early-returns when no identity), so + // switching to a wallet without an identity needs a rebuild that also + // REMOVES them. `reconfigureDashPayTabsForActiveWalletChange` rebuilds + // unconditionally. + NotificationCenter.default.publisher(for: SwiftDashSDKWalletState.activeWalletDidChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.reconfigureDashPayTabsForActiveWalletChange() + } + .store(in: &cancellableBag) #endif } @@ -238,6 +251,24 @@ extension MainTabbarController { reconfigureDashPayTabsPreservingSelection() } + /// Rebuild the tab set for a runtime active-wallet switch. Unlike + /// `reconfigureDashPayTabsIfNeeded` (which only adds tabs when an identity + /// exists), this rebuilds unconditionally so the DashPay tabs are removed + /// when the newly-active wallet has no identity. Selection resets to Home + /// because the previously-selected tab index may not exist under the new + /// wallet's tab set (the +2 shift `reconfigureDashPayTabsPreservingSelection` + /// applies assumes tabs were added, which doesn't hold on removal). + private func reconfigureDashPayTabsForActiveWalletChange() { + if containsCreateUsernameController(in: self) { + pendingDashPayTabReconfiguration = true + return + } + configureControllers() + selectedIndex = 0 + view.setNeedsLayout() + view.layoutIfNeeded() + } + private func reconfigureDashPayTabsPreservingSelection() { let previousIndex = selectedIndex pendingDashPayTabReconfiguration = false diff --git a/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift b/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift index 1b7d14e76..7a05c7b98 100644 --- a/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift +++ b/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift @@ -148,9 +148,9 @@ struct SecurityMenuScreen: View { self.vc.pushViewController(controller, animated: true) } } - case .resetWallet: - let controller = DWResetWalletInfoViewController.make() - controller.delegate = delegateInternal + case .wallets: + let controller = UIHostingController(rootView: WalletsScreen(vc: vc)) + controller.hidesBottomBarWhenPushed = true self.vc.pushViewController(controller, animated: true) case .resetWalletDebug: showResetWalletDebugAlert = true diff --git a/DashWallet/Sources/UI/Menu/Security/SecurityMenuViewModel.swift b/DashWallet/Sources/UI/Menu/Security/SecurityMenuViewModel.swift index 88760a96e..0546ab0d6 100644 --- a/DashWallet/Sources/UI/Menu/Security/SecurityMenuViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Security/SecurityMenuViewModel.swift @@ -23,7 +23,7 @@ enum SecurityMenuNavigationDestination { case viewRecoveryPhrase case changePin case advancedSecurity - case resetWallet + case wallets case resetWalletDebug } @@ -108,10 +108,10 @@ class SecurityMenuViewModel: ObservableObject { )) menuItems.append(MenuItemModel( - title: NSLocalizedString("Reset Wallet", comment: ""), + title: NSLocalizedString("Wallets", comment: ""), icon: .custom("image.reset.wallet", maxHeight: 22), action: { [weak self] in - self?.navigationDestination = .resetWallet + self?.navigationDestination = .wallets } )) diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift new file mode 100644 index 000000000..f8adcaeaf --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift @@ -0,0 +1,677 @@ +// +// WalletsScreen.swift +// DashWallet +// +// Copyright Β© 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// "Wallets" screen (Security menu). Lists the on-device SwiftDashSDK wallets +// for the current network, one marked active, and offers switch-on-tap, +// rename, and per-wallet remove. Add Wallet is Phase 3 and is intentionally +// absent. All logic lives in `WalletsViewModel`; this view only renders. +// + +import SwiftUI +import UIKit + +struct WalletsScreen: View { + private let vc: UINavigationController + private let resetDelegate: ResetDelegate + + @StateObject private var viewModel = WalletsViewModel() + + /// The wallet a confirmation/rename/remove flow is currently targeting. + @State private var pendingSwitch: WalletRow? = nil + @State private var renameTarget: WalletRow? = nil + @State private var renameText: String = "" + @State private var removeTarget: WalletRow? = nil + /// True while the "Add Wallet" sheet (create / import) is presented. + @State private var showAddWallet = false + + init(vc: UINavigationController) { + self.vc = vc + self.resetDelegate = ResetDelegate(onFinish: { [weak vc] in + vc?.popViewController(animated: true) + }) + } + + var body: some View { + ZStack { + Color.primaryBackground.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + header + + ScrollView { + VStack(spacing: 0) { + ForEach(viewModel.rows) { row in + WalletRowView(row: row) + .contentShape(Rectangle()) + .onTapGesture { + if !row.isActive { pendingSwitch = row } + } + .contextMenu { + Button { + renameText = row.displayName + renameTarget = row + } label: { + Label(NSLocalizedString("Rename", comment: "Wallets"), systemImage: "pencil") + } + Button(role: .destructive) { + beginRemove(row) + } label: { + Label(NSLocalizedString("Remove", comment: "Wallets"), systemImage: "trash") + } + } + if row.id != viewModel.rows.last?.id { + Divider().padding(.leading, 16) + } + } + } + .background(Color.secondaryBackground) + .cornerRadius(12) + .shadow(color: Color.shadow, radius: 20, x: 0, y: 5) + .padding(.horizontal, 20) + .padding(.top, 4) + } + + Spacer(minLength: 0) + } + + if viewModel.switchInProgress { + progressOverlay + } + } + .navigationBarHidden(true) + .onAppear { viewModel.reload() } + // Switch confirmation + .alert( + NSLocalizedString("Switch Wallet", comment: "Wallets"), + isPresented: Binding( + get: { pendingSwitch != nil }, + set: { if !$0 { pendingSwitch = nil } }) + ) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { pendingSwitch = nil } + Button(NSLocalizedString("Switch", comment: "Wallets")) { + if let target = pendingSwitch { viewModel.switchWallet(to: target.walletId) } + pendingSwitch = nil + } + } message: { + Text(String( + format: NSLocalizedString("Switch to \"%@\"? The app will reload with this wallet active.", comment: "Wallets"), + pendingSwitch?.displayName ?? "")) + } + // Rename + .alert( + NSLocalizedString("Rename Wallet", comment: "Wallets"), + isPresented: Binding( + get: { renameTarget != nil }, + set: { if !$0 { renameTarget = nil } }) + ) { + TextField(NSLocalizedString("Wallet name", comment: "Wallets"), text: $renameText) + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { renameTarget = nil } + Button(NSLocalizedString("Save", comment: "Wallets")) { + if let target = renameTarget { viewModel.rename(walletId: target.walletId, to: renameText) } + renameTarget = nil + } + } message: { + Text(NSLocalizedString("Leave the name empty to reset it to the default.", comment: "Wallets")) + } + // Add Wallet (create / import) + .sheet(isPresented: $showAddWallet) { + AddWalletSheet( + viewModel: viewModel, + onFinished: { showAddWallet = false }, + onSwitchToExisting: { walletId in + showAddWallet = false + viewModel.switchWallet(to: walletId) + }) + } + // Remove (recovery-phrase confirmation sheet) + .sheet(item: $removeTarget) { target in + RemoveWalletSheet( + row: target, + verify: { phrase in viewModel.recoveryPhraseMatches(phrase, walletId: target.walletId) }, + onConfirmed: { + removeTarget = nil + Task { await viewModel.removeWallet(walletId: target.walletId) } + }, + onCancel: { removeTarget = nil }) + } + // Errors + .alert( + NSLocalizedString("Error", comment: ""), + isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } }) + ) { + Button(NSLocalizedString("OK", comment: ""), role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + // MARK: - Subviews + + private var header: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Button(action: { vc.popViewController(animated: true) }) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.primary) + .frame(width: 36, height: 36) + .overlay(Circle().stroke(Color.gray300.opacity(0.3), lineWidth: 1)) + } + Spacer() + Button(action: { showAddWallet = true }) { + Image(systemName: "plus") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.primary) + .frame(width: 36, height: 36) + .overlay(Circle().stroke(Color.gray300.opacity(0.3), lineWidth: 1)) + } + .accessibilityLabel(NSLocalizedString("Add Wallet", comment: "Wallets")) + } + .padding(.horizontal, 5) + .padding(.top, 10) + + HStack { + Text(NSLocalizedString("Wallets", comment: "Wallets")) + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primaryText) + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 30) + .padding(.bottom, 10) + } + } + + private var progressOverlay: some View { + ZStack { + Color.black.opacity(0.35).ignoresSafeArea() + VStack(spacing: 14) { + // Fully qualified: the app has a UIKit `ProgressView: UIView` + // that otherwise shadows SwiftUI's. + SwiftUI.ProgressView() + .tint(.white) + Text(NSLocalizedString("Switching wallet…", comment: "Wallets")) + .font(.subheadline) + .foregroundColor(.white) + } + .padding(24) + .background(Color.black.opacity(0.6)) + .cornerRadius(14) + } + } + + // MARK: - Remove routing + + /// The last remaining wallet routes to the existing full reset flow + /// (`DWResetWalletInfoViewController`), preserving today's reset semantics; + /// any other wallet gets the per-wallet recovery-phrase remove sheet. + private func beginRemove(_ row: WalletRow) { + if viewModel.removalRoutesToFullReset(walletId: row.walletId) { + let controller = DWResetWalletInfoViewController.make() + controller.delegate = resetDelegate + vc.pushViewController(controller, animated: true) + } else { + removeTarget = row + } + } +} + +// MARK: - Row + +private struct WalletRowView: View { + let row: WalletRow + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(row.displayName) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primaryText) + .lineLimit(1) + if let username = row.username { + Text("@\(username)") + .font(.system(size: 13)) + .foregroundColor(.dashBlue) + .lineLimit(1) + } + if let balance = row.balanceText { + Text(balance) + .font(.system(size: 13)) + .foregroundColor(.secondaryText) + .lineLimit(1) + } + } + Spacer(minLength: 8) + if row.isActive { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.dashBlue) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(minHeight: 60) + } +} + +// MARK: - Remove sheet + +/// Recovery-phrase remove confirmation. Warns exactly what removal destroys +/// (this device's copy of the wallet; funds recoverable only with the phrase) +/// and requires typing the wallet's own recovery phrase, verified by deriving +/// its walletId from the entered mnemonic (`WalletsViewModel.recoveryPhraseMatches`). +private struct RemoveWalletSheet: View { + let row: WalletRow + let verify: (String) -> Bool + let onConfirmed: () -> Void + let onCancel: () -> Void + + @State private var phrase: String = "" + @State private var showMismatch = false + + var body: some View { + NavigationStack { + ZStack { + Color.primaryBackground.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(String( + format: NSLocalizedString("Remove \"%@\"", comment: "Wallets"), + row.displayName)) + .font(.title2).fontWeight(.bold) + .foregroundColor(.primaryText) + + Text(NSLocalizedString( + "This removes this device's copy of the wallet, including its keys and synced data. Your funds are NOT deleted β€” they remain on the Dash network and can only be recovered with this wallet's recovery phrase. If you have not backed up the phrase, you will lose access to these funds.", + comment: "Wallets")) + .font(.subheadline) + .foregroundColor(.secondaryText) + + Text(NSLocalizedString( + "Type this wallet's recovery phrase to confirm.", + comment: "Wallets")) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primaryText) + + TextEditor(text: $phrase) + .frame(minHeight: 100) + .padding(8) + .background(Color.secondaryBackground) + .cornerRadius(10) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + + if showMismatch { + Text(NSLocalizedString( + "That is not this wallet's recovery phrase.", + comment: "Wallets")) + .font(.footnote) + .foregroundColor(.systemRed) + } + + Button(action: confirm) { + Text(NSLocalizedString("Remove Wallet", comment: "Wallets")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(canConfirm ? Color.systemRed : Color.systemRed.opacity(0.4)) + .cornerRadius(12) + } + .disabled(!canConfirm) + } + .padding(20) + } + } + .navigationTitle(NSLocalizedString("Remove Wallet", comment: "Wallets")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("Cancel", comment: "")) { onCancel() } + } + } + } + } + + private var canConfirm: Bool { + !phrase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func confirm() { + if verify(phrase) { + onConfirmed() + } else { + showMismatch = true + } + } +} + +// MARK: - Add Wallet sheet + +/// "Add Wallet" flow: a chooser between creating a brand-new wallet (generate a +/// 12-word phrase, show it, require a written-down confirmation) and importing +/// one from an existing recovery phrase. Both paths add the wallet additively +/// via `WalletsViewModel.addWallet` and switch to it on success. All SDK work +/// (generate / validate / add / switch) lives in the ViewModel; this view only +/// drives the steps and renders (SwiftUI-first guardrail). +private struct AddWalletSheet: View { + @ObservedObject var viewModel: WalletsViewModel + let onFinished: () -> Void + /// Called with an existing wallet's id when the imported phrase is already + /// on this device and the user chooses to switch to it instead. + let onSwitchToExisting: (Data) -> Void + + private enum Step: Equatable { + case chooser + case create + case importPhrase + } + + @State private var step: Step = .chooser + + var body: some View { + NavigationStack { + ZStack { + Color.primaryBackground.ignoresSafeArea() + content + } + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("Cancel", comment: "")) { onFinished() } + .disabled(viewModel.addInProgress || viewModel.switchInProgress) + } + } + } + .interactiveDismissDisabled(viewModel.addInProgress || viewModel.switchInProgress) + } + + private var navigationTitle: String { + switch step { + case .chooser: return NSLocalizedString("Add Wallet", comment: "Wallets") + case .create: return NSLocalizedString("New Wallet", comment: "Wallets") + case .importPhrase: return NSLocalizedString("Import Wallet", comment: "Wallets") + } + } + + @ViewBuilder + private var content: some View { + switch step { + case .chooser: + chooser + case .create: + CreateWalletView( + viewModel: viewModel, + onFinished: onFinished) + case .importPhrase: + ImportWalletView( + viewModel: viewModel, + onFinished: onFinished, + onSwitchToExisting: onSwitchToExisting) + } + } + + private var chooser: some View { + VStack(spacing: 16) { + Spacer(minLength: 20) + Text(NSLocalizedString( + "Add another wallet to this device. You can create a brand-new wallet or restore one from its recovery phrase.", + comment: "Wallets")) + .font(.subheadline) + .foregroundColor(.secondaryText) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + + Button(action: { step = .create }) { + chooserRow( + title: NSLocalizedString("Create New Wallet", comment: "Wallets"), + subtitle: NSLocalizedString("Generate a new recovery phrase", comment: "Wallets"), + systemImage: "plus.circle") + } + Button(action: { step = .importPhrase }) { + chooserRow( + title: NSLocalizedString("Import from Phrase", comment: "Wallets"), + subtitle: NSLocalizedString("Restore an existing wallet", comment: "Wallets"), + systemImage: "square.and.arrow.down") + } + Spacer() + } + .padding(20) + } + + private func chooserRow(title: String, subtitle: String, systemImage: String) -> some View { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 22)) + .foregroundColor(.dashBlue) + .frame(width: 32) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primaryText) + Text(subtitle) + .font(.system(size: 13)) + .foregroundColor(.secondaryText) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.secondaryText) + } + .padding(16) + .background(Color.secondaryBackground) + .cornerRadius(12) + } +} + +// MARK: - Create wallet step + +/// Generates a 12-word phrase, shows it in a grid, and requires an explicit +/// "I've written it down" confirmation before adding the wallet. +private struct CreateWalletView: View { + @ObservedObject var viewModel: WalletsViewModel + let onFinished: () -> Void + + @State private var mnemonic: String? = nil + @State private var confirmedWrittenDown = false + + private var words: [String] { + mnemonic.map { $0.split(separator: " ").map(String.init) } ?? [] + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(NSLocalizedString( + "Write down these 12 words in order and keep them somewhere safe. They are the ONLY way to recover this wallet. Anyone with them can spend your funds.", + comment: "Wallets")) + .font(.subheadline) + .foregroundColor(.secondaryText) + + phraseGrid + + Toggle(isOn: $confirmedWrittenDown) { + Text(NSLocalizedString("I have written down my recovery phrase.", comment: "Wallets")) + .font(.system(size: 15)) + .foregroundColor(.primaryText) + } + .disabled(mnemonic == nil || viewModel.addInProgress || viewModel.switchInProgress) + + Button(action: create) { + HStack { + if viewModel.addInProgress || viewModel.switchInProgress { + SwiftUI.ProgressView().tint(.white) + } + Text(NSLocalizedString("Create Wallet", comment: "Wallets")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(canCreate ? Color.dashBlue : Color.dashBlue.opacity(0.4)) + .cornerRadius(12) + } + .disabled(!canCreate) + } + .padding(20) + } + .onAppear { + if mnemonic == nil { mnemonic = viewModel.generateMnemonic() } + } + } + + private var phraseGrid: some View { + LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible())], + spacing: 10 + ) { + ForEach(Array(words.enumerated()), id: \.offset) { index, word in + HStack(spacing: 8) { + Text("\(index + 1)") + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.secondaryText) + .frame(width: 22, alignment: .trailing) + Text(word) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primaryText) + Spacer(minLength: 0) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(Color.secondaryBackground) + .cornerRadius(10) + } + } + } + + private var canCreate: Bool { + mnemonic != nil && confirmedWrittenDown && !viewModel.addInProgress && !viewModel.switchInProgress + } + + private func create() { + guard let mnemonic else { return } + Task { + let outcome = await viewModel.addWallet(mnemonic: mnemonic, isImported: false) + if outcome == .switched { onFinished() } + // A brand-new phrase can't already exist, and on error the sheet + // stays open (the ViewModel surfaces the message). + } + } +} + +// MARK: - Import wallet step + +/// Multiline recovery-phrase entry with BIP39 validation. On import, if the +/// derived wallet is already on this device, offers switching to it instead of +/// reporting a fabricated success. +private struct ImportWalletView: View { + @ObservedObject var viewModel: WalletsViewModel + let onFinished: () -> Void + let onSwitchToExisting: (Data) -> Void + + @State private var phrase: String = "" + @State private var existingWalletId: Data? = nil + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(NSLocalizedString( + "Enter the recovery phrase of the wallet you want to add.", + comment: "Wallets")) + .font(.subheadline) + .foregroundColor(.secondaryText) + + TextEditor(text: $phrase) + .frame(minHeight: 120) + .padding(8) + .background(Color.secondaryBackground) + .cornerRadius(10) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .disabled(viewModel.addInProgress || viewModel.switchInProgress) + + if let existingWalletId { + VStack(alignment: .leading, spacing: 10) { + Text(NSLocalizedString( + "This wallet is already on this device.", + comment: "Wallets")) + .font(.footnote) + .foregroundColor(.secondaryText) + Button(NSLocalizedString("Switch to it", comment: "Wallets")) { + onSwitchToExisting(existingWalletId) + } + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dashBlue) + } + } + + Button(action: importWallet) { + HStack { + if viewModel.addInProgress || viewModel.switchInProgress { + SwiftUI.ProgressView().tint(.white) + } + Text(NSLocalizedString("Import Wallet", comment: "Wallets")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(canImport ? Color.dashBlue : Color.dashBlue.opacity(0.4)) + .cornerRadius(12) + } + .disabled(!canImport) + } + .padding(20) + } + } + + private var canImport: Bool { + viewModel.isValidMnemonic(phrase) && !viewModel.addInProgress && !viewModel.switchInProgress + } + + private func importWallet() { + existingWalletId = nil + Task { + let outcome = await viewModel.addWallet(mnemonic: phrase, isImported: true) + switch outcome { + case .switched: + onFinished() + case .alreadyOnDevice(let walletId): + existingWalletId = walletId + case .none: + break // error surfaced by the ViewModel; sheet stays open + } + } + } +} + +// MARK: - Reset flow delegate + +extension WalletsScreen { + /// Bridges the last-wallet full reset flow (`DWResetWalletInfoViewController`) + /// back to this screen's navigation, popping on completion or cancel. + final class ResetDelegate: NSObject, DWWipeDelegate { + private let onFinish: () -> Void + init(onFinish: @escaping () -> Void) { self.onFinish = onFinish } + func didWipeWallet() { onFinish() } + } +} diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift new file mode 100644 index 000000000..1f93f367c --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift @@ -0,0 +1,375 @@ +// +// WalletsViewModel.swift +// DashWallet +// +// Copyright Β© 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// ViewModel for the "Wallets" screen (Security menu β†’ Wallets). Owns the +// list of on-device SwiftDashSDK wallets for the current network and the +// switch / rename / remove flows. All SDK, SwiftData, and mnemonic work +// lives here β€” the SwiftUI `WalletsScreen` only renders `rows` and calls +// these intents (SwiftUI-first guardrail). +// + +import Combine +import Foundation +import OSLog +import SwiftData +import SwiftDashSDK + +/// One on-device wallet as rendered by the Wallets screen. +struct WalletRow: Identifiable, Equatable { + /// Raw 32-byte walletId β€” also the switch/rename/remove key. + let walletId: Data + /// Display name: `PersistentWallet.name` when set, else "Wallet …". + let displayName: String + /// House-formatted DASH balance (e.g. "DASH 1.2345"), or nil when the SDK + /// could not report a balance for this wallet. + let balanceText: String? + /// DPNS username of this wallet's identity, when it has one. + let username: String? + /// True for the wallet currently bound as active on this network. + let isActive: Bool + + var id: Data { walletId } +} + +@MainActor +final class WalletsViewModel: ObservableObject { + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "wallets-screen") + + @Published private(set) var rows: [WalletRow] = [] + /// Non-nil while a switch (or an auto-switch before removing the active + /// wallet) is in flight β€” the screen shows a blocking progress overlay. + @Published private(set) var switchInProgress = false + /// True while an add-wallet (create or import) is in flight β€” the add sheet + /// shows a progress state and disables its controls. + @Published private(set) var addInProgress = false + /// Set to surface an error alert; the screen clears it on dismiss. + @Published var errorMessage: String? = nil + + private var cancellables = Set() + + init() { + // Rebuild the list whenever the active wallet changes (switch success) + // or wallet material changes (a remove completed). Both post on main. + NotificationCenter.default.publisher( + for: SwiftDashSDKWalletState.activeWalletDidChangeNotification) + .sink { [weak self] _ in self?.reload() } + .store(in: &cancellables) + } + + // MARK: - List sourcing + + /// Rebuild `rows` from ground truth: the manager's loaded wallets + /// (`SwiftDashSDKHost.shared.manager?.wallets`) intersected with the + /// walletIds that have a persisted mnemonic (a wallet without a mnemonic + /// isn't switchable). Per-row name/username come from the SwiftData store + /// scoped by walletId; the balance from the managed wallet. + func reload() { + let host = SwiftDashSDKHost.shared + guard let manager = host.manager else { + rows = [] + return + } + + let switchableIds = Set(SwiftDashSDKHost.persistedMnemonics().map { $0.walletId }) + let activeId = activeWalletId() + let context = host.modelContainer?.mainContext + + let built: [WalletRow] = manager.wallets.values + .filter { switchableIds.contains($0.walletId) } + .map { wallet in + let walletId = wallet.walletId + let persisted = context.flatMap { Self.fetchPersistentWallet(walletId: walletId, in: $0) } + let name = persisted?.name?.nonEmpty + let username = persisted.flatMap { Self.username(for: $0) } + let balanceText = (try? wallet.balance().total).map { $0.formattedDashAmount } + return WalletRow( + walletId: walletId, + displayName: name ?? Self.fallbackName(for: walletId), + balanceText: balanceText, + username: username, + isActive: walletId == activeId) + } + // Stable ordering: active first, then by display name. + .sorted { lhs, rhs in + if lhs.isActive != rhs.isActive { return lhs.isActive } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + + rows = built + } + + var hasSingleWallet: Bool { rows.count <= 1 } + + // MARK: - Switch + + /// Switch the active wallet to `walletId`, showing a progress overlay while + /// the runtime rebuilds. On success `activeWalletDidChangeNotification` + /// fires and `reload()` refreshes the list; on failure the error surfaces + /// and the list is left unchanged. + func switchWallet(to walletId: Data) { + guard !switchInProgress else { return } + switchInProgress = true + Task { + defer { switchInProgress = false } + do { + try await SwiftDashSDKWalletRuntime.shared.switchWallet(to: walletId) + // reload() also runs from the change notification, but call it + // directly so the list is fresh even if delivery order lags. + reload() + } catch { + Self.logger.error("switchWallet failed: \(String(describing: error), privacy: .public)") + errorMessage = error.localizedDescription + } + } + } + + // MARK: - Add Wallet + + /// Outcome of an add-wallet attempt, surfaced to the sheet. + enum AddOutcome: Equatable { + /// The wallet was added and the app switched to it β€” dismiss the sheet. + case switched + /// A wallet with this recovery phrase is already on this device. Carries + /// its walletId so the sheet can offer switching to it instead. + case alreadyOnDevice(walletId: Data) + } + + /// Generate a fresh 12-word BIP39 mnemonic via the SDK for the "Create New + /// Wallet" flow. Returns nil (and surfaces an error) on FFI failure. Nothing + /// is persisted here β€” creation happens in `addWallet` after the user + /// confirms they wrote the phrase down. + func generateMnemonic() -> String? { + do { + return try Mnemonic.generate(wordCount: 12) + } catch { + Self.logger.error("mnemonic generation failed: \(String(describing: error), privacy: .public)") + errorMessage = NSLocalizedString("Could not generate a recovery phrase.", comment: "Wallets") + return nil + } + } + + /// Whether `phrase` is a valid BIP39 mnemonic β€” the import field's gate, + /// checked before enabling its confirm button. + func isValidMnemonic(_ phrase: String) -> Bool { + Mnemonic.validate(Self.normalize(phrase)) + } + + /// Add a wallet from `mnemonic` and switch to it. `isImported` distinguishes + /// the two entry paths and sets the new wallet's `walletNeedsBackup` flag: + /// a created wallet still needs a backup (its phrase was only shown, not + /// verified β€” matches onboarding); an imported wallet does not (the user + /// already holds the phrase β€” matches the recover flow). + /// + /// Flow: `SwiftDashSDKHost.addWallet` (additive β€” no rebind) β†’ on `.added`, + /// `switchWallet(to:)` the new wallet (awaited, progress overlay) β†’ set the + /// per-wallet backup flag for the now-active new wallet β†’ refresh. On + /// `.alreadyExists` nothing is written and `.alreadyOnDevice` is returned so + /// the sheet can offer switching instead. + /// + /// Returns the outcome; returns nil after surfacing an error (the sheet + /// stays open on failure β€” never claims a success that didn't happen). + func addWallet(mnemonic: String, isImported: Bool) async -> AddOutcome? { + guard !addInProgress, !switchInProgress else { return nil } + let normalized = Self.normalize(mnemonic) + + addInProgress = true + defer { addInProgress = false } + + let result: SwiftDashSDKHost.AddWalletResult + do { + result = try SwiftDashSDKHost.shared.addWallet(mnemonic: normalized) + } catch { + Self.logger.error("addWallet failed: \(String(describing: error), privacy: .public)") + errorMessage = error.localizedDescription + return nil + } + + switch result { + case .alreadyExists(let walletId): + return .alreadyOnDevice(walletId: walletId) + case .added(let walletId): + switchInProgress = true + do { + try await SwiftDashSDKWalletRuntime.shared.switchWallet(to: walletId) + } catch { + switchInProgress = false + Self.logger.error("post-add switch failed: \(String(describing: error), privacy: .public)") + // The wallet was added but the switch failed; leave it on device + // and surface the error. The list still gains the new wallet. + errorMessage = error.localizedDescription + reload() + return nil + } + switchInProgress = false + + // The new wallet is now the active wallet, so this per-wallet flag + // targets it (DWGlobalOptions scopes by the active walletId). + DWGlobalOptions.sharedInstance().walletNeedsBackup = !isImported + + reload() + return .switched + } + } + + // MARK: - Rename + + /// Write `name` to `PersistentWallet.name` for `walletId` (empty string + /// clears it to nil). Persists via the host's `modelContainer` mainContext. + func rename(walletId: Data, to name: String) { + guard let context = SwiftDashSDKHost.shared.modelContainer?.mainContext, + let persisted = Self.fetchPersistentWallet(walletId: walletId, in: context) else { + Self.logger.error("rename: no PersistentWallet row for target") + errorMessage = NSLocalizedString("Could not rename this wallet.", comment: "Wallets") + return + } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + persisted.name = trimmed.isEmpty ? nil : trimmed + do { + try context.save() + reload() + } catch { + Self.logger.error("rename save failed: \(String(describing: error), privacy: .public)") + errorMessage = error.localizedDescription + } + } + + // MARK: - Remove + + /// Whether removing `walletId` requires routing to the full reset flow β€” + /// true only when it is the sole remaining wallet. The screen presents + /// `DWResetWalletInfoViewController` in that case (preserving today's + /// last-wallet reset semantics); otherwise it drives `removeWallet`. + func removalRoutesToFullReset(walletId: Data) -> Bool { + rows.count <= 1 + } + + /// Derive the walletId of `mnemonic` on the current network WITHOUT + /// creating or persisting anything, and confirm it equals `expected`. Used + /// by the remove sheet to verify the user typed the target wallet's own + /// recovery phrase before deleting this device's copy of it. Returns false + /// on an invalid mnemonic, an unsupported network, or a mismatch. + func recoveryPhraseMatches(_ mnemonic: String, walletId expected: Data) -> Bool { + let trimmed = Self.normalize(mnemonic) + guard Mnemonic.validate(trimmed) else { return false } + guard let network = WalletEnvironment.network else { return false } + do { + // `Wallet(mnemonic:network:)` derives keys locally in the key-wallet + // FFI and persists nothing (same read-only surface `SwiftDashSDKHost + // .derivationWallet` uses); its `id` is the deterministic walletId. + let derivedId = try Wallet(mnemonic: trimmed, network: network).id + return derivedId == expected + } catch { + Self.logger.error("walletId derivation from mnemonic failed: \(String(describing: error), privacy: .public)") + return false + } + } + + /// Remove `walletId` from this device (this device's copy only β€” funds stay + /// recoverable with the phrase). Precondition (enforced by the screen): the + /// recovery phrase was verified, and this is NOT the last wallet. + /// + /// If `walletId` is the active wallet, auto-switch to any other wallet + /// first (await the rebind) so the runtime never ends up bound to a + /// deleted wallet, then delete. Deletion reuses the wiper's promoted + /// per-wallet primitive (`SwiftDashSDKWalletWiper.deleteWalletFromSDK`) and + /// clears the per-network registry entry if it still names this wallet. + func removeWallet(walletId: Data) async { + guard !switchInProgress else { return } + + if walletId == activeWalletId() { + guard let other = rows.first(where: { $0.walletId != walletId })?.walletId else { + // No other wallet β€” the screen routes the last wallet to the + // full reset flow instead of calling this. Bail defensively. + Self.logger.error("removeWallet: refusing to remove the only wallet") + errorMessage = NSLocalizedString("Cannot remove the last wallet here.", comment: "Wallets") + return + } + switchInProgress = true + do { + try await SwiftDashSDKWalletRuntime.shared.switchWallet(to: other) + } catch { + switchInProgress = false + Self.logger.error("pre-remove auto-switch failed: \(String(describing: error), privacy: .public)") + errorMessage = error.localizedDescription + return + } + switchInProgress = false + } + + SwiftDashSDKWalletWiper.deleteWalletFromSDK(walletId) + + // Keep the registry honest: if the removed wallet is still recorded as + // active for either registry network, drop it so a stale id can't be + // resolved. The removed wallet is gone from `wallets` now. + for kind in [WalletEnvironment.NetworkKind.mainnet, .testnet] { + if WalletEnvironment.activeWalletId(for: kind) == walletId { + WalletEnvironment.setActiveWalletId(nil, for: kind) + } + } + + reload() + } + + // MARK: - Helpers + + private func activeWalletId() -> Data? { + WalletEnvironment.activeWalletId(for: WalletEnvironment.isTestnet ? .testnet : .mainnet) + } + + /// Collapse a user-entered recovery phrase to canonical form: trim, then + /// join words on single spaces (tolerates newlines / extra spacing from + /// paste). Shared by import and the remove-sheet verification. + static func normalize(_ phrase: String) -> String { + phrase + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + } + + private static func fetchPersistentWallet(walletId: Data, in context: ModelContext) -> PersistentWallet? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + descriptor.fetchLimit = 1 + return try? context.fetch(descriptor).first + } + + /// The wallet's DPNS username via its pinned identity row, or nil. + /// Reads `PersistentIdentity` directly (target-neutral SDK model), so it + /// compiles in both the dashwallet and dashpay targets without a DASHPAY + /// gate. Mirrors `DWCurrentUserIdentityInfo`'s walletβ†’identities lookup. + private static func username(for wallet: PersistentWallet) -> String? { + wallet.identities + .first(where: { $0.identityIndex == 0 })? + .dpnsName? + .nonEmpty + } + + private static func fallbackName(for walletId: Data) -> String { + let prefix = walletId.prefix(4).map { String(format: "%02x", $0) }.joined() + return String( + format: NSLocalizedString("Wallet %@…", comment: "Wallets β€” placeholder name with short id"), + prefix) + } +} + +private extension String { + /// Self when non-empty, else nil. + var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/DashWallet/Sources/UI/Menu/Tools/ZenLedger/ZenLedgerViewModel.swift b/DashWallet/Sources/UI/Menu/Tools/ZenLedger/ZenLedgerViewModel.swift index fcd630731..501e0db00 100644 --- a/DashWallet/Sources/UI/Menu/Tools/ZenLedger/ZenLedgerViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Tools/ZenLedger/ZenLedgerViewModel.swift @@ -41,8 +41,10 @@ class ZenLedgerViewModel: ObservableObject { /// ZenLedger only needs each address once. @MainActor private static func walletOutputAddresses() -> [String] { - guard let container = SwiftDashSDKHost.shared.modelContainer else { return [] } + guard let container = SwiftDashSDKHost.shared.modelContainer, + let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { return [] } let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }, sortBy: [SortDescriptor(\.createdAt, order: .forward)]) guard let rows = try? container.mainContext.fetch(descriptor) else { return [] }