diff --git a/DASHSYNC_KEY_MIGRATION.md b/DASHSYNC_KEY_MIGRATION.md index 36843c3f7..ed1a3253f 100644 --- a/DASHSYNC_KEY_MIGRATION.md +++ b/DASHSYNC_KEY_MIGRATION.md @@ -6,9 +6,12 @@ multi-wallet and active-wallet behavior. ## Deployment model and invariant -The migrator ships in the same release as the final DashSync removal. It may -read old DashSync state, but it must never mutate or delete the DashSync-owned -keychain service `org.dashfoundation.dash`. +The migrator ships in the same release as the final DashSync removal. Normal +migration treats old DashSync state as read-only. Explicit production +`Wallets -> Remove`, recovery wipe, and confirmed `Delete All` may delete only +the affected `WALLET_MNEMONIC_KEY_*` accounts from the DashSync-owned keychain +service `org.dashfoundation.dash`. They never delete the service, PIN, chain +lists, or unrelated records. All new writes go to SDK-owned persistence: @@ -16,7 +19,8 @@ All new writes go to SDK-owned persistence: - SwiftDashSDK `WalletStorage` for mnemonic bytes keyed by SDK wallet ID; - `WalletEnvironment` UserDefaults entries for the active wallet per network. -DashSync mnemonic entries remain a read-only recovery/rollback source. +DashSync mnemonic entries remain a recovery/rollback source until the user +explicitly removes the corresponding wallet. ## Frozen DashSync keychain contract @@ -91,10 +95,10 @@ work. ## Host recovery behavior If SwiftData wallet rows are missing but SDK-owned mnemonic entries remain, -`SwiftDashSDKHost.recoverPersistedWallet` recreates managed wallets from every -valid stored mnemonic. Wallet creation is idempotent by SDK wallet ID. Because -mnemonics are network-agnostic while wallet IDs can be network-specific, the -host re-stores the mnemonic under the created ID when needed. +`SwiftDashSDKHost.recoverPersistedWallet` recreates only wallets whose stored +ID belongs to the runtime network. It never stores a mainnet mirror from a +testnet ID or vice versa, and rejects a created ID that differs from the +stored source ID. Create/import derives the deterministic SDK wallet ID, stores and verifies the SDK-owned mnemonic under that ID, and only then creates the live managed @@ -115,8 +119,17 @@ state belonging to a wallet already deleted successfully may be cleared with that wallet during a partially successful attempt. Every wipe entry point waits for the same explicit result before navigating or -creating a replacement wallet. The frozen DashSync mnemonic keychain service -`org.dashfoundation.dash` remains read-only and is never deleted by app wipe. +creating a replacement wallet. Production recovery removes only the legacy +mnemonic accounts matching its single authorized SDK seed; confirmed Delete +All removes every legacy mnemonic account. Both run before SDK deletion, and a +cleanup failure aborts the SDK wipe. Debug reset and screenshot replacement +preserve legacy data. The app never deletes the whole +`org.dashfoundation.dash` service. + +Per-wallet Remove deletes matching legacy mnemonic accounts, then the +deterministic mainnet and testnet SDK IDs for that seed, with the live network +last. The old `CHAIN_WALLETS_KEY_*` lists may retain harmless orphan IDs: they +contain no seed and the migrator only starts from mnemonic accounts. A Wallets-screen Remove may route into the global wipe only after Keychain ground truth proves every stored wallet ID belongs to the same recovery phrase @@ -136,20 +149,17 @@ copy that says so (it is not reported as a phrase mismatch). The plain `wipe` shortcut is allowed only with exactly one stored wallet ID and only while the active network is mainnet, because the published balance is scoped to the active wallet/network and cannot prove a mainnet balance while running on -testnet. The explicit strong acceptance phrase overrides both checks on this -path. Forgot-PIN recovery stays non-destructive and may prove ownership with -any one stored wallet phrase. +testnet. The support acknowledgement is accepted only by the separate Support +Wipe path. Forgot-PIN recovery stays non-destructive and may prove ownership +with any one stored wallet phrase. Every call to `DWSwiftDashSDKWalletWiper` supplies an explicit authorization -reason. Recovery-screen wipes use `.recoveryFlow`; screenshot-triggered wallet -replacement uses `.screenshotReplacement`; and phrase-less global deletion -uses `.confirmedDeleteAll`. The post-reinstall and lock-screen routes obtain a -strict count of distinct stored recovery phrases and, when more than one is -present, name that count and require an explicit Delete All confirmation. A -Keychain inventory error never produces the destructive action. The dev-only -Reset All Wallets item uses the same all-wallet confirmation wording. Any new -wipe entry point must collect one of these authorizations before invoking the -wiper. +reason. Ordinary recovery-screen wipes use `.recoveryFlow`; Support Wipe uses +`.confirmedDeleteAll`; screenshot-triggered replacement uses +`.screenshotReplacement`; and the dev-only reset uses `.debugReset`. +Post-reinstall and lock-screen Delete All warnings deliberately omit an SDK-only +count because the operation may also remove legacy-only seeds. Any new wipe +entry point must collect one of these authorizations before invoking the wiper. ## Acceptance criteria @@ -167,7 +177,8 @@ wiper. any additional wallet ID is stored or while the active network is testnet; - a successful wipe deletes all SDK-owned mnemonic/managed-wallet/active-wallet state, while a failed wipe reports failure without an app-side seed deletion; -- no migration code deletes `org.dashfoundation.dash` entries; +- normal migration never deletes legacy entries; explicit production Remove + and Delete All delete only the intended legacy mnemonic accounts; - both app schemes build and upgrade/multi-wallet/wipe runtime smokes pass. ## Source files diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift index a155fe23c..007326352 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift @@ -261,6 +261,43 @@ final class SwiftDashSDKHost { } } + /// Networks represented by a strict SDK-owned Keychain inventory. Used + /// after reinstall to select a sole stored network without manufacturing + /// a network mirror from the same seed. + nonisolated static func persistedSDKWalletNetworks( + in entries: [(walletId: Data, mnemonic: String)] + ) throws -> Set { + try Set(entries.map { entry in + try SwiftDashSDKStoredWalletNetworkResolver.resolve( + walletId: entry.walletId, + mnemonic: entry.mnemonic + ).network + }) + } + + nonisolated static func persistedSDKWalletNetworks() throws -> Set { + try persistedSDKWalletNetworks(in: strictlyPersistedMnemonics()) + } + + /// Pure filtering used by reinstall recovery: a manager must only receive + /// entries whose deterministic id belongs to that manager's network. + nonisolated static func recoverablePersistedMnemonics( + _ entries: [(walletId: Data, mnemonic: String)], + for network: Network + ) -> [(walletId: Data, mnemonic: String)] { + entries.compactMap { entry in + let mnemonic = Mnemonic.normalizePhrase(entry.mnemonic) + guard Mnemonic.validate(mnemonic), + let resolution = try? SwiftDashSDKStoredWalletNetworkResolver.resolve( + walletId: entry.walletId, + mnemonic: mnemonic), + resolution.network == network else { + return nil + } + return (walletId: entry.walletId, mnemonic: mnemonic) + } + } + /// 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. @@ -905,22 +942,19 @@ final class SwiftDashSDKHost { /// `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`), - /// so a re-run after a partial failure converges. The mnemonic is re-stored - /// under the created walletId when the stored key was derived for a - /// different network (mnemonics are network-agnostic; ids aren't). + /// so a re-run after a partial failure converges. Network-scoped ids are + /// never replayed through the other network's manager. /// Wipe race note: the wiper deletes mnemonics before `handleWalletWiped`, /// so a refresh racing a wipe finds an empty list here and fails the start /// — and the next refresh's `hasSDKWallet` gate stays closed. private func recoverPersistedWallet(handles: RuntimeHandles) -> ManagedPlatformWallet? { - let entries = Self.persistedMnemonics() + let inventory = Self.persistedMnemonics() + let entries = Self.recoverablePersistedMnemonics( + inventory, + for: handles.network) guard !entries.isEmpty else { return nil } - let storage = WalletStorage() for entry in entries { - guard Mnemonic.validate(entry.mnemonic) else { - Self.logger.error("🪺 HOST :: skipping keychain mnemonic for \(entry.walletId.prefix(4).map { String(format: "%02x", $0) }.joined(), privacy: .public)… — failed validation") - continue - } do { let created = try handles.manager.createWallet( mnemonic: entry.mnemonic, @@ -938,7 +972,8 @@ final class SwiftDashSDKHost { // funds is not. birthHeight: Self.importedWalletBirthHeight(for: handles.network)) if created.walletId != entry.walletId { - try? storage.storeMnemonic(entry.mnemonic, for: created.walletId) + try? handles.manager.deleteWallet(walletId: created.walletId) + Self.logger.error("🪺 HOST :: recovered wallet id did not match its network-scoped Keychain id; discarded created wallet") } } catch { Self.logger.error("🪺 HOST :: keychain wallet recovery failed for one entry: \(String(describing: error), privacy: .public)") @@ -946,7 +981,7 @@ final class SwiftDashSDKHost { } 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)") + Self.logger.info("🪺 HOST :: recovered persisted wallet from keychain mnemonic(s); network=\(handles.network.networkName, privacy: .public) eligible=\(entries.count, privacy: .public) skipped=\(inventory.count - entries.count, privacy: .public)") return resolved } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift index d75e5ca7a..866f92465 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift @@ -8,13 +8,13 @@ // stores the mnemonic in WalletStorage under the returned wallet id. // // Hard invariants — see DASHSYNC_KEY_MIGRATION.md: -// 1. NEVER deletes from `org.dashfoundation.dash` (DashSync's keychain -// service). DashSync entries are read-only here forever; they are -// preserved indefinitely as belt-and-suspenders rollback source. +// 1. DashSync entries are read-only except when an explicit production +// Remove/Delete All operation deletes wallet mnemonic accounts. The +// service itself, PIN, chain lists, and unrelated records are untouched. // 2. NEVER throws and NEVER crashes — `migrateIfNeeded()` returns Void // and swallows all errors into os.log entries. -// 3. NEVER modifies user-visible state. No UI, no DWGlobalOptions, -// no DashSync state mutation. +// 3. The migration path never modifies user-visible state. No UI, no +// DWGlobalOptions, and no DashSync mutation outside explicit cleanup. // 4. Runs early in app launch, BEFORE any DashSync initialization, so // the migrator owns the keychain read window before DashSync touches // its own wallet state. @@ -24,7 +24,7 @@ // keychain layout. The constants below are a frozen contract — once this // ships, DashSync the *library* can be removed from the binary and the // migrator will still work, because the keychain items previous app -// versions wrote survive app updates and we never delete them. +// versions wrote survive app updates until the user explicitly removes them. // import Foundation @@ -41,7 +41,15 @@ final class SwiftDashSDKKeyMigrator: NSObject { subsystem: "org.dashfoundation.dash", category: "swift-sdk-migration.key-migrator") - // MARK: - Frozen contract: DashSync keychain layout (read-only forever) + /// Migration and destructive legacy cleanup share one queue. If cleanup + /// arrives during launch migration, it waits for the import and the caller + /// then deletes the resulting SDK wallet; if cleanup wins, migration sees + /// no mnemonic account to import. + private static let legacyWalletQueue = DispatchQueue( + label: "org.dashfoundation.dash.legacy-wallet-material", + qos: .userInitiated) + + // MARK: - Frozen contract: DashSync keychain layout /// DashSync's keychain service identifier — `NSData+Dash.h:37`. private static let dashSyncService = "org.dashfoundation.dash" @@ -98,7 +106,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { // MARK: - Public entry point /// Synchronous Obj-C entry point. Dispatches the entire migrator body - /// to a background queue (`DispatchQueue.global(qos: .userInitiated)`) + /// to the serial legacy-wallet queue /// and returns to the caller in microseconds, so launch is not blocked. /// The actual migration completes ~300–500 ms later in the background /// while the user is already looking at the home screen. @@ -109,7 +117,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { /// Never throws, never crashes. @objc(migrateIfNeeded) static func migrateIfNeeded() { - DispatchQueue.global(qos: .userInitiated).async { + legacyWalletQueue.async { performMigration() } } @@ -138,7 +146,15 @@ final class SwiftDashSDKKeyMigrator: NSObject { defaults.removeObject(forKey: deferredUnknownChainKey) defaults.removeObject(forKey: deferredFailureKey) - let mnemonicAccounts = enumerateDashSyncMnemonicAccounts() + let mnemonicAccounts: [String] + do { + mnemonicAccounts = try strictlyEnumerateDashSyncMnemonicAccounts() + } catch { + defaults.set(true, forKey: deferredFailureKey) + logger.error( + "🔑 KEYMIG :: DashSync mnemonic enumeration failed: \(String(describing: error), privacy: .public)") + return + } if mnemonicAccounts.isEmpty { defaults.set("v1", forKey: doneKey) logger.info("🔑 KEYMIG :: no DashSync mnemonics found — fresh install or post-wipe, marking done") @@ -233,6 +249,116 @@ final class SwiftDashSDKKeyMigrator: NSObject { .contains { defaults.object(forKey: $0) != nil } } + // MARK: - Explicit legacy wallet cleanup + + struct LegacyMnemonicEntry: Equatable { + let account: String + let mnemonic: String + } + + private enum LegacyMnemonicCleanupError: LocalizedError { + case keychainRead(OSStatus) + case mnemonicRead + case deletionFailed + case verificationFailed + + var errorDescription: String? { + NSLocalizedString( + "The old wallet data could not be removed. Please try again.", + comment: "Wallets") + } + } + + /// Pure matching used by targeted Remove and its regression tests. + static func legacyMnemonicAccountsToRemove( + matching mnemonic: String, + in entries: [LegacyMnemonicEntry] + ) -> [String] { + let target = Mnemonic.normalizePhrase(mnemonic) + return entries.compactMap { entry in + Mnemonic.normalizePhrase(entry.mnemonic) == target ? entry.account : nil + } + } + + /// Remove every old DashSync mnemonic account for one logical seed without + /// blocking MainActor while a launch migration is finishing. + static func removeLegacyMnemonicAccounts(matching mnemonic: String) async throws { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + legacyWalletQueue.async { + do { + try performRemoveLegacyMnemonicAccounts(matching: mnemonic) + continuation.resume(returning: ()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// The global wiper already runs off-main and must wait for any migration + /// that started first. This is the synchronous counterpart of targeted + /// Remove, using the same queue and the same matching implementation. + static func removeLegacyMnemonicAccountsBeforeWipe(matching mnemonic: String) throws { + try legacyWalletQueue.sync { + try performRemoveLegacyMnemonicAccounts(matching: mnemonic) + } + } + + /// Full production wipe runs on the wiper's background queue, so it can + /// synchronously wait behind any launch migration without blocking UI. + static func removeAllLegacyMnemonicAccounts() throws { + try legacyWalletQueue.sync { + let accounts = try strictlyEnumerateDashSyncMnemonicAccounts() + try deleteLegacyMnemonicAccounts(accounts) + guard try strictlyEnumerateDashSyncMnemonicAccounts().isEmpty else { + throw LegacyMnemonicCleanupError.verificationFailed + } + logger.info( + "🔑 KEYMIG :: explicit Delete All removed \(accounts.count, privacy: .public) legacy mnemonic account(s)") + } + } + + private static func performRemoveLegacyMnemonicAccounts(matching mnemonic: String) throws { + let entries = try loadLegacyMnemonicEntries() + let accounts = legacyMnemonicAccountsToRemove( + matching: mnemonic, + in: entries) + try deleteLegacyMnemonicAccounts(accounts) + + let remaining = try loadLegacyMnemonicEntries() + guard legacyMnemonicAccountsToRemove( + matching: mnemonic, + in: remaining).isEmpty else { + throw LegacyMnemonicCleanupError.verificationFailed + } + logger.info( + "🔑 KEYMIG :: explicit matching cleanup deleted \(accounts.count, privacy: .public) legacy mnemonic account(s)") + } + + private static func loadLegacyMnemonicEntries() throws -> [LegacyMnemonicEntry] { + try strictlyEnumerateDashSyncMnemonicAccounts().map { account in + guard let mnemonic = readKeychainString( + service: dashSyncService, + account: account) else { + throw LegacyMnemonicCleanupError.mnemonicRead + } + return LegacyMnemonicEntry(account: account, mnemonic: mnemonic) + } + } + + private static func deleteLegacyMnemonicAccounts(_ accounts: [String]) throws { + for account in accounts { + guard KeychainStore.set( + data: nil, + service: dashSyncService, + account: account, + accessibility: .whenUnlockedThisDeviceOnly) else { + throw LegacyMnemonicCleanupError.deletionFailed + } + } + } + private static func createWalletOnHost( mnemonic: String, network: Network, @@ -276,6 +402,10 @@ final class SwiftDashSDKKeyMigrator: NSObject { /// account name starts with `WALLET_MNEMONIC_KEY_`. Returns the full /// account names (including the prefix), sorted for determinism. private static func enumerateDashSyncMnemonicAccounts() -> [String] { + (try? strictlyEnumerateDashSyncMnemonicAccounts()) ?? [] + } + + private static func strictlyEnumerateDashSyncMnemonicAccounts() throws -> [String] { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: dashSyncService, @@ -284,9 +414,12 @@ final class SwiftDashSDKKeyMigrator: NSObject { ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let items = result as? [[String: Any]] else { + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + throw LegacyMnemonicCleanupError.keychainRead(status) + } return items .compactMap { $0[kSecAttrAccount as String] as? String } .filter { $0.hasPrefix(dashSyncMnemonicAccountPrefix) } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift index aac3859e2..f19c49c42 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift @@ -306,6 +306,15 @@ final class SwiftDashSDKWalletRuntime: NSObject { await fullReset(lastError: nil, forWipe: false) + // A reinstall clears the selected-network UserDefaults key but + // preserves SDK mnemonics. If every stored wallet belongs to the + // other supported network, select that network instead of replaying + // its seed through the current manager. + if trigger == .walletMaterialChanged, + selectSolePersistedNetworkIfNeeded(currentNetwork: network) { + return + } + // Gate on SDK presence, not DashSync's hasAWallet (C6-A): the // runtime consumes the SDK wallet, and every SDK-wallet writer // re-triggers a refresh (the creator's and migrator's @@ -369,6 +378,20 @@ final class SwiftDashSDKWalletRuntime: NSObject { "🧭 RUNTIME :: active-wallet change reason=\(reason, privacy: .public) wallet=\(walletId, privacy: .public)") } + private func selectSolePersistedNetworkIfNeeded(currentNetwork: Network) -> Bool { + guard let storedNetworks = try? SwiftDashSDKHost.persistedSDKWalletNetworks(), + !storedNetworks.contains(currentNetwork), + storedNetworks.count == 1, + let storedNetwork = storedNetworks.first else { + return false + } + + let kind: WalletEnvironment.NetworkKind = storedNetwork == .mainnet ? .mainnet : .testnet + Self.logger.info( + "🧭 RUNTIME :: selecting sole persisted wallet network \(storedNetwork.networkName, privacy: .public)") + return WalletEnvironment.switchToNetwork(kind) + } + private func shouldSkipRefresh(for network: Network, trigger: RefreshTrigger) -> Bool { switch trigger { case .walletMaterialChanged, .walletDidChange: diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift index 1d704b721..24812be3a 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift @@ -26,16 +26,26 @@ enum SwiftDashSDKWalletWipeAuthorization: Int { case recoveryFlow case confirmedDeleteAll case screenshotReplacement + case debugReset fileprivate var removesPin: Bool { self != .screenshotReplacement } + var removesMatchingLegacyMnemonicAccounts: Bool { + self == .recoveryFlow + } + + var removesAllLegacyMnemonicAccounts: Bool { + self == .confirmedDeleteAll + } + fileprivate var logLabel: String { switch self { case .recoveryFlow: return "recovery-flow" case .confirmedDeleteAll: return "confirmed-delete-all" case .screenshotReplacement: return "screenshot-replacement" + case .debugReset: return "debug-reset" } } } @@ -71,11 +81,17 @@ final class WalletWipeSerialExecutor { } enum SwiftDashSDKWalletDeletionError: LocalizedError { + case invalidMnemonic case managerUnavailable case unrecognizedWalletNetwork + case walletDeletionIncomplete var errorDescription: String? { switch self { + case .invalidMnemonic: + return NSLocalizedString( + "The wallet recovery phrase is invalid. Please try again.", + comment: "Wallets") case .managerUnavailable: return NSLocalizedString( "The wallet manager is not available. Please try again.", @@ -84,6 +100,10 @@ enum SwiftDashSDKWalletDeletionError: LocalizedError { return NSLocalizedString( "The wallet network could not be determined. Please try again.", comment: "Wallets") + case .walletDeletionIncomplete: + return NSLocalizedString( + "The wallet could not be fully removed. Please try again.", + comment: "Wallets") } } } @@ -98,19 +118,33 @@ enum SwiftDashSDKStoredWalletNetworkResolver { let canonicalMainnetWalletId: Data } + static func walletIds(for mnemonic: String) throws -> [Network: Data] { + let mnemonic = Mnemonic.normalizePhrase(mnemonic) + guard Mnemonic.validate(mnemonic) else { + throw SwiftDashSDKWalletDeletionError.invalidMnemonic + } + return [ + .mainnet: try SwiftDashSDK.Wallet( + mnemonic: mnemonic, + network: .mainnet + ).id, + .testnet: try SwiftDashSDK.Wallet( + mnemonic: mnemonic, + network: .testnet + ).id, + ] + } + static func resolve(walletId: Data, mnemonic: String) throws -> Resolution { - let mainnetWalletId = try SwiftDashSDK.Wallet( - mnemonic: mnemonic, - network: .mainnet - ).id + let walletIds = try walletIds(for: mnemonic) + guard let mainnetWalletId = walletIds[.mainnet], + let testnetWalletId = walletIds[.testnet] else { + throw SwiftDashSDKWalletDeletionError.unrecognizedWalletNetwork + } 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) } @@ -186,19 +220,59 @@ final class SwiftDashSDKWalletWiper: NSObject { authorization: SwiftDashSDKWalletWipeAuthorization ) -> Bool { let startedAt = ContinuousClock.now - - // Classify the global Keychain inventory by its network-derived wallet - // id. Wallet ids and SwiftData stores are network-scoped even though a - // mnemonic can be used on both networks; sending every id through the - // current manager would silently delete the wrong Keychain row while - // leaving the other network's persisted wallet behind. let storage = WalletStorage() let walletIdsByNetwork: [Network: Set] - do { - walletIdsByNetwork = try classifyStoredWalletIdsByNetwork(storage: storage) - } catch { - logger.error("failed to classify wallet inventory: \(String(describing: error), privacy: .public)") - return false + + if authorization.removesMatchingLegacyMnemonicAccounts { + do { + let beforeCleanup = try classifyStoredWallets(storage: storage) + guard let authorizedMnemonic = soleNormalizedMnemonic( + in: beforeCleanup.mnemonics) else { + logger.error( + "recovery wipe requires exactly one distinct SDK mnemonic; refusing legacy and SDK deletion") + return false + } + + try SwiftDashSDKKeyMigrator.removeLegacyMnemonicAccountsBeforeWipe( + matching: authorizedMnemonic) + + // Migration and cleanup share a queue. Re-read SDK state after + // that barrier so a different seed imported while cleanup was + // waiting can never be swept by this phrase-authorized flow. + let afterCleanup = try classifyStoredWallets(storage: storage) + guard soleNormalizedMnemonic(in: afterCleanup.mnemonics) == authorizedMnemonic else { + logger.error( + "SDK mnemonic inventory changed during recovery wipe; refusing SDK deletion") + return false + } + walletIdsByNetwork = afterCleanup.walletIdsByNetwork + } catch { + logger.error( + "matching legacy mnemonic cleanup failed; refusing SDK wipe: \(String(describing: error), privacy: .public)") + return false + } + } else { + if authorization.removesAllLegacyMnemonicAccounts { + do { + try SwiftDashSDKKeyMigrator.removeAllLegacyMnemonicAccounts() + } catch { + logger.error( + "legacy mnemonic cleanup failed; refusing SDK wipe: \(String(describing: error), privacy: .public)") + return false + } + } + + // Classify the global Keychain inventory by its network-derived + // wallet id. Debug and screenshot flows reach this branch without + // touching legacy DashSync data. + do { + walletIdsByNetwork = try classifyStoredWallets( + storage: storage).walletIdsByNetwork + } catch { + logger.error( + "failed to classify wallet inventory: \(String(describing: error), privacy: .public)") + return false + } } // Delete each network through a manager configured with that network's @@ -259,20 +333,33 @@ final class SwiftDashSDKWalletWiper: NSObject { /// included in its deterministic id. An unknown id is a hard failure: /// guessing a manager could recreate the false-success/data-resurrection /// bug this classification exists to prevent. - private static func classifyStoredWalletIdsByNetwork( + private static func classifyStoredWallets( storage: WalletStorage - ) throws -> [Network: Set] { - var result: [Network: Set] = [.mainnet: [], .testnet: []] + ) throws -> ( + walletIdsByNetwork: [Network: Set], + mnemonics: [String] + ) { + var walletIdsByNetwork: [Network: Set] = [.mainnet: [], .testnet: []] + var mnemonics: [String] = [] for walletId in try storage.listWalletIdsWithMnemonic() { let mnemonic = try storage.retrieveMnemonic(for: walletId) let resolution = try SwiftDashSDKStoredWalletNetworkResolver.resolve( walletId: walletId, mnemonic: mnemonic) - result[resolution.network, default: []].insert(walletId) + walletIdsByNetwork[resolution.network, default: []].insert(walletId) + mnemonics.append(mnemonic) } - return result + return (walletIdsByNetwork, mnemonics) + } + + /// Phrase-authorized recovery may wipe mirrored mainnet/testnet IDs, but + /// never two different logical seeds. Empty or ambiguous input fails closed. + static func soleNormalizedMnemonic(in mnemonics: [String]) -> String? { + let distinct = Set(mnemonics.map(Mnemonic.normalizePhrase)) + guard distinct.count == 1 else { return nil } + return distinct.first } /// Run synchronous full deletion through the manager belonging to each @@ -358,6 +445,66 @@ final class SwiftDashSDKWalletWiper: NSObject { "deleteWallet failed for \(network.networkName, privacy: .public)/\(walletLabel, privacy: .public)…: \(String(describing: error), privacy: .public)") } + /// Delete every mainnet/testnet SDK representation of one recovery phrase. + /// Legacy cleanup runs first on the migrator's serial queue, so an import + /// that was already in flight is visible to the managers prepared below. + /// The live network is deleted last, preserving its mnemonic for Retry if + /// inactive-network deletion fails. + @MainActor + static func deleteLogicalWallet(mnemonic phrase: String) async throws { + let mnemonic = Mnemonic.normalizePhrase(phrase) + guard Mnemonic.validate(mnemonic) else { + throw SwiftDashSDKWalletDeletionError.invalidMnemonic + } + + let walletIds = try SwiftDashSDKStoredWalletNetworkResolver.walletIds( + for: mnemonic) + guard walletIds.count == 2 else { + throw SwiftDashSDKWalletDeletionError.unrecognizedWalletNetwork + } + + try await SwiftDashSDKKeyMigrator.removeLegacyMnemonicAccounts( + matching: mnemonic) + + let storage = WalletStorage() + let storedWalletIds = Set(try storage.listWalletIdsWithMnemonic()) + let host = SwiftDashSDKHost.shared + var networks: [Network] = [.mainnet, .testnet] + if let current = host.runningNetwork ?? WalletEnvironment.network, + let currentIndex = networks.firstIndex(of: current) { + networks.remove(at: currentIndex) + networks.append(current) + } + + var deletions: [(network: Network, walletId: Data, manager: PlatformWalletManager)] = [] + for network in networks { + guard let walletId = walletIds[network] else { continue } + let manager = try host.managerForWipe(network: network) + if storedWalletIds.contains(walletId) || manager.wallets[walletId] != nil { + deletions.append((network, walletId, manager)) + } + } + + for deletion in deletions { + try deleteWalletFromSDK( + deletion.walletId, + deleteWallet: { walletId in + try deletion.manager.deleteWallet(walletId: walletId) + }) + + let kind: WalletEnvironment.NetworkKind = + deletion.network == .mainnet ? .mainnet : .testnet + if WalletEnvironment.activeWalletId(for: kind) == deletion.walletId { + WalletEnvironment.setActiveWalletId(nil, for: kind) + } + } + + let remaining = Set(try storage.listWalletIdsWithMnemonic()) + guard walletIds.values.allSatisfy({ !remaining.contains($0) }) else { + throw SwiftDashSDKWalletDeletionError.walletDeletionIncomplete + } + } + /// Full per-wallet SwiftDashSDK deletion of a single wallet: the Rust /// manager state + this wallet's SwiftData rows and Keychain mnemonic via /// `PlatformWalletManager.deleteWallet(walletId:)`. The app-side cleanup diff --git a/DashWallet/Sources/UI/Main/MainTabbarController.swift b/DashWallet/Sources/UI/Main/MainTabbarController.swift index 2e769b708..eb63cc24d 100644 --- a/DashWallet/Sources/UI/Main/MainTabbarController.swift +++ b/DashWallet/Sources/UI/Main/MainTabbarController.swift @@ -445,6 +445,10 @@ extension MainTabbarController: DWWipeDelegate { func beginWipeWallet() { wipeDelegate?.beginWipeWallet?() } + + func beginDebugWipeWallet() { + wipeDelegate?.beginDebugWipeWallet?() + } } // MARK: PaymentsViewControllerDelegate diff --git a/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift b/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift index bc9d2f2b6..f53c2d3d4 100644 --- a/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift +++ b/DashWallet/Sources/UI/Menu/Security/SecurityMenuScreen.swift @@ -94,10 +94,10 @@ struct SecurityMenuScreen: View { .alert("Reset All Wallets (Debug)", isPresented: $showResetWalletDebugAlert) { Button("Cancel", role: .cancel) { } Button("Delete All", role: .destructive) { - delegateInternal.beginWipeWallet() + delegateInternal.beginDebugWipeWallet() } } message: { - Text("Permanently deletes all wallets on this device without asking for their recovery phrases.") + Text("Deletes all SDK wallets without asking for their recovery phrases. Legacy DashSync seed fixtures are preserved.") } .alert( recoveryPhraseFlow.alertState?.title ?? "", @@ -224,14 +224,14 @@ extension SecurityMenuScreen { /// Debug Reset must not expose a live onboarding screen while the SDK /// wipe is still queued. The root coordinator presents a blocking wipe /// gate first, then starts deletion after its HUD is visible. - func beginWipeWallet() { + func beginDebugWipeWallet() { if let wipeDelegate, - wipeDelegate.responds(to: #selector(DWWipeDelegate.beginWipeWallet)) { - wipeDelegate.beginWipeWallet?() + wipeDelegate.responds(to: #selector(DWWipeDelegate.beginDebugWipeWallet)) { + wipeDelegate.beginDebugWipeWallet?() } else { // This screen can be embedded without the app-root delegate in // previews. Preserve the local delete-all behavior in that case. - SwiftDashSDKWalletWiper.wipeWallet(authorization: .confirmedDeleteAll) + SwiftDashSDKWalletWiper.wipeWallet(authorization: .debugReset) onHide() } } diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift index 262b3c6ff..716ef69da 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift @@ -450,7 +450,7 @@ private struct RemoveWalletSheet: View { .foregroundColor(.dash.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.", + "This removes this wallet from this device on every network where it is stored, including its private keys and synced data. Other wallets are not affected. 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(.dash.secondaryText) diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift index 391d09dd9..b468aefec 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift @@ -300,8 +300,8 @@ final class WalletsViewModel: ObservableObject { } } - /// Remove `walletId` from this device (this device's copy only — funds stay - /// recoverable with the phrase). Precondition (enforced by the screen): the + /// Remove the logical wallet represented by `walletId` from this device on + /// both supported networks. Precondition (enforced by the screen): the /// recovery phrase was verified, and another wallet exists on this network /// to switch to — also re-checked here, independent of the registry's /// active id, so a stale registry can never let the only rendered wallet @@ -309,9 +309,8 @@ final class WalletsViewModel: ObservableObject { /// /// 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. + /// deleted wallet, then delete both deterministic network ids plus any + /// matching legacy DashSync mnemonic account. func removeWallet(walletId: Data) async { guard !addInProgress, !switchInProgress, !removeInProgress else { return } removeInProgress = true @@ -326,6 +325,15 @@ final class WalletsViewModel: ObservableObject { return } + let mnemonic: String + do { + mnemonic = try SwiftDashSDKHost.strictlyPersistedMnemonic(for: walletId) + } catch { + Self.logger.error("removeWallet mnemonic read failed: \(String(describing: error), privacy: .public)") + errorMessage = error.localizedDescription + return + } + if walletId == activeWalletId() { switchInProgress = true do { @@ -340,7 +348,8 @@ final class WalletsViewModel: ObservableObject { } do { - try SwiftDashSDKWalletWiper.deleteWalletFromSDK(walletId) + try await SwiftDashSDKWalletWiper.deleteLogicalWallet( + mnemonic: mnemonic) } catch { Self.logger.error("removeWallet failed: \(String(describing: error), privacy: .public)") errorMessage = error.localizedDescription @@ -348,22 +357,15 @@ final class WalletsViewModel: ObservableObject { return } - // 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) + SwiftDashSDKHost.shared.wallet?.walletId + ?? WalletEnvironment.activeWalletId( + for: WalletEnvironment.isTestnet ? .testnet : .mainnet) } /// Collapse a user-entered recovery phrase to canonical form: trim, then diff --git a/DashWallet/Sources/UI/RootNavigation/DWAppRootViewController.m b/DashWallet/Sources/UI/RootNavigation/DWAppRootViewController.m index da84f79ca..75af9d361 100644 --- a/DashWallet/Sources/UI/RootNavigation/DWAppRootViewController.m +++ b/DashWallet/Sources/UI/RootNavigation/DWAppRootViewController.m @@ -51,6 +51,9 @@ @interface DWAppRootViewController () Void, deleteAllHandler: @escaping () -> Void - ) { - do { - let walletCount = try SwiftDashSDKHost.distinctStoredWalletCount() - guard walletCount > 0 else { - presentWithoutVerifiedCount( - from: host, - cancelHandler: cancelHandler, - continueHandler: deleteAllHandler) - return - } - present( - from: host, - walletCount: walletCount, - cancelHandler: cancelHandler, - deleteAllHandler: deleteAllHandler) - } catch { - logger.error( - "failed to inventory wallets for delete-all confirmation: \(String(describing: error), privacy: .public)") - presentWithoutVerifiedCount( - from: host, - cancelHandler: cancelHandler, - continueHandler: deleteAllHandler) - } - } - - static func present( - from host: UIViewController, - walletCount: Int, - cancelHandler: @escaping () -> Void, - deleteAllHandler: @escaping () -> Void - ) { - precondition(walletCount > 0) - - let title: String - let message: String - let destructiveTitle: String - if walletCount == 1 { - title = NSLocalizedString("Delete Wallet?", comment: "") - message = NSLocalizedString( - "This permanently removes the wallet, private keys, and recovery phrase from this device. This cannot be undone.", - comment: "") - destructiveTitle = NSLocalizedString("Delete", comment: "") - } else { - title = NSLocalizedString("Delete All Wallets?", comment: "") - message = String( - format: NSLocalizedString( - "This will erase all %ld wallets stored on this device. They can only be restored with their recovery phrases. Deleting them from this device cannot be undone.", - comment: ""), - walletCount) - destructiveTitle = NSLocalizedString("Delete All", comment: "") - } - - let alert = UIAlertController( - title: title, - message: message, - preferredStyle: .alert) - alert.addAction( - UIAlertAction( - title: NSLocalizedString("Cancel", comment: ""), - style: .cancel, - handler: { _ in cancelHandler() })) - alert.addAction( - UIAlertAction( - title: destructiveTitle, - style: .destructive, - handler: { _ in deleteAllHandler() })) - host.present(alert, animated: true) - } - - private static func presentWithoutVerifiedCount( - from host: UIViewController, - cancelHandler: @escaping () -> Void, - continueHandler: @escaping () -> Void ) { let alert = UIAlertController( title: NSLocalizedString("Delete All Wallets?", comment: ""), message: NSLocalizedString( - "The number of wallets stored on this device could not be verified. Continuing requires a confirmation phrase provided by Dash Support before any wallet is deleted.", + "This permanently removes all wallets, private keys, and recovery phrases stored by this app on this device. They can only be restored from backups. This cannot be undone.", comment: ""), preferredStyle: .alert) alert.addAction( @@ -114,9 +36,9 @@ final class WalletDeleteAllConfirmationCoordinator: NSObject { handler: { _ in cancelHandler() })) alert.addAction( UIAlertAction( - title: NSLocalizedString("Continue", comment: ""), + title: NSLocalizedString("Delete All", comment: ""), style: .destructive, - handler: { _ in continueHandler() })) + handler: { _ in deleteAllHandler() })) host.present(alert, animated: true) } } @@ -132,14 +54,15 @@ final class KeychainWalletRecoveryCoordinator: NSObject { completion: @escaping (Bool) -> Void ) { do { - let walletCount = try SwiftDashSDKHost.distinctStoredWalletCount() - guard walletCount > 0 else { + let entries = try SwiftDashSDKHost.strictlyPersistedMnemonics() + guard SwiftDashSDKHost.distinctWalletCount(in: entries) > 0 else { completion(true) return } + let storedNetworks = try SwiftDashSDKHost.persistedSDKWalletNetworks(in: entries) presentPrimaryAlert( from: host, - walletCount: walletCount, + storedNetworks: storedNetworks, completion: completion) } catch { presentInventoryReadFailure(from: host, completion: completion) @@ -148,50 +71,27 @@ final class KeychainWalletRecoveryCoordinator: NSObject { private static func presentPrimaryAlert( from host: UIViewController, - walletCount: Int, + storedNetworks: Set, completion: @escaping (Bool) -> Void ) { - let title: String - let message: String - let deleteTitle: String - let keepTitle: String - if walletCount == 1 { - title = NSLocalizedString("Wallet found on this device", comment: "") - message = NSLocalizedString( - "A wallet from a previous installation is still stored on this device. Keep using it, or delete it and start fresh? Make sure your recovery phrase is backed up before deleting.", - comment: "") - deleteTitle = NSLocalizedString("Delete", comment: "") - keepTitle = NSLocalizedString("Keep Wallet", comment: "") - } else { - title = String( - format: NSLocalizedString("%ld wallets found on this device", comment: ""), - walletCount) - message = String( - format: NSLocalizedString( - "%ld wallets from a previous installation are stored on this device. Delete All removes every wallet. Back up every recovery phrase before deleting.", - comment: ""), - walletCount) - deleteTitle = NSLocalizedString("Delete All", comment: "") - keepTitle = NSLocalizedString("Keep Wallets", comment: "") - } - let alert = UIAlertController( - title: title, - message: message, + title: NSLocalizedString("Wallets found on this device", comment: ""), + message: NSLocalizedString( + "Wallet data from a previous installation is still stored on this device. Keep using these wallets, or delete all wallet data and start fresh? Make sure every recovery phrase is backed up before deleting.", + comment: ""), preferredStyle: .alert) alert.addAction( UIAlertAction( - title: deleteTitle, + title: NSLocalizedString("Delete All", comment: ""), style: .destructive, handler: { _ in WalletDeleteAllConfirmationCoordinator.present( from: host, - walletCount: walletCount, cancelHandler: { presentPrimaryAlert( from: host, - walletCount: walletCount, + storedNetworks: storedNetworks, completion: completion) }, deleteAllHandler: { completion(false) }) @@ -199,15 +99,33 @@ final class KeychainWalletRecoveryCoordinator: NSObject { alert.addAction( UIAlertAction( - title: keepTitle, + title: NSLocalizedString("Keep Wallets", comment: ""), style: .default, handler: { _ in - completion(true) + keepWallets( + storedNetworks: storedNetworks, + completion: completion) })) host.present(alert, animated: true) } + private static func keepWallets( + storedNetworks: Set, + completion: @escaping (Bool) -> Void + ) { + guard storedNetworks.count == 1, let network = storedNetworks.first else { + completion(true) + return + } + + Task { @MainActor in + let kind: WalletEnvironment.NetworkKind = network == .mainnet ? .mainnet : .testnet + _ = WalletEnvironment.switchToNetwork(kind) + completion(true) + } + } + private static func presentInventoryReadFailure( from host: UIViewController, completion: @escaping (Bool) -> Void diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 7a3b44716..1af9626cd 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -5570,8 +5570,11 @@ "No stored wallets were found. Nothing was deleted." = "No stored wallets were found. Nothing was deleted."; "No Wallets Found" = "No Wallets Found"; "Not all wallets could be deleted. Please try again." = "Not all wallets could be deleted. Please try again."; +"This permanently removes all wallets, private keys, and recovery phrases stored by this app on this device. They can only be restored from backups. This cannot be undone." = "This permanently removes all wallets, private keys, and recovery phrases stored by this app on this device. They can only be restored from backups. This cannot be undone."; "The wallets stored on this device could not be verified. Nothing was deleted. Please try again." = "The wallets stored on this device could not be verified. Nothing was deleted. Please try again."; "This will erase all %ld wallets stored on this device. They can only be restored with their recovery phrases. Deleting them from this device cannot be undone." = "This will erase all %ld wallets stored on this device. They can only be restored with their recovery phrases. Deleting them from this device cannot be undone."; +"Wallet data from a previous installation is still stored on this device. Keep using these wallets, or delete all wallet data and start fresh? Make sure every recovery phrase is backed up before deleting." = "Wallet data from a previous installation is still stored on this device. Keep using these wallets, or delete all wallet data and start fresh? Make sure every recovery phrase is backed up before deleting."; +"Wallets found on this device" = "Wallets found on this device"; "Wipe All Wallets" = "Wipe All Wallets"; "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."; diff --git a/DashWalletTests/WalletWipeSerialExecutorTests.swift b/DashWalletTests/WalletWipeSerialExecutorTests.swift index ade7cd964..089b142d6 100644 --- a/DashWalletTests/WalletWipeSerialExecutorTests.swift +++ b/DashWalletTests/WalletWipeSerialExecutorTests.swift @@ -93,9 +93,36 @@ final class WalletWipeSerialExecutorTests: XCTestCase { XCTAssertFalse(appStateCleared) } + + func testLegacyCleanupAuthorizationScope() { + XCTAssertTrue( + SwiftDashSDKWalletWipeAuthorization.recoveryFlow + .removesMatchingLegacyMnemonicAccounts) + XCTAssertFalse( + SwiftDashSDKWalletWipeAuthorization.recoveryFlow + .removesAllLegacyMnemonicAccounts) + + XCTAssertTrue( + SwiftDashSDKWalletWipeAuthorization.confirmedDeleteAll + .removesAllLegacyMnemonicAccounts) + XCTAssertFalse( + SwiftDashSDKWalletWipeAuthorization.confirmedDeleteAll + .removesMatchingLegacyMnemonicAccounts) + + for authorization in [ + SwiftDashSDKWalletWipeAuthorization.debugReset, + .screenshotReplacement, + ] { + XCTAssertFalse(authorization.removesMatchingLegacyMnemonicAccounts) + XCTAssertFalse(authorization.removesAllLegacyMnemonicAccounts) + } + } } final class StoredWalletInventoryTests: XCTestCase { + 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 testDistinctWalletCountDeduplicatesNetworkScopedCopiesOfOneSeed() { let seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" let entries = [ @@ -118,6 +145,96 @@ final class StoredWalletInventoryTests: XCTestCase { XCTAssertEqual(SwiftDashSDKHost.distinctWalletCount(in: entries), 2) } + + func testRecoveryFiltersPersistedWalletsByNetwork() throws { + let idsA = try SwiftDashSDKStoredWalletNetworkResolver.walletIds(for: seedA) + let idsB = try SwiftDashSDKStoredWalletNetworkResolver.walletIds(for: seedB) + let mainnetId = try XCTUnwrap(idsA[.mainnet]) + let testnetId = try XCTUnwrap(idsB[.testnet]) + let entries = [ + (walletId: mainnetId, mnemonic: seedA), + (walletId: testnetId, mnemonic: seedB), + ] + + let mainnet = SwiftDashSDKHost.recoverablePersistedMnemonics( + entries, + for: .mainnet) + let testnet = SwiftDashSDKHost.recoverablePersistedMnemonics( + entries, + for: .testnet) + + XCTAssertEqual(mainnet.map { $0.walletId }, [mainnetId]) + XCTAssertEqual(testnet.map { $0.walletId }, [testnetId]) + } + + func testRecoverySkipsUnrecognizedStoredId() { + let entries = [ + (walletId: Data(repeating: 0xff, count: 32), mnemonic: seedA), + ] + + XCTAssertTrue(SwiftDashSDKHost.recoverablePersistedMnemonics( + entries, + for: .mainnet).isEmpty) + XCTAssertTrue(SwiftDashSDKHost.recoverablePersistedMnemonics( + entries, + for: .testnet).isEmpty) + } + + func testMirroredSeedClassifiesAsTwoStoredNetworks() throws { + let ids = try SwiftDashSDKStoredWalletNetworkResolver.walletIds(for: seedA) + let mainnetId = try XCTUnwrap(ids[.mainnet]) + let testnetId = try XCTUnwrap(ids[.testnet]) + let entries = [ + (walletId: mainnetId, mnemonic: seedA), + (walletId: testnetId, mnemonic: seedA), + ] + + XCTAssertEqual( + try SwiftDashSDKHost.persistedSDKWalletNetworks(in: entries), + Set([.mainnet, .testnet])) + } + + func testLogicalWalletIdsAreNetworkScoped() throws { + let ids = try SwiftDashSDKStoredWalletNetworkResolver.walletIds(for: seedA) + + XCTAssertEqual(ids.count, 2) + XCTAssertNotEqual(ids[.mainnet], ids[.testnet]) + } + + func testRecoveryWipeTreatsMirroredIdsAsOneNormalizedSeed() { + XCTAssertEqual( + SwiftDashSDKWalletWiper.soleNormalizedMnemonic( + in: [seedA, " \(seedA.uppercased()) "]), + seedA) + } + + func testRecoveryWipeRejectsEmptyOrDifferentSeeds() { + XCTAssertNil(SwiftDashSDKWalletWiper.soleNormalizedMnemonic(in: [])) + XCTAssertNil( + SwiftDashSDKWalletWiper.soleNormalizedMnemonic( + in: [seedA, seedB])) + } +} + +final class LegacyMnemonicSelectionTests: XCTestCase { + func testTargetedCleanupSelectsOnlyMatchingNormalizedSeed() { + let target = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + let other = "legal winner thank year wave sausage worth useful legal winner thank yellow" + let entries = [ + SwiftDashSDKKeyMigrator.LegacyMnemonicEntry( + account: "WALLET_MNEMONIC_KEY_a", + mnemonic: " \(target.uppercased()) "), + SwiftDashSDKKeyMigrator.LegacyMnemonicEntry( + account: "WALLET_MNEMONIC_KEY_b", + mnemonic: other), + ] + + XCTAssertEqual( + SwiftDashSDKKeyMigrator.legacyMnemonicAccountsToRemove( + matching: target, + in: entries), + ["WALLET_MNEMONIC_KEY_a"]) + } } final class WipeAcceptancePhraseTests: XCTestCase {