diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 4bc07e8c1..7e38d71a7 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -128,7 +128,6 @@ jobs: - { name: migration_1-restore, setup_type: standard } - { name: migration_2-migration, setup_type: standard } - { name: migration_3-with-passphrase, setup_type: passphrase } - - { name: migration_4-with-sweep, setup_type: sweep } with: e2e_branch: ${{ needs.e2e-branch.outputs.branch }} rn_version: ${{ matrix.rn_version }} @@ -149,7 +148,6 @@ jobs: - { name: migration_1-restore, setup_type: standard, grep: "@migration_1" } - { name: migration_2-migration, setup_type: standard, grep: "@migration_2" } - { name: migration_3-with-passphrase, setup_type: passphrase, grep: "@migration_3" } - - { name: migration_4-with-sweep, setup_type: sweep, grep: "@migration_4" } name: e2e-tests - ${{ matrix.rn_version }} - ${{ matrix.scenario.name }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4112aa060..849384d2a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -67,6 +67,7 @@ jobs: -only-testing:BitkitTests/UtxoSelectionTests \ -only-testing:BitkitTests/BlocktankTests \ -only-testing:BitkitTests/PaymentFlowTests \ + -only-testing:BitkitTests/AddressTypeIntegrationTests \ | xcbeautify --report junit } diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 20a1a4893..1bf13f918 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -71,6 +71,7 @@ jobs: -skip-testing:BitkitTests/UtxoSelectionTests \ -skip-testing:BitkitTests/BlocktankTests \ -skip-testing:BitkitTests/PaymentFlowTests \ + -skip-testing:BitkitTests/AddressTypeIntegrationTests \ | xcbeautify --report junit echo "✅ Unit tests completed at $(date)" diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index c5ce6136e..ee1e78db3 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -81,6 +81,7 @@ membershipExceptions = ( Constants/Env.swift, Extensions/HexBytes.swift, + "Extensions/LDKNode+AddressType.swift", Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, Models/LnPeer.swift, @@ -109,6 +110,7 @@ membershipExceptions = ( Constants/Env.swift, Extensions/HexBytes.swift, + "Extensions/LDKNode+AddressType.swift", Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, Models/LnPeer.swift, @@ -893,7 +895,7 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/vss-rust-client-ffi"; requirement = { - branch = "master"; + branch = master; kind = branch; }; }; @@ -925,8 +927,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { - branch = main; - kind = branch; + kind = revision; + revision = 2ccd7aba932d245850d90e7906559025b5be2f43; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 840eabd05..8a3c86a21 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "branch" : "main", - "revision" : "65f616fb466bde34a95c09eb85217eaee176e1e9" + "revision" : "2ccd7aba932d245850d90e7906559025b5be2f43" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index dff8a3719..b8934abd5 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -87,8 +87,6 @@ struct AppScene: View { if UserDefaults.standard.bool(forKey: "pinOnLaunch") && settings.pinEnabled { isPinVerified = false } - SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets) - if migrations.needsPostMigrationSync { app.toast( type: .warning, diff --git a/Bitkit/Extensions/LDKNode+AddressType.swift b/Bitkit/Extensions/LDKNode+AddressType.swift new file mode 100644 index 000000000..bd7095cf5 --- /dev/null +++ b/Bitkit/Extensions/LDKNode+AddressType.swift @@ -0,0 +1,148 @@ +import LDKNode + +extension LDKNode.AddressType { + // MARK: - All cases (ordered) + + static let allAddressTypes: [LDKNode.AddressType] = [.legacy, .nestedSegwit, .nativeSegwit, .taproot] + + /// All address types with `selected` first, remaining in standard order. + static func prioritized(selected: LDKNode.AddressType) -> [LDKNode.AddressType] { + var types = [selected] + for type in allAddressTypes where type != selected { + types.append(type) + } + return types + } + + // MARK: - Storage string (UserDefaults / BitkitCore APIs) + + /// String value used in UserDefaults and BitkitCore APIs. + var stringValue: String { + switch self { + case .legacy: return "legacy" + case .nestedSegwit: return "nestedSegwit" + case .nativeSegwit: return "nativeSegwit" + case .taproot: return "taproot" + } + } + + /// Parses storage string; returns nil for invalid or unknown values. + static func from(string: String) -> LDKNode.AddressType? { + switch string { + case "legacy": return .legacy + case "nestedSegwit": return .nestedSegwit + case "nativeSegwit": return .nativeSegwit + case "taproot": return .taproot + default: return nil + } + } + + /// Parses storage string; returns `.nativeSegwit` for nil or invalid (backward compatibility). + static func fromStorage(_ string: String?) -> LDKNode.AddressType { + guard let s = string, let type = from(string: s) else { return .nativeSegwit } + return type + } + + /// Parses a comma-separated string of address types; filters invalid values. + static func parseCommaSeparated(_ string: String) -> [LDKNode.AddressType] { + string.split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .compactMap { from(string: $0) } + } + + // MARK: - Derivation path + + /// BIP derivation path using current network (Env.network) for coin type. + var derivationPath: String { + let coinType = Env.network == .bitcoin ? "0" : "1" + return derivationPath(coinType: coinType) + } + + /// BIP derivation path for the given coin type ("0" mainnet, "1" testnet). + func derivationPath(coinType: String) -> String { + switch self { + case .legacy: return "m/44'/\(coinType)'/0'/0" // BIP 44 + case .nestedSegwit: return "m/49'/\(coinType)'/0'/0" // BIP 49 + case .nativeSegwit: return "m/84'/\(coinType)'/0'/0" // BIP 84 + case .taproot: return "m/86'/\(coinType)'/0'/0" // BIP 86 + } + } + + // MARK: - Localized display + + var localizedTitle: String { + switch self { + case .legacy: return "Legacy" + case .nestedSegwit: return "Nested Segwit" + case .nativeSegwit: return "Native Segwit" + case .taproot: return "Taproot" + } + } + + /// Short label for compact UI (e.g. "Native"). + var shortLabel: String { + switch self { + case .legacy: return "Legacy" + case .nestedSegwit: return "Nested" + case .nativeSegwit: return "Native" + case .taproot: return "Taproot" + } + } + + var localizedDescription: String { + switch self { + case .legacy: return "Pay-to-public-key-hash (1x...)" + case .nestedSegwit: return "Pay-to-Script-Hash (3x...)" + case .nativeSegwit: return "Pay-to-witness-public-key-hash (bc1x...)" + case .taproot: return "Pay-to-Taproot (bc1px...)" + } + } + + var example: String { + switch self { + case .legacy: return "(1x...)" + case .nestedSegwit: return "(3x...)" + case .nativeSegwit: return "(bc1x...)" + case .taproot: return "(bc1px...)" + } + } + + var shortExample: String { + switch self { + case .legacy: return "1x..." + case .nestedSegwit: return "3x..." + case .nativeSegwit: return "bc1q..." + case .taproot: return "bc1p..." + } + } + + /// Accessibility / UI test identifier. + var testId: String { + switch self { + case .legacy: return "p2pkh" + case .nestedSegwit: return "p2sh-p2wpkh" + case .nativeSegwit: return "p2wpkh" + case .taproot: return "p2tr" + } + } + + // MARK: - Address format validation + + /// Returns true if the address has the expected prefix for this address type on the given network. + /// Defensive check only; not a full script/checksum validation. + func matchesAddressFormat(_ address: String, network: LDKNode.Network) -> Bool { + let trimmed = address.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return false } + let isMainnet = network == .bitcoin + switch self { + case .legacy: + return isMainnet ? trimmed.hasPrefix("1") : trimmed.hasPrefix("m") || trimmed.hasPrefix("n") + case .nestedSegwit: + return isMainnet ? trimmed.hasPrefix("3") : trimmed.hasPrefix("2") + case .nativeSegwit: + return isMainnet ? trimmed.hasPrefix("bc1q") : trimmed.hasPrefix("tb1q") || trimmed.hasPrefix("bcrt1q") + case .taproot: + return isMainnet ? trimmed.hasPrefix("bc1p") : trimmed.hasPrefix("tb1p") || trimmed.hasPrefix("bcrt1p") + } + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 9ffc02444..e438be36f 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -10,8 +10,6 @@ struct MainNavView: View { @EnvironmentObject private var wallet: WalletViewModel @Environment(\.scenePhase) var scenePhase - @StateObject private var sweepViewModel = SweepViewModel() - @State private var showClipboardAlert = false @State private var clipboardUri: String? @@ -164,14 +162,6 @@ struct MainNavView: View { ) { config in ForceTransferSheet(config: config) } - .sheet( - item: $sheets.sweepPromptSheetItem, - onDismiss: { - sheets.hideSheet() - } - ) { - config in SweepPromptSheet(config: config) - } .accentColor(.white) .overlay { TabBar() @@ -403,6 +393,7 @@ struct MainNavView: View { // Advanced settings case .coinSelection: CoinSelectionSettingsView() + case .addressTypePreference: AddressTypePreferenceView() case .connections: LightningConnectionsView() case let .connectionDetail(channelId): LightningConnectionDetailView(channelId: channelId) case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) @@ -410,11 +401,6 @@ struct MainNavView: View { case .electrumSettings: ElectrumSettingsScreen() case .rgsSettings: RgsSettingsScreen() case .addressViewer: AddressViewer() - case .sweep: SweepSettingsView().environmentObject(sweepViewModel) - case .sweepConfirm: SweepConfirmView().environmentObject(sweepViewModel) - case .sweepFeeRate: SweepFeeRateView().environmentObject(sweepViewModel) - case .sweepFeeCustom: SweepFeeCustomView().environmentObject(sweepViewModel) - case let .sweepSuccess(txid): SweepSuccessView(txid: txid).environmentObject(sweepViewModel) // Dev settings case .blocktankRegtest: BlocktankRegtestView() diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index c02423215..4a4e9060c 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -41,6 +41,8 @@ enum SettingsBackupConfig { "defaultTransactionSpeed": .string(optional: true), "coinSelectionMethod": .string(optional: true), "coinSelectionAlgorithm": .string(optional: true), + "selectedAddressType": .string(optional: true), + "addressTypesToMonitor": .string(optional: true), "enableQuickpay": .bool, "showWidgets": .bool, "showWidgetTitles": .bool, diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 1844973aa..3786aefa7 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -710,8 +710,6 @@ "settings__adv__section_other" = "Další"; "settings__adv__address_type" = "Typ bitcoinové adresy"; "settings__adv__monitored_address_types" = "Sledované typy adres"; -"settings__adv__monitored_address_types_update_title" = "Aktualizace sledovaných typů adres"; -"settings__adv__monitored_address_types_update_description" = "Změny se plně projeví po restartu aplikace."; "settings__adv__gap_limit" = "Limit mezery v adrese"; "settings__adv__coin_selection" = "Výběr mince"; "settings__adv__cs_method" = "Metoda výběru mince"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 89415cb11..c21b68021 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -708,8 +708,6 @@ "settings__adv__section_other" = "Andere"; "settings__adv__address_type" = "Bitcoin Adressen-Typ"; "settings__adv__monitored_address_types" = "Überwachte Adress-Typen"; -"settings__adv__monitored_address_types_update_title" = "Überwachte Adress-Typen aktualisiert"; -"settings__adv__monitored_address_types_update_description" = "Änderungen werden nach dem Neustart der App vollständig wirksam."; "settings__adv__gap_limit" = "Address Gap Limit"; "settings__adv__coin_selection" = "Coin-Auswahl"; "settings__adv__cs_method" = "Coin-Auswahlmethode"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index c297be4f6..72caccfbb 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -738,8 +738,19 @@ "settings__adv__section_other" = "Other"; "settings__adv__address_type" = "Bitcoin Address Type"; "settings__adv__monitored_address_types" = "Monitored Address Types"; -"settings__adv__monitored_address_types_update_title" = "Monitored Address Types Updated"; -"settings__adv__monitored_address_types_update_description" = "Changes will take full effect after app restarts."; +"settings__adv__addr_type_failed_title" = "Failed"; +"settings__adv__addr_type_change_failed_desc" = "Could not change address type. Please try again."; +"settings__adv__addr_type_applying" = "Applying changes…"; +"settings__adv__addr_type_changed_title" = "Address Type Changed"; +"settings__adv__addr_type_changed_desc" = "Now using {type} addresses."; +"settings__adv__addr_type_monitored_updated_title" = "Settings Updated"; +"settings__adv__addr_type_monitored_updated_desc" = "Address monitoring settings applied."; +"settings__adv__addr_type_cannot_disable_title" = "Cannot Disable"; +"settings__adv__addr_type_cannot_disable_native_desc" = "At least one Native SegWit or Taproot wallet is required for Lightning channels."; +"settings__adv__addr_type_cannot_disable_balance_desc" = "{type} addresses have balance."; +"settings__adv__addr_type_monitored_failed_desc" = "Could not update monitoring settings. Please try again."; +"settings__adv__addr_type_currently_selected" = "Currently selected"; +"settings__adv__addr_type_monitored_note" = "Enable monitoring to track funds received at different address types. The app will watch these addresses for incoming transactions. Disabling monitoring for a type with balance may hide your funds."; "settings__adv__gap_limit" = "Address Gap Limit"; "settings__adv__coin_selection" = "Coin Selection"; "settings__adv__cs_method" = "Coin Selection Method"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index e3decc0ec..4bd44c092 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -717,8 +717,6 @@ "settings__adv__section_other" = "Otros"; "settings__adv__address_type" = "Tipo de dirección Bitcoin"; "settings__adv__monitored_address_types" = "Tipos de Direcciones en monitoreo"; -"settings__adv__monitored_address_types_update_title" = "Tipos de dirección monitoreados actualizados"; -"settings__adv__monitored_address_types_update_description" = "Los cambios surtirán pleno efecto tras reiniciar la aplicación."; "settings__adv__gap_limit" = "Límite de la brecha de direcciones"; "settings__adv__coin_selection" = "Selección de monedas"; "settings__adv__cs_method" = "Método de selección de monedas"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 4f65eb31b..efb6a5578 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -721,8 +721,6 @@ "settings__adv__section_other" = "Autre"; "settings__adv__address_type" = "Type d\'adresse Bitcoin"; "settings__adv__monitored_address_types" = "Types d\'adresses suivies"; -"settings__adv__monitored_address_types_update_title" = "Mise à jour des types d\'adresses suivies"; -"settings__adv__monitored_address_types_update_description" = "Les modifications prendront effet après le redémarrage de l\'application."; "settings__adv__gap_limit" = "Limite de l\'écart d\'adresse"; "settings__adv__coin_selection" = "Sélection des UTXOs"; "settings__adv__cs_method" = "Méthode de sélection des UTXOs"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 9832602b8..56fa30ce5 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -699,8 +699,6 @@ "settings__adv__section_other" = "Altro"; "settings__adv__address_type" = "Tipologia Indirizzo Bitcoin"; "settings__adv__monitored_address_types" = "Tipi di indirizzi monitorati"; -"settings__adv__monitored_address_types_update_title" = "Tipi di indirizzi monitorati aggiornati"; -"settings__adv__monitored_address_types_update_description" = "Le modifiche avranno pieno effetto dopo il riavvio dell\'app."; "settings__adv__gap_limit" = "Limite del gap di indirizzi"; "settings__adv__coin_selection" = "Coin Selection"; "settings__adv__cs_method" = "Metodo di Coin Selection"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 890b6bcf2..ccce1096c 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -718,8 +718,6 @@ "settings__adv__section_other" = "Overige"; "settings__adv__address_type" = "Bitcoin adres type"; "settings__adv__monitored_address_types" = "Bewaakte adrestypes"; -"settings__adv__monitored_address_types_update_title" = "Bewaakte adrestypes bijgewerkt"; -"settings__adv__monitored_address_types_update_description" = "Wijzigingen worden volledig van kracht nadat de app opnieuw is opgestart."; "settings__adv__gap_limit" = "Adres Gap Limit"; "settings__adv__coin_selection" = "Coin selectie"; "settings__adv__cs_method" = "Coin Selectie Methode"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 809c1c9b4..8a6182bfd 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -722,8 +722,6 @@ "settings__adv__section_other" = "Inne"; "settings__adv__address_type" = "Typ adresu Bitcoin"; "settings__adv__monitored_address_types" = "Monitorowane typy adresów"; -"settings__adv__monitored_address_types_update_title" = "Zaktualizowano monitorowane typy adresów"; -"settings__adv__monitored_address_types_update_description" = "Zmiany zostaną zastosowane po ponownym uruchomieniu aplikacji."; "settings__adv__gap_limit" = "Limit odstępu adresów"; "settings__adv__coin_selection" = "Wybór monet"; "settings__adv__cs_method" = "Metoda wyboru monet"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 4d0911b8f..2abbeca01 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -722,8 +722,6 @@ "settings__adv__section_other" = "Outros"; "settings__adv__address_type" = "Tipo de endereço Bitcoin"; "settings__adv__monitored_address_types" = "Tipos de Endereços Monitorados"; -"settings__adv__monitored_address_types_update_title" = "Tipos de Endereços Monitorados Atualizados"; -"settings__adv__monitored_address_types_update_description" = "As alterações terão após a reinicialização do aplicativo."; "settings__adv__gap_limit" = "Limite de Endereços"; "settings__adv__coin_selection" = "Controle de Moedas"; "settings__adv__cs_method" = "Método de Controle de Moedas"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 517fc0f82..8376885f1 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -722,8 +722,6 @@ "settings__adv__section_other" = "Другое"; "settings__adv__address_type" = "Тип Биткойн-Адреса"; "settings__adv__monitored_address_types" = "Отслеживаемые Типы Адресов"; -"settings__adv__monitored_address_types_update_title" = "Отслеживаемые Типы Адресов Обновлены"; -"settings__adv__monitored_address_types_update_description" = "Изменения вступят в силу после перезапуска приложения."; "settings__adv__gap_limit" = "Gap Limit Адресов"; "settings__adv__coin_selection" = "Выбор Монет"; "settings__adv__cs_method" = "Метод Выбора Монет"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index a437a4d61..c1a739e4b 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -22,8 +22,7 @@ class ActivityService { // MARK: - Constants - /// Maximum address index to search when current address exists - private static let maxAddressSearchIndex: UInt32 = 100_000 + private let addressSearchCoordinator: AddressSearchCoordinator // MARK: - BoostTxIds Cache @@ -124,10 +123,8 @@ class ActivityService { } func isOnchainActivitySeen(txid: String) async -> Bool { - if let activity = try? await getOnchainActivityByTxId(txid: txid) { - return activity.seenAt != nil - } - return false + let activity = try? await getOnchainActivityByTxId(txid: txid) + return activity?.seenAt != nil } func markActivityAsSeen(id: String, seenAt: UInt64? = nil) async { @@ -286,6 +283,7 @@ class ActivityService { init(coreService: CoreService) { self.coreService = coreService + addressSearchCoordinator = AddressSearchCoordinator(coreService: coreService) } func removeAll() async throws { @@ -348,7 +346,7 @@ class ActivityService { _ payment: PaymentDetails, transactionDetails: BitkitCore.TransactionDetails? = nil ) async throws { - guard case let .onchain(txid, _) = payment.kind else { return } + guard case let .onchain(txid, txStatus) = payment.kind else { return } let paymentTimestamp = payment.latestUpdateTimestamp @@ -358,10 +356,15 @@ class ActivityService { existingActivity = try BitkitCore.getActivityByTxId(txId: txid).map { .onchain($0) } } - // Skip if existing activity has newer timestamp to avoid overwriting local data + // Determine if confirmation status is changing + let ldkConfirmed = if case .confirmed = txStatus { true } else { false } + + // Skip if existing activity has newer timestamp, unless confirmation status is changing if let existingActivity, case let .onchain(existing) = existingActivity { let existingUpdatedAt = existing.updatedAt ?? 0 - if existingUpdatedAt > paymentTimestamp { + let confirmationStatusChanging = existing.confirmed != ldkConfirmed + + if existingUpdatedAt > paymentTimestamp && !confirmationStatusChanging { return } } @@ -396,6 +399,7 @@ class ActivityService { let doesExist = existingOnchain?.doesExist ?? true let seenAt = existingOnchain?.seenAt + // Preserve existing value if it's larger than what LDK reports let ldkValue = payment.amountSats ?? 0 let value: UInt64 = if let existingValue = existingOnchain?.value, existingValue > ldkValue { existingValue @@ -487,7 +491,10 @@ class ActivityService { } return false }) else { - Logger.warn("Payment not found for transaction \(txid) - LDK should have updated payment store before emitting event", context: context) + Logger.warn( + "Payment not found for transaction \(txid) - activity not created (see docs/ldk-onchain-activity-timing-issue.md)", + context: context + ) return } @@ -659,17 +666,20 @@ class ActivityService { let existingActivity = try getActivityById(activityId: payment.id) let existingLightning: LightningActivity? = if let existingActivity, case let .lightning(ln) = existingActivity { ln } else { nil } - // Skip if existing activity has newer timestamp to avoid overwriting local data - if let existingUpdatedAt = existingLightning?.updatedAt, existingUpdatedAt > paymentTimestamp { - return - } - let state: BitkitCore.PaymentState = switch payment.status { case .failed: .failed case .pending: .pending case .succeeded: .succeeded } + // Skip if existing activity has newer timestamp, unless payment status is changing + if let existing = existingLightning, let existingUpdatedAt = existing.updatedAt { + let statusChanging = existing.status != state + if existingUpdatedAt > paymentTimestamp && !statusChanging { + return + } + } + let ln = LightningActivity( id: payment.id, txType: payment.direction == .outbound ? .sent : .received, @@ -780,18 +790,10 @@ class ActivityService { switch sweepBalance { case let .broadcastAwaitingConfirmation(channelId, _, latestSpendingTxid, _): if latestSpendingTxid.description == txid, let channelId { - Logger.info( - "Matched sweep tx \(txid) to channel \(channelId) via pendingSweepBalance (awaiting confirmation)", - context: "findClosedChannelForTransaction" - ) return channelId.description } case let .awaitingThresholdConfirmations(channelId, latestSpendingTxid, _, _, _): if latestSpendingTxid.description == txid, let channelId { - Logger.info( - "Matched sweep tx \(txid) to channel \(channelId) via pendingSweepBalance (threshold confirmations)", - context: "findClosedChannelForTransaction" - ) return channelId.description } case .pendingBroadcast: @@ -898,98 +900,22 @@ class ActivityService { return nil } - let batchSize: UInt32 = 20 - let currentWalletAddress = UserDefaults.standard.string(forKey: "onchainAddress") ?? "" - - // Check if an address matches any transaction output - func matchesTransaction(_ address: String) -> Bool { - details.outputs.contains { output in - output.scriptpubkeyAddress == address - } - } - - // Find matching address from a list, preferring exact value match - func findMatch(in addresses: [String]) -> String? { - // Try exact value match first - for address in addresses { - for output in details.outputs { - if output.scriptpubkeyAddress == address, - output.value == value - { - return address - } - } - } - // Fallback to any address match - for address in addresses { - if matchesTransaction(address) { - return address - } - } - return nil - } - - // First, check pre-activity metadata for addresses in the transaction if let address = await findAddressInPreActivityMetadata(details: details, value: value) { return address } - // Check current address if it exists - if !currentWalletAddress.isEmpty && matchesTransaction(currentWalletAddress) { - return currentWalletAddress - } - - // Search addresses forward in batches - func searchAddresses(isChange: Bool) async throws -> String? { - var index: UInt32 = 0 - var currentAddressIndex: UInt32? = nil - let hasCurrentAddress = !currentWalletAddress.isEmpty - let maxIndex: UInt32 = hasCurrentAddress ? Self.maxAddressSearchIndex : batchSize - - while index < maxIndex { - let accountAddresses = try await coreService.utility.getAccountAddresses( - walletIndex: 0, - isChange: isChange, - startIndex: index, - count: batchSize - ) - - let addresses = accountAddresses.unused.map(\.address) + accountAddresses.used.map(\.address) - - // Track when we find the current address - if hasCurrentAddress, currentAddressIndex == nil, addresses.contains(currentWalletAddress) { - currentAddressIndex = index - } - - // Check for matches - if let match = findMatch(in: addresses) { - return match - } - - // Stop if we've checked one batch after finding current address - if let foundIndex = currentAddressIndex, index >= foundIndex + batchSize { - break - } - - // Stop if we've reached the end - if addresses.count < Int(batchSize) { - break - } - - index += batchSize - } - return nil - } + let currentWalletAddress = UserDefaults.standard.string(forKey: "onchainAddress") ?? "" + let selectedAddressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) - // Try receiving addresses first, then change addresses - if let address = try await searchAddresses(isChange: false) { - return address - } - if let address = try await searchAddresses(isChange: true) { + if let address = try await addressSearchCoordinator.runAddressSearch( + details: details, + value: value, + currentWalletAddress: currentWalletAddress, + selectedAddressType: selectedAddressType + ) { return address } - // Fallback: return first output address return details.outputs.first?.scriptpubkeyAddress } @@ -1058,6 +984,52 @@ class ActivityService { } } + /// Create sent onchain activity from send result so it appears immediately; LDK events update it later (e.g. confirmation). + func createSentOnchainActivityFromSendResult( + txid: String, + address: String, + amount: UInt64, + fee: UInt64, + feeRate: UInt32 + ) async { + do { + try await ServiceQueue.background(.core) { + if let _ = try? BitkitCore.getActivityByTxId(txId: txid) { + Logger.debug("Activity already exists for txid \(txid), skipping immediate creation", context: "ActivityService") + return + } + let now = UInt64(Date().timeIntervalSince1970) + let onchain = OnchainActivity( + id: txid, + txType: .sent, + txId: txid, + value: amount, + fee: fee, + feeRate: UInt64(feeRate), + address: address, + confirmed: false, + timestamp: now, + isBoosted: false, + boostTxIds: [], + isTransfer: false, + doesExist: true, + confirmTimestamp: nil, + channelId: nil, + transferTxId: nil, + createdAt: now, + updatedAt: now, + seenAt: now + ) + try upsertActivity(activity: .onchain(onchain)) + self.updateBoostTxIdsCache(for: .onchain(onchain)) + self.activitiesChangedSubject.send() + Logger.info("Created sent onchain activity for txid \(txid) from send result", context: "ActivityService") + } + } catch { + Logger.error("Failed to create sent onchain activity for txid \(txid): \(error)", context: "ActivityService") + } + } + func delete(id: String) async throws -> Bool { try await ServiceQueue.background(.core) { // Rebuild cache if deleting an onchain activity with boostTxIds @@ -1300,6 +1272,102 @@ class ActivityService { } } +// MARK: - Address search (actor for single-flight concurrency) + +private actor AddressSearchCoordinator { + private let coreService: CoreService + private var isSearching = false + private var waitQueue: [CheckedContinuation] = [] + + init(coreService: CoreService) { + self.coreService = coreService + } + + /// Runs the batch address search at most one at a time. Enqueues if a search is already in progress. + func runAddressSearch( + details: BitkitCore.TransactionDetails, + value: UInt64, + currentWalletAddress: String, + selectedAddressType: LDKNode.AddressType + ) async throws -> String? { + if isSearching { + await withCheckedContinuation { waitQueue.append($0) } + } + isSearching = true + defer { + isSearching = false + if !waitQueue.isEmpty { + waitQueue.removeFirst().resume() + } + } + return try await searchReceivingAddress( + details: details, value: value, + currentWalletAddress: currentWalletAddress, + selectedAddressType: selectedAddressType + ) + } + + private func searchReceivingAddress( + details: BitkitCore.TransactionDetails, + value: UInt64, + currentWalletAddress: String, + selectedAddressType: LDKNode.AddressType + ) async throws -> String? { + let batchSize: UInt32 = 200 + let searchWindow: UInt32 = 1000 + + func matchesTransaction(_ address: String) -> Bool { + details.outputs.contains { $0.scriptpubkeyAddress == address } + } + + func findMatch(in addresses: [String]) -> String? { + if let exact = details.outputs.first(where: { $0.value == value }), + let addr = exact.scriptpubkeyAddress, addresses.contains(addr) + { return addr } + return addresses.first { matchesTransaction($0) } + } + + if !currentWalletAddress.isEmpty, matchesTransaction(currentWalletAddress) { + return currentWalletAddress + } + + let addressTypesToSearch = LDKNode.AddressType.prioritized(selected: selectedAddressType) + + for isChange in [false, true] { + for addressType in addressTypesToSearch { + let key = isChange ? "addressSearch_lastUsedChangeIndex_\(addressType.stringValue)" : "addressSearch_lastUsedReceiveIndex_\(addressType.stringValue)" + let lastUsed: UInt32? = (UserDefaults.standard.object(forKey: key) as? Int).flatMap { $0 >= 0 ? UInt32($0) : nil } + let endIndex = lastUsed.map { $0 + searchWindow } ?? searchWindow + + var index: UInt32 = 0 + var currentAddressBatch: UInt32? + while index < endIndex { + let accountAddresses = try await coreService.utility.getAccountAddresses( + walletIndex: 0, + isChange: isChange, + startIndex: index, + count: batchSize, + addressTypeString: addressType.stringValue + ) + let addresses = accountAddresses.unused.map(\.address) + accountAddresses.used.map(\.address) + + if !currentWalletAddress.isEmpty, currentAddressBatch == nil, addresses.contains(currentWalletAddress) { + currentAddressBatch = index + } + if let match = findMatch(in: addresses) { + UserDefaults.standard.set(Int(index), forKey: key) + return match + } + if let found = currentAddressBatch, index >= found + batchSize { break } + if addresses.count < Int(batchSize) { break } + index += batchSize + } + } + } + return nil + } +} + // MARK: - Test Data Generation (Development Only) private struct ActivityTemplate { @@ -1710,7 +1778,8 @@ class UtilityService { walletIndex: Int = 0, isChange: Bool? = nil, startIndex: UInt32? = nil, - count: UInt32? = nil + count: UInt32? = nil, + addressTypeString: String? = nil ) async throws -> AccountAddresses { return try await ServiceQueue.background(.core) { guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { @@ -1719,9 +1788,10 @@ class UtilityService { let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) - // Create the correct derivation path based on network + // Create the correct derivation path based on address type and network let coinType = Env.network == .bitcoin ? "0" : "1" - let derivationPath = "m/84'/\(coinType)'/0'/0" + let addressType = LDKNode.AddressType.fromStorage(addressTypeString) + let derivationPath = addressType.derivationPath(coinType: coinType) let response = try deriveBitcoinAddresses( mnemonicPhrase: mnemonic, diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index b708bfa56..2fd2b4db5 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -81,6 +81,13 @@ class LightningService { ) config.includeUntrustedPendingInSpendable = true + let selectedAddressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) + config.addressType = selectedAddressType + + let monitoredTypesString = UserDefaults.standard.string(forKey: "addressTypesToMonitor") ?? "nativeSegwit" + let monitoredTypes = LDKNode.AddressType.parseCommaSeparated(monitoredTypesString) + config.addressTypesToMonitor = monitoredTypes.filter { $0 != selectedAddressType } + let builder = Builder.fromConfig(config: config) builder.setCustomLogger(logWriter: LdkLogWriter()) @@ -188,7 +195,6 @@ class LightningService { try await stop() } catch { Logger.error("Failed to stop node during recovery: \(error)") - // Clear the node reference anyway node = nil try? StateLocker.unlock(.lightning) } @@ -394,6 +400,16 @@ class LightningService { } } + func newAddressForType(_ addressType: LDKNode.AddressType) async throws -> String { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + try node.onchainPayment().newAddressForType(addressType: addressType) + } + } + func receive(amountSats: UInt64? = nil, description: String, expirySecs: UInt32 = 3600) async throws -> String { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -866,6 +882,99 @@ extension LightningService { } } + func getBalanceForAddressType(_ addressType: LDKNode.AddressType) async throws -> AddressTypeBalance { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + try node.getBalanceForAddressType(addressType: addressType) + } + } + + func addAddressTypeToMonitor(_ addressType: LDKNode.AddressType) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: currentWalletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphraseRaw = try? Keychain.loadString(key: .bip39Passphrase(index: currentWalletIndex)) + let passphrase = passphraseRaw?.isEmpty == true ? nil : passphraseRaw + + try await ServiceQueue.background(.ldk) { + try node.addAddressTypeToMonitorWithMnemonic(addressType: addressType, mnemonic: mnemonic, passphrase: passphrase) + } + } + + func removeAddressTypeFromMonitor(_ addressType: LDKNode.AddressType) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + try await ServiceQueue.background(.ldk) { + try node.removeAddressTypeFromMonitor(addressType: addressType) + } + } + + func setPrimaryAddressType(_ addressType: LDKNode.AddressType) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: currentWalletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphraseRaw = try? Keychain.loadString(key: .bip39Passphrase(index: currentWalletIndex)) + let passphrase = passphraseRaw?.isEmpty == true ? nil : passphraseRaw + + try await ServiceQueue.background(.ldk) { + try node.setPrimaryAddressTypeWithMnemonic(addressType: addressType, mnemonic: mnemonic, passphrase: passphrase) + } + } + + /// Sum of spendable on-chain balance for non-legacy address types (selected + monitored), for channel funding. + /// - Parameters: + /// - selectedType: Current primary address type. + /// - monitoredTypes: Address types currently monitored (from settings). + func getChannelFundableBalance(selectedType: LDKNode.AddressType, monitoredTypes: [LDKNode.AddressType]) async throws -> UInt64 { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + var typesToSum = Set() + if selectedType != .legacy { + typesToSum.insert(selectedType) + } + for type in monitoredTypes where type != .legacy { + typesToSum.insert(type) + } + + var totalFundable: UInt64 = 0 + + for addressType in typesToSum { + do { + let balance = try await ServiceQueue.background(.ldk) { + try node.getBalanceForAddressType(addressType: addressType) + } + totalFundable += balance.spendableSats + } catch { + Logger.warn("Failed to get balance for \(addressType) when calculating channel fundable balance: \(error)") + } + } + + return totalFundable + } + + /// Reads selected and monitored address types from UserDefaults. Use when calling from UI/balance flow. + static func addressTypeStateFromUserDefaults(_ defaults: UserDefaults = .standard) + -> (selectedType: LDKNode.AddressType, monitoredTypes: [LDKNode.AddressType]) + { + let selectedType = LDKNode.AddressType.fromStorage(defaults.string(forKey: "selectedAddressType")) + let monitoredString = defaults.string(forKey: "addressTypesToMonitor") ?? "nativeSegwit" + let monitoredTypes = LDKNode.AddressType.parseCommaSeparated(monitoredString) + return (selectedType, monitoredTypes) + } + /// Returns LSP (Blocktank) peer node IDs func getLspPeerNodeIds() -> [String] { return Env.trustedLnPeers.map(\.nodeId) diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 01ceeba1d..0324081e2 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -166,11 +166,13 @@ struct RNWalletBackup: Codable { struct RNWalletState: Codable { var wallets: [String: RNWalletData]? + var addressTypesToMonitor: [String]? } struct RNWalletData: Codable { var boostedTransactions: [String: [String: RNBoostedTransaction]]? var transfers: [String: [RNTransfer]]? + var addressType: [String: String]? } struct RNLightningState: Codable { @@ -898,6 +900,63 @@ extension MigrationsService { } } + private static let rnAddressTypeMapping: [String: String] = [ + "p2pkh": "legacy", + "p2sh": "nestedSegwit", + "p2wpkh": "nativeSegwit", + "p2tr": "taproot", + ] + + func extractRNAddressTypeSettings(from mmkvData: [String: String]) -> (selectedAddressType: String?, addressTypesToMonitor: [String]?)? { + guard let rootJson = mmkvData["persist:root"], + let jsonStart = rootJson.firstIndex(of: "{") + else { return nil } + + let jsonString = String(rootJson[jsonStart...]) + guard let data = jsonString.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let walletJson = root["wallet"] as? String, + let walletData = walletJson.data(using: .utf8), + let walletDict = try? JSONSerialization.jsonObject(with: walletData) as? [String: Any] + else { + return nil + } + + var selectedAddressType: String? + var addressTypesToMonitor: [String]? + + // wallets.wallet0.addressType. + if let wallets = walletDict["wallets"] as? [String: Any], + let wallet0 = wallets["wallet0"] as? [String: Any], + let addressTypePerNetwork = wallet0["addressType"] as? [String: String] + { + let rnNetworkKey = rnNetworkString + if let rnValue = addressTypePerNetwork[rnNetworkKey], + let iosValue = Self.rnAddressTypeMapping[rnValue] + { + selectedAddressType = iosValue + } + } + + // Top-level addressTypesToMonitor + if let rnMonitoredTypes = walletDict["addressTypesToMonitor"] as? [String] { + let iosTypes = rnMonitoredTypes.compactMap { Self.rnAddressTypeMapping[$0] } + if !iosTypes.isEmpty { + addressTypesToMonitor = iosTypes + } + } + + if selectedAddressType == nil, addressTypesToMonitor == nil { + return nil + } + + Logger.debug( + "Extracted RN address type settings: selected=\(selectedAddressType ?? "nil"), monitored=\(addressTypesToMonitor?.joined(separator: ",") ?? "nil")", + context: "Migration" + ) + return (selectedAddressType: selectedAddressType, addressTypesToMonitor: addressTypesToMonitor) + } + func extractRNTodos(from mmkvData: [String: String]) -> RNTodos? { guard let rootJson = mmkvData["persist:root"], let jsonStart = rootJson.firstIndex(of: "{") @@ -1175,6 +1234,29 @@ extension MigrationsService { Logger.info("Applied RN settings to UserDefaults", context: "Migration") } + func applyRNAddressTypeSettings(selectedAddressType: String?, addressTypesToMonitor: [String]?) { + let defaults = UserDefaults.standard + + if let selected = selectedAddressType { + defaults.set(selected, forKey: "selectedAddressType") + Logger.info("Migrated selectedAddressType: \(selected)", context: "Migration") + } + + if var monitored = addressTypesToMonitor { + let nativeWitnessTypes = ["nativeSegwit", "taproot"] + let hasNativeWitness = monitored.contains(where: { nativeWitnessTypes.contains($0) }) + // Lightning requires at least one native witness type + if !hasNativeWitness { + monitored.append("nativeSegwit") + Logger.info("Added nativeSegwit to monitored types (required for Lightning channel scripts)", context: "Migration") + } + + let monitoredString = monitored.joined(separator: ",") + defaults.set(monitoredString, forKey: "addressTypesToMonitor") + Logger.info("Migrated addressTypesToMonitor: \(monitoredString)", context: "Migration") + } + } + func applyRNWidgets(_ widgetsWithOptions: RNWidgetsWithOptions) { let widgets = widgetsWithOptions.widgets let widgetOptions = widgetsWithOptions.widgetOptions @@ -1383,6 +1465,16 @@ extension MigrationsService { Logger.warn("Failed to extract settings from MMKV", context: "Migration") } + if let addressTypeSettings = extractRNAddressTypeSettings(from: mmkvData) { + Logger.info("Migrating address type settings", context: "Migration") + applyRNAddressTypeSettings( + selectedAddressType: addressTypeSettings.selectedAddressType, + addressTypesToMonitor: addressTypeSettings.addressTypesToMonitor + ) + } else { + Logger.debug("No address type settings found in MMKV", context: "Migration") + } + if let metadata = extractRNMetadata(from: mmkvData) { Logger.info("Storing metadata for application after sync", context: "Migration") // Store metadata for later - activities don't exist yet until LDK syncs diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 30518167a..883af6594 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -38,6 +38,9 @@ enum AppReset { UserDefaults.standard.removePersistentDomain(forName: bundleID) } + // Singleton retains stale @AppStorage values after removePersistentDomain + SettingsViewModel.shared.resetToDefaults() + // Prevent RN migration from triggering after wipe MigrationsService.shared.markMigrationChecked() diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index b1301a7b4..3b9494c01 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -400,6 +400,18 @@ struct AppError: LocalizedError { case let .BackgroundSyncNotEnabled(ldkMessage): message = "Background sync not enabled" debugMessage = ldkMessage + case let .AddressTypeAlreadyMonitored(message: ldkMessage): + message = "Address type already monitored" + debugMessage = ldkMessage + case let .AddressTypeIsPrimary(message: ldkMessage): + message = "Address type is primary" + debugMessage = ldkMessage + case let .AddressTypeNotMonitored(message: ldkMessage): + message = "Address type not monitored" + debugMessage = ldkMessage + case let .InvalidSeedBytes(message: ldkMessage): + message = "Invalid seed bytes" + debugMessage = ldkMessage } Logger.error("\(message) [\(debugMessage ?? "")]", context: "ldk-node error") } diff --git a/Bitkit/Utilities/StartupHandler.swift b/Bitkit/Utilities/StartupHandler.swift index 9ad16941a..847257488 100644 --- a/Bitkit/Utilities/StartupHandler.swift +++ b/Bitkit/Utilities/StartupHandler.swift @@ -19,6 +19,10 @@ class StartupHandler { try Keychain.saveString(key: .bip39Passphrase(index: walletIndex), str: bip39Passphrase) } + // Set default address type settings for new wallets + UserDefaults.standard.set("nativeSegwit", forKey: "selectedAddressType") + UserDefaults.standard.set("nativeSegwit", forKey: "addressTypesToMonitor") + return mnemonic } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index e6c628a93..8b5094a73 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -890,6 +890,15 @@ extension AppViewModel { case let .syncCompleted(syncType, syncedBlockHeight): Logger.info("Sync completed: \(syncType) at height \(syncedBlockHeight)") + // After mnemonic restore, prune empty address types once sync has completed + if SettingsViewModel.shared.pendingRestoreAddressTypePrune { + SettingsViewModel.shared.pendingRestoreAddressTypePrune = false + Task { @MainActor in + try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30s delay after sync + await SettingsViewModel.shared.pruneEmptyAddressTypesAfterRestore() + } + } + if MigrationsService.shared.needsPostMigrationSync { Task { @MainActor in try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.payments ?? []) diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index d718f8424..a848ae65f 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -78,6 +78,7 @@ enum Route: Hashable { // Advanced settings case coinSelection + case addressTypePreference case connections case connectionDetail(channelId: String) case closeConnection(channel: ChannelDetails) @@ -85,11 +86,6 @@ enum Route: Hashable { case electrumSettings case rgsSettings case addressViewer - case sweep - case sweepConfirm - case sweepFeeRate - case sweepFeeCustom - case sweepSuccess(txid: String) // Support settings case reportIssue diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 22b6e9b83..a34adcab0 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -4,6 +4,9 @@ import LDKNode import SwiftUI import UserNotifications +// Avoids conflict with AddressViewer.AddressType +typealias AddressScriptType = LDKNode.AddressType + enum CoinSelectionMethod: String, CaseIterable { case manual case autopilot @@ -44,6 +47,8 @@ class SettingsViewModel: NSObject, ObservableObject { static let shared = SettingsViewModel() private let defaults = UserDefaults.standard + + private var isChangingAddressType = false private var observedKeys: Set = [] // Reactive publishers for settings changes (used by BackupService) @@ -181,6 +186,33 @@ class SettingsViewModel: NSObject, ObservableObject { appStateSubject.send() } + /// Call after removePersistentDomain; singleton retains stale @AppStorage values. + func resetToDefaults() { + _swipeBalanceToHide = true + defaultTransactionSpeed = .normal + hideBalance = false + hideBalanceOnOpen = false + readClipboard = false + warnWhenSendingOver100 = false + enableQuickpay = false + quickpayAmount = 5 + enableNotifications = false + enableNotificationsAmount = false + ignoresSwitchUnitToast = false + ignoresHideBalanceToast = false + pinFailedAttempts = 0 + requirePinForPayments = false + useBiometrics = false + showWidgets = true + showWidgetTitles = false + _coinSelectionMethod = CoinSelectionMethod.autopilot.rawValue + _coinSelectionAlgorithm = CoinSelectionAlgorithm.branchAndBound.stringValue + _selectedAddressType = "nativeSegwit" + _addressTypesToMonitor = "nativeSegwit" + pinEnabled = false + isChangingAddressType = false + } + // MARK: - Computed Properties var electrumHasEdited: Bool { @@ -242,6 +274,244 @@ class SettingsViewModel: NSObject, ObservableObject { } } + // Address Type Settings + @AppStorage("selectedAddressType") private var _selectedAddressType: String = "nativeSegwit" + + @AppStorage("addressTypesToMonitor") private var _addressTypesToMonitor: String = "nativeSegwit" + + /// Parses a comma-separated string of address types, filtering invalid values. + static func parseAddressTypesString(_ string: String) -> [AddressScriptType] { + LDKNode.AddressType.parseCommaSeparated(string) + } + + var addressTypesToMonitor: [AddressScriptType] { + get { + Self.parseAddressTypesString(_addressTypesToMonitor) + } + set { + _addressTypesToMonitor = newValue.map(\.stringValue).joined(separator: ",") + } + } + + /// Check if an address type is being monitored + func isMonitoring(_ addressType: AddressScriptType) -> Bool { + addressTypesToMonitor.contains(addressType) + } + + func getBalanceForAddressType(_ addressType: AddressScriptType) async throws -> UInt64 { + let balance = try await lightningService.getBalanceForAddressType(addressType) + return balance.totalSats + } + + func setMonitoring(_ addressType: AddressScriptType, enabled: Bool, wallet: WalletViewModel? = nil) async -> Bool { + guard !isChangingAddressType else { return false } + + isChangingAddressType = true + defer { isChangingAddressType = false } + + let previousAddressTypesToMonitor = addressTypesToMonitor + var current = addressTypesToMonitor + + if enabled { + if !current.contains(addressType) { + current.append(addressType) + addressTypesToMonitor = current + + do { + try await lightningService.addAddressTypeToMonitor(addressType) + try await lightningService.sync() + } catch { + Logger.error("Failed to add address type to monitor: \(error)") + addressTypesToMonitor = previousAddressTypesToMonitor + return false + } + } + } else { + if addressType == selectedAddressType { return false } + + do { + let balance = try await getBalanceForAddressType(addressType) + if balance > 0 { return false } + } catch { + Logger.error("Failed to check balance for \(addressType), preventing disable: \(error)") + return false + } + + let nativeWitnessTypes: [AddressScriptType] = [.nativeSegwit, .taproot] + let remainingNativeWitness = current.filter { $0 != addressType && nativeWitnessTypes.contains($0) } + if remainingNativeWitness.isEmpty { + return false + } + + current.removeAll { $0 == addressType } + addressTypesToMonitor = current + + do { + try await lightningService.removeAddressTypeFromMonitor(addressType) + try await lightningService.sync() + } catch { + Logger.error("Failed to remove address type from monitor: \(error)") + addressTypesToMonitor = previousAddressTypesToMonitor + return false + } + } + + wallet?.syncState() + return true + } + + func ensureMonitoring(_ addressType: AddressScriptType) { + if !addressTypesToMonitor.contains(addressType) { + var current = addressTypesToMonitor + current.append(addressType) + addressTypesToMonitor = current + } + } + + func monitorAllAddressTypes() { + addressTypesToMonitor = AddressScriptType.allAddressTypes + } + + private static let pendingRestoreAddressTypePruneKey = "pendingRestoreAddressTypePrune" + + /// Tracks whether to prune empty address types after restore (set when user taps Get Started; cleared when prune runs). + var pendingRestoreAddressTypePrune: Bool { + get { UserDefaults.standard.bool(forKey: Self.pendingRestoreAddressTypePruneKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.pendingRestoreAddressTypePruneKey) } + } + + /// After restore, disables monitoring for address types with zero balance. + /// Keeps nativeSegwit as primary and monitored; only types with funds stay monitored. + func pruneEmptyAddressTypesAfterRestore() async { + guard !isChangingAddressType else { return } + + let nativeWitnessTypes: [AddressScriptType] = [.nativeSegwit, .taproot] + var newMonitored = addressTypesToMonitor + var changed = false + + for type in addressTypesToMonitor { + // Always keep nativeSegwit (primary, required for Lightning) + if type == .nativeSegwit { continue } + + do { + let balance = try await getBalanceForAddressType(type) + if balance == 0 { + newMonitored.removeAll { $0 == type } + changed = true + Logger.debug("Pruned empty address type from monitoring: \(type)", context: "SettingsViewModel") + } + } catch { + Logger.warn("Could not check balance for \(type), keeping monitored: \(error)") + // Don't disable on error - fail safe + } + } + + // Ensure at least one native witness type + if !newMonitored.contains(where: { nativeWitnessTypes.contains($0) }) { + if !newMonitored.contains(.nativeSegwit) { + newMonitored.append(.nativeSegwit) + changed = true + } + } + + guard changed else { return } + + let toRemove = addressTypesToMonitor.filter { !newMonitored.contains($0) } + addressTypesToMonitor = newMonitored + for type in toRemove { + do { + try await lightningService.removeAddressTypeFromMonitor(type) + } catch { + Logger.error("Failed to remove address type \(type) from monitor: \(error)") + } + } + do { + try await lightningService.sync() + Logger.info( + "Pruned empty address types after restore: \(newMonitored.map(\.stringValue).joined(separator: ","))", + context: "SettingsViewModel" + ) + } catch { + Logger.error("Failed to sync after prune: \(error)") + } + } + + /// True if disabling this would leave no native witness wallet (required for Lightning). + func isLastRequiredNativeWitnessWallet(_ addressType: AddressScriptType) -> Bool { + let nativeWitnessTypes: [AddressScriptType] = [.nativeSegwit, .taproot] + guard nativeWitnessTypes.contains(addressType) else { return false } + + let remainingNativeWitness = addressTypesToMonitor.filter { $0 != addressType && nativeWitnessTypes.contains($0) } + return remainingNativeWitness.isEmpty + } + + var selectedAddressType: AddressScriptType { + get { + LDKNode.AddressType.fromStorage(_selectedAddressType) + } + set { + _selectedAddressType = newValue.stringValue + } + } + + func updateAddressType(_ addressType: AddressScriptType, wallet: WalletViewModel? = nil) async -> Bool { + guard !isChangingAddressType else { return false } + guard addressType != selectedAddressType else { return true } + + isChangingAddressType = true + defer { isChangingAddressType = false } + + let previousSelectedAddressType = selectedAddressType + let previousAddressTypesToMonitor = addressTypesToMonitor + let previousOnchainAddress = UserDefaults.standard.string(forKey: "onchainAddress") ?? "" + let previousBip21 = UserDefaults.standard.string(forKey: "bip21") ?? "" + + selectedAddressType = addressType + ensureMonitoring(addressType) + + do { + try await lightningService.setPrimaryAddressType(addressType) + try await lightningService.sync() + await generateAndUpdateAddress(addressType: addressType, wallet: wallet) + } catch { + Logger.error("Failed to set primary address type: \(error)") + selectedAddressType = previousSelectedAddressType + addressTypesToMonitor = previousAddressTypesToMonitor + UserDefaults.standard.set(previousOnchainAddress, forKey: "onchainAddress") + UserDefaults.standard.set(previousBip21, forKey: "bip21") + if let wallet { + wallet.onchainAddress = previousOnchainAddress + wallet.bip21 = previousBip21 + } + wallet?.syncState() + return false + } + + wallet?.syncState() + return true + } + + private func generateAndUpdateAddress(addressType: AddressScriptType, wallet: WalletViewModel?) async { + do { + let newAddress = try await lightningService.newAddressForType(addressType) + guard addressType.matchesAddressFormat(newAddress, network: Env.network) else { + Logger.error("Generated address did not match expected format for \(addressType.stringValue): \(newAddress)") + return + } + UserDefaults.standard.set(newAddress, forKey: "onchainAddress") + if let wallet { + wallet.onchainAddress = newAddress + wallet.bip21 = "bitcoin:\(newAddress)" + } + } catch { + Logger.error("Failed to generate new address: \(error)") + UserDefaults.standard.set("", forKey: "onchainAddress") + if let wallet { + wallet.onchainAddress = "" + } + } + } + // MARK: - RGS URL Validation func isValidRgsUrl(_ url: String) -> Bool { diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 00b50ef68..e811dd6cc 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -19,7 +19,6 @@ enum SheetID: String, CaseIterable { case scanner case security case send - case sweepPrompt case tagFilter case dateRangeSelector } @@ -336,16 +335,4 @@ class SheetViewModel: ObservableObject { } } } - - var sweepPromptSheetItem: SweepPromptSheetItem? { - get { - guard let config = activeSheetConfiguration, config.id == .sweepPrompt else { return nil } - return SweepPromptSheetItem() - } - set { - if newValue == nil { - activeSheetConfiguration = nil - } - } - } } diff --git a/Bitkit/ViewModels/SweepViewModel.swift b/Bitkit/ViewModels/SweepViewModel.swift deleted file mode 100644 index 6165a3402..000000000 --- a/Bitkit/ViewModels/SweepViewModel.swift +++ /dev/null @@ -1,323 +0,0 @@ -import BitkitCore -import Foundation -import SwiftUI - -/// Manages sweep transaction state and operations -@MainActor -class SweepViewModel: ObservableObject { - // MARK: - Published State - - /// Current state of the sweep check - @Published var checkState: CheckState = .idle - - /// Sweepable balances from external wallets - @Published var sweepableBalances: SweepableBalances? - - /// Transaction preview after preparation - @Published var transactionPreview: SweepTransactionPreview? - - /// Selected fee rate in sats/vbyte - @Published var selectedFeeRate: UInt32? - - /// Available fee rates - @Published var feeRates: FeeRates? - - /// Selected transaction speed - @Published var selectedSpeed: TransactionSpeed = .normal - - /// Error message to display - @Published var errorMessage: String? - - /// Result after broadcast - @Published var sweepResult: SweepResult? - - /// Destination address for the sweep - @Published var destinationAddress: String? - - /// Whether a transaction is currently being prepared - @Published var isPreparingTransaction = false - - // MARK: - Types - - enum CheckState { - case idle - case checking - case found(balance: UInt64) - case noFunds - case error(String) - } - - enum SweepState { - case idle - case preparing - case ready - case broadcasting - case success(SweepResult) - case error(String) - - var isLoading: Bool { - switch self { - case .idle, .preparing: - return true - default: - return false - } - } - } - - @Published var sweepState: SweepState = .idle - - // MARK: - Private Properties - - private let walletIndex: Int - - // MARK: - Computed Properties - - var totalBalance: UInt64 { - sweepableBalances?.totalBalance ?? 0 - } - - var hasBalance: Bool { - totalBalance > 0 - } - - var estimatedFee: UInt64 { - transactionPreview?.estimatedFee ?? 0 - } - - var amountAfterFees: UInt64 { - transactionPreview?.amountAfterFees ?? 0 - } - - var utxosCount: UInt32 { - sweepableBalances?.totalUtxosCount ?? 0 - } - - // MARK: - Initialization - - init(walletIndex: Int = 0) { - self.walletIndex = walletIndex - } - - // MARK: - Public Methods - - /// Check for sweepable balances from external addresses - func checkBalance() async { - checkState = .checking - errorMessage = nil - - do { - let mnemonic = try getMnemonic() - let passphrase = try getPassphrase() - let electrumUrl = Self.getElectrumUrl() - let network = Env.bitkitCoreNetwork - - let balances = try await BitkitCore.checkSweepableBalances( - mnemonicPhrase: mnemonic, - network: network, - bip39Passphrase: passphrase, - electrumUrl: electrumUrl - ) - - sweepableBalances = balances - - if balances.totalBalance > 0 { - checkState = .found(balance: balances.totalBalance) - } else { - checkState = .noFunds - } - } catch { - Logger.error("Failed to check sweepable balance: \(error)", context: "SweepViewModel") - checkState = .error(error.localizedDescription) - errorMessage = error.localizedDescription - } - } - - /// Prepare the sweep transaction - func prepareSweep(destinationAddress: String) async { - self.destinationAddress = destinationAddress - sweepState = .preparing - isPreparingTransaction = true - errorMessage = nil - - guard let selectedFeeRate, selectedFeeRate > 0 else { - let error = t("sweep__error_fee_rate_not_set") - sweepState = .error(error) - errorMessage = error - isPreparingTransaction = false - return - } - - do { - let mnemonic = try getMnemonic() - let passphrase = try getPassphrase() - let electrumUrl = Self.getElectrumUrl() - let network = Env.bitkitCoreNetwork - - let preview = try await BitkitCore.prepareSweepTransaction( - mnemonicPhrase: mnemonic, - network: network, - bip39Passphrase: passphrase, - electrumUrl: electrumUrl, - destinationAddress: destinationAddress, - feeRateSatsPerVbyte: selectedFeeRate - ) - - transactionPreview = preview - sweepState = .ready - } catch { - Logger.error("Failed to prepare sweep: \(error)", context: "SweepViewModel") - sweepState = .error(error.localizedDescription) - errorMessage = error.localizedDescription - } - - isPreparingTransaction = false - } - - /// Broadcast the sweep transaction - func broadcastSweep() async { - guard let preview = transactionPreview else { - sweepState = .error("No transaction prepared") - return - } - - sweepState = .broadcasting - errorMessage = nil - - do { - let mnemonic = try getMnemonic() - let passphrase = try getPassphrase() - let electrumUrl = Self.getElectrumUrl() - let network = Env.bitkitCoreNetwork - - let result = try await BitkitCore.broadcastSweepTransaction( - psbt: preview.psbt, - mnemonicPhrase: mnemonic, - network: network, - bip39Passphrase: passphrase, - electrumUrl: electrumUrl - ) - - sweepResult = result - sweepState = .success(result) - } catch { - Logger.error("Failed to broadcast sweep: \(error)", context: "SweepViewModel") - sweepState = .error(error.localizedDescription) - errorMessage = error.localizedDescription - } - } - - /// Set fee rate based on selected speed - func setFeeRate(speed: TransactionSpeed) async { - selectedSpeed = speed - - switch speed { - case let .custom(rate): - selectedFeeRate = rate - default: - if let rates = feeRates { - selectedFeeRate = speed.getFeeRate(from: rates) - } - } - } - - /// Load current fee estimates - func loadFeeEstimates() async throws { - var rates = try? await CoreService.shared.blocktank.fees(refresh: true) - - if rates == nil { - Logger.warn("Failed to fetch fresh fee rate, using cached rate.", context: "SweepViewModel") - rates = try await CoreService.shared.blocktank.fees(refresh: false) - } - - guard let rates else { - throw AppError(message: "Fee rates unavailable", debugMessage: nil) - } - - feeRates = rates - selectedFeeRate = selectedSpeed.getFeeRate(from: rates) - } - - /// Reset the view model state - func reset() { - checkState = .idle - sweepState = .idle - isPreparingTransaction = false - sweepableBalances = nil - transactionPreview = nil - sweepResult = nil - errorMessage = nil - selectedFeeRate = nil - selectedSpeed = .normal - destinationAddress = nil - } - - // MARK: - Private Methods - - private func getMnemonic() throws -> String { - guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { - throw NSError( - domain: "SweepViewModel", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Mnemonic not found"] - ) - } - return mnemonic - } - - private func getPassphrase() throws -> String? { - try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) - } - - private static func getElectrumUrl() -> String { - let configService = ElectrumConfigService() - let server = configService.getCurrentServer() - return server.fullUrl.isEmpty ? Env.electrumServerUrl : server.fullUrl - } - - // MARK: - Static Methods - - /// Check for sweepable funds after migration/restore and show prompt sheet if funds found - static func checkAndPromptForSweepableFunds(sheets: SheetViewModel) { - Task { - let hasSweepableFunds = await checkForSweepableFundsAfterMigration() - if hasSweepableFunds { - await MainActor.run { - sheets.showSheet(.sweepPrompt) - } - } - } - } - - /// Check for sweepable funds after migration and return true if funds were found - static func checkForSweepableFundsAfterMigration(walletIndex: Int = 0) async -> Bool { - do { - guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { - Logger.debug("No mnemonic found for sweep check", context: "SweepViewModel") - return false - } - - let passphrase = try? Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) - let electrumUrl = Self.getElectrumUrl() - let network = Env.bitkitCoreNetwork - - let balances = try await BitkitCore.checkSweepableBalances( - mnemonicPhrase: mnemonic, - network: network, - bip39Passphrase: passphrase, - electrumUrl: electrumUrl - ) - - if balances.totalBalance > 0 { - Logger.info("Found \(balances.totalBalance) sats to sweep after migration", context: "SweepViewModel") - return true - } - - Logger.debug("No sweepable funds found after migration", context: "SweepViewModel") - return false - } catch { - Logger.error("Failed to check sweepable funds after migration: \(error)", context: "SweepViewModel") - return false - } - } -} diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index e6ffeca84..1bca933b1 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -11,6 +11,7 @@ class WalletViewModel: ObservableObject { @AppStorage("totalLightningSats") var totalLightningSats: Int = 0 // Combined LN @AppStorage("spendableOnchainBalanceSats") var spendableOnchainBalanceSats: Int = 0 // The spendable balance of our on-chain wallet @AppStorage("maxSendLightningSats") var maxSendLightningSats: Int = 0 // Maximum amount that can be sent via lightning (outbound capacity) + @AppStorage("channelFundableBalanceSats") var channelFundableBalanceSats: Int = 0 // Balance usable for channel funding (excludes Legacy UTXOs) // Receive flow @AppStorage("onchainAddress") var onchainAddress = "" @@ -592,10 +593,10 @@ class WalletViewModel: ObservableObject { /// Sync balance details only private func syncBalances() { - balanceDetails = lightningService.balances - - if let balanceDetails { - spendableOnchainBalanceSats = Int(balanceDetails.spendableOnchainBalanceSats) + // Only update balanceDetails if we have valid data (don't overwrite with nil during restart) + if let newBalances = lightningService.balances { + balanceDetails = newBalances + spendableOnchainBalanceSats = Int(newBalances.spendableOnchainBalanceSats) } Task { @MainActor in @@ -619,6 +620,15 @@ class WalletViewModel: ObservableObject { totalBalanceSats = Int(state.totalBalanceSats) maxSendLightningSats = Int(state.maxSendLightningSats) + // Update channel fundable balance (excludes Legacy UTXOs which can't be used for channels) + let (selectedType, monitoredTypes) = LightningService.addressTypeStateFromUserDefaults() + if let fundableBalance = try? await lightningService.getChannelFundableBalance( + selectedType: selectedType, + monitoredTypes: monitoredTypes + ) { + channelFundableBalanceSats = Int(fundableBalance) + } + // Get force close timelock from active transfers let activeTransfers = try? transferService.getActiveTransfers() let forceCloseTransfer = activeTransfers?.first { diff --git a/Bitkit/Views/Onboarding/RestoreWalletView.swift b/Bitkit/Views/Onboarding/RestoreWalletView.swift index 86051f633..486ac8f7b 100644 --- a/Bitkit/Views/Onboarding/RestoreWalletView.swift +++ b/Bitkit/Views/Onboarding/RestoreWalletView.swift @@ -257,6 +257,10 @@ struct RestoreWalletView: View { wallet.nodeLifecycleState = .initializing wallet.isRestoringWallet = true app.showAllEmptyStates(false) + + // When restoring a wallet, monitor all address types to catch any existing funds + SettingsViewModel.shared.monitorAllAddressTypes() + _ = try StartupHandler.restoreWallet(mnemonic: bip39Mnemonic, bip39Passphrase: bip39Passphrase) try wallet.setWalletExistsState() } catch { diff --git a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift index 29d51b951..7371fcd9e 100644 --- a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift +++ b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift @@ -2,7 +2,6 @@ import SwiftUI struct WalletRestoreSuccess: View { @EnvironmentObject var app: AppViewModel - @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var suggestionsManager: SuggestionsManager @EnvironmentObject var tagManager: TagManager @EnvironmentObject var wallet: WalletViewModel @@ -36,7 +35,9 @@ struct WalletRestoreSuccess: View { // Mark backup as verified since user just restored with their phrase app.backupVerified = true wallet.isRestoringWallet = false - SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets) + + // Prune empty address types on next syncCompleted + SettingsViewModel.shared.pendingRestoreAddressTypePrune = true } .accessibilityIdentifier("GetStartedButton") } diff --git a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift new file mode 100644 index 000000000..c65bcc1c7 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -0,0 +1,234 @@ +import LDKNode +import SwiftUI + +struct AddressTypeOption: View { + let addressType: AddressScriptType + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + BodyMText("\(addressType.localizedTitle) \(addressType.example)", textColor: .textPrimary) + BodySText(addressType.localizedDescription) + .multilineTextAlignment(.leading) + } + Spacer() + if isSelected { + Image("checkmark") + .resizable() + .frame(width: 32, height: 32) + .foregroundColor(.brandAccent) + } + } + .frame(height: 51) + .padding(.bottom, 16) + + Divider() + } + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier(addressType.testId) + } +} + +struct MonitoredAddressTypeToggle: View { + let addressType: AddressScriptType + let isMonitored: Bool + let isSelectedType: Bool + let onToggle: (Bool) -> Void + + private var toggleId: String { + "MonitorToggle-\(addressType.testId)" + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button(action: { + if !isSelectedType { + onToggle(!isMonitored) + } + }) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + BodyMText("\(addressType.localizedTitle) \(addressType.shortExample)", textColor: .textPrimary) + if isSelectedType { + BodySText(t("settings__adv__addr_type_currently_selected"), textColor: .textSecondary) + } + } + Spacer() + Toggle("", isOn: .constant(isMonitored)) + .tint(.brandAccent) + .labelsHidden() + .allowsHitTesting(false) + } + .frame(minHeight: 51) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .disabled(isSelectedType) + .opacity(isSelectedType ? 0.5 : 1.0) + .accessibilityIdentifier(toggleId) + + Divider() + } + } +} + +struct AddressTypePreferenceView: View { + @EnvironmentObject private var settingsViewModel: SettingsViewModel + @EnvironmentObject private var wallet: WalletViewModel + @EnvironmentObject private var app: AppViewModel + + @AppStorage("showDevSettings") private var showDevSettings = Env.isDebug + + @State private var showMonitoredTypesNote = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("settings__adv__address_type")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("settings__adv__address_type")) + .padding(.bottom, 8) + + VStack(spacing: 0) { + ForEach(AddressScriptType.allAddressTypes, id: \.self) { addressType in + AddressTypeOption( + addressType: addressType, + isSelected: settingsViewModel.selectedAddressType == addressType + ) { + guard settingsViewModel.selectedAddressType != addressType else { return } + + app.toast( + type: .info, + title: t("settings__adv__addr_type_applying"), + autoHide: false, + accessibilityIdentifier: "AddressTypeApplyingToast" + ) + + Task { + let success = await settingsViewModel.updateAddressType(addressType, wallet: wallet) + if success { + Haptics.notify(.success) + app.toast( + type: .success, + title: t("settings__adv__addr_type_changed_title"), + description: t( + "settings__adv__addr_type_changed_desc", + variables: ["type": addressType.localizedTitle] + ), + accessibilityIdentifier: "AddressTypeSettingsUpdatedToast" + ) + } else { + app.toast( + type: .error, + title: t("settings__adv__addr_type_failed_title"), + description: t("settings__adv__addr_type_change_failed_desc") + ) + } + } + } + } + } + } + + if showDevSettings { + VStack(alignment: .leading, spacing: 0) { + HStack { + CaptionMText(t("settings__adv__monitored_address_types")) + Spacer() + Button(action: { showMonitoredTypesNote.toggle() }) { + Image(systemName: "info.circle") + .foregroundColor(.textSecondary) + } + } + .padding(.top, 24) + .padding(.bottom, 8) + + if showMonitoredTypesNote { + BodySText( + t("settings__adv__addr_type_monitored_note"), + textColor: .textSecondary + ) + .padding(.bottom, 12) + } + + VStack(spacing: 0) { + ForEach(AddressScriptType.allAddressTypes, id: \.self) { addressType in + MonitoredAddressTypeToggle( + addressType: addressType, + isMonitored: settingsViewModel.isMonitoring(addressType), + isSelectedType: settingsViewModel.selectedAddressType == addressType + ) { enabled in + app.toast(type: .info, title: t("settings__adv__addr_type_applying"), autoHide: false) + + Task { + let success = await settingsViewModel.setMonitoring(addressType, enabled: enabled, wallet: wallet) + if success { + Haptics.notify(.success) + app.toast( + type: .success, + title: t("settings__adv__addr_type_monitored_updated_title"), + description: t("settings__adv__addr_type_monitored_updated_desc") + ) + } else if !enabled { + if settingsViewModel.isLastRequiredNativeWitnessWallet(addressType) { + app.toast( + type: .error, + title: t("settings__adv__addr_type_cannot_disable_title"), + description: t("settings__adv__addr_type_cannot_disable_native_desc") + ) + } else { + app.toast( + type: .error, + title: t("settings__adv__addr_type_cannot_disable_title"), + description: t( + "settings__adv__addr_type_cannot_disable_balance_desc", + variables: ["type": addressType.localizedTitle] + ) + ) + } + } else { + app.toast( + type: .error, + title: t("settings__adv__addr_type_failed_title"), + description: t("settings__adv__addr_type_monitored_failed_desc") + ) + } + } + } + } + } + } + } + + Spacer() + .frame(height: 32) + } + .padding(.trailing, 4) + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } +} + +#Preview { + let app = AppViewModel() + return NavigationStack { + AddressTypePreferenceView() + .environmentObject(SettingsViewModel.shared) + .environmentObject(app) + .environmentObject(WalletViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Settings/Advanced/AddressViewer.swift b/Bitkit/Views/Settings/Advanced/AddressViewer.swift index b2c4d4aa6..94dbfdbba 100644 --- a/Bitkit/Views/Settings/Advanced/AddressViewer.swift +++ b/Bitkit/Views/Settings/Advanced/AddressViewer.swift @@ -1,7 +1,8 @@ import BitkitCore +import LDKNode import SwiftUI -enum AddressType: CaseIterable, CustomStringConvertible { +enum AddressKind: CaseIterable, CustomStringConvertible { case receiving, change var description: String { @@ -16,13 +17,15 @@ enum AddressType: CaseIterable, CustomStringConvertible { struct AddressViewer: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var settings: SettingsViewModel @State private var addresses: [BitkitCore.AddressInfo] = [] @State private var addressBalances: [String: UInt64] = [:] @State private var loadedCount: UInt32 = 20 @State private var isLoading = false @State private var isLoadingBalances = false - @State private var selectedAddressType: AddressType = .receiving + @State private var selectedAddressKind: AddressKind = .receiving + @State private var selectedScriptType: LDKNode.AddressType = .nativeSegwit @State private var searchText = "" @State private var selectedAddress: String = "" @State private var showScrollToTop = false @@ -30,20 +33,17 @@ struct AddressViewer: View { private let initialLoadCount: UInt32 = 20 private let loadMoreCount: UInt32 = 20 private let walletIndex = 0 + private let scriptTypes: [LDKNode.AddressType] = LDKNode.AddressType.allAddressTypes - private var addressTypeTabItems: [TabItem] { + private var addressKindTabItems: [TabItem] { [ TabItem(.receiving), TabItem(.change), ] } - private var defaultDerivationPath: String { - // BIP44 derivation path: m/purpose'/coin_type'/account'/change/address_index - // Purpose 84 = P2WPKH (Native SegWit) - // Coin type: 0 = Bitcoin mainnet, 1 = Bitcoin testnet/regtest - let coinType = Env.network == .bitcoin ? "0" : "1" - return "m/84'/\(coinType)'/0'/0" // P2WPKH path + private var currentDerivationPath: String { + selectedScriptType.derivationPath } // Get the first address for QR display @@ -97,7 +97,7 @@ struct AddressViewer: View { VStack(alignment: .leading, spacing: 4) { CaptionText("Index: \(selectedAddressIndex)", textColor: .white80) - CaptionText("Path: \(defaultDerivationPath)/\(selectedAddressIndex)", textColor: .white80) + CaptionText("Path: \(currentDerivationPath)/\(selectedAddressIndex)", textColor: .white80) Button { guard !displayAddress.isEmpty else { return } @@ -133,8 +133,30 @@ struct AddressViewer: View { .cornerRadius(32) .padding(.bottom, 16) - // Address Type Toggle - SegmentedControl(selectedTab: $selectedAddressType, tabItems: addressTypeTabItems) + // Address script type selector (Legacy, Nested, Native, Taproot) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(scriptTypes, id: \.self) { scriptType in + Button { + selectedScriptType = scriptType + } label: { + Text(scriptType.shortLabel) + .font(.custom(Fonts.medium, size: 14)) + .foregroundColor(selectedScriptType == scriptType ? .white : .white80) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(selectedScriptType == scriptType ? Color.brandAccent : Color.white10) + .cornerRadius(20) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 4) + } + .padding(.bottom, 12) + + // Receiving / Change toggle + SegmentedControl(selectedTab: $selectedAddressKind, tabItems: addressKindTabItems) .padding(.bottom, 16) // Address List with Sticky Bottom Buttons @@ -177,8 +199,17 @@ struct AddressViewer: View { .frame(height: 80) } } - .onChange(of: selectedAddressType) { _ in - // Reset scroll position when switching address types + .onChange(of: selectedAddressKind) { _ in + selectedAddress = "" + withAnimation(.easeInOut(duration: 0.5)) { + proxy.scrollTo("top", anchor: .top) + showScrollToTop = false + } + Task { + await loadAddresses() + } + } + .onChange(of: selectedScriptType) { _ in selectedAddress = "" withAnimation(.easeInOut(duration: 0.5)) { proxy.scrollTo("top", anchor: .top) @@ -252,6 +283,7 @@ struct AddressViewer: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .task { + selectedScriptType = settings.selectedAddressType await loadAddresses() } } @@ -263,9 +295,10 @@ struct AddressViewer: View { do { let accountAddresses = try await CoreService.shared.utility.getAccountAddresses( walletIndex: walletIndex, - isChange: selectedAddressType == .change, + isChange: selectedAddressKind == .change, startIndex: 0, - count: initialLoadCount + count: initialLoadCount, + addressTypeString: selectedScriptType.stringValue ) await MainActor.run { @@ -297,9 +330,10 @@ struct AddressViewer: View { do { let accountAddresses = try await CoreService.shared.utility.getAccountAddresses( walletIndex: walletIndex, - isChange: selectedAddressType == .change, + isChange: selectedAddressKind == .change, startIndex: nextStartIndex, - count: loadMoreCount + count: loadMoreCount, + addressTypeString: selectedScriptType.stringValue ) await MainActor.run { @@ -411,6 +445,8 @@ struct AddressRow: View { #Preview { NavigationView { AddressViewer() + .environmentObject(AppViewModel()) + .environmentObject(SettingsViewModel.shared) .preferredColorScheme(.dark) } } diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index dde38459c..c693806d4 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct AdvancedSettingsView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var suggestionsManager: SuggestionsManager + @EnvironmentObject var settings: SettingsViewModel @State private var showingResetAlert = false var body: some View { @@ -17,13 +18,13 @@ struct AdvancedSettingsView: View { CaptionMText(t("settings__adv__section_payments")) .padding(.bottom, 8) - // Maybe never implemented - // NavigationLink(destination: Text("Coming soon")) { - // SettingsListLabel( - // title: t("settings__adv__address_type"), - // rightText: "Native Segwit" - // ) - // } + NavigationLink(value: Route.addressTypePreference) { + SettingsListLabel( + title: t("settings__adv__address_type"), + rightText: settings.selectedAddressType.localizedTitle + ) + } + .accessibilityIdentifier("AddressTypePreference") NavigationLink(value: Route.coinSelection) { SettingsListLabel(title: t("settings__adv__coin_selection")) @@ -79,11 +80,6 @@ struct AdvancedSettingsView: View { } .accessibilityIdentifier("AddressViewer") - NavigationLink(value: Route.sweep) { - SettingsListLabel(title: t("settings__adv__sweep_funds")) - } - .accessibilityIdentifier("SweepFunds") - // SettingsListLabel(title: t("settings__adv__rescan"), rightIcon: nil) Button(action: { diff --git a/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift b/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift deleted file mode 100644 index aa5fa3780..000000000 --- a/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift +++ /dev/null @@ -1,245 +0,0 @@ -import BitkitCore -import SwiftUI - -struct SweepConfirmView: View { - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var settings: SettingsViewModel - @EnvironmentObject private var viewModel: SweepViewModel - - @State private var showPinCheck = false - @State private var pinCheckContinuation: CheckedContinuation? - @State private var showingBiometricError = false - @State private var biometricErrorMessage = "" - @State private var isLoadingAddress = true - - private var isLoading: Bool { - isLoadingAddress || viewModel.isPreparingTransaction || viewModel.sweepState.isLoading - } - - var body: some View { - ZStack { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("sweep__confirm_title")) - .padding(.bottom, 16) - - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 24) { - VStack(alignment: .leading, spacing: 8) { - if case .ready = viewModel.sweepState, !viewModel.isPreparingTransaction { - MoneyStack( - sats: Int(viewModel.amountAfterFees), - showSymbol: true, - testIdPrefix: "SweepAmount" - ) - } else { - MoneyStack( - sats: Int(viewModel.totalBalance), - showSymbol: true, - testIdPrefix: "SweepAmount" - ) - .opacity(0.5) - } - } - - Divider() - - // Destination section - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("sweep__destination")) - - if let address = viewModel.destinationAddress { - BodySSBText(address.ellipsis(maxLength: 20)) - .lineLimit(1) - .truncationMode(.middle) - } else { - BodySSBText("...") - .opacity(0.5) - } - } - - Divider() - - // Fee section - Button(action: { - navigation.navigate(.sweepFeeRate) - }) { - HStack { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("wallet__send_fee_and_speed")) - HStack(spacing: 0) { - Image(viewModel.selectedSpeed.iconName) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(viewModel.selectedSpeed.iconColor) - .frame(width: 16, height: 16) - .padding(.trailing, 4) - - if viewModel.estimatedFee > 0, !viewModel.isPreparingTransaction { - HStack(spacing: 0) { - BodySSBText("\(viewModel.selectedSpeed.displayTitle) (") - MoneyText(sats: Int(viewModel.estimatedFee), size: .bodySSB, symbol: true, symbolColor: .textPrimary) - BodySSBText(")") - } - - Image("pencil") - .foregroundColor(.textPrimary) - .frame(width: 12, height: 12) - .padding(.leading, 6) - } else { - BodySSBText(viewModel.selectedSpeed.displayTitle) - } - } - } - - Spacer() - - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("wallet__send_confirming_in")) - HStack(spacing: 0) { - Image("clock") - .foregroundColor(.brandAccent) - .frame(width: 16, height: 16) - .padding(.trailing, 4) - - BodySSBText(viewModel.selectedSpeed.displayDescription) - } - } - } - } - .buttonStyle(.plain) - .disabled(isLoading) - - Divider() - - // Error display - if let error = viewModel.errorMessage { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.redAccent) - BodyMText(error) - .foregroundColor(.redAccent) - } - .padding() - .background(Color.redAccent.opacity(0.1)) - .cornerRadius(8) - } - } - } - - Spacer() - - // Bottom button area - if case .broadcasting = viewModel.sweepState { - VStack(spacing: 32) { - ActivityIndicator(size: 32) - CaptionMText(t("sweep__broadcasting")) - .foregroundColor(.textSecondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } else if isLoading { - VStack(spacing: 32) { - ActivityIndicator(size: 32) - CaptionMText(t("sweep__preparing")) - .foregroundColor(.textSecondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } else if case .ready = viewModel.sweepState, viewModel.destinationAddress != nil { - SwipeButton(title: t("sweep__swipe"), accentColor: .greenAccent) { - // Check if authentication is required - if settings.requirePinForPayments && settings.pinEnabled { - if settings.useBiometrics && BiometricAuth.isAvailable { - let result = await BiometricAuth.authenticate() - switch result { - case .success: - break - case .cancelled: - throw CancellationError() - case let .failed(message): - biometricErrorMessage = message - showingBiometricError = true - throw CancellationError() - } - } else { - showPinCheck = true - let shouldProceed = try await waitForPinCheck() - if !shouldProceed { - throw CancellationError() - } - } - } - - // Broadcast the sweep - await viewModel.broadcastSweep() - - if case let .success(result) = viewModel.sweepState { - navigation.navigate(.sweepSuccess(txid: result.txid)) - } - } - } - } - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() - .task { - await loadDestinationAddress() - do { - try await viewModel.loadFeeEstimates() - } catch { - Logger.error("Failed to load fee estimates: \(error)", context: "SweepConfirmView") - viewModel.errorMessage = error.localizedDescription - } - if let address = viewModel.destinationAddress { - await viewModel.prepareSweep(destinationAddress: address) - } - } - .onChange(of: viewModel.selectedSpeed) { _ in - Task { - if let address = viewModel.destinationAddress { - await viewModel.prepareSweep(destinationAddress: address) - } - } - } - .alert( - t("security__bio_error_title"), - isPresented: $showingBiometricError - ) { - Button(t("common__ok")) {} - } message: { - Text(biometricErrorMessage) - } - .navigationDestination(isPresented: $showPinCheck) { - PinCheckView( - title: t("security__pin_send_title"), - explanation: t("security__pin_send"), - onCancel: { - pinCheckContinuation?.resume(returning: false) - pinCheckContinuation = nil - }, - onPinVerified: { _ in - pinCheckContinuation?.resume(returning: true) - pinCheckContinuation = nil - } - ) - } - } - - private func loadDestinationAddress() async { - isLoadingAddress = true - do { - viewModel.destinationAddress = try await LightningService.shared.newAddress() - } catch { - Logger.error("Failed to get destination address: \(error)", context: "SweepConfirmView") - viewModel.errorMessage = t("sweep__error_destination_address") - } - isLoadingAddress = false - } - - private func waitForPinCheck() async throws -> Bool { - try await withCheckedThrowingContinuation { continuation in - pinCheckContinuation = continuation - } - } -} diff --git a/Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift b/Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift deleted file mode 100644 index 28abdcca8..000000000 --- a/Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift +++ /dev/null @@ -1,111 +0,0 @@ -import SwiftUI - -struct SweepFeeCustomView: View { - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject private var viewModel: SweepViewModel - - @State private var feeRate: UInt32 = 1 - @State private var transactionFee: UInt64 = 0 - - private let minFee: UInt32 = 1 - private let maxFee: UInt32 = 999 - - private var isValid: Bool { - feeRate >= minFee && feeRate <= maxFee - } - - private var estimatedTxVbytes: UInt64 { - viewModel.transactionPreview?.estimatedVsize ?? 0 - } - - private var totalFeeText: String { - let fee = UInt64(feeRate) * estimatedTxVbytes - return t("sweep__fee_total", variables: ["fee": String(fee)]) - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("wallet__send_fee_custom")) - .padding(.horizontal, 16) - - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("common__sat_vbyte")) - .padding(.bottom, 16) - .padding(.horizontal, 16) - - HStack { - MoneyText(sats: Int(feeRate), symbol: true, color: feeRate == 0 ? .textSecondary : .textPrimary) - } - .padding(.bottom, 16) - .padding(.horizontal, 16) - - if isValid { - BodyMText(totalFeeText) - .padding(.bottom, 32) - .padding(.horizontal, 16) - } - - Spacer() - - NumberPad { key in - handleNumberPadInput(key) - } - .padding(.horizontal, 16) - - CustomButton(title: t("common__continue")) { - onContinue() - } - .disabled(!isValid) - .padding(.horizontal, 16) - .padding(.top, 16) - } - } - .navigationBarHidden(true) - .bottomSafeAreaPadding() - .task { - initializeFromCurrentFee() - } - } - - private func initializeFromCurrentFee() { - if case let .custom(rate) = viewModel.selectedSpeed { - feeRate = rate - } else { - feeRate = viewModel.selectedFeeRate ?? 0 - } - } - - private func handleNumberPadInput(_ key: String) { - let current = String(feeRate) - - if key == "delete" { - if current.count > 1 { - let newString = String(current.dropLast()) - feeRate = UInt32(newString) ?? 0 - } else { - feeRate = 0 - } - } else { - let newString: String = if current == "0" { - key - } else { - current + key - } - - // Limit to 3 digits (max 999 sat/vB) - if newString.count <= 3, let newRate = UInt32(newString) { - feeRate = newRate - } - } - } - - private func onContinue() { - guard isValid else { return } - - Task { - await viewModel.setFeeRate(speed: .custom(satsPerVByte: feeRate)) - viewModel.selectedFeeRate = feeRate - navigation.navigateBack() - } - } -} diff --git a/Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift b/Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift deleted file mode 100644 index 9bcb2a7ef..000000000 --- a/Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift +++ /dev/null @@ -1,148 +0,0 @@ -import BitkitCore -import SwiftUI - -struct SweepFeeRateView: View { - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var wallet: WalletViewModel - @EnvironmentObject private var viewModel: SweepViewModel - - @State private var isLoading = true - - private var estimatedTxVbytes: UInt64 { - viewModel.transactionPreview?.estimatedVsize ?? 0 - } - - private func getFee(for speed: TransactionSpeed) -> UInt64 { - let feeRate: UInt32 - switch speed { - case let .custom(rate): - feeRate = rate - default: - guard let rates = viewModel.feeRates else { return 0 } - feeRate = speed.getFeeRate(from: rates) - } - return UInt64(feeRate) * estimatedTxVbytes - } - - private func getAmountAfterFee(for speed: TransactionSpeed) -> UInt64 { - let fee = getFee(for: speed) - let total = viewModel.totalBalance - return total > fee ? total - fee : 0 - } - - private func isDisabled(for speed: TransactionSpeed) -> Bool { - let fee = getFee(for: speed) - let totalBalance = viewModel.totalBalance - // Disable if fee would leave less than dust limit - return fee + UInt64(Env.dustLimit) > totalBalance - } - - private func selectFee(_ speed: TransactionSpeed) { - Task { - await viewModel.setFeeRate(speed: speed) - navigation.navigateBack() - } - } - - private var currentCustomFeeRate: UInt32 { - if case let .custom(rate) = viewModel.selectedSpeed { - return rate - } else { - return viewModel.selectedFeeRate ?? 0 - } - } - - private var isCustomSelected: Bool { - if case .custom = viewModel.selectedSpeed { - return true - } - return false - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("wallet__send_fee_speed")) - .padding(.horizontal, 16) - - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("wallet__send_fee_and_speed")) - .padding(.bottom, 16) - .padding(.horizontal, 16) - - if isLoading { - HStack { - Spacer() - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .brandAccent)) - Spacer() - } - .padding(.top, 32) - } else { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - FeeItem( - speed: .fast, - amount: getFee(for: .fast), - isSelected: viewModel.selectedSpeed == .fast, - isDisabled: isDisabled(for: .fast) - ) { - selectFee(.fast) - } - - FeeItem( - speed: .normal, - amount: getFee(for: .normal), - isSelected: viewModel.selectedSpeed == .normal, - isDisabled: isDisabled(for: .normal) - ) { - selectFee(.normal) - } - - FeeItem( - speed: .slow, - amount: getFee(for: .slow), - isSelected: viewModel.selectedSpeed == .slow, - isDisabled: isDisabled(for: .slow) - ) { - selectFee(.slow) - } - - // Custom fee option - FeeItem( - speed: .custom(satsPerVByte: currentCustomFeeRate), - amount: getFee(for: .custom(satsPerVByte: currentCustomFeeRate)), - isSelected: isCustomSelected, - isDisabled: false - ) { - navigation.navigate(.sweepFeeCustom) - } - } - } - } - - Spacer() - - CustomButton(title: t("common__continue")) { - navigation.navigateBack() - } - .padding(.horizontal, 16) - } - } - .navigationBarHidden(true) - .bottomSafeAreaPadding() - .task { - await loadFeeEstimates() - } - } - - private func loadFeeEstimates() async { - isLoading = true - do { - try await viewModel.loadFeeEstimates() - } catch { - Logger.error("Failed to load fee estimates: \(error)", context: "SweepFeeRateView") - viewModel.errorMessage = error.localizedDescription - } - isLoading = false - } -} diff --git a/Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift b/Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift deleted file mode 100644 index ee694fca6..000000000 --- a/Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift +++ /dev/null @@ -1,40 +0,0 @@ -import SwiftUI - -struct SweepPromptSheetItem: SheetItem { - let id: SheetID = .sweepPrompt - let size: SheetSize = .large -} - -struct SweepPromptSheet: View { - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var sheets: SheetViewModel - let config: SweepPromptSheetItem - - var body: some View { - Sheet(id: .sweepPrompt, data: config) { - SheetIntro( - navTitle: t("sweep__prompt_title"), - title: t("sweep__prompt_headline"), - description: t("sweep__prompt_description"), - image: "coin-stack", - continueText: t("sweep__prompt_sweep"), - cancelText: t("common__cancel"), - testID: "SweepPromptSheet", - continueTestID: "SweepButton", - onCancel: { - sheets.hideSheet() - }, - onContinue: { - sheets.hideSheet() - navigation.navigate(.sweep) - } - ) - } - } -} - -#Preview { - SweepPromptSheet(config: SweepPromptSheetItem()) - .environmentObject(NavigationViewModel()) - .environmentObject(SheetViewModel()) -} diff --git a/Bitkit/Views/Settings/Advanced/SweepSettingsView.swift b/Bitkit/Views/Settings/Advanced/SweepSettingsView.swift deleted file mode 100644 index 62c9de9f2..000000000 --- a/Bitkit/Views/Settings/Advanced/SweepSettingsView.swift +++ /dev/null @@ -1,198 +0,0 @@ -import BitkitCore -import Lottie -import SwiftUI - -struct SweepSettingsView: View { - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject private var viewModel: SweepViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: navigationTitle) - .padding(.bottom, 30) - - switch viewModel.checkState { - case .idle, .checking: - loadingView - case .found: - foundFundsView - case .noFunds: - noFundsView - case let .error(message): - errorView(message: message) - } - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() - .background(Color.customBlack) - .task { - viewModel.reset() - await viewModel.checkBalance() - } - } - - private var navigationTitle: String { - switch viewModel.checkState { - case .found: - return t("sweep__found_title") - case .noFunds: - return t("sweep__no_funds_title") - default: - return t("sweep__title") - } - } - - // MARK: - Loading View - - @ViewBuilder - private var loadingView: some View { - VStack(alignment: .leading, spacing: 0) { - BodyMText(t("sweep__loading_description")) - .foregroundColor(.textSecondary) - - Spacer() - - // Magnifying glass image - Image("magnifying-glass-illustration") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 311, height: 311) - .frame(maxWidth: .infinity, alignment: .center) - - Spacer() - - // Loading indicator - VStack(spacing: 32) { - ActivityIndicator(size: 32) - - CaptionMText(t("sweep__looking_for_funds")) - .foregroundColor(.textSecondary) - } - .frame(maxWidth: .infinity, alignment: .center) - } - } - - // MARK: - Found Funds View - - @ViewBuilder - private var foundFundsView: some View { - VStack(alignment: .leading, spacing: 0) { - BodyMText(t("sweep__found_description")) - .foregroundColor(.textSecondary) - .padding(.bottom, 24) - - CaptionMText(t("sweep__funds_found")) - .foregroundColor(.textSecondary) - .padding(.bottom, 16) - - if let balances = viewModel.sweepableBalances { - VStack(alignment: .leading, spacing: 0) { - if balances.legacyBalance > 0 { - fundRow( - title: "Legacy (P2PKH)", - utxoCount: balances.legacyUtxosCount, - balance: balances.legacyBalance - ) - } - if balances.p2shBalance > 0 { - fundRow( - title: "SegWit (P2SH)", - utxoCount: balances.p2shUtxosCount, - balance: balances.p2shBalance - ) - } - if balances.taprootBalance > 0 { - fundRow( - title: "Taproot (P2TR)", - utxoCount: balances.taprootUtxosCount, - balance: balances.taprootBalance - ) - } - - // Total row - HStack { - TitleText(t("common__total")) - Spacer() - MoneyText(sats: Int(balances.totalBalance), size: .title, symbol: true, symbolColor: .textPrimary) - } - .padding(.top, 16) - } - } - - Spacer() - - CustomButton(title: t("sweep__sweep_to_wallet")) { - navigation.navigate(.sweepConfirm) - } - .accessibilityIdentifier("SweepToWalletButton") - } - } - - @ViewBuilder - private func fundRow(title: String, utxoCount: UInt32, balance: UInt64) -> some View { - VStack(spacing: 0) { - HStack { - Text("\(title), \(utxoCount) UTXO\(utxoCount == 1 ? "" : "s")") - .font(Fonts.semiBold(size: 13)) - .foregroundColor(.textPrimary) - Spacer() - MoneyText(sats: Int(balance), size: .captionB, symbol: true, symbolColor: .textPrimary) - } - .padding(.vertical, 16) - - Divider() - .background(Color.gray5) - } - } - - // MARK: - No Funds View - - @ViewBuilder - private var noFundsView: some View { - VStack(alignment: .leading, spacing: 0) { - BodyMText(t("sweep__no_funds_description")) - .foregroundColor(.textSecondary) - - Spacer() - - Image("check") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 311, height: 311) - .frame(maxWidth: .infinity, alignment: .center) - - Spacer() - - CustomButton(title: t("common__ok")) { - navigation.navigateBack() - } - } - } - - // MARK: - Error View - - @ViewBuilder - private func errorView(message: String) -> some View { - VStack(spacing: 24) { - Spacer() - - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 64)) - .foregroundColor(.redAccent) - - VStack(spacing: 8) { - BodyMSBText(t("sweep__error_title")) - BodyMText(message) - .foregroundColor(.textSecondary) - .multilineTextAlignment(.center) - } - - Spacer() - - CustomButton(title: t("common__retry")) { - Task { await viewModel.checkBalance() } - } - } - } -} diff --git a/Bitkit/Views/Settings/Advanced/SweepSuccessView.swift b/Bitkit/Views/Settings/Advanced/SweepSuccessView.swift deleted file mode 100644 index 3145ea368..000000000 --- a/Bitkit/Views/Settings/Advanced/SweepSuccessView.swift +++ /dev/null @@ -1,64 +0,0 @@ -import BitkitCore -import Lottie -import SwiftUI - -struct SweepSuccessView: View { - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var viewModel: SweepViewModel - - let txid: String - - private var confettiAnimation: LottieAnimation? { - guard let filepathURL = Bundle.main.url(forResource: "confetti-orange", withExtension: "json") else { - return nil - } - return LottieAnimation.filepath(filepathURL.path) - } - - private var amountSwept: UInt64 { - viewModel.sweepResult?.amountSwept ?? 0 - } - - var body: some View { - ZStack { - if let animation = confettiAnimation { - LottieView(animation: animation) - .playing(loopMode: .loop) - .scaleEffect(1.9) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("sweep__complete_title")) - .padding(.bottom, 16) - - BodyMText(t("sweep__complete_description")) - .foregroundColor(.textSecondary) - .padding(.bottom, 24) - - VStack(alignment: .leading, spacing: 16) { - MoneyText(sats: Int(amountSwept), unitType: .secondary, size: .caption, color: .textSecondary) - MoneyText(sats: Int(amountSwept), size: .display, symbol: true, symbolColor: .textSecondary) - } - - Spacer() - - Image("check") - .resizable() - .scaledToFit() - .frame(width: 256, height: 256) - .frame(maxWidth: .infinity) - - Spacer() - - CustomButton(title: t("sweep__wallet_overview")) { - navigation.reset() - } - } - .padding(.horizontal, 16) - } - .navigationBarHidden(true) - .bottomSafeAreaPadding() - .background(Color.customBlack) - } -} diff --git a/Bitkit/Views/Transfer/FundManualAmountView.swift b/Bitkit/Views/Transfer/FundManualAmountView.swift index dd7c4e6bc..709332011 100644 --- a/Bitkit/Views/Transfer/FundManualAmountView.swift +++ b/Bitkit/Views/Transfer/FundManualAmountView.swift @@ -33,9 +33,10 @@ struct FundManualAmountView: View { Spacer() HStack(alignment: .bottom) { - AvailableAmount(label: t("wallet__send_available"), amount: wallet.totalOnchainSats) + // Excludes Legacy (not usable for channel funding) + AvailableAmount(label: t("wallet__send_available"), amount: wallet.channelFundableBalanceSats) .onTapGesture { - amountViewModel.updateFromSats(UInt64(wallet.totalOnchainSats), currency: currency) + amountViewModel.updateFromSats(UInt64(wallet.channelFundableBalanceSats), currency: currency) } Spacer() @@ -79,11 +80,11 @@ struct FundManualAmountView: View { } NumberPadActionButton(text: t("lightning__spending_amount__quarter")) { - amountViewModel.updateFromSats(UInt64(wallet.totalOnchainSats) / 4, currency: currency) + amountViewModel.updateFromSats(UInt64(wallet.channelFundableBalanceSats) / 4, currency: currency) } NumberPadActionButton(text: t("common__max")) { - amountViewModel.updateFromSats(UInt64(wallet.totalOnchainSats), currency: currency) + amountViewModel.updateFromSats(UInt64(wallet.channelFundableBalanceSats), currency: currency) } } } diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index 5ba46af88..7104e2b8b 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -79,6 +79,11 @@ struct SpendingAmount: View { .task(id: blocktank.info?.options.maxChannelSizeSat) { await calculateMaxTransferAmount() } + .onChange(of: wallet.spendableOnchainBalanceSats) { _ in + Task { + await calculateMaxTransferAmount() + } + } } private var actionButtons: some View { diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 827698c77..622d67131 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -139,9 +139,13 @@ struct ActivityExplorerView: View { } } + private var navTitle: String { + activity.txType == .sent ? t("wallet__activity_bitcoin_sent") : t("wallet__activity_bitcoin_received") + } + var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("wallet__activity_bitcoin_received")) + NavigationBar(title: navTitle) .padding(.bottom, 16) HStack(alignment: .bottom) { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 9406e60fb..6fdf4834c 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -245,6 +245,15 @@ struct SendConfirmationView: View { // Create pre-activity metadata for tags and activity address await createPreActivityMetadata(paymentId: txid, address: invoice.address, txId: txid, feeRate: wallet.selectedFeeRateSatsPerVByte) + // Create sent onchain activity immediately so it appears before LDK event (which can be delayed) + await CoreService.shared.activity.createSentOnchainActivityFromSendResult( + txid: txid, + address: invoice.address, + amount: amount, + fee: UInt64(transactionFee), + feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1 + ) + // Set the amount for the success screen wallet.sendAmountSats = amount diff --git a/BitkitTests/AddressTypeDerivationTests.swift b/BitkitTests/AddressTypeDerivationTests.swift new file mode 100644 index 000000000..a14101a98 --- /dev/null +++ b/BitkitTests/AddressTypeDerivationTests.swift @@ -0,0 +1,99 @@ +@testable import Bitkit +import LDKNode +import XCTest + +/// Unit tests for LDKNode.AddressType derivation path and storage mapping (LDKNode+AddressType extension). +final class AddressTypeDerivationTests: XCTestCase { + // MARK: - fromStorage + + func testFromStorage_ValidStrings_ReturnsCorrectType() { + XCTAssertEqual(LDKNode.AddressType.fromStorage("legacy"), .legacy) + XCTAssertEqual(LDKNode.AddressType.fromStorage("nestedSegwit"), .nestedSegwit) + XCTAssertEqual(LDKNode.AddressType.fromStorage("nativeSegwit"), .nativeSegwit) + XCTAssertEqual(LDKNode.AddressType.fromStorage("taproot"), .taproot) + } + + func testFromStorage_NilOrInvalid_ReturnsNativeSegwit() { + XCTAssertEqual(LDKNode.AddressType.fromStorage(nil), .nativeSegwit) + XCTAssertEqual(LDKNode.AddressType.fromStorage(""), .nativeSegwit) + XCTAssertEqual(LDKNode.AddressType.fromStorage("invalid"), .nativeSegwit) + XCTAssertEqual(LDKNode.AddressType.fromStorage("p2wpkh"), .nativeSegwit) + } + + // MARK: - derivationPath(coinType:) + + func testDerivationPath_Mainnet_ReturnsBIPPaths() { + XCTAssertEqual(LDKNode.AddressType.legacy.derivationPath(coinType: "0"), "m/44'/0'/0'/0") + XCTAssertEqual(LDKNode.AddressType.nestedSegwit.derivationPath(coinType: "0"), "m/49'/0'/0'/0") + XCTAssertEqual(LDKNode.AddressType.nativeSegwit.derivationPath(coinType: "0"), "m/84'/0'/0'/0") + XCTAssertEqual(LDKNode.AddressType.taproot.derivationPath(coinType: "0"), "m/86'/0'/0'/0") + } + + func testDerivationPath_Testnet_ReturnsBIPPathsWithCoinType1() { + XCTAssertEqual(LDKNode.AddressType.legacy.derivationPath(coinType: "1"), "m/44'/1'/0'/0") + XCTAssertEqual(LDKNode.AddressType.nestedSegwit.derivationPath(coinType: "1"), "m/49'/1'/0'/0") + XCTAssertEqual(LDKNode.AddressType.nativeSegwit.derivationPath(coinType: "1"), "m/84'/1'/0'/0") + XCTAssertEqual(LDKNode.AddressType.taproot.derivationPath(coinType: "1"), "m/86'/1'/0'/0") + } + + func testDerivationPath_StringValueRoundTrip() { + for type in LDKNode.AddressType.allAddressTypes { + let parsed = LDKNode.AddressType.fromStorage(type.stringValue) + XCTAssertEqual(parsed, type, "fromStorage(\(type.stringValue)) should round-trip") + } + } + + // MARK: - parseCommaSeparated(_:) + + func testParseCommaSeparated_ValidTypes() { + let result = LDKNode.AddressType.parseCommaSeparated("nativeSegwit,taproot") + XCTAssertEqual(result, [.nativeSegwit, .taproot]) + } + + func testParseCommaSeparated_HandlesWhitespace() { + let result = LDKNode.AddressType.parseCommaSeparated("nativeSegwit , taproot ") + XCTAssertEqual(result, [.nativeSegwit, .taproot]) + } + + func testParseCommaSeparated_FiltersInvalid() { + let result = LDKNode.AddressType.parseCommaSeparated("nativeSegwit,invalid,taproot") + XCTAssertEqual(result, [.nativeSegwit, .taproot]) + } + + // MARK: - matchesAddressFormat(_:network:) + + func testMatchesAddressFormat_Mainnet_AcceptsCorrectPrefixes() { + let network: LDKNode.Network = .bitcoin + XCTAssertTrue(LDKNode.AddressType.legacy.matchesAddressFormat("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", network: network)) + XCTAssertTrue(LDKNode.AddressType.nestedSegwit.matchesAddressFormat("3J98t1WpEZ73CNmYviecrnyiWrnqRhWNLy", network: network)) + XCTAssertTrue(LDKNode.AddressType.nativeSegwit.matchesAddressFormat("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", network: network)) + XCTAssertTrue(LDKNode.AddressType.taproot.matchesAddressFormat( + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", + network: network + )) + } + + func testMatchesAddressFormat_NonMainnet_AcceptsTestnetRegtestPrefixes() { + let network: LDKNode.Network = .regtest + XCTAssertTrue(LDKNode.AddressType.legacy.matchesAddressFormat("n1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", network: network)) + XCTAssertTrue(LDKNode.AddressType.legacy.matchesAddressFormat("mipcBbFg9gMi2EuSb4p2xS1jG7xJsK9Np2", network: network)) + XCTAssertTrue(LDKNode.AddressType.nestedSegwit.matchesAddressFormat("2N1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", network: network)) + XCTAssertTrue(LDKNode.AddressType.nativeSegwit.matchesAddressFormat("bcrt1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", network: network)) + XCTAssertTrue(LDKNode.AddressType.taproot.matchesAddressFormat( + "bcrt1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqejdw4v", + network: network + )) + } + + func testMatchesAddressFormat_RejectsWrongType() { + let network: LDKNode.Network = .bitcoin + XCTAssertFalse(LDKNode.AddressType.legacy.matchesAddressFormat("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", network: network)) + XCTAssertFalse(LDKNode.AddressType.nativeSegwit.matchesAddressFormat("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", network: network)) + XCTAssertFalse(LDKNode.AddressType.taproot.matchesAddressFormat("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", network: network)) + } + + func testMatchesAddressFormat_RejectsEmpty() { + XCTAssertFalse(LDKNode.AddressType.nativeSegwit.matchesAddressFormat("", network: .bitcoin)) + XCTAssertFalse(LDKNode.AddressType.legacy.matchesAddressFormat(" ", network: .regtest)) + } +} diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift new file mode 100644 index 000000000..e66a8f2e8 --- /dev/null +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -0,0 +1,326 @@ +import BitkitCore +import LDKNode +import XCTest + +@testable import Bitkit + +final class AddressTypeIntegrationTests: XCTestCase { + let walletIndex = 0 + let settings = SettingsViewModel.shared + + override func setUp() async throws { + try await super.setUp() + Logger.test("Starting address type integration test setup", context: "AddressTypeIntegrationTests") + try Keychain.wipeEntireKeychain() + } + + override func tearDown() async throws { + let lightning = await MainActor.run { settings.lightningService } + lightning.dumpLdkLogs() + try Keychain.wipeEntireKeychain() + let isRunning = await MainActor.run { lightning.status?.isRunning == true } + if isRunning { + try? await lightning.stop() + } + try? await lightning.wipeStorage(walletIndex: walletIndex) + await MainActor.run { settings.resetToDefaults() } + try await super.tearDown() + } + + /// Skip if not regtest - integration tests require regtest + private func skipIfNotRegtest() throws { + guard Env.network == .regtest else { + throw XCTSkip("Address type integration tests require regtest") + } + } + + /// Shared setup: create wallet, start lightning node, sync + private func setupWalletAndNode() async throws { + try skipIfNotRegtest() + let mnemonic = try StartupHandler.createNewWallet(bip39Passphrase: nil, walletIndex: walletIndex) + XCTAssertFalse(mnemonic.isEmpty) + let lightning = await MainActor.run { settings.lightningService } + try await lightning.setup(walletIndex: walletIndex) + try await lightning.start() + try await lightning.sync() + } + + @MainActor + func testGetBalanceForAddressType() async throws { + try await setupWalletAndNode() + + Logger.test("Getting balance for nativeSegwit", context: "AddressTypeIntegrationTests") + let balance = try await settings.lightningService.getBalanceForAddressType(.nativeSegwit) + XCTAssertGreaterThanOrEqual(balance.totalSats, 0) + Logger.test("Balance: \(balance.totalSats) sats", context: "AddressTypeIntegrationTests") + } + + @MainActor + func testGetChannelFundableBalance() async throws { + try await setupWalletAndNode() + + Logger.test("Getting channel fundable balance", context: "AddressTypeIntegrationTests") + let (selectedType, monitoredTypes) = Bitkit.LightningService.addressTypeStateFromUserDefaults() + let fundable = try await settings.lightningService.getChannelFundableBalance(selectedType: selectedType, monitoredTypes: monitoredTypes) + XCTAssertGreaterThanOrEqual(fundable, 0) + Logger.test("Channel fundable: \(fundable) sats", context: "AddressTypeIntegrationTests") + } + + @MainActor + func testUpdateAddressType() async throws { + try await setupWalletAndNode() + + Logger.test("Updating address type to taproot", context: "AddressTypeIntegrationTests") + let success = await settings.updateAddressType(.taproot, wallet: nil) + XCTAssertTrue(success, "updateAddressType should succeed") + + XCTAssertEqual(UserDefaults.standard.string(forKey: "selectedAddressType"), "taproot") + XCTAssertTrue(settings.addressTypesToMonitor.contains(.taproot)) + Logger.test("Address type updated successfully", context: "AddressTypeIntegrationTests") + } + + @MainActor + func testUpdateAddressTypeToLegacy() async throws { + try await setupWalletAndNode() + + Logger.test("Updating address type to legacy", context: "AddressTypeIntegrationTests") + let success = await settings.updateAddressType(.legacy, wallet: nil) + XCTAssertTrue(success, "updateAddressType to legacy should succeed") + + XCTAssertEqual(UserDefaults.standard.string(forKey: "selectedAddressType"), "legacy") + XCTAssertTrue(settings.addressTypesToMonitor.contains(.legacy)) + Logger.test("Address type updated to legacy successfully", context: "AddressTypeIntegrationTests") + } + + @MainActor + func testSetMonitoringEnable() async throws { + try await setupWalletAndNode() + + settings.addressTypesToMonitor = [.nativeSegwit] + UserDefaults.standard.synchronize() + + Logger.test("Enabling monitoring for taproot", context: "AddressTypeIntegrationTests") + let success = await settings.setMonitoring(.taproot, enabled: true, wallet: nil) + XCTAssertTrue(success) + XCTAssertTrue(settings.addressTypesToMonitor.contains(.taproot)) + } + + @MainActor + func testSetMonitoringDisableForEmptyTypeSucceeds() async throws { + try await setupWalletAndNode() + + settings.addressTypesToMonitor = [.nativeSegwit] + UserDefaults.standard.synchronize() + let addSuccess = await settings.setMonitoring(.taproot, enabled: true, wallet: nil) + XCTAssertTrue(addSuccess, "Adding taproot to monitoring should succeed") + + Logger.test("Disabling monitoring for empty taproot type", context: "AddressTypeIntegrationTests") + let success = await settings.setMonitoring(.taproot, enabled: false, wallet: nil) + XCTAssertTrue(success, "Disabling empty type should succeed when nativeSegwit remains") + XCTAssertFalse(settings.addressTypesToMonitor.contains(.taproot)) + XCTAssertTrue(settings.addressTypesToMonitor.contains(.nativeSegwit)) + } + + @MainActor + func testSetMonitoringDisableLastNativeWitnessFails() async throws { + try await setupWalletAndNode() + + settings.addressTypesToMonitor = [.nativeSegwit] + UserDefaults.standard.synchronize() + + Logger.test("Attempting to disable last native witness type", context: "AddressTypeIntegrationTests") + let success = await settings.setMonitoring(.nativeSegwit, enabled: false, wallet: nil) + XCTAssertFalse(success, "Disabling last native witness type should fail") + XCTAssertTrue(settings.addressTypesToMonitor.contains(.nativeSegwit)) + } + + @MainActor + func testSetMonitoringDisableSelectedTypeFails() async throws { + try await setupWalletAndNode() + + settings.addressTypesToMonitor = [.nativeSegwit] + UserDefaults.standard.synchronize() + let addSuccess = await settings.setMonitoring(.taproot, enabled: true, wallet: nil) + XCTAssertTrue(addSuccess) + let updateSuccess = await settings.updateAddressType(.taproot, wallet: nil) + XCTAssertTrue(updateSuccess, "Taproot should be selected") + + Logger.test("Attempting to disable selected type (taproot)", context: "AddressTypeIntegrationTests") + let success = await settings.setMonitoring(.taproot, enabled: false, wallet: nil) + XCTAssertFalse(success, "Disabling selected address type should fail") + XCTAssertTrue(settings.addressTypesToMonitor.contains(.taproot)) + } + + @MainActor + func testPruneEmptyAddressTypesAfterRestore() async throws { + try await setupWalletAndNode() + + settings.addressTypesToMonitor = [.nativeSegwit, .taproot] + UserDefaults.standard.synchronize() + try await settings.lightningService.addAddressTypeToMonitor(.taproot) + try await settings.lightningService.sync() + + Logger.test("Pruning empty address types after restore", context: "AddressTypeIntegrationTests") + await settings.pruneEmptyAddressTypesAfterRestore() + + XCTAssertTrue(settings.addressTypesToMonitor.contains(.nativeSegwit)) + let monitored = settings.addressTypesToMonitor + XCTAssertLessThanOrEqual(monitored.count, 4) + Logger.test( + "Pruned monitored types: \(monitored.map(\.stringValue).joined(separator: ","))", + context: "AddressTypeIntegrationTests" + ) + } + + // MARK: - Mutex / Concurrency + + @MainActor + func testUpdateAddressTypeMutexReturnsImmediately() async throws { + try await setupWalletAndNode() + + Logger.test("Testing updateAddressType mutex guard", context: "AddressTypeIntegrationTests") + // First call should succeed + let success = await settings.updateAddressType(.taproot, wallet: nil) + XCTAssertTrue(success) + + // Same type returns true (guard: addressType == selectedAddressType) + let sameTypeResult = await settings.updateAddressType(.taproot, wallet: nil) + XCTAssertTrue(sameTypeResult, "Same type should return true immediately") + } + + // MARK: - Channel Fundable Balance Excludes Legacy + + @MainActor + func testGetChannelFundableBalanceExcludesLegacy() async throws { + try await setupWalletAndNode() + + let blocktank = CoreService.shared.blocktank + + // Enable legacy monitoring and switch to legacy + settings.addressTypesToMonitor = [.nativeSegwit, .legacy] + UserDefaults.standard.synchronize() + let updateSuccess = await settings.updateAddressType(.legacy, wallet: nil) + XCTAssertTrue(updateSuccess) + + let legacyAddress = try await settings.lightningService.newAddressForType(.legacy) + Logger.test("Funding legacy address: \(legacyAddress)", context: "AddressTypeIntegrationTests") + let txId = try await blocktank.regtestDepositFunds(address: legacyAddress, amountSat: 50000) + XCTAssertFalse(txId.isEmpty) + + try await blocktank.regtestMineBlocks(6) + try await Task.sleep(nanoseconds: 15_000_000_000) + try await settings.lightningService.sync() + + // Verify legacy has balance + let legacyBalance = try await settings.lightningService.getBalanceForAddressType(.legacy) + XCTAssertGreaterThan(legacyBalance.totalSats, 0, "Legacy should have balance") + + // Channel fundable should NOT include legacy + let fundable = try await settings.lightningService.getChannelFundableBalance( + selectedType: .legacy, + monitoredTypes: [.nativeSegwit, .legacy] + ) + XCTAssertEqual(fundable, 0, "Channel fundable should exclude legacy even when it has balance") + Logger.test("Channel fundable correctly excludes legacy: \(fundable)", context: "AddressTypeIntegrationTests") + } + + // MARK: - Disable Monitoring With Balance Fails + + @MainActor + func testSetMonitoringDisableWithBalanceFails() async throws { + try await setupWalletAndNode() + + let blocktank = CoreService.shared.blocktank + + // Enable taproot monitoring + settings.addressTypesToMonitor = [.nativeSegwit] + UserDefaults.standard.synchronize() + let addSuccess = await settings.setMonitoring(.taproot, enabled: true, wallet: nil) + XCTAssertTrue(addSuccess, "Adding taproot should succeed") + + // Fund the taproot address + let taprootAddress = try await settings.lightningService.newAddressForType(.taproot) + Logger.test("Funding taproot address: \(taprootAddress)", context: "AddressTypeIntegrationTests") + let txId = try await blocktank.regtestDepositFunds(address: taprootAddress, amountSat: 50000) + XCTAssertFalse(txId.isEmpty) + + try await blocktank.regtestMineBlocks(6) + try await Task.sleep(nanoseconds: 15_000_000_000) + try await settings.lightningService.sync() + + // Verify taproot has balance + let taprootBalance = try await settings.lightningService.getBalanceForAddressType(.taproot) + XCTAssertGreaterThan(taprootBalance.totalSats, 0, "Taproot should have balance after funding") + + // Attempt to disable — should fail because of balance + Logger.test("Attempting to disable taproot monitoring with balance", context: "AddressTypeIntegrationTests") + let disableSuccess = await settings.setMonitoring(.taproot, enabled: false, wallet: nil) + XCTAssertFalse(disableSuccess, "Disabling type with balance should fail") + XCTAssertTrue(settings.addressTypesToMonitor.contains(.taproot), "Taproot should remain monitored") + } + + // MARK: - Prune Preserves Types With Balance + + @MainActor + func testPruneEmptyPreservesTypesWithBalance() async throws { + try await setupWalletAndNode() + + let blocktank = CoreService.shared.blocktank + + // Enable taproot monitoring + settings.addressTypesToMonitor = [.nativeSegwit] + UserDefaults.standard.synchronize() + let addSuccess = await settings.setMonitoring(.taproot, enabled: true, wallet: nil) + XCTAssertTrue(addSuccess) + + // Fund the taproot address + let taprootAddress = try await settings.lightningService.newAddressForType(.taproot) + Logger.test("Funding taproot for prune test: \(taprootAddress)", context: "AddressTypeIntegrationTests") + let txId = try await blocktank.regtestDepositFunds(address: taprootAddress, amountSat: 50000) + XCTAssertFalse(txId.isEmpty) + + try await blocktank.regtestMineBlocks(6) + try await Task.sleep(nanoseconds: 15_000_000_000) + try await settings.lightningService.sync() + + // Add legacy (will be empty) + let addLegacy = await settings.setMonitoring(.legacy, enabled: true, wallet: nil) + XCTAssertTrue(addLegacy) + XCTAssertEqual(settings.addressTypesToMonitor.count, 3) + + Logger.test("Pruning — should remove empty legacy but keep funded taproot", context: "AddressTypeIntegrationTests") + await settings.pruneEmptyAddressTypesAfterRestore() + + XCTAssertTrue(settings.addressTypesToMonitor.contains(.nativeSegwit), "nativeSegwit should remain") + XCTAssertTrue(settings.addressTypesToMonitor.contains(.taproot), "Funded taproot should remain") + XCTAssertFalse(settings.addressTypesToMonitor.contains(.legacy), "Empty legacy should be pruned") + } + + // MARK: - Address Format Verification + + @MainActor + func testNewAddressMatchesTypeFormat() async throws { + try await setupWalletAndNode() + + // Enable all types so LDK creates wallets for each + settings.addressTypesToMonitor = [.nativeSegwit] + UserDefaults.standard.synchronize() + for type in [LDKNode.AddressType.taproot, .nestedSegwit, .legacy] { + let success = await settings.setMonitoring(type, enabled: true, wallet: nil) + XCTAssertTrue(success, "Enabling \(type.stringValue) monitoring should succeed") + } + + let expectations: [(LDKNode.AddressType, String, String)] = [ + (.legacy, "m", "Legacy address should start with m or n on regtest"), + (.nestedSegwit, "2", "Nested SegWit address should start with 2 on regtest"), + (.nativeSegwit, "bcrt1q", "Native SegWit address should start with bcrt1q on regtest"), + (.taproot, "bcrt1p", "Taproot address should start with bcrt1p on regtest"), + ] + + for (type, prefix, message) in expectations { + let address = try await settings.lightningService.newAddressForType(type) + Logger.test("\(type.stringValue) address: \(address)", context: "AddressTypeIntegrationTests") + XCTAssertTrue(address.hasPrefix(prefix), "\(message), got: \(address)") + } + } +} diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift new file mode 100644 index 000000000..a6479a475 --- /dev/null +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -0,0 +1,202 @@ +import LDKNode +import XCTest + +@testable import Bitkit + +/// Tests for the multi-address-type feature in SettingsViewModel. +/// Covers address type conversion, monitoring, native witness rules, and backup/restore. +@MainActor +final class AddressTypeSettingsTests: XCTestCase { + private let settings = SettingsViewModel.shared + + override func setUp() { + super.setUp() + settings.resetToDefaults() + } + + override func tearDown() { + settings.resetToDefaults() + super.tearDown() + } + + // MARK: - SettingsBackupConfig (address type keys) + + func testSettingsBackupConfigContainsAddressTypeKeys() { + XCTAssertTrue(SettingsBackupConfig.settingsKeyTypes.keys.contains("selectedAddressType")) + XCTAssertTrue(SettingsBackupConfig.settingsKeyTypes.keys.contains("addressTypesToMonitor")) + XCTAssertTrue(SettingsBackupConfig.settingsKeys.contains("selectedAddressType")) + XCTAssertTrue(SettingsBackupConfig.settingsKeys.contains("addressTypesToMonitor")) + } + + // MARK: - LDKNode.AddressType stringValue (storage string) + + func testAddressTypeStringValue() { + XCTAssertEqual(LDKNode.AddressType.legacy.stringValue, "legacy") + XCTAssertEqual(LDKNode.AddressType.nestedSegwit.stringValue, "nestedSegwit") + XCTAssertEqual(LDKNode.AddressType.nativeSegwit.stringValue, "nativeSegwit") + XCTAssertEqual(LDKNode.AddressType.taproot.stringValue, "taproot") + } + + // MARK: - LDKNode.AddressType from(string:) + + func testAddressTypeFromString() { + XCTAssertEqual(LDKNode.AddressType.from(string: "legacy"), .legacy) + XCTAssertEqual(LDKNode.AddressType.from(string: "nestedSegwit"), .nestedSegwit) + XCTAssertEqual(LDKNode.AddressType.from(string: "nativeSegwit"), .nativeSegwit) + XCTAssertEqual(LDKNode.AddressType.from(string: "taproot"), .taproot) + } + + func testAddressTypeFromStringInvalidReturnsNil() { + XCTAssertNil(LDKNode.AddressType.from(string: "invalid")) + XCTAssertNil(LDKNode.AddressType.from(string: "")) + XCTAssertNil(LDKNode.AddressType.from(string: "p2wpkh")) + } + + // MARK: - addressTypesToMonitor round-trip + + func testAddressTypesToMonitorHandlesWhitespace() { + settings.addressTypesToMonitor = [.nativeSegwit, .taproot] + // Simulate comma-separated string with spaces (as could come from restore/migration) + UserDefaults.standard.set("nativeSegwit , taproot", forKey: "addressTypesToMonitor") + UserDefaults.standard.synchronize() + let monitored = settings.addressTypesToMonitor + XCTAssertTrue(monitored.contains(.nativeSegwit)) + XCTAssertTrue(monitored.contains(.taproot)) + } + + func testSelectedAddressTypeReturnsDefaultForInvalidStoredValue() { + UserDefaults.standard.set("invalidType", forKey: "selectedAddressType") + UserDefaults.standard.synchronize() + XCTAssertEqual(settings.selectedAddressType, .nativeSegwit) + } + + func testAddressTypesToMonitorRoundTrip() { + let types: [LDKNode.AddressType] = [.nativeSegwit, .taproot] + settings.addressTypesToMonitor = types + XCTAssertEqual(settings.addressTypesToMonitor, types) + + let allTypes: [LDKNode.AddressType] = [.legacy, .nestedSegwit, .nativeSegwit, .taproot] + settings.addressTypesToMonitor = allTypes + XCTAssertEqual(settings.addressTypesToMonitor, allTypes) + } + + // MARK: - isMonitoring + + func testIsMonitoring() { + settings.addressTypesToMonitor = [.nativeSegwit] + XCTAssertTrue(settings.isMonitoring(.nativeSegwit)) + XCTAssertFalse(settings.isMonitoring(.taproot)) + XCTAssertFalse(settings.isMonitoring(.legacy)) + + settings.addressTypesToMonitor = [.nativeSegwit, .taproot] + XCTAssertTrue(settings.isMonitoring(.nativeSegwit)) + XCTAssertTrue(settings.isMonitoring(.taproot)) + XCTAssertFalse(settings.isMonitoring(.legacy)) + } + + // MARK: - ensureMonitoring + + func testEnsureMonitoringAddsType() { + settings.addressTypesToMonitor = [.nativeSegwit] + settings.ensureMonitoring(.taproot) + XCTAssertTrue(settings.addressTypesToMonitor.contains(.taproot)) + XCTAssertEqual(settings.addressTypesToMonitor.count, 2) + } + + func testEnsureMonitoringNoOpWhenAlreadyPresent() { + settings.addressTypesToMonitor = [.nativeSegwit, .taproot] + settings.ensureMonitoring(.taproot) + XCTAssertEqual(settings.addressTypesToMonitor.count, 2) + XCTAssertTrue(settings.addressTypesToMonitor.contains(.taproot)) + } + + // MARK: - monitorAllAddressTypes + + func testMonitorAllAddressTypes() { + settings.addressTypesToMonitor = [.nativeSegwit] + settings.monitorAllAddressTypes() + XCTAssertEqual(settings.addressTypesToMonitor.count, 4) + XCTAssertTrue(settings.addressTypesToMonitor.contains(.legacy)) + XCTAssertTrue(settings.addressTypesToMonitor.contains(.nestedSegwit)) + XCTAssertTrue(settings.addressTypesToMonitor.contains(.nativeSegwit)) + XCTAssertTrue(settings.addressTypesToMonitor.contains(.taproot)) + } + + // MARK: - isLastRequiredNativeWitnessWallet + + func testIsLastRequiredNativeWitnessWalletWhenOnlyNativeSegwit() { + settings.addressTypesToMonitor = [.nativeSegwit] + XCTAssertTrue(settings.isLastRequiredNativeWitnessWallet(.nativeSegwit)) + } + + func testIsLastRequiredNativeWitnessWalletWhenOnlyTaproot() { + settings.addressTypesToMonitor = [.taproot] + XCTAssertTrue(settings.isLastRequiredNativeWitnessWallet(.taproot)) + } + + func testIsLastRequiredNativeWitnessWalletFalseForLegacy() { + settings.addressTypesToMonitor = [.legacy] + XCTAssertFalse(settings.isLastRequiredNativeWitnessWallet(.legacy)) + } + + func testIsLastRequiredNativeWitnessWalletFalseForNestedSegwit() { + settings.addressTypesToMonitor = [.nestedSegwit] + XCTAssertFalse(settings.isLastRequiredNativeWitnessWallet(.nestedSegwit)) + } + + func testIsLastRequiredNativeWitnessWalletFalseWhenOtherNativeWitnessExists() { + settings.addressTypesToMonitor = [.nativeSegwit, .taproot] + XCTAssertFalse(settings.isLastRequiredNativeWitnessWallet(.nativeSegwit)) + XCTAssertFalse(settings.isLastRequiredNativeWitnessWallet(.taproot)) + } + + // MARK: - resetToDefaults + + func testResetToDefaultsSetsAddressTypes() { + settings.addressTypesToMonitor = [.legacy, .taproot] + settings.selectedAddressType = .taproot + + settings.resetToDefaults() + + XCTAssertEqual(settings.selectedAddressType, .nativeSegwit) + XCTAssertEqual(settings.addressTypesToMonitor, [.nativeSegwit]) + } + + // MARK: - Backup/Restore + + func testGetSettingsDictionaryIncludesAddressTypes() { + settings.selectedAddressType = .taproot + settings.addressTypesToMonitor = [.nativeSegwit, .taproot] + UserDefaults.standard.synchronize() + + let dict = settings.getSettingsDictionary() + + XCTAssertEqual(dict["selectedAddressType"] as? String, "taproot") + XCTAssertEqual(dict["addressTypesToMonitor"] as? String, "nativeSegwit,taproot") + } + + func testRestoreSettingsDictionaryAddressTypes() { + let dict: [String: Any] = [ + "selectedAddressType": "taproot", + "addressTypesToMonitor": "nativeSegwit,taproot", + ] + + settings.restoreSettingsDictionary(dict) + + XCTAssertEqual(UserDefaults.standard.string(forKey: "selectedAddressType"), "taproot") + XCTAssertEqual(UserDefaults.standard.string(forKey: "addressTypesToMonitor"), "nativeSegwit,taproot") + } + + func testRestoreSettingsDictionaryFiltersInvalidAddressTypes() { + // Restore writes raw string; parseAddressTypesString filters invalid when reading + let dict: [String: Any] = [ + "addressTypesToMonitor": "nativeSegwit,invalid,taproot", + ] + settings.restoreSettingsDictionary(dict) + + let raw = UserDefaults.standard.string(forKey: "addressTypesToMonitor") + XCTAssertEqual(raw, "nativeSegwit,invalid,taproot", "Restore should write raw string") + let monitored = SettingsViewModel.parseAddressTypesString(raw ?? "") + XCTAssertEqual(monitored, [.nativeSegwit, .taproot], "Invalid types should be filtered when parsing") + } +} diff --git a/BitkitTests/RNMigrationAddressTypeTests.swift b/BitkitTests/RNMigrationAddressTypeTests.swift new file mode 100644 index 000000000..3eef7fa86 --- /dev/null +++ b/BitkitTests/RNMigrationAddressTypeTests.swift @@ -0,0 +1,127 @@ +@testable import Bitkit +import XCTest + +/// Tests for RN (React Native) migration of address type settings from MMKV data. +/// Covers extractRNAddressTypeSettings and applyRNAddressTypeSettings in MigrationsService. +final class RNMigrationAddressTypeTests: XCTestCase { + private let migrations = MigrationsService.shared + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "selectedAddressType") + UserDefaults.standard.removeObject(forKey: "addressTypesToMonitor") + super.tearDown() + } + + // MARK: - Helper Methods + + private func makePersistRoot( + addressTypePerNetwork: [String: String]? = nil, + addressTypesToMonitor: [String]? = nil + ) -> String { + var walletDict: [String: Any] = [:] + if let addressType = addressTypePerNetwork { + walletDict["wallets"] = ["wallet0": ["addressType": addressType]] + } + if let monitored = addressTypesToMonitor { + walletDict["addressTypesToMonitor"] = monitored + } + let walletData = try! JSONSerialization.data(withJSONObject: walletDict) + let walletJson = String(data: walletData, encoding: .utf8)! + let root: [String: Any] = ["wallet": walletJson] + let rootData = try! JSONSerialization.data(withJSONObject: root) + return String(data: rootData, encoding: .utf8)! + } + + private func makeMmkvData( + addressTypePerNetwork: [String: String]? = nil, + addressTypesToMonitor: [String]? = nil + ) -> [String: String] { + ["persist:root": makePersistRoot(addressTypePerNetwork: addressTypePerNetwork, addressTypesToMonitor: addressTypesToMonitor)] + } + + // MARK: - Extract Tests + + func testExtractRNAddressTypeSettingsFromMmkvData() { + let networkKey = "bitcoinRegtest" + let mmkvData = makeMmkvData( + addressTypePerNetwork: [networkKey: "p2wpkh"], + addressTypesToMonitor: ["p2pkh", "p2wpkh"] + ) + let result = migrations.extractRNAddressTypeSettings(from: mmkvData) + XCTAssertNotNil(result) + XCTAssertEqual(result?.selectedAddressType, "nativeSegwit") + XCTAssertEqual(result?.addressTypesToMonitor, ["legacy", "nativeSegwit"]) + } + + func testExtractRNAddressTypeSettingsMapping() { + let networkKey = "bitcoinRegtest" + let mappings: [(String, String)] = [ + ("p2pkh", "legacy"), + ("p2sh", "nestedSegwit"), + ("p2wpkh", "nativeSegwit"), + ("p2tr", "taproot"), + ] + for (rnValue, iosValue) in mappings { + let mmkvData = makeMmkvData( + addressTypePerNetwork: [networkKey: rnValue], + addressTypesToMonitor: [rnValue] + ) + let result = migrations.extractRNAddressTypeSettings(from: mmkvData) + XCTAssertNotNil(result, "Failed for RN value: \(rnValue)") + XCTAssertEqual(result?.selectedAddressType, iosValue, "RN \(rnValue) should map to \(iosValue)") + XCTAssertEqual(result?.addressTypesToMonitor, [iosValue]) + } + } + + func testExtractRNAddressTypeSettingsReturnsNilWhenNoWalletData() { + XCTAssertNil(migrations.extractRNAddressTypeSettings(from: [:])) + XCTAssertNil(migrations.extractRNAddressTypeSettings(from: ["otherKey": "value"])) + XCTAssertNil(migrations.extractRNAddressTypeSettings(from: makeMmkvData(addressTypePerNetwork: nil, addressTypesToMonitor: nil))) + } + + func testExtractRNAddressTypeSettingsFiltersUnknownRNValues() { + let networkKey = "bitcoinRegtest" + let mmkvData = makeMmkvData( + addressTypePerNetwork: [networkKey: "p2wpkh"], + addressTypesToMonitor: ["p2wpkh", "unknown", "p2tr"] + ) + let result = migrations.extractRNAddressTypeSettings(from: mmkvData) + XCTAssertNotNil(result) + XCTAssertEqual(result?.selectedAddressType, "nativeSegwit") + // Unknown values filtered out; p2wpkh -> nativeSegwit, p2tr -> taproot + XCTAssertEqual(Set(result?.addressTypesToMonitor ?? []), ["nativeSegwit", "taproot"]) + } + + // MARK: - Apply Tests + + func testApplyRNAddressTypeSettingsSelectedType() { + migrations.applyRNAddressTypeSettings(selectedAddressType: "taproot", addressTypesToMonitor: nil) + XCTAssertEqual(UserDefaults.standard.string(forKey: "selectedAddressType"), "taproot") + } + + func testApplyRNAddressTypeSettingsMonitoredTypes() { + migrations.applyRNAddressTypeSettings(selectedAddressType: nil, addressTypesToMonitor: ["nativeSegwit", "taproot"]) + XCTAssertEqual(UserDefaults.standard.string(forKey: "addressTypesToMonitor"), "nativeSegwit,taproot") + } + + func testApplyRNAddressTypeSettingsNativeWitnessAdded() { + migrations.applyRNAddressTypeSettings(selectedAddressType: nil, addressTypesToMonitor: ["legacy"]) + let monitored = UserDefaults.standard.string(forKey: "addressTypesToMonitor") + XCTAssertNotNil(monitored) + XCTAssertTrue(monitored!.contains("nativeSegwit")) + XCTAssertTrue(monitored!.contains("legacy")) + } + + func testApplyRNAddressTypeSettingsNestedSegwitOnlyAddsNativeSegwit() { + migrations.applyRNAddressTypeSettings(selectedAddressType: nil, addressTypesToMonitor: ["nestedSegwit"]) + let monitored = UserDefaults.standard.string(forKey: "addressTypesToMonitor") + XCTAssertNotNil(monitored) + XCTAssertTrue(monitored!.contains("nativeSegwit")) + XCTAssertTrue(monitored!.contains("nestedSegwit")) + } + + func testApplyRNAddressTypeSettingsWithNativeWitnessDoesNotDuplicate() { + migrations.applyRNAddressTypeSettings(selectedAddressType: nil, addressTypesToMonitor: ["nativeSegwit", "legacy"]) + XCTAssertEqual(UserDefaults.standard.string(forKey: "addressTypesToMonitor"), "nativeSegwit,legacy") + } +}