diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 801c6fb61..ddefe1ad4 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -695,6 +695,8 @@ 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 */; }; + A1C0D0020000000000000002 /* RecoveryPhraseFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0D0010000000000000001 /* RecoveryPhraseFlow.swift */; }; + A1C0D0030000000000000003 /* RecoveryPhraseFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0D0010000000000000001 /* RecoveryPhraseFlow.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 */; }; @@ -2856,6 +2858,7 @@ 752D03B12E2F7B3C00B88784 /* MainMenuViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuViewControllerDelegate.swift; sourceTree = ""; }; 752F81962E30F55E00ADA76D /* SecurityMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityMenuScreen.swift; sourceTree = ""; }; 752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityMenuViewModel.swift; sourceTree = ""; }; + A1C0D0010000000000000001 /* RecoveryPhraseFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseFlow.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 = ""; }; 7531308C2B47EC910069C9B7 /* UpholdClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpholdClient.swift; sourceTree = ""; }; @@ -4748,6 +4751,7 @@ children = ( 2A7F3B18238C643D00DEA3EF /* Advanced Security */, 2A10EB3F2358D29500C38B61 /* ResetWalletInfo */, + A1C0D0010000000000000001 /* RecoveryPhraseFlow.swift */, 752F81962E30F55E00ADA76D /* SecurityMenuScreen.swift */, 752F81972E30F55E00ADA76D /* SecurityMenuViewModel.swift */, FB64DD5B1F2691BC2FC4149B /* Wallets */, @@ -9899,6 +9903,7 @@ 472D13E3299E23B7006903F1 /* BalanceNotifier.swift in Sources */, 752F819A2E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */, 752F819B2E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */, + A1C0D0020000000000000002 /* RecoveryPhraseFlow.swift in Sources */, 2AD1CE6422D9127600C99324 /* DWSeedWordModel.m in Sources */, 7592AA7C2B9B08C000417F9E /* SupportedTopperPaymentMethods.swift in Sources */, 75AA33CC2BF9C82700F12465 /* ModalDialog.swift in Sources */, @@ -10730,6 +10735,7 @@ C9D2C7C42A320AA000D15901 /* CurrencyExchanger_Objc.m in Sources */, 752F81982E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */, 752F81992E30F55E00ADA76D /* SecurityMenuViewModel.swift in Sources */, + A1C0D0030000000000000003 /* RecoveryPhraseFlow.swift in Sources */, C943B32B2A408CED00AF23C5 /* DWAvatarExternalLoadingView.m in Sources */, C943B5972A40EDDA00AF23C5 /* DWConfirmUsernameViewController.m in Sources */, C9D2C7C62A320AA000D15901 /* Tools.swift in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift index 8e3134c48..a155fe23c 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift @@ -261,6 +261,13 @@ final class SwiftDashSDKHost { } } + /// Strict exact-id read for flows that already selected a concrete wallet. + /// Unlike the legacy active-wallet reader, this never substitutes another + /// Keychain entry when the requested id is missing or unreadable. + nonisolated static func strictlyPersistedMnemonic(for walletId: Data) throws -> String { + try WalletStorage().retrieveMnemonic(for: walletId) + } + /// Count of persisted SDK wallet ids. Attributes-only Keychain /// enumeration — no mnemonic secrets are read. Throws on enumeration /// failure so destructive callers stay fail-closed. diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift index 5a7f25557..1d704b721 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift @@ -88,6 +88,37 @@ enum SwiftDashSDKWalletDeletionError: LocalizedError { } } +/// Resolves a Keychain wallet id against the deterministic ids derived from +/// its mnemonic. Wallet ids are network-scoped even when a seed is shared; +/// the mainnet-derived id is also a stable logical identifier for grouping +/// mirrored mainnet/testnet entries without retaining the mnemonic. +enum SwiftDashSDKStoredWalletNetworkResolver { + struct Resolution { + let network: Network + let canonicalMainnetWalletId: Data + } + + static func resolve(walletId: Data, mnemonic: String) throws -> Resolution { + let mainnetWalletId = try SwiftDashSDK.Wallet( + mnemonic: mnemonic, + network: .mainnet + ).id + if mainnetWalletId == walletId { + return Resolution(network: .mainnet, canonicalMainnetWalletId: mainnetWalletId) + } + + let testnetWalletId = try SwiftDashSDK.Wallet( + mnemonic: mnemonic, + network: .testnet + ).id + if testnetWalletId == walletId { + return Resolution(network: .testnet, canonicalMainnetWalletId: mainnetWalletId) + } + + throw SwiftDashSDKWalletDeletionError.unrecognizedWalletNetwork + } +} + private final class WalletWipeResultAccumulator: @unchecked Sendable { private let lock = NSLock() private var failureCount = 0 @@ -235,23 +266,10 @@ final class SwiftDashSDKWalletWiper: NSObject { for walletId in try storage.listWalletIdsWithMnemonic() { let mnemonic = try storage.retrieveMnemonic(for: walletId) - var matchedNetwork: Network? - - for network in [Network.mainnet, .testnet] { - let derivedId = try SwiftDashSDK.Wallet( - mnemonic: mnemonic, - network: network - ).id - if derivedId == walletId { - matchedNetwork = network - break - } - } - - guard let matchedNetwork else { - throw SwiftDashSDKWalletDeletionError.unrecognizedWalletNetwork - } - result[matchedNetwork, default: []].insert(walletId) + let resolution = try SwiftDashSDKStoredWalletNetworkResolver.resolve( + walletId: walletId, + mnemonic: mnemonic) + result[resolution.network, default: []].insert(walletId) } return result diff --git a/DashWallet/Sources/UI/Menu/Security/RecoveryPhraseFlow.swift b/DashWallet/Sources/UI/Menu/Security/RecoveryPhraseFlow.swift new file mode 100644 index 000000000..a5e8bf04d --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Security/RecoveryPhraseFlow.swift @@ -0,0 +1,664 @@ +// +// RecoveryPhraseFlow.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 +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +// + +import Combine +import DashUIKit +import Foundation +import OSLog +import SwiftDashSDK +import SwiftUI +import UIKit + +enum RecoveryPhraseWalletNetwork: Int, CaseIterable, Hashable { + case mainnet + case testnet + + init?(sdkNetwork: Network) { + switch sdkNetwork { + case .mainnet: self = .mainnet + case .testnet: self = .testnet + case .devnet, .regtest: return nil + } + } + + init?(environmentKind: WalletEnvironment.NetworkKind) { + switch environmentKind { + case .mainnet: self = .mainnet + case .testnet: self = .testnet + case .devnet: return nil + } + } + + var displayName: String { + switch self { + case .mainnet: return NSLocalizedString("Mainnet", comment: "Wallet network") + case .testnet: return NSLocalizedString("Testnet", comment: "Wallet network") + } + } +} + +struct RecoveryPhraseWalletDescriptor: Identifiable, Equatable { + /// Mainnet-derived wallet id. It is stable when the same seed later gains + /// or loses a mirrored testnet Keychain entry. + let id: Data + /// Exact Keychain key used for the final read. The reveal path never + /// substitutes the active, first, or sole wallet id. + let sourceWalletId: Data + let displayName: String + let networks: [RecoveryPhraseWalletNetwork] + let isActive: Bool + + var shortIdentifier: String { + id.prefix(4).map { String(format: "%02x", $0) }.joined() + } + + var networkText: String { + networks.map(\.displayName).joined(separator: ", ") + } + + var detailsText: String { + "\(networkText) • \(shortIdentifier)…" + } + + var contextLabel: String { + "\(displayName) • \(networkText)" + } +} + +enum RecoveryPhraseRoute: Equatable { + case direct(RecoveryPhraseWalletDescriptor) + case choose([RecoveryPhraseWalletDescriptor]) + case unavailable +} + +struct RecoveryPhraseInventoryEntry: Equatable { + let walletId: Data + let canonicalWalletId: Data + let normalizedMnemonic: String + let network: RecoveryPhraseWalletNetwork +} + +enum RecoveryPhraseInventoryError: Error { + case noRecoveryPhrase + case noReadableRecoveryPhrase + case invalidMnemonic + case unsupportedWalletNetwork + case walletChanged +} + +enum RecoveryPhraseInventory { + struct ReadableEntries { + let entries: [RecoveryPhraseInventoryEntry] + let skippedWalletIds: [Data] + } + + struct MnemonicMaterial { + let mnemonic: String + let canonicalWalletId: Data + let network: RecoveryPhraseWalletNetwork + } + + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "recovery-phrase-inventory") + + @MainActor + static func load() throws -> [RecoveryPhraseWalletDescriptor] { + let walletIds = try WalletStorage().listWalletIdsWithMnemonic() + let result = try collectReadableEntries(walletIds: walletIds) { walletId in + let mnemonic = try SwiftDashSDKHost.strictlyPersistedMnemonic(for: walletId) + return try classify((walletId: walletId, mnemonic: mnemonic)) + } + if !result.skippedWalletIds.isEmpty { + let labels = result.skippedWalletIds.map { + $0.prefix(4).map { String(format: "%02x", $0) }.joined() + }.joined(separator: ",") + logger.error( + "Skipping \(result.skippedWalletIds.count, privacy: .public) unreadable recovery-phrase entry/entries ids=\(labels, privacy: .public)") + } + + let entries = result.entries + let displayNames = Dictionary(uniqueKeysWithValues: entries.map { + ($0.walletId, WalletsViewModel.displayName(for: $0.walletId)) + }) + let activeWalletIds: [RecoveryPhraseWalletNetwork: Data] = Dictionary( + uniqueKeysWithValues: RecoveryPhraseWalletNetwork.allCases.compactMap { network in + let kind: WalletEnvironment.NetworkKind = network == .mainnet ? .mainnet : .testnet + return WalletEnvironment.activeWalletId(for: kind).map { (network, $0) } + }) + + return try makeDescriptors( + entries: entries, + currentNetwork: RecoveryPhraseWalletNetwork(environmentKind: WalletEnvironment.networkKind), + activeWalletIds: activeWalletIds, + displayNames: displayNames) + } + + /// Best-effort collection for the non-destructive recovery-phrase list. + /// Enumeration itself remains strict, while an unreadable or malformed + /// neighboring entry cannot hide every otherwise valid recovery phrase. + static func collectReadableEntries( + walletIds: [Data], + loadEntry: (Data) throws -> RecoveryPhraseInventoryEntry + ) throws -> ReadableEntries { + var entries: [RecoveryPhraseInventoryEntry] = [] + var skippedWalletIds: [Data] = [] + for walletId in walletIds { + do { + entries.append(try loadEntry(walletId)) + } catch { + skippedWalletIds.append(walletId) + } + } + + guard walletIds.isEmpty || !entries.isEmpty else { + throw RecoveryPhraseInventoryError.noReadableRecoveryPhrase + } + return ReadableEntries( + entries: entries, + skippedWalletIds: skippedWalletIds) + } + + static func route(for descriptors: [RecoveryPhraseWalletDescriptor]) -> RecoveryPhraseRoute { + switch descriptors.count { + case 0: return .unavailable + case 1: return .direct(descriptors[0]) + default: return .choose(descriptors) + } + } + + static func mnemonic(for walletId: Data) throws -> MnemonicMaterial { + let mnemonic = try SwiftDashSDKHost.strictlyPersistedMnemonic(for: walletId) + let entry = try classify((walletId: walletId, mnemonic: mnemonic)) + return MnemonicMaterial( + mnemonic: entry.normalizedMnemonic, + canonicalWalletId: entry.canonicalWalletId, + network: entry.network) + } + + static func makeDescriptors( + entries: [RecoveryPhraseInventoryEntry], + currentNetwork: RecoveryPhraseWalletNetwork?, + activeWalletIds: [RecoveryPhraseWalletNetwork: Data], + displayNames: [Data: String] + ) throws -> [RecoveryPhraseWalletDescriptor] { + guard !entries.isEmpty else { return [] } + + let groups = Dictionary(grouping: entries, by: \.canonicalWalletId) + let descriptors = try groups.map { canonicalWalletId, group -> RecoveryPhraseWalletDescriptor in + guard let firstMnemonic = group.first?.normalizedMnemonic, + group.allSatisfy({ $0.normalizedMnemonic == firstMnemonic }) else { + throw RecoveryPhraseInventoryError.walletChanged + } + + let sortedEntries = group.sorted { lhs, rhs in + if lhs.network != rhs.network { + return lhs.network.rawValue < rhs.network.rawValue + } + return lhs.walletId.lexicographicallyPrecedes(rhs.walletId) + } + let source = currentNetwork.flatMap { current in + sortedEntries.first(where: { $0.network == current }) + } ?? sortedEntries[0] + let displayName = displayNames[source.walletId] + ?? sortedEntries.compactMap { displayNames[$0.walletId] }.first + ?? WalletsViewModel.fallbackName(for: source.walletId) + let networks = Array(Set(group.map(\.network))).sorted { $0.rawValue < $1.rawValue } + let isActive = currentNetwork.map { current in + guard let activeWalletId = activeWalletIds[current] else { return false } + return group.contains { + $0.network == current && $0.walletId == activeWalletId + } + } ?? false + + return RecoveryPhraseWalletDescriptor( + id: canonicalWalletId, + sourceWalletId: source.walletId, + displayName: displayName, + networks: networks, + isActive: isActive) + } + + return descriptors.sorted { lhs, rhs in + if lhs.isActive != rhs.isActive { return lhs.isActive } + let nameOrder = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + if nameOrder != .orderedSame { return nameOrder == .orderedAscending } + return lhs.id.lexicographicallyPrecedes(rhs.id) + } + } + + private static func classify( + _ entry: (walletId: Data, mnemonic: String) + ) throws -> RecoveryPhraseInventoryEntry { + let mnemonic = Mnemonic.normalizePhrase(entry.mnemonic) + guard Mnemonic.validate(mnemonic) else { + throw RecoveryPhraseInventoryError.invalidMnemonic + } + let resolution = try SwiftDashSDKStoredWalletNetworkResolver.resolve( + walletId: entry.walletId, + mnemonic: mnemonic) + guard let network = RecoveryPhraseWalletNetwork(sdkNetwork: resolution.network) else { + throw RecoveryPhraseInventoryError.unsupportedWalletNetwork + } + return RecoveryPhraseInventoryEntry( + walletId: entry.walletId, + canonicalWalletId: resolution.canonicalMainnetWalletId, + normalizedMnemonic: mnemonic, + network: network) + } +} + +struct RecoveryPhrasePresentation { + let mnemonic: String + let contextLabel: String? +} + +@MainActor +final class RecoveryPhraseFlowViewModel: ObservableObject { + enum Destination { + case picker([RecoveryPhraseWalletDescriptor]) + case phrase(RecoveryPhrasePresentation) + } + + struct NavigationEvent: Identifiable { + let id = UUID() + let destination: Destination + } + + struct AlertState: Identifiable { + let id = UUID() + let title: String + let message: String + } + + private enum RetryRequest { + case global + case wallet(walletId: Data, displayName: String) + #if DEBUG + case copy(walletId: Data) + #endif + } + + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "recovery-phrase-flow") + + @Published private(set) var navigationEvent: NavigationEvent? + @Published private(set) var alertState: AlertState? + @Published private(set) var pickerAlertState: AlertState? + @Published private(set) var isBusy = false + + private var retryRequest: RetryRequest? + private var pickerRetryDescriptor: RecoveryPhraseWalletDescriptor? + + func beginGlobal() { + authenticate(for: .global) { [weak self] in + self?.loadGlobalRoute() + } + } + + func beginWallet(walletId: Data, displayName: String) { + let request = RetryRequest.wallet(walletId: walletId, displayName: displayName) + authenticate(for: request) { [weak self] in + self?.loadWallet(walletId: walletId, displayName: displayName) + } + } + + #if DEBUG + func copyWalletMnemonic(walletId: Data) { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + do { + let material = try RecoveryPhraseInventory.mnemonic(for: walletId) + UIPasteboard.general.string = material.mnemonic + } catch { + showReadError(error, retry: .copy(walletId: walletId)) + } + } + #endif + + func select(_ descriptor: RecoveryPhraseWalletDescriptor) { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + do { + let material = try RecoveryPhraseInventory.mnemonic(for: descriptor.sourceWalletId) + guard material.canonicalWalletId == descriptor.id else { + throw RecoveryPhraseInventoryError.walletChanged + } + emit(.phrase(RecoveryPhrasePresentation( + mnemonic: material.mnemonic, + contextLabel: descriptor.contextLabel))) + } catch { + Self.logger.error("Selected recovery phrase read failed: \(String(describing: error), privacy: .public)") + pickerRetryDescriptor = descriptor + pickerAlertState = Self.readErrorAlert(for: error) + } + } + + func consumeNavigationEvent(id: UUID) { + guard navigationEvent?.id == id else { return } + navigationEvent = nil + } + + func dismissAlert() { + alertState = nil + retryRequest = nil + } + + func dismissPickerAlert() { + pickerAlertState = nil + pickerRetryDescriptor = nil + } + + func retryPickerSelection() { + let descriptor = pickerRetryDescriptor + pickerAlertState = nil + pickerRetryDescriptor = nil + if let descriptor { + select(descriptor) + } + } + + func retry() { + let request = retryRequest + alertState = nil + retryRequest = nil + switch request { + case .global: + beginGlobal() + case .wallet(let walletId, let displayName): + beginWallet(walletId: walletId, displayName: displayName) + #if DEBUG + case .copy(let walletId): + copyWalletMnemonic(walletId: walletId) + #endif + case .none: + break + } + } + + private func authenticate(for request: RetryRequest, action: @escaping () -> Void) { + guard !isBusy else { return } + isBusy = true + Task { @MainActor in + let outcome = await AuthenticationGate.authenticate(biometric: false) + isBusy = false + switch outcome { + case .ok: + action() + case .cancelled: + break + case .failed, .timedOut: + retryRequest = request + alertState = AlertState( + title: NSLocalizedString("Authentication failed", comment: ""), + message: NSLocalizedString("Please try again", comment: "")) + } + } + } + + private func loadGlobalRoute() { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + do { + switch RecoveryPhraseInventory.route(for: try RecoveryPhraseInventory.load()) { + case .direct(let descriptor): + let material = try RecoveryPhraseInventory.mnemonic(for: descriptor.sourceWalletId) + guard material.canonicalWalletId == descriptor.id else { + throw RecoveryPhraseInventoryError.walletChanged + } + emit(.phrase(RecoveryPhrasePresentation( + mnemonic: material.mnemonic, + contextLabel: nil))) + case .choose(let descriptors): + emit(.picker(descriptors)) + case .unavailable: + throw RecoveryPhraseInventoryError.noRecoveryPhrase + } + } catch { + showReadError(error, retry: .global) + } + } + + private func loadWallet(walletId: Data, displayName: String) { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + do { + let material = try RecoveryPhraseInventory.mnemonic(for: walletId) + let context = "\(displayName) • \(material.network.displayName)" + emit(.phrase(RecoveryPhrasePresentation( + mnemonic: material.mnemonic, + contextLabel: context))) + } catch { + showReadError( + error, + retry: .wallet(walletId: walletId, displayName: displayName)) + } + } + + private func emit(_ destination: Destination) { + navigationEvent = NavigationEvent(destination: destination) + } + + private func showReadError(_ error: Error, retry: RetryRequest) { + Self.logger.error("Recovery phrase read failed: \(String(describing: error), privacy: .public)") + retryRequest = retry + alertState = Self.readErrorAlert(for: error) + } + + private static func readErrorAlert(for error: Error) -> AlertState { + if case RecoveryPhraseInventoryError.noRecoveryPhrase = error { + return AlertState( + title: NSLocalizedString("No Recovery Phrase Found", comment: "Recovery phrase"), + message: NSLocalizedString( + "No recovery phrase is stored on this device.", + comment: "Recovery phrase")) + } else { + return AlertState( + title: NSLocalizedString("Couldn’t Read Recovery Phrases", comment: "Recovery phrase"), + message: NSLocalizedString( + "The recovery phrases stored on this device could not be read. Please try again.", + comment: "Recovery phrase")) + } + } +} + +@MainActor +final class RecoveryPhrasePickerViewModel: ObservableObject { + let options: [RecoveryPhraseWalletDescriptor] + + private let onSelect: (RecoveryPhraseWalletDescriptor) -> Void + private let onCancel: () -> Void + + init( + options: [RecoveryPhraseWalletDescriptor], + onSelect: @escaping (RecoveryPhraseWalletDescriptor) -> Void, + onCancel: @escaping () -> Void + ) { + self.options = options + self.onSelect = onSelect + self.onCancel = onCancel + } + + func select(_ descriptor: RecoveryPhraseWalletDescriptor) { + onSelect(descriptor) + } + + func cancel() { + onCancel() + } +} + +struct RecoveryPhrasePickerScreen: View { + @ObservedObject private var flowModel: RecoveryPhraseFlowViewModel + @StateObject private var viewModel: RecoveryPhrasePickerViewModel + + init( + options: [RecoveryPhraseWalletDescriptor], + flowModel: RecoveryPhraseFlowViewModel, + onCancel: @escaping () -> Void + ) { + self.flowModel = flowModel + _viewModel = StateObject(wrappedValue: RecoveryPhrasePickerViewModel( + options: options, + onSelect: { [weak flowModel] descriptor in + flowModel?.select(descriptor) + }, + onCancel: { [weak flowModel] in + flowModel?.dismissPickerAlert() + onCancel() + })) + } + + var body: some View { + ZStack { + Color.dash.primaryBackground.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + NavBarBack { viewModel.cancel() } + + Text(NSLocalizedString("Choose Wallet", comment: "Recovery phrase")) + .font(.title) + .fontWeight(.bold) + .foregroundColor(.dash.primaryText) + .padding(.horizontal, 20) + .padding(.top, 30) + + Text(NSLocalizedString( + "Select the wallet whose recovery phrase you want to view.", + comment: "Recovery phrase")) + .font(.subheadline) + .foregroundColor(.dash.secondaryText) + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 20) + + ScrollView { + VStack(spacing: 0) { + ForEach(viewModel.options) { option in + Button { + viewModel.select(option) + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(option.displayName) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + Text(option.detailsText) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 8) + if option.isActive { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.dash.blue) + } + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.secondaryText) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(minHeight: 60) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if option.id != viewModel.options.last?.id { + Divider().padding(.leading, 16) + } + } + } + .background(Color.dash.secondaryBackground) + .cornerRadius(12) + .shadow(color: Color.dash.shadow, radius: 20, x: 0, y: 5) + .padding(.horizontal, 20) + } + + Spacer(minLength: 0) + } + } + .navigationBarHidden(true) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in + viewModel.cancel() + } + .alert( + flowModel.pickerAlertState?.title ?? "", + isPresented: Binding( + get: { flowModel.pickerAlertState != nil }, + set: { if !$0 { flowModel.dismissPickerAlert() } }) + ) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { + flowModel.dismissPickerAlert() + } + Button(NSLocalizedString("Retry", comment: "")) { + flowModel.retryPickerSelection() + } + } message: { + Text(flowModel.pickerAlertState?.message ?? "") + } + } +} + +@MainActor +enum RecoveryPhraseNavigation { + static func pickerController( + options: [RecoveryPhraseWalletDescriptor], + flowModel: RecoveryPhraseFlowViewModel, + onCancel: @escaping () -> Void + ) -> UIViewController { + let controller = UIHostingController(rootView: RecoveryPhrasePickerScreen( + options: options, + flowModel: flowModel, + onCancel: onCancel)) + controller.hidesBottomBarWhenPushed = true + return controller + } + + static func showPhrase( + _ presentation: RecoveryPhrasePresentation, + in navigationController: UINavigationController, + delegate: DWSecureWalletDelegate? + ) { + let model = DWPreviewSeedPhraseModel(existingSeedPhrase: presentation.mnemonic) + let controller = DWPreviewSeedPhraseViewController(model: model) + controller.delegate = delegate + controller.navigationItem.prompt = presentation.contextLabel + controller.hidesBottomBarWhenPushed = true + + var controllers = navigationController.viewControllers + if controllers.last is UIHostingController { + controllers[controllers.count - 1] = controller + navigationController.setViewControllers(controllers, animated: true) + } else { + navigationController.pushViewController(controller, animated: true) + } + } +} diff --git a/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift b/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift index 970532d5b..bc9d2f2b6 100644 --- a/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift +++ b/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift @@ -24,6 +24,7 @@ struct SecurityMenuScreen: View { private let delegateInternal: DelegateInternal @StateObject private var viewModel = SecurityMenuViewModel() + @StateObject private var recoveryPhraseFlow = RecoveryPhraseFlowViewModel() @State private var showBiometricsAlert = false @State private var showResetWalletDebugAlert = false @@ -79,6 +80,9 @@ struct SecurityMenuScreen: View { .onReceive(viewModel.$showBiometricsAlert) { show in showBiometricsAlert = show } + .onReceive(recoveryPhraseFlow.$navigationEvent.compactMap { $0 }) { event in + handleRecoveryPhraseNavigation(event) + } .alert(NSLocalizedString("Biometrics Access Required", comment: ""), isPresented: $showBiometricsAlert) { Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } Button(NSLocalizedString("Settings", comment: "")) { @@ -95,21 +99,27 @@ struct SecurityMenuScreen: View { } message: { Text("Permanently deletes all wallets on this device without asking for their recovery phrases.") } + .alert( + recoveryPhraseFlow.alertState?.title ?? "", + isPresented: Binding( + get: { recoveryPhraseFlow.alertState != nil }, + set: { if !$0 { recoveryPhraseFlow.dismissAlert() } }) + ) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { + recoveryPhraseFlow.dismissAlert() + } + Button(NSLocalizedString("Retry", comment: "")) { + recoveryPhraseFlow.retry() + } + } message: { + Text(recoveryPhraseFlow.alertState?.message ?? "") + } } private func handleNavigation(_ destination: SecurityMenuNavigationDestination?) { switch destination { case .viewRecoveryPhrase: - viewModel.authenticate { authenticated in - if authenticated { - let model = DWPreviewSeedPhraseModel() - model.getOrCreateNewWallet() - let controller = DWPreviewSeedPhraseViewController(model: model) - controller.delegate = delegateInternal - controller.hidesBottomBarWhenPushed = true - self.vc.pushViewController(controller, animated: true) - } - } + recoveryPhraseFlow.beginGlobal() case .changePin: viewModel.authenticate { authenticated in if authenticated { @@ -141,6 +151,28 @@ struct SecurityMenuScreen: View { viewModel.resetNavigation() } } + + private func handleRecoveryPhraseNavigation(_ event: RecoveryPhraseFlowViewModel.NavigationEvent) { + switch event.destination { + case .picker(let options): + let controller = RecoveryPhraseNavigation.pickerController( + options: options, + flowModel: recoveryPhraseFlow, + onCancel: { [weak vc] in + vc?.popViewController(animated: true) + }) + vc.pushViewController(controller, animated: true) + case .phrase(let presentation): + RecoveryPhraseNavigation.showPhrase( + presentation, + in: vc, + delegate: delegateInternal) + } + + DispatchQueue.main.async { + recoveryPhraseFlow.consumeNavigationEvent(id: event.id) + } + } private var biometricsAlertMessage: String { let displayName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "" diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift index cbbab3174..262b3c6ff 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift @@ -18,8 +18,8 @@ // // "Wallets" screen (main menu). Lists the on-device SwiftDashSDK wallets // for the current network, one marked active. Tapping a wallet opens its -// per-account breakdown (`WalletAccountsScreen`); switch, rename, and -// per-wallet remove live in the row's menu. All logic lives in +// per-account breakdown (`WalletAccountsScreen`); switch, rename, recovery +// phrase preview, and per-wallet remove live in the row's menu. All logic lives in // `WalletsViewModel`; this view only renders. // @@ -32,6 +32,7 @@ struct WalletsScreen: View { private let resetDelegate: ResetDelegate @StateObject private var viewModel = WalletsViewModel() + @StateObject private var recoveryPhraseFlow = RecoveryPhraseFlowViewModel() /// The wallet a confirmation/rename/remove flow is currently targeting. @State private var pendingSwitch: WalletRow? = nil @@ -65,6 +66,16 @@ struct WalletsScreen: View { renameText = row.displayName renameTarget = row }, + onViewRecoveryPhrase: { + recoveryPhraseFlow.beginWallet( + walletId: row.walletId, + displayName: row.displayName) + }, + onCopySeed: { + #if DEBUG + recoveryPhraseFlow.copyWalletMnemonic(walletId: row.walletId) + #endif + }, onRemove: { beginRemove(row) }) .contentShape(Rectangle()) .onTapGesture { showAccounts(row) } @@ -82,6 +93,20 @@ struct WalletsScreen: View { } label: { Label(NSLocalizedString("Rename", comment: "Wallets"), systemImage: "pencil") } + Button { + recoveryPhraseFlow.beginWallet( + walletId: row.walletId, + displayName: row.displayName) + } label: { + Label(NSLocalizedString("View Recovery Phrase", comment: ""), systemImage: "key") + } + #if DEBUG + Button { + recoveryPhraseFlow.copyWalletMnemonic(walletId: row.walletId) + } label: { + Label("Copy Seed (Debug)", systemImage: "doc.on.doc") + } + #endif Button(role: .destructive) { beginRemove(row) } label: { @@ -109,6 +134,9 @@ struct WalletsScreen: View { } .navigationBarHidden(true) .onAppear { viewModel.reload() } + .onReceive(recoveryPhraseFlow.$navigationEvent.compactMap { $0 }) { event in + handleRecoveryPhraseNavigation(event) + } // Switch confirmation .alert( NSLocalizedString("Switch Wallet", comment: "Wallets"), @@ -174,6 +202,21 @@ struct WalletsScreen: View { } message: { Text(viewModel.errorMessage ?? "") } + .alert( + recoveryPhraseFlow.alertState?.title ?? "", + isPresented: Binding( + get: { recoveryPhraseFlow.alertState != nil }, + set: { if !$0 { recoveryPhraseFlow.dismissAlert() } }) + ) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { + recoveryPhraseFlow.dismissAlert() + } + Button(NSLocalizedString("Retry", comment: "")) { + recoveryPhraseFlow.retry() + } + } message: { + Text(recoveryPhraseFlow.alertState?.message ?? "") + } } // MARK: - Subviews @@ -244,6 +287,30 @@ struct WalletsScreen: View { vc.pushViewController(controller, animated: true) } + // MARK: - Recovery phrase + + private func handleRecoveryPhraseNavigation(_ event: RecoveryPhraseFlowViewModel.NavigationEvent) { + switch event.destination { + case .picker(let options): + let controller = RecoveryPhraseNavigation.pickerController( + options: options, + flowModel: recoveryPhraseFlow, + onCancel: { [weak vc] in + vc?.popViewController(animated: true) + }) + vc.pushViewController(controller, animated: true) + case .phrase(let presentation): + RecoveryPhraseNavigation.showPhrase( + presentation, + in: vc, + delegate: resetDelegate) + } + + DispatchQueue.main.async { + recoveryPhraseFlow.consumeNavigationEvent(id: event.id) + } + } + // MARK: - Remove routing /// A wallet whose recovery phrase provably covers every stored wallet @@ -276,6 +343,8 @@ private struct WalletRowView: View { let row: WalletRow let onSwitch: () -> Void let onRename: () -> Void + let onViewRecoveryPhrase: () -> Void + let onCopySeed: () -> Void let onRemove: () -> Void var body: some View { @@ -304,7 +373,7 @@ private struct WalletRowView: View { .font(.system(size: 16, weight: .semibold)) .foregroundColor(.dash.blue) } - // Visible entry point for switch/rename/remove — the long-press + // Visible entry point for switch/rename/recovery phrase/remove — the long-press // context menu duplicates these but is undiscoverable on its own. Menu { if !row.isActive { @@ -319,6 +388,18 @@ private struct WalletRowView: View { } label: { Label(NSLocalizedString("Rename", comment: "Wallets"), systemImage: "pencil") } + Button { + onViewRecoveryPhrase() + } label: { + Label(NSLocalizedString("View Recovery Phrase", comment: ""), systemImage: "key") + } + #if DEBUG + Button { + onCopySeed() + } label: { + Label("Copy Seed (Debug)", systemImage: "doc.on.doc") + } + #endif Button(role: .destructive) { onRemove() } label: { @@ -741,7 +822,7 @@ extension WalletsScreen { /// DWWipeDelegate chain (MainTabbar → root VC), which transitions to /// onboarding and drops the whole main stack. Popping back to this screen /// is only the fallback when no chain was provided. - final class ResetDelegate: NSObject, DWWipeDelegate { + final class ResetDelegate: NSObject, DWSecureWalletDelegate, DWWipeDelegate { private let onFinish: () -> Void private weak var wipeDelegate: DWWipeDelegate? @@ -757,5 +838,9 @@ extension WalletsScreen { onFinish() } } + + func secureWalletRoutineDidVerify(_ controller: UIViewController) { } + func secureWalletRoutineDidFinish(_ controller: VerifiedSuccessfullyViewController) { } + func secureWalletRoutineDidCancel(_ controller: UIViewController) { onFinish() } } } diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift index 34ffe577f..391d09dd9 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift @@ -394,7 +394,18 @@ final class WalletsViewModel: ObservableObject { .nonEmpty } - private static func fallbackName(for walletId: Data) -> String { + /// Best available name for a wallet id in the current network's store. + /// Recovery-phrase inventory also uses this for Keychain entries that are + /// not loaded into the current manager; those safely fall back to a short + /// id rather than being omitted from the chooser. + static func displayName(for walletId: Data) -> String { + let context = SwiftDashSDKHost.shared.modelContainer?.mainContext + let persisted = context.flatMap { fetchPersistentWallet(walletId: walletId, in: $0) } + let name = persisted?.name?.nonEmpty + return name ?? fallbackName(for: walletId) + } + + nonisolated 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"), diff --git a/DashWallet/Sources/UI/Setup/SecureWallet/Seed/DWPreviewSeedPhraseModel.h b/DashWallet/Sources/UI/Setup/SecureWallet/Seed/DWPreviewSeedPhraseModel.h index 1ef96708e..4a4e8989f 100644 --- a/DashWallet/Sources/UI/Setup/SecureWallet/Seed/DWPreviewSeedPhraseModel.h +++ b/DashWallet/Sources/UI/Setup/SecureWallet/Seed/DWPreviewSeedPhraseModel.h @@ -25,6 +25,7 @@ NS_ASSUME_NONNULL_BEGIN @property (readonly, class, nonatomic, assign) BOOL shouldVerifyPassphrase; +- (instancetype)initWithExistingSeedPhrase:(NSString *)seedPhrase; - (DWSeedPhraseModel *)getOrCreateNewWallet; - (void)clearAllWallets; diff --git a/DashWallet/Sources/UI/Setup/SecureWallet/Seed/DWPreviewSeedPhraseModel.m b/DashWallet/Sources/UI/Setup/SecureWallet/Seed/DWPreviewSeedPhraseModel.m index 825d98725..e789a6b46 100644 --- a/DashWallet/Sources/UI/Setup/SecureWallet/Seed/DWPreviewSeedPhraseModel.m +++ b/DashWallet/Sources/UI/Setup/SecureWallet/Seed/DWPreviewSeedPhraseModel.m @@ -24,8 +24,24 @@ NS_ASSUME_NONNULL_BEGIN +@interface DWPreviewSeedPhraseModel () + +@property (nullable, nonatomic, copy) NSString *existingSeedPhrase; + +@end + @implementation DWPreviewSeedPhraseModel +- (instancetype)initWithExistingSeedPhrase:(NSString *)seedPhrase { + NSParameterAssert(seedPhrase.length > 0); + + self = [super init]; + if (self) { + _existingSeedPhrase = [seedPhrase copy]; + } + return self; +} + - (void)dealloc { DWLog(@"☠️ %@", NSStringFromClass(self.class)); } @@ -35,6 +51,10 @@ + (BOOL)shouldVerifyPassphrase { } - (DWSeedPhraseModel *)getOrCreateNewWallet { + if (self.existingSeedPhrase.length > 0) { + return [[DWSeedPhraseModel alloc] initWithSeed:self.existingSeedPhrase]; + } + BOOL hasAWallet = DWWalletEnvironment.hasWallet; NSString *seedPhrase; diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index f62b07856..7a3b44716 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -5576,3 +5576,11 @@ "A single wallet's recovery phrase cannot authorize deleting multiple different wallets." = "A single wallet's recovery phrase cannot authorize deleting multiple different wallets."; "A zero testnet balance cannot prove this wallet is empty on mainnet. Enter the recovery phrase to continue." = "A zero testnet balance cannot prove this wallet is empty on mainnet. Enter the recovery phrase to continue."; "Enter the recovery phrase to continue." = "Enter the recovery phrase to continue."; + +/* Recovery phrase selection */ +"Choose Wallet" = "Choose Wallet"; +"Couldn’t Read Recovery Phrases" = "Couldn’t Read Recovery Phrases"; +"No Recovery Phrase Found" = "No Recovery Phrase Found"; +"No recovery phrase is stored on this device." = "No recovery phrase is stored on this device."; +"Select the wallet whose recovery phrase you want to view." = "Select the wallet whose recovery phrase you want to view."; +"The recovery phrases stored on this device could not be read. Please try again." = "The recovery phrases stored on this device could not be read. Please try again."; diff --git a/DashWalletTests/WalletWipeSerialExecutorTests.swift b/DashWalletTests/WalletWipeSerialExecutorTests.swift index 1adf96ca9..515a4824c 100644 --- a/DashWalletTests/WalletWipeSerialExecutorTests.swift +++ b/DashWalletTests/WalletWipeSerialExecutorTests.swift @@ -186,6 +186,180 @@ final class WipeAcceptancePhraseTests: XCTestCase { } } +final class RecoveryPhraseRoutingTests: XCTestCase { + private enum EntryReadError: Error { + case unreadable + case invalidMnemonic + } + + private let seedA = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private let seedB = "legal winner thank year wave sausage worth useful legal winner thank yellow" + + func testUnreadableNeighborDoesNotHideReadableWallet() throws { + let walletA = Data(repeating: 0x01, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + let result = try RecoveryPhraseInventory.collectReadableEntries( + walletIds: [walletA, walletB], + loadEntry: { walletId in + guard walletId == walletA else { throw EntryReadError.unreadable } + return self.entry( + walletId: walletA, + canonicalId: walletA, + mnemonic: self.seedA, + network: .mainnet) + }) + + XCTAssertEqual(result.entries.map(\.walletId), [walletA]) + XCTAssertEqual(result.skippedWalletIds, [walletB]) + } + + func testInvalidNeighborDoesNotHideTwoReadableWallets() throws { + let walletA = Data(repeating: 0x11, count: 32) + let walletB = Data(repeating: 0x12, count: 32) + let walletC = Data(repeating: 0x13, count: 32) + let result = try RecoveryPhraseInventory.collectReadableEntries( + walletIds: [walletA, walletB, walletC], + loadEntry: { walletId in + switch walletId { + case walletA: + return self.entry( + walletId: walletA, + canonicalId: walletA, + mnemonic: self.seedA, + network: .mainnet) + case walletB: + return self.entry( + walletId: walletB, + canonicalId: walletB, + mnemonic: self.seedB, + network: .testnet) + default: + throw EntryReadError.invalidMnemonic + } + }) + let descriptors = try RecoveryPhraseInventory.makeDescriptors( + entries: result.entries, + currentNetwork: .mainnet, + activeWalletIds: [:], + displayNames: [walletA: "A", walletB: "B"]) + + guard case .choose(let options) = RecoveryPhraseInventory.route(for: descriptors) else { + return XCTFail("Expected readable wallets to remain in the chooser") + } + XCTAssertEqual(options.map(\.displayName), ["A", "B"]) + XCTAssertEqual(result.skippedWalletIds, [walletC]) + } + + func testOnlyUnreadableEntriesProduceReadFailure() { + let walletA = Data(repeating: 0x21, count: 32) + XCTAssertThrowsError(try RecoveryPhraseInventory.collectReadableEntries( + walletIds: [walletA], + loadEntry: { _ in throw EntryReadError.unreadable } + )) { error in + guard case RecoveryPhraseInventoryError.noReadableRecoveryPhrase = error else { + return XCTFail("Unexpected error: \(error)") + } + } + } + + func testNoStoredWalletIdsRemainAvailableAsEmptyInventory() throws { + let result = try RecoveryPhraseInventory.collectReadableEntries( + walletIds: [], + loadEntry: { _ in throw EntryReadError.unreadable }) + + XCTAssertTrue(result.entries.isEmpty) + XCTAssertTrue(result.skippedWalletIds.isEmpty) + } + + func testSingleWalletRoutesDirectly() throws { + let walletId = Data(repeating: 0x01, count: 32) + let descriptors = try RecoveryPhraseInventory.makeDescriptors( + entries: [entry(walletId: walletId, canonicalId: walletId, mnemonic: seedA, network: .mainnet)], + currentNetwork: .mainnet, + activeWalletIds: [.mainnet: walletId], + displayNames: [walletId: "Primary"]) + + guard case .direct(let descriptor) = RecoveryPhraseInventory.route(for: descriptors) else { + return XCTFail("Expected one logical wallet to route directly") + } + XCTAssertEqual(descriptor.sourceWalletId, walletId) + XCTAssertEqual(descriptor.displayName, "Primary") + } + + func testMirroredNetworkEntriesForSameSeedRouteDirectly() throws { + let mainnetId = Data(repeating: 0x11, count: 32) + let testnetId = Data(repeating: 0x22, count: 32) + let entries = [ + entry(walletId: mainnetId, canonicalId: mainnetId, mnemonic: seedA, network: .mainnet), + entry(walletId: testnetId, canonicalId: mainnetId, mnemonic: seedA, network: .testnet), + ] + let descriptors = try RecoveryPhraseInventory.makeDescriptors( + entries: entries, + currentNetwork: .testnet, + activeWalletIds: [.testnet: testnetId], + displayNames: [testnetId: "Mirrored"]) + + XCTAssertEqual(descriptors.count, 1) + XCTAssertEqual(descriptors[0].sourceWalletId, testnetId) + XCTAssertEqual(descriptors[0].networks, [.mainnet, .testnet]) + guard case .direct = RecoveryPhraseInventory.route(for: descriptors) else { + return XCTFail("A mirrored seed must not show a chooser") + } + } + + func testDistinctSeedsRouteToChooser() throws { + let walletA = Data(repeating: 0x31, count: 32) + let walletB = Data(repeating: 0x42, count: 32) + let descriptors = try RecoveryPhraseInventory.makeDescriptors( + entries: [ + entry(walletId: walletA, canonicalId: walletA, mnemonic: seedA, network: .mainnet), + entry(walletId: walletB, canonicalId: walletB, mnemonic: seedB, network: .testnet), + ], + currentNetwork: .mainnet, + activeWalletIds: [.mainnet: walletA], + displayNames: [walletA: "A", walletB: "B"]) + + guard case .choose(let options) = RecoveryPhraseInventory.route(for: descriptors) else { + return XCTFail("Distinct seeds must show the wallet chooser") + } + XCTAssertEqual(options.map(\.displayName), ["A", "B"]) + XCTAssertEqual(options.map(\.sourceWalletId), [walletA, walletB]) + } + + func testEmptyInventoryIsUnavailable() { + XCTAssertEqual(RecoveryPhraseInventory.route(for: []), .unavailable) + } + + func testConflictingMnemonicForCanonicalWalletFailsClosed() { + let canonicalId = Data(repeating: 0x51, count: 32) + XCTAssertThrowsError(try RecoveryPhraseInventory.makeDescriptors( + entries: [ + entry(walletId: canonicalId, canonicalId: canonicalId, mnemonic: seedA, network: .mainnet), + entry( + walletId: Data(repeating: 0x52, count: 32), + canonicalId: canonicalId, + mnemonic: seedB, + network: .testnet), + ], + currentNetwork: .mainnet, + activeWalletIds: [:], + displayNames: [:])) + } + + private func entry( + walletId: Data, + canonicalId: Data, + mnemonic: String, + network: RecoveryPhraseWalletNetwork + ) -> RecoveryPhraseInventoryEntry { + RecoveryPhraseInventoryEntry( + walletId: walletId, + canonicalWalletId: canonicalId, + normalizedMnemonic: mnemonic, + network: network) + } +} + final class MnemonicFirstWalletCreationTests: XCTestCase { func testPersistenceFailureDoesNotCreateWallet() { var storedMnemonic: String?