From a29617f19e9b80884d2ff3c335cceafcb436e8dc Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 5 Feb 2026 08:36:44 +0700 Subject: [PATCH 01/17] feat: support all address types --- Bitkit.xcodeproj/project.pbxproj | 46 +-- .../xcshareddata/swiftpm/Package.resolved | 3 +- Bitkit/MainNavView.swift | 1 + Bitkit/Services/CoreService.swift | 134 ++++--- Bitkit/Services/LightningService.swift | 47 ++- Bitkit/Utilities/StartupHandler.swift | 4 + Bitkit/ViewModels/NavigationViewModel.swift | 1 + Bitkit/ViewModels/SettingsViewModel.swift | 207 +++++++++++ Bitkit/ViewModels/WalletViewModel.swift | 8 +- .../Views/Onboarding/RestoreWalletView.swift | 4 + .../Advanced/AddressTypeLoadingView.swift | 78 ++++ .../Advanced/AddressTypePreferenceView.swift | 332 ++++++++++++++++++ .../Advanced/AdvancedSettingsView.swift | 33 +- Bitkit/Views/Transfer/SpendingAmount.swift | 6 + 14 files changed, 815 insertions(+), 89 deletions(-) create mode 100644 Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift create mode 100644 Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 352a71c1..d234d645 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -61,27 +61,6 @@ }; /* End PBXCopyFilesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 96EMBED0012026012000FRAME /* Remove Static Framework Stubs */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Remove Static Framework Stubs"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Remove static framework stubs from app bundle\\n# LDKNodeFFI is a static library - its code is linked into the main executable.\\n# The empty framework structure causes iOS install errors.\\nFRAMEWORK_PATH=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Frameworks/LDKNodeFFI.framework\"\\n\\nif [ -d \"$FRAMEWORK_PATH\" ]; then\\n echo \"Removing LDKNodeFFI static framework stub...\"\\n rm -rf \"$FRAMEWORK_PATH\"\\n echo \"Done.\"\\nfi\\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXFileReference section */ 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitNotification.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bitkit.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -443,6 +422,27 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 96EMBED0012026012000FRAME /* Remove Static Framework Stubs */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Remove Static Framework Stubs"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Remove static framework stubs from app bundle\\n# LDKNodeFFI is a static library - its code is linked into the main executable.\\n# The empty framework structure causes iOS install errors.\\nFRAMEWORK_PATH=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Frameworks/LDKNodeFFI.framework\"\\n\\nif [ -d \"$FRAMEWORK_PATH\" ]; then\\n echo \"Removing LDKNodeFFI static framework stub...\"\\n rm -rf \"$FRAMEWORK_PATH\"\\n echo \"Done.\"\\nfi\\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 961058D82C355B5500E1F1D8 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -925,8 +925,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { - branch = main; - kind = branch; + kind = revision; + revision = 2281589d699cb6f821f1ad720435c8110cf1fa7c; }; }; 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 cd17d19b..01b03e3f 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" : "af29894afa4b32ba7e506f321c09d200dc6ab8a2" } }, { diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index fad633e4..4fa41186 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -399,6 +399,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) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index a437a4d6..7cdf8b55 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -23,7 +23,10 @@ class ActivityService { // MARK: - Constants /// Maximum address index to search when current address exists - private static let maxAddressSearchIndex: UInt32 = 100_000 + private static let maxAddressSearchIndex: UInt32 = 1000 + /// Lock to prevent concurrent address searches + private let addressSearchLock = NSLock() + private var isSearchingAddresses = false // MARK: - BoostTxIds Cache @@ -124,10 +127,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 { @@ -348,7 +349,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 +359,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 +402,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 +494,7 @@ 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)", 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: @@ -892,6 +894,20 @@ class ActivityService { private func findReceivingAddress(for txid: String, value: UInt64, transactionDetails: BitkitCore.TransactionDetails? = nil) async throws -> String? { + // Prevent concurrent searches that could cause infinite loops + addressSearchLock.lock() + guard !isSearchingAddresses else { + addressSearchLock.unlock() + return nil + } + isSearchingAddresses = true + addressSearchLock.unlock() + defer { + addressSearchLock.lock() + isSearchingAddresses = false + addressSearchLock.unlock() + } + let details = if let provided = transactionDetails { provided } else { await fetchTransactionDetails(txid: txid) } guard let details else { Logger.warn("Transaction details not available for \(txid)", context: "CoreService.findReceivingAddress") @@ -903,9 +919,7 @@ class ActivityService { // Check if an address matches any transaction output func matchesTransaction(_ address: String) -> Bool { - details.outputs.contains { output in - output.scriptpubkeyAddress == address - } + details.outputs.contains { $0.scriptpubkeyAddress == address } } // Find matching address from a list, preferring exact value match @@ -913,20 +927,13 @@ class ActivityService { // Try exact value match first for address in addresses { for output in details.outputs { - if output.scriptpubkeyAddress == address, - output.value == value - { + 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 + return addresses.first { matchesTransaction($0) } } // First, check pre-activity metadata for addresses in the transaction @@ -939,19 +946,20 @@ class ActivityService { return currentWalletAddress } - // Search addresses forward in batches - func searchAddresses(isChange: Bool) async throws -> String? { + // Search addresses forward in batches across all address types + func searchAddresses(isChange: Bool, addressTypeString: String) async throws -> String? { var index: UInt32 = 0 - var currentAddressIndex: UInt32? = nil + var currentAddressIndex: UInt32? let hasCurrentAddress = !currentWalletAddress.isEmpty - let maxIndex: UInt32 = hasCurrentAddress ? Self.maxAddressSearchIndex : batchSize + let maxIndex: UInt32 = hasCurrentAddress ? min(Self.maxAddressSearchIndex, 500) : batchSize while index < maxIndex { let accountAddresses = try await coreService.utility.getAccountAddresses( walletIndex: 0, isChange: isChange, startIndex: index, - count: batchSize + count: batchSize, + addressTypeString: addressTypeString ) let addresses = accountAddresses.unused.map(\.address) + accountAddresses.used.map(\.address) @@ -961,7 +969,6 @@ class ActivityService { currentAddressIndex = index } - // Check for matches if let match = findMatch(in: addresses) { return match } @@ -981,12 +988,27 @@ class ActivityService { return nil } + let selectedAddressTypeString = UserDefaults.standard.string(forKey: "selectedAddressType") ?? "nativeSegwit" + + // Search all address types, prioritizing the selected type + let addressTypesToSearch: [String] = { + var types = [selectedAddressTypeString] + for type in ["legacy", "nestedSegwit", "nativeSegwit", "taproot"] where !types.contains(type) { + types.append(type) + } + return types + }() + // Try receiving addresses first, then change addresses - if let address = try await searchAddresses(isChange: false) { - return address + for addressTypeString in addressTypesToSearch { + if let address = try await searchAddresses(isChange: false, addressTypeString: addressTypeString) { + return address + } } - if let address = try await searchAddresses(isChange: true) { - return address + for addressTypeString in addressTypesToSearch { + if let address = try await searchAddresses(isChange: true, addressTypeString: addressTypeString) { + return address + } } // Fallback: return first output address @@ -1710,7 +1732,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 +1742,26 @@ 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 derivationPath = if let addressTypeString { + // Use specified address type + switch addressTypeString.lowercased() { + case "legacy": + "m/44'/\(coinType)'/0'/0" // BIP 44 + case "nestedsegwit", "nested_segwit": + "m/49'/\(coinType)'/0'/0" // BIP 49 + case "nativesegwit", "native_segwit": + "m/84'/\(coinType)'/0'/0" // BIP 84 + case "taproot": + "m/86'/\(coinType)'/0'/0" // BIP 86 + default: + "m/84'/\(coinType)'/0'/0" // Default to native segwit + } + } else { + // Default to native segwit (BIP 84) for backward compatibility + "m/84'/\(coinType)'/0'/0" + } let response = try deriveBitcoinAddresses( mnemonicPhrase: mnemonic, diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index ccac9e0a..83ecf52c 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -81,6 +81,17 @@ class LightningService { ) config.includeUntrustedPendingInSpendable = true + // Set address type from user preference + let selectedAddressType = Self.parseAddressType(UserDefaults.standard.string(forKey: "selectedAddressType")) + config.addressType = selectedAddressType + + // Set additional monitored address types (excluding the primary type) + let monitoredTypesString = UserDefaults.standard.string(forKey: "addressTypesToMonitor") ?? "nativeSegwit" + let monitoredTypes = monitoredTypesString.split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .compactMap { Self.parseAddressType($0) } + config.addressTypesToMonitor = monitoredTypes.filter { $0 != selectedAddressType } + let builder = Builder.fromConfig(config: config) builder.setCustomLogger(logWriter: LdkLogWriter()) @@ -182,7 +193,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) } @@ -378,6 +388,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) @@ -715,6 +735,20 @@ extension LightningService { } } + /// Get balance for a specific address type + /// - Parameter addressType: The address type to check + /// - Returns: AddressTypeBalance with total and spendable sats + /// - Throws: AppError if node is not setup + 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) + } + } + /// Returns LSP (Blocktank) peer node IDs func getLspPeerNodeIds() -> [String] { return Env.trustedLnPeers.map(\.nodeId) @@ -1127,4 +1161,15 @@ extension LightningService { } } } + + // MARK: - Helpers + + private static func parseAddressType(_ string: String?) -> LDKNode.AddressType { + switch string { + case "legacy": return .legacy + case "nestedSegwit": return .nestedSegwit + case "taproot": return .taproot + default: return .nativeSegwit + } + } } diff --git a/Bitkit/Utilities/StartupHandler.swift b/Bitkit/Utilities/StartupHandler.swift index 9ad16941..84725748 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/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 8f0219e2..165f96a7 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -76,6 +76,7 @@ enum Route: Hashable { // Advanced settings case coinSelection + case addressTypePreference case connections case connectionDetail(channelId: String) case closeConnection(channel: ChannelDetails) diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 22b6e9b8..30613abc 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -4,6 +4,10 @@ import LDKNode import SwiftUI import UserNotifications +/// Typealias for LDKNode.AddressType to avoid naming conflicts with local AddressType enums +/// used elsewhere in the app for UI purposes (e.g., receiving/change in AddressViewer). +typealias AddressScriptType = LDKNode.AddressType + enum CoinSelectionMethod: String, CaseIterable { case manual case autopilot @@ -44,6 +48,9 @@ class SettingsViewModel: NSObject, ObservableObject { static let shared = SettingsViewModel() private let defaults = UserDefaults.standard + + /// Flag to prevent concurrent address type changes + private var isChangingAddressType = false private var observedKeys: Set = [] // Reactive publishers for settings changes (used by BackupService) @@ -242,6 +249,206 @@ class SettingsViewModel: NSObject, ObservableObject { } } + // Address Type Settings + @AppStorage("selectedAddressType") private var _selectedAddressType: String = "nativeSegwit" + + // Monitored Address Types - stored as comma-separated string for @AppStorage compatibility + // Default to only Native Segwit, matching React Native behavior + @AppStorage("addressTypesToMonitor") private var _addressTypesToMonitor: String = "nativeSegwit" + + /// All available address types + static let allAddressTypes: [AddressScriptType] = [.legacy, .nestedSegwit, .nativeSegwit, .taproot] + + /// Convert address type to string for storage + static func addressTypeToString(_ addressType: AddressScriptType) -> String { + switch addressType { + case .legacy: return "legacy" + case .nestedSegwit: return "nestedSegwit" + case .nativeSegwit: return "nativeSegwit" + case .taproot: return "taproot" + } + } + + /// Convert string to address type + static func stringToAddressType(_ string: String) -> AddressScriptType? { + switch string { + case "legacy": return .legacy + case "nestedSegwit": return .nestedSegwit + case "nativeSegwit": return .nativeSegwit + case "taproot": return .taproot + default: return nil + } + } + + /// Address types currently being monitored + var addressTypesToMonitor: [AddressScriptType] { + get { + let strings = _addressTypesToMonitor.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + return strings.compactMap { Self.stringToAddressType($0) } + } + set { + _addressTypesToMonitor = newValue.map { Self.addressTypeToString($0) }.joined(separator: ",") + } + } + + /// Check if an address type is being monitored + func isMonitoring(_ addressType: AddressScriptType) -> Bool { + addressTypesToMonitor.contains(addressType) + } + + /// Check if an address type has balance + /// - Parameter addressType: The address type to check + /// - Returns: The balance in sats, or 0 if unable to check + func getBalanceForAddressType(_ addressType: AddressScriptType) async -> UInt64 { + do { + let balance = try await lightningService.getBalanceForAddressType(addressType) + return balance.totalSats + } catch { + Logger.error("Failed to get balance for address type \(addressType): \(error)") + return 0 + } + } + + /// Enable or disable monitoring for an address type + /// - Parameters: + /// - addressType: The address type to toggle + /// - enabled: Whether to enable or disable monitoring + /// - wallet: Optional wallet view model to update UI state during restart + /// - Returns: True if the operation succeeded, false if it was prevented (e.g., type has balance) + func setMonitoring(_ addressType: AddressScriptType, enabled: Bool, wallet: WalletViewModel? = nil) async -> Bool { + guard !isChangingAddressType else { return false } + + var current = addressTypesToMonitor + + if enabled { + if !current.contains(addressType) { + current.append(addressType) + addressTypesToMonitor = current + } + } else { + // Don't allow disabling if it's the currently selected type + if addressType == selectedAddressType { return false } + + // Check if address type has balance - don't allow disabling if it has funds + let balance = await getBalanceForAddressType(addressType) + if balance > 0 { return false } + + current.removeAll { $0 == addressType } + addressTypesToMonitor = current + } + + UserDefaults.standard.synchronize() + + do { + try await lightningService.restart() + try await lightningService.sync() + } catch { + Logger.error("Failed to restart node after monitored types change: \(error)") + } + + wallet?.syncState() + return true + } + + /// Add an address type to monitored types if not already present + func ensureMonitoring(_ addressType: AddressScriptType) { + if !addressTypesToMonitor.contains(addressType) { + var current = addressTypesToMonitor + current.append(addressType) + addressTypesToMonitor = current + } + } + + /// Set all address types as monitored (used during wallet restore) + func monitorAllAddressTypes() { + addressTypesToMonitor = Self.allAddressTypes + } + + var selectedAddressType: AddressScriptType { + get { + // Parse the stored string value + switch _selectedAddressType { + case "legacy": + return .legacy + case "nestedSegwit": + return .nestedSegwit + case "nativeSegwit": + return .nativeSegwit + case "taproot": + return .taproot + default: + return .nativeSegwit // Default fallback + } + } + set { + // Convert AddressScriptType to string for storage + switch newValue { + case .legacy: + _selectedAddressType = "legacy" + case .nestedSegwit: + _selectedAddressType = "nestedSegwit" + case .nativeSegwit: + _selectedAddressType = "nativeSegwit" + case .taproot: + _selectedAddressType = "taproot" + } + } + } + + func updateAddressType(_ addressType: AddressScriptType, wallet: WalletViewModel? = nil) async { + guard !isChangingAddressType else { return } + guard addressType != selectedAddressType else { return } + + isChangingAddressType = true + defer { isChangingAddressType = false } + + selectedAddressType = addressType + ensureMonitoring(addressType) + + // Clear cached address + UserDefaults.standard.set("", forKey: "onchainAddress") + UserDefaults.standard.set("", forKey: "bip21") + UserDefaults.standard.synchronize() + + if let wallet { + wallet.onchainAddress = "" + wallet.bip21 = "" + } + + do { + try await lightningService.restart() + try await lightningService.sync() + await generateAndUpdateAddress(addressType: addressType, wallet: wallet) + } catch { + Logger.error("Failed to restart node after address type change: \(error)") + await generateAndUpdateAddress(addressType: addressType, wallet: wallet) + } + + wallet?.syncState() + } + + /// Generate a new address for the specified type and update wallet properties + private func generateAndUpdateAddress(addressType: AddressScriptType, wallet: WalletViewModel?) async { + do { + let newAddress = try await lightningService.newAddressForType(addressType) + + UserDefaults.standard.set(newAddress, forKey: "onchainAddress") + UserDefaults.standard.synchronize() + + if let wallet { + wallet.onchainAddress = newAddress + wallet.bip21 = "bitcoin:\(newAddress)" + } + } catch { + Logger.error("Failed to generate new address: \(error)") + UserDefaults.standard.set("", forKey: "onchainAddress") + UserDefaults.standard.synchronize() + if let wallet { + wallet.onchainAddress = "" + } + } + } + // MARK: - RGS URL Validation func isValidRgsUrl(_ url: String) -> Bool { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index fb76b076..6e3966bc 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -560,10 +560,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 diff --git a/Bitkit/Views/Onboarding/RestoreWalletView.swift b/Bitkit/Views/Onboarding/RestoreWalletView.swift index 86051f63..486ac8f7 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/Settings/Advanced/AddressTypeLoadingView.swift b/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift new file mode 100644 index 00000000..3718b388 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +/// Loading view shown during address type or monitoring changes +struct AddressTypeLoadingView: View { + let targetAddressType: AddressScriptType? + let isMonitoringChange: Bool + + private var navTitle: String { + isMonitoringChange ? "Address Monitoring" : "Address Type" + } + + private var headline: String { + if let addressType = targetAddressType, !isMonitoringChange { + return "Switching to \(addressType.localizedTitle)" + } + return "Updating Wallet" + } + + private var description: String { + "Please wait while the wallet restarts..." + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: navTitle, showBackButton: false, showMenuButton: false) + + VStack(spacing: 0) { + VStack { + Spacer() + + Image("wallet") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + + Spacer() + } + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .layoutPriority(1) + + VStack(alignment: .leading, spacing: 14) { + DisplayText(headline) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + BodyMText(description) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + + ActivityIndicator(size: 32) + .padding(.top, 32) + } + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + .accessibilityIdentifier("AddressTypeLoadingView") + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } + } +} + +#Preview { + AddressTypeLoadingView( + targetAddressType: .taproot, + isMonitoringChange: false + ) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift new file mode 100644 index 00000000..6a45b01d --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -0,0 +1,332 @@ +import LDKNode +import SwiftUI + +extension AddressScriptType { + var localizedTitle: String { + switch self { + case .legacy: + return "Legacy" + case .nestedSegwit: + return "Nested Segwit" + case .nativeSegwit: + return "Native Segwit" + 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..." + } + } +} + +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) + } +} + +extension AddressScriptType { + var testId: String { + switch self { + case .legacy: + return "p2pkh" + case .nestedSegwit: + return "p2sh-p2wpkh" + case .nativeSegwit: + return "p2wpkh" + case .taproot: + return "p2tr" + } + } +} + +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("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 + @EnvironmentObject private var navigation: NavigationViewModel + + @AppStorage("showDevSettings") private var showDevSettings = Env.isDebug + + @State private var showMonitoredTypesNote = false + @State private var showLoadingView = false + @State private var loadingAddressType: AddressScriptType? + @State private var isMonitoringChange = false + @State private var loadingTask: Task? + + private let timeoutSeconds: UInt64 = 60 + + 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.legacy, .nestedSegwit, .nativeSegwit, .taproot], id: \.self) { addressType in + AddressTypeOption( + addressType: addressType, + isSelected: settingsViewModel.selectedAddressType == addressType + ) { + guard settingsViewModel.selectedAddressType != addressType else { return } + + loadingAddressType = addressType + isMonitoringChange = false + showLoadingView = true + + loadingTask = Task { + let didTimeout = await withTimeout(seconds: timeoutSeconds) { + await settingsViewModel.updateAddressType(addressType, wallet: wallet) + } + + showLoadingView = false + + if didTimeout { + app.toast(type: .error, title: "Timeout", description: "The operation took too long. Please try again.") + } else { + Haptics.notify(.success) + navigation.reset() + app.toast( + type: .success, + title: "Address Type Changed", + description: "Now using \(addressType.localizedTitle) addresses." + ) + } + } + } + } + } + } + + if showDevSettings { + VStack(alignment: .leading, spacing: 0) { + HStack { + CaptionMText("Monitored Address Types") + Spacer() + Button(action: { showMonitoredTypesNote.toggle() }) { + Image(systemName: "info.circle") + .foregroundColor(.textSecondary) + } + } + .padding(.top, 24) + .padding(.bottom, 8) + + if showMonitoredTypesNote { + BodySText( + "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.", + textColor: .textSecondary + ) + .padding(.bottom, 12) + } + + VStack(spacing: 0) { + ForEach([AddressScriptType.legacy, .nestedSegwit, .nativeSegwit, .taproot], id: \.self) { addressType in + MonitoredAddressTypeToggle( + addressType: addressType, + isMonitored: settingsViewModel.isMonitoring(addressType), + isSelectedType: settingsViewModel.selectedAddressType == addressType + ) { enabled in + loadingAddressType = addressType + isMonitoringChange = true + showLoadingView = true + + loadingTask = Task { + var success = false + let didTimeout = await withTimeout(seconds: timeoutSeconds) { + success = await settingsViewModel.setMonitoring(addressType, enabled: enabled, wallet: wallet) + } + + showLoadingView = false + + if didTimeout { + app.toast( + type: .error, + title: "Timeout", + description: "The operation took too long. Please try again." + ) + } else if success { + Haptics.notify(.success) + app.toast( + type: .success, + title: "Settings Updated", + description: "Address monitoring settings applied." + ) + } else if !enabled { + app.toast( + type: .error, + title: "Cannot Disable", + description: "\(addressType.localizedTitle) addresses have balance." + ) + } + } + } + } + } + } + } + + Spacer() + .frame(height: 32) + } + .padding(.trailing, 4) + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .fullScreenCover(isPresented: $showLoadingView) { + AddressTypeLoadingView( + targetAddressType: loadingAddressType, + isMonitoringChange: isMonitoringChange + ) + } + .onDisappear { + loadingTask?.cancel() + } + } +} + +/// Executes an async operation with a timeout. Returns true if the operation timed out. +private func withTimeout(seconds: UInt64, operation: @escaping () async -> some Any) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { + _ = await operation() + return false // Operation completed + } + + group.addTask { + try? await Task.sleep(nanoseconds: seconds * 1_000_000_000) + return true // Timeout + } + + // Return whichever finishes first + let result = await group.next() ?? false + group.cancelAll() + return result + } +} + +#Preview { + let app = AppViewModel() + return NavigationStack { + AddressTypePreferenceView() + .environmentObject(SettingsViewModel.shared) + .environmentObject(app) + .environmentObject(WalletViewModel()) + .environmentObject(NavigationViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index dde38459..5abd3771 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -3,8 +3,22 @@ import SwiftUI struct AdvancedSettingsView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var suggestionsManager: SuggestionsManager + @EnvironmentObject var settings: SettingsViewModel @State private var showingResetAlert = false + private func addressTypeDisplayName(_ addressType: AddressScriptType) -> String { + switch addressType { + case .legacy: + return "Legacy" + case .nestedSegwit: + return "Nested Segwit" + case .nativeSegwit: + return "Native Segwit" + case .taproot: + return "Taproot" + } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("settings__advanced_title")) @@ -17,13 +31,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: addressTypeDisplayName(settings.selectedAddressType) + ) + } + .accessibilityIdentifier("AddressTypePreference") NavigationLink(value: Route.coinSelection) { SettingsListLabel(title: t("settings__adv__coin_selection")) @@ -79,11 +93,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/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index 5ba46af8..d434bb66 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -79,6 +79,12 @@ struct SpendingAmount: View { .task(id: blocktank.info?.options.maxChannelSizeSat) { await calculateMaxTransferAmount() } + .onChange(of: wallet.spendableOnchainBalanceSats) { _ in + // Recalculate when balance changes (e.g., after receiving funds) + Task { + await calculateMaxTransferAmount() + } + } } private var actionButtons: some View { From f070e913665206e464fff71d0da1bf349f3b3285 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 11 Feb 2026 08:44:32 +0700 Subject: [PATCH 02/17] Fix legacy type issues --- Bitkit/Services/LightningService.swift | 43 +++++++++++++++++++ Bitkit/ViewModels/SettingsViewModel.swift | 26 +++++++++++ Bitkit/ViewModels/WalletViewModel.swift | 6 +++ .../Advanced/AddressTypePreferenceView.swift | 19 +++++--- .../Views/Transfer/FundManualAmountView.swift | 9 ++-- 5 files changed, 94 insertions(+), 9 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 83ecf52c..dbb3077a 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -749,6 +749,49 @@ extension LightningService { } } + /// Get the total balance that can be used for channel funding (excludes Legacy/P2PKH UTXOs) + /// LDK channel funding requires witness-compatible UTXOs (NativeSegwit, NestedSegwit, Taproot) + /// - Returns: Total spendable sats from witness-compatible address types + func getChannelFundableBalance() async throws -> UInt64 { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + // Get monitored address types from UserDefaults + let storedTypes = UserDefaults.standard.string(forKey: "addressTypesToMonitor") ?? "nativeSegwit" + let typeStrings = storedTypes.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + let monitoredTypes: [LDKNode.AddressType] = typeStrings.compactMap { str in + switch str { + case "legacy": return .legacy + case "nestedSegwit": return .nestedSegwit + case "nativeSegwit": return .nativeSegwit + case "taproot": return .taproot + default: return nil + } + } + + var totalFundable: UInt64 = 0 + + for addressType in monitoredTypes { + // Skip Legacy (P2PKH) as it cannot be used for channel funding + if addressType == .legacy { + continue + } + + do { + let balance = try await ServiceQueue.background(.ldk) { + try node.getBalanceForAddressType(addressType: addressType) + } + totalFundable += balance.spendableSats + } catch { + // If we can't get balance for this type, log and continue + Logger.warn("Failed to get balance for \(addressType) when calculating channel fundable balance: \(error)") + } + } + + return totalFundable + } + /// Returns LSP (Blocktank) peer node IDs func getLspPeerNodeIds() -> [String] { return Env.trustedLnPeers.map(\.nodeId) diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 30613abc..0145ee7f 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -333,6 +333,16 @@ class SettingsViewModel: NSObject, ObservableObject { let balance = await getBalanceForAddressType(addressType) if balance > 0 { return false } + // If primary is Legacy, ensure at least one SegWit-compatible wallet remains enabled + // (Legacy UTXOs cannot be used for Lightning channel funding) + if selectedAddressType == .legacy { + let segwitTypes: [AddressScriptType] = [.nestedSegwit, .nativeSegwit, .taproot] + let remainingSegwit = current.filter { $0 != addressType && segwitTypes.contains($0) } + if remainingSegwit.isEmpty { + return false + } + } + current.removeAll { $0 == addressType } addressTypesToMonitor = current } @@ -364,6 +374,22 @@ class SettingsViewModel: NSObject, ObservableObject { addressTypesToMonitor = Self.allAddressTypes } + /// Check if disabling an address type would leave no SegWit wallets when Legacy is primary + /// - Parameter addressType: The address type to check + /// - Returns: True if this is the last SegWit wallet and Legacy is primary + func isLastRequiredSegwitWallet(_ addressType: AddressScriptType) -> Bool { + // Only applies when Legacy is the primary wallet + guard selectedAddressType == .legacy else { return false } + + // Only applies to SegWit-compatible types + let segwitTypes: [AddressScriptType] = [.nestedSegwit, .nativeSegwit, .taproot] + guard segwitTypes.contains(addressType) else { return false } + + // Check if disabling this would leave no SegWit wallets + let remainingSegwit = addressTypesToMonitor.filter { $0 != addressType && segwitTypes.contains($0) } + return remainingSegwit.isEmpty + } + var selectedAddressType: AddressScriptType { get { // Parse the stored string value diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 6e3966bc..073f0d31 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 = "" @@ -587,6 +588,11 @@ 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) + if let fundableBalance = try? await lightningService.getChannelFundableBalance() { + channelFundableBalanceSats = Int(fundableBalance) + } + // Get force close timelock from active transfers let activeTransfers = try? transferService.getActiveTransfers() let forceCloseTransfer = activeTransfers?.first { diff --git a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift index 6a45b01d..b77b6dc2 100644 --- a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -265,11 +265,20 @@ struct AddressTypePreferenceView: View { description: "Address monitoring settings applied." ) } else if !enabled { - app.toast( - type: .error, - title: "Cannot Disable", - description: "\(addressType.localizedTitle) addresses have balance." - ) + // Determine reason for failure + if settingsViewModel.isLastRequiredSegwitWallet(addressType) { + app.toast( + type: .error, + title: "Cannot Disable", + description: "At least one SegWit wallet is required for Lightning when using Legacy as primary." + ) + } else { + app.toast( + type: .error, + title: "Cannot Disable", + description: "\(addressType.localizedTitle) addresses have balance." + ) + } } } } diff --git a/Bitkit/Views/Transfer/FundManualAmountView.swift b/Bitkit/Views/Transfer/FundManualAmountView.swift index b633f182..207c53a2 100644 --- a/Bitkit/Views/Transfer/FundManualAmountView.swift +++ b/Bitkit/Views/Transfer/FundManualAmountView.swift @@ -32,9 +32,10 @@ struct FundManualAmountView: View { Spacer() HStack(alignment: .bottom) { - AvailableAmount(label: t("wallet__send_available"), amount: wallet.totalOnchainSats) + // Show channel fundable balance (excludes Legacy UTXOs which can't be used 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() @@ -80,11 +81,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) } } } From 8c8e243954bb54b707fb92794d3976445bf4fe1e Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 11 Feb 2026 11:30:38 +0700 Subject: [PATCH 03/17] Update ldk-node --- Bitkit.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index d234d645..7a4c6f2b 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -926,7 +926,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = 2281589d699cb6f821f1ad720435c8110cf1fa7c; + revision = af29894afa4b32ba7e506f321c09d200dc6ab8a2; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { From 6c2c47dec20104228f24b5fd64c82b0f4341e484 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 11 Feb 2026 11:40:55 +0700 Subject: [PATCH 04/17] fix balance check on disable --- Bitkit/ViewModels/SettingsViewModel.swift | 24 ++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 0145ee7f..9f6a633b 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -298,15 +298,11 @@ class SettingsViewModel: NSObject, ObservableObject { /// Check if an address type has balance /// - Parameter addressType: The address type to check - /// - Returns: The balance in sats, or 0 if unable to check - func getBalanceForAddressType(_ addressType: AddressScriptType) async -> UInt64 { - do { - let balance = try await lightningService.getBalanceForAddressType(addressType) - return balance.totalSats - } catch { - Logger.error("Failed to get balance for address type \(addressType): \(error)") - return 0 - } + /// - Returns: The balance in sats + /// - Throws: If unable to check balance + func getBalanceForAddressType(_ addressType: AddressScriptType) async throws -> UInt64 { + let balance = try await lightningService.getBalanceForAddressType(addressType) + return balance.totalSats } /// Enable or disable monitoring for an address type @@ -330,8 +326,14 @@ class SettingsViewModel: NSObject, ObservableObject { if addressType == selectedAddressType { return false } // Check if address type has balance - don't allow disabling if it has funds - let balance = await getBalanceForAddressType(addressType) - if balance > 0 { return false } + // Fail safely: if we can't verify balance, don't allow disabling + 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 + } // If primary is Legacy, ensure at least one SegWit-compatible wallet remains enabled // (Legacy UTXOs cannot be used for Lightning channel funding) From 1aa9d183bb066955816c77f7c58289c704c8df76 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 11 Feb 2026 12:04:26 +0700 Subject: [PATCH 05/17] fix timeout issue --- .../Advanced/AddressTypePreferenceView.swift | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift index b77b6dc2..2be6cc1c 100644 --- a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -308,23 +308,32 @@ struct AddressTypePreferenceView: View { } } +/// Error thrown when operation times out +private struct TimeoutError: Error {} + /// Executes an async operation with a timeout. Returns true if the operation timed out. +/// Note: If timeout occurs, the operation continues running in the background. private func withTimeout(seconds: UInt64, operation: @escaping () async -> some Any) async -> Bool { - await withTaskGroup(of: Bool.self) { group in - group.addTask { - _ = await operation() - return false // Operation completed - } + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + _ = await operation() + } - group.addTask { - try? await Task.sleep(nanoseconds: seconds * 1_000_000_000) - return true // Timeout - } + group.addTask { + try await Task.sleep(nanoseconds: seconds * 1_000_000_000) + throw TimeoutError() + } - // Return whichever finishes first - let result = await group.next() ?? false - group.cancelAll() - return result + // Wait for first task to complete or throw + try await group.next() + group.cancelAll() + } + return false // Operation completed + } catch is TimeoutError { + return true // Timeout + } catch { + return false // Other error, treat as completed } } From 11430e74bbaefa59408d31ab6908cdded7e3beb8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 11 Feb 2026 12:27:57 +0700 Subject: [PATCH 06/17] fix --- Bitkit/ViewModels/SettingsViewModel.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 9f6a633b..95b347d8 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -314,6 +314,9 @@ class SettingsViewModel: NSObject, ObservableObject { func setMonitoring(_ addressType: AddressScriptType, enabled: Bool, wallet: WalletViewModel? = nil) async -> Bool { guard !isChangingAddressType else { return false } + isChangingAddressType = true + defer { isChangingAddressType = false } + var current = addressTypesToMonitor if enabled { From a54d3f0a0067e483c42b297dd480ee847bd880a6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 11 Feb 2026 13:09:15 +0700 Subject: [PATCH 07/17] fix multiple address support for backup and migration --- Bitkit/Models/SettingsBackupConfig.swift | 2 + Bitkit/Services/MigrationsService.swift | 85 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index c0242321..4a4e9060 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/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 9c98318a..1094417b 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 { @@ -879,6 +881,64 @@ extension MigrationsService { } } + /// Maps RN EAddressType enum values to iOS AddressScriptType string values + 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]? + + // Extract selectedAddressType from 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 + } + } + + // Extract addressTypesToMonitor from top-level wallet state + 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: "{") @@ -1156,6 +1216,21 @@ 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 let monitored = addressTypesToMonitor { + 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 @@ -1364,6 +1439,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 From 4fe39e2720674a8c8a1a8069d2bbe607bb27d891 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 11 Feb 2026 13:15:08 +0700 Subject: [PATCH 08/17] remove funds sweep for different address types --- .github/workflows/e2e_migration.yml | 2 - Bitkit/AppScene.swift | 2 - Bitkit/MainNavView.swift | 15 - .../Localization/ar.lproj/Localizable.strings | 29 -- .../Localization/ca.lproj/Localizable.strings | 29 -- .../Localization/cs.lproj/Localizable.strings | 29 -- .../Localization/de.lproj/Localizable.strings | 29 -- .../Localization/el.lproj/Localizable.strings | 29 -- .../Localization/en.lproj/Localizable.strings | 29 -- .../es-419.lproj/Localizable.strings | 29 -- .../Localization/es.lproj/Localizable.strings | 29 -- .../Localization/fr.lproj/Localizable.strings | 29 -- .../Localization/it.lproj/Localizable.strings | 29 -- .../Localization/nl.lproj/Localizable.strings | 29 -- .../Localization/pl.lproj/Localizable.strings | 29 -- .../pt-BR.lproj/Localizable.strings | 29 -- .../Localization/pt.lproj/Localizable.strings | 29 -- .../Localization/ru.lproj/Localizable.strings | 29 -- Bitkit/Services/LightningService.swift | 2 +- Bitkit/ViewModels/NavigationViewModel.swift | 5 - Bitkit/ViewModels/SheetViewModel.swift | 13 - Bitkit/ViewModels/SweepViewModel.swift | 323 ------------------ .../Onboarding/WalletRestoreSuccess.swift | 2 - .../Settings/Advanced/SweepConfirmView.swift | 245 ------------- .../Advanced/SweepFeeCustomView.swift | 111 ------ .../Settings/Advanced/SweepFeeRateView.swift | 148 -------- .../Settings/Advanced/SweepPromptSheet.swift | 40 --- .../Settings/Advanced/SweepSettingsView.swift | 198 ----------- .../Settings/Advanced/SweepSuccessView.swift | 64 ---- 29 files changed, 1 insertion(+), 1604 deletions(-) delete mode 100644 Bitkit/ViewModels/SweepViewModel.swift delete mode 100644 Bitkit/Views/Settings/Advanced/SweepConfirmView.swift delete mode 100644 Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift delete mode 100644 Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift delete mode 100644 Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift delete mode 100644 Bitkit/Views/Settings/Advanced/SweepSettingsView.swift delete mode 100644 Bitkit/Views/Settings/Advanced/SweepSuccessView.swift diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 53dcdf4c..2b8e808f 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -130,7 +130,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 }} @@ -153,7 +152,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/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index dff8a371..b8934abd 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/MainNavView.swift b/Bitkit/MainNavView.swift index 4fa41186..1cd4d2a6 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() @@ -407,11 +397,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/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 9996df27..6d446545 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -668,7 +668,6 @@ "settings__adv__section_other" = "أخرى"; "settings__adv__section_payments" = "المدفوعات"; "settings__adv__suggestions_reset" = "إعادة تعيين الاقتراحات"; -"settings__adv__sweep_funds" = "مسح الأموال"; "settings__adv__web_relay" = "مرحل Slashtags للويب"; "settings__advanced_title" = "متقدم"; "settings__backup__category_connection_receipts" = "إيصالات الاتصال"; @@ -918,34 +917,6 @@ "slashtags__profile_scan_to_add" = "امسح لإضافة {name}"; "slashtags__your_name" = "اسمك"; "slashtags__your_name_capital" = "اسمك"; -"sweep__amount" = "المبلغ المستلم"; -"sweep__broadcasting" = "جارٍ البث..."; -"sweep__complete_description" = "تم مسح أموالك وستُضاف إلى رصيد محفظتك."; -"sweep__complete_title" = "اكتمل المسح"; -"sweep__confirm_title" = "تأكيد المسح"; -"sweep__destination" = "الوجهة"; -"sweep__error_destination_address" = "فشل الحصول على عنوان الوجهة"; -"sweep__error_fee_rate_not_set" = "لم يتم تعيين معدل الرسوم"; -"sweep__error_title" = "خطأ"; -"sweep__fee_total" = "{fee} sats إجمالي الرسوم"; -"sweep__found_description" = "وجد Bitkit أموالاً في عناوين غير مدعومة (Legacy و Nested SegWit و Taproot)."; -"sweep__found_title" = "تم العثور على أموال"; -"sweep__funds_found" = "تم العثور على أموال"; -"sweep__loading_address" = "جارٍ الحصول على العنوان..."; -"sweep__loading_description" = "يرجى الانتظار بينما يبحث Bitkit عن أموال في عناوين غير مدعومة (Legacy و Nested SegWit و Taproot)."; -"sweep__looking_for_funds" = "جارٍ البحث عن الأموال..."; -"sweep__no_funds_description" = "تحقق Bitkit من أنواع العناوين غير المدعومة ولم يجد أموالاً لمسحها."; -"sweep__no_funds_title" = "لا توجد أموال للمسح"; -"sweep__preparing" = "جارٍ تحضير المعاملة..."; -"sweep__prompt_description" = "وجد Bitkit أموالاً على أنواع عناوين Bitcoin غير مدعومة. امسح لنقل الأموال إلى رصيد محفظتك الجديد."; -"sweep__prompt_headline" = "امسح أموال\nBitkit القديمة"; -"sweep__prompt_sweep" = "مسح"; -"sweep__prompt_title" = "مسح الأموال"; -"sweep__sweep_to_wallet" = "مسح إلى المحفظة"; -"sweep__swipe" = "اسحب للمسح"; -"sweep__title" = "مسح الأموال"; -"sweep__view_details" = "عرض التفاصيل"; -"sweep__wallet_overview" = "نظرة عامة على المحفظة"; "wallet__activity" = "النشاط"; "wallet__activity_address" = "العنوان"; "wallet__activity_all" = "كل النشاط"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 2afacc80..5caa8f0f 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -648,7 +648,6 @@ "settings__adv__section_other" = "Altres"; "settings__adv__section_payments" = "Pagaments"; "settings__adv__suggestions_reset" = "Restablir suggeriments"; -"settings__adv__sweep_funds" = "Escombrar fons"; "settings__adv__web_relay" = "Slashtags Web Relay"; "settings__addr__addr_change" = "Adreces de canvi"; "settings__addr__addr_receiving" = "Adreces de recepció"; @@ -918,34 +917,6 @@ "slashtags__profile_scan_to_add" = "Escaneja per afegir {name}"; "slashtags__your_name" = "El teu nom"; "slashtags__your_name_capital" = "El teu nom"; -"sweep__amount" = "Import a rebre"; -"sweep__broadcasting" = "Emetent..."; -"sweep__complete_description" = "Els teus fons s\'han escombrat i s\'afegiran al saldo de la teva cartera."; -"sweep__complete_title" = "Escombrat complet"; -"sweep__confirm_title" = "Confirma l\'escombrat"; -"sweep__destination" = "Destinació"; -"sweep__error_destination_address" = "No s\'ha pogut obtenir l\'adreça de destinació"; -"sweep__error_fee_rate_not_set" = "Taxa de comissió no establerta"; -"sweep__error_title" = "Error"; -"sweep__fee_total" = "{fee} sats de comissió total"; -"sweep__found_description" = "Bitkit ha trobat fons en adreces no compatibles (Legacy, Nested SegWit i Taproot)."; -"sweep__found_title" = "Fons trobats"; -"sweep__funds_found" = "FONS TROBATS"; -"sweep__loading_address" = "Obtenint adreça..."; -"sweep__loading_description" = "Si us plau, espera mentre Bitkit busca fons en adreces no compatibles (Legacy, Nested SegWit i Taproot)."; -"sweep__looking_for_funds" = "BUSCANT FONS..."; -"sweep__no_funds_description" = "Bitkit ha comprovat els tipus d\'adreces no compatibles i no ha trobat fons per escombrar."; -"sweep__no_funds_title" = "No hi ha fons per escombrar"; -"sweep__preparing" = "Preparant transacció..."; -"sweep__prompt_description" = "Bitkit ha trobat fons en tipus d\'adreces Bitcoin no compatibles. Escombra per moure els fons al teu nou saldo de cartera."; -"sweep__prompt_headline" = "ESCOMBRA FONS\nANTICS DE BITKIT"; -"sweep__prompt_sweep" = "Escombrar"; -"sweep__prompt_title" = "Escombrar fons"; -"sweep__sweep_to_wallet" = "Escombra a la cartera"; -"sweep__swipe" = "Llisca per escombrar"; -"sweep__title" = "Escombrar fons"; -"sweep__view_details" = "Veure detalls"; -"sweep__wallet_overview" = "Vista general de la cartera"; "wallet__activity" = "Activitat"; "wallet__activity_address" = "Adreça"; "wallet__activity_all" = "Tota l\'activitat"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index a2d8bb71..94252470 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1171,7 +1171,6 @@ "settings__adv__cs_branch_and_bound_description" = "Najde přesné shody částek pro minimalizaci drobných"; "settings__adv__cs_single_random_draw" = "Náhodný výběr"; "settings__adv__cs_single_random_draw_description" = "Náhodný výběr pro soukromí"; -"settings__adv__sweep_funds" = "Převést prostředky"; "settings__general__language" = "Jazyk"; "settings__general__language_other" = "Jazyk rozhraní"; "settings__general__language_title" = "Jazyk"; @@ -1194,34 +1193,6 @@ "settings__notifications__settings__toggle" = "Přijímat platby, když je Bitkit zavřený"; "settings__rgs__error_peer" = "Chyba připojení RGS"; "settings__rgs__server_error_description" = "Nelze se připojit k serveru Rapid-Gossip-Sync."; -"sweep__amount" = "Částka k přijetí"; -"sweep__broadcasting" = "Vysílání..."; -"sweep__complete_description" = "Vaše prostředky byly převedeny a budou přidány k zůstatku vaší peněženky."; -"sweep__complete_title" = "Převod dokončen"; -"sweep__confirm_title" = "Potvrdit převod"; -"sweep__destination" = "Cíl"; -"sweep__error_destination_address" = "Nepodařilo se získat cílovou adresu"; -"sweep__error_fee_rate_not_set" = "Sazba poplatku není nastavena"; -"sweep__error_title" = "Chyba"; -"sweep__fee_total" = "{fee} sats celkový poplatek"; -"sweep__found_description" = "Bitkit našel prostředky na nepodporovaných adresách (Legacy, Nested SegWit a Taproot)."; -"sweep__found_title" = "Nalezeny prostředky"; -"sweep__funds_found" = "NALEZENY PROSTŘEDKY"; -"sweep__loading_address" = "Načítání adresy..."; -"sweep__loading_description" = "Počkejte prosím, zatímco Bitkit hledá prostředky na nepodporovaných adresách (Legacy, Nested SegWit a Taproot)."; -"sweep__looking_for_funds" = "HLEDÁNÍ PROSTŘEDKŮ..."; -"sweep__no_funds_description" = "Bitkit zkontroloval nepodporované typy adres a nenašel žádné prostředky k převodu."; -"sweep__no_funds_title" = "Žádné prostředky k převodu"; -"sweep__preparing" = "Příprava transakce..."; -"sweep__prompt_description" = "Bitkit našel prostředky na nepodporovaných typech bitcoinových adres. Převeďte je do nového zůstatku vaší peněženky."; -"sweep__prompt_headline" = "PŘEVEĎTE STARÉ\nPROSTŘEDKY BITKITU"; -"sweep__prompt_sweep" = "Převést"; -"sweep__prompt_title" = "Převést prostředky"; -"sweep__sweep_to_wallet" = "Převést do peněženky"; -"sweep__swipe" = "Přejetím převeďte"; -"sweep__title" = "Převést prostředky"; -"sweep__view_details" = "Zobrazit podrobnosti"; -"sweep__wallet_overview" = "Přehled peněženky"; "wallet__activity_boost_fee" = "Poplatek za posílení"; "wallet__activity_boost_fee_description" = "Posílená příchozí transakce"; "wallet__activity_fee_prepaid" = "Poplatek (předplacený)"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 484ad8db..a85c3259 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1163,7 +1163,6 @@ "settings__adv__cs_min_description" = "Sortiere nach und verwende zuerst den größten UTXO. Möglicherweise niedrigere Gebühr, aber enthüllt deine größten UTXOs."; "settings__adv__cs_single_random_draw" = "Einzelne zufällige Auswahl"; "settings__adv__cs_single_random_draw_description" = "Zufällige Auswahl für Privatsphäre"; -"settings__adv__sweep_funds" = "Gelder zusammenführen"; "settings__general__language" = "Sprache"; "settings__general__language_other" = "App-Sprache"; "settings__general__language_title" = "Sprache"; @@ -1186,34 +1185,6 @@ "settings__notifications__settings__toggle" = "Bezahlt werden, wenn Bitkit geschlossen ist"; "settings__rgs__error_peer" = "RGS-Verbindungsfehler"; "settings__rgs__server_error_description" = "Verbindung zum Rapid-Gossip-Sync-Server konnte nicht hergestellt werden."; -"sweep__amount" = "Zu empfangender Betrag"; -"sweep__broadcasting" = "Übertrage..."; -"sweep__complete_description" = "Deine Gelder wurden zusammengeführt und werden deinem Wallet-Guthaben hinzugefügt."; -"sweep__complete_title" = "Zusammenführung abgeschlossen"; -"sweep__confirm_title" = "Zusammenführung bestätigen"; -"sweep__destination" = "Ziel"; -"sweep__error_destination_address" = "Zieladresse konnte nicht abgerufen werden"; -"sweep__error_fee_rate_not_set" = "Gebührensatz nicht festgelegt"; -"sweep__error_title" = "Fehler"; -"sweep__fee_total" = "{fee} sats Gesamtgebühr"; -"sweep__found_description" = "Bitkit hat Gelder in nicht unterstützten Adressen gefunden (Legacy, Nested SegWit und Taproot)."; -"sweep__found_title" = "Gelder gefunden"; -"sweep__funds_found" = "GELDER GEFUNDEN"; -"sweep__loading_address" = "Adresse wird abgerufen..."; -"sweep__loading_description" = "Bitte warte, während Bitkit nach Geldern in nicht unterstützten Adressen sucht (Legacy, Nested SegWit und Taproot)."; -"sweep__looking_for_funds" = "SUCHE NACH GELDERN..."; -"sweep__no_funds_description" = "Bitkit hat nicht unterstützte Adresstypen überprüft und keine Gelder zum Zusammenführen gefunden."; -"sweep__no_funds_title" = "Keine Gelder zum Zusammenführen"; -"sweep__preparing" = "Transaktion wird vorbereitet..."; -"sweep__prompt_description" = "Bitkit hat Gelder auf nicht unterstützten Bitcoin-Adresstypen gefunden. Führe sie zusammen, um die Gelder zu deinem neuen Wallet-Guthaben zu verschieben."; -"sweep__prompt_headline" = "ALTE\nBITKIT-GELDER\nZUSAMMENFÜHREN"; -"sweep__prompt_sweep" = "Zusammenführen"; -"sweep__prompt_title" = "Gelder zusammenführen"; -"sweep__sweep_to_wallet" = "Zum Wallet zusammenführen"; -"sweep__swipe" = "Zum Zusammenführen wischen"; -"sweep__title" = "Gelder zusammenführen"; -"sweep__view_details" = "Details anzeigen"; -"sweep__wallet_overview" = "Wallet Übersicht"; "wallet__activity_boost_fee" = "Beschleunigungsgebühr"; "wallet__activity_boost_fee_description" = "Eingehende Transaktion beschleunigt"; "wallet__activity_fee_prepaid" = "Gebühr (vorausbezahlt)"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index f78dc5a7..78081df4 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -769,7 +769,6 @@ "settings__adv__pp_contacts" = "Πληρωμή προς/από επαφές"; "settings__adv__pp_contacts_switch" = "Ενεργοποίηση πληρωμών με επαφές*"; "settings__adv__address_viewer" = "Προβολή Διευθύνσεων"; -"settings__adv__sweep_funds" = "Σάρωση Κεφαλαίων"; "settings__adv__rescan" = "Επανασάρωση Διευθύνσεων"; "settings__adv__suggestions_reset" = "Επαναφορά Προτάσεων"; "settings__adv__reset_title" = "Επαναφορά Προτάσεων;"; @@ -918,34 +917,6 @@ "slashtags__error_pay_empty_msg" = "Η επαφή στην οποία προσπαθείτε να στείλετε δεν έχει ενεργοποιήσει τις πληρωμές."; "slashtags__auth_depricated_title" = "Καταργήθηκε"; "slashtags__auth_depricated_msg" = "Το Slashauth καταργήθηκε. Παρακαλώ χρησιμοποιήστε το Bitkit Beta."; -"sweep__title" = "Σάρωση Κεφαλαίων"; -"sweep__found_title" = "Βρέθηκαν Κεφάλαια"; -"sweep__loading_description" = "Παρακαλώ περιμένετε όσο το Bitkit αναζητά κεφάλαια σε μη υποστηριζόμενες διευθύνσεις (Legacy, Nested SegWit και Taproot)."; -"sweep__looking_for_funds" = "ΑΝΑΖΗΤΗΣΗ ΚΕΦΑΛΑΙΩΝ..."; -"sweep__found_description" = "Το Bitkit βρήκε κεφάλαια σε μη υποστηριζόμενες διευθύνσεις (Legacy, Nested SegWit και Taproot)."; -"sweep__funds_found" = "ΒΡΕΘΗΚΑΝ ΚΕΦΑΛΑΙΑ"; -"sweep__sweep_to_wallet" = "Σάρωση στο Πορτοφόλι"; -"sweep__no_funds_title" = "Δεν Υπάρχουν Κεφάλαια για Σάρωση"; -"sweep__no_funds_description" = "Το Bitkit έλεγξε μη υποστηριζόμενους τύπους διευθύνσεων και δεν βρήκε κεφάλαια για σάρωση."; -"sweep__error_title" = "Σφάλμα"; -"sweep__error_destination_address" = "Αποτυχία λήψης διεύθυνσης προορισμού"; -"sweep__error_fee_rate_not_set" = "Δεν έχει οριστεί ποσοστό τέλους"; -"sweep__confirm_title" = "Επιβεβαίωση Σάρωσης"; -"sweep__amount" = "Ποσό προς Λήψη"; -"sweep__destination" = "Προορισμός"; -"sweep__loading_address" = "Λήψη διεύθυνσης..."; -"sweep__preparing" = "Προετοιμασία συναλλαγής..."; -"sweep__broadcasting" = "Μετάδοση..."; -"sweep__swipe" = "Σύρετε για Σάρωση"; -"sweep__fee_total" = "{fee} sats συνολικό τέλος"; -"sweep__complete_title" = "Η Σάρωση Ολοκληρώθηκε"; -"sweep__complete_description" = "Τα κεφάλαιά σας σαρώθηκαν και θα προστεθούν στο υπόλοιπο του πορτοφολιού σας."; -"sweep__wallet_overview" = "Επισκόπηση Πορτοφολιού"; -"sweep__view_details" = "Προβολή Λεπτομερειών"; -"sweep__prompt_title" = "Σάρωση Κεφαλαίων"; -"sweep__prompt_headline" = "ΣΑΡΩΣΗ ΠΑΛΑΙΩΝ\nΚΕΦΑΛΑΙΩΝ BITKIT"; -"sweep__prompt_description" = "Το Bitkit βρήκε κεφάλαια σε μη υποστηριζόμενους τύπους διευθύνσεων Bitcoin. Σαρώστε για να μεταφέρετε τα κεφάλαια στο νέο υπόλοιπο πορτοφολιού σας."; -"sweep__prompt_sweep" = "Σάρωση"; "wallet__drawer__wallet" = "Πορτοφόλι"; "wallet__drawer__activity" = "Δραστηριότητα"; "wallet__drawer__contacts" = "Επαφές"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index a51cd401..e85f01b8 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -767,36 +767,7 @@ "settings__adv__pp_contacts" = "Pay to/from contacts"; "settings__adv__pp_contacts_switch" = "Enable payments with contacts*"; "settings__adv__address_viewer" = "Address Viewer"; -"settings__adv__sweep_funds" = "Sweep Funds"; "settings__adv__rescan" = "Rescan Addresses"; -"sweep__title" = "Sweep Funds"; -"sweep__found_title" = "Found Funds"; -"sweep__loading_description" = "Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot)."; -"sweep__looking_for_funds" = "LOOKING FOR FUNDS..."; -"sweep__found_description" = "Bitkit found funds in unsupported addresses (Legacy, Nested SegWit, and Taproot)."; -"sweep__funds_found" = "FUNDS FOUND"; -"sweep__sweep_to_wallet" = "Sweep To Wallet"; -"sweep__no_funds_title" = "No Funds To Sweep"; -"sweep__no_funds_description" = "Bitkit checked unsupported address types and found no funds to sweep."; -"sweep__error_title" = "Error"; -"sweep__error_destination_address" = "Failed to get destination address"; -"sweep__error_fee_rate_not_set" = "Fee rate not set"; -"sweep__confirm_title" = "Confirm Sweep"; -"sweep__amount" = "Amount to Receive"; -"sweep__destination" = "Destination"; -"sweep__loading_address" = "Getting address..."; -"sweep__preparing" = "Preparing transaction..."; -"sweep__broadcasting" = "Broadcasting..."; -"sweep__swipe" = "Swipe to Sweep"; -"sweep__fee_total" = "{fee} sats total fee"; -"sweep__complete_title" = "Sweep Complete"; -"sweep__complete_description" = "Your funds have been swept and will be added to your wallet balance."; -"sweep__wallet_overview" = "Wallet Overview"; -"sweep__view_details" = "View Details"; -"sweep__prompt_title" = "Sweep Funds"; -"sweep__prompt_headline" = "SWEEP OLD\nBITKIT FUNDS"; -"sweep__prompt_description" = "Bitkit found funds on unsupported Bitcoin address types. Sweep to move the funds to your new wallet balance."; -"sweep__prompt_sweep" = "Sweep"; "migration__title" = "Wallet Migration"; "migration__headline" = "MIGRATING\nWALLET"; "migration__description" = "Please wait while your old wallet data migrates to this new Bitkit version..."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 3b67416a..6a7e6afd 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1168,7 +1168,6 @@ "settings__adv__cs_branch_and_bound_description" = "Encuentra coincidencias exactas de monto para minimizar el cambio"; "settings__adv__cs_single_random_draw" = "Selección aleatoria única"; "settings__adv__cs_single_random_draw_description" = "Selección aleatoria para mayor privacidad"; -"settings__adv__sweep_funds" = "Barrer fondos"; "settings__general__language" = "Idioma"; "settings__general__language_other" = "Idioma de la interfaz"; "settings__general__language_title" = "Idioma"; @@ -1190,34 +1189,6 @@ "settings__notifications__settings__privacy__text" = "Incluir monto en las notificaciones"; "settings__notifications__settings__toggle" = "Recibir pagos cuando Bitkit está cerrado"; "settings__rgs__error_peer" = "Error de conexión RGS"; -"sweep__amount" = "Monto a recibir"; -"sweep__broadcasting" = "Transmitiendo..."; -"sweep__complete_description" = "Sus fondos han sido barridos y se agregarán al saldo de su billetera."; -"sweep__complete_title" = "Barrido completado"; -"sweep__confirm_title" = "Confirmar barrido"; -"sweep__destination" = "Destino"; -"sweep__error_destination_address" = "Error al obtener la dirección de destino"; -"sweep__error_fee_rate_not_set" = "Tarifa no establecida"; -"sweep__error_title" = "Error"; -"sweep__fee_total" = "{fee} sats de tarifa total"; -"sweep__found_description" = "Bitkit encontró fondos en direcciones no soportadas (Legacy, Nested SegWit y Taproot)."; -"sweep__found_title" = "Fondos encontrados"; -"sweep__funds_found" = "FONDOS ENCONTRADOS"; -"sweep__loading_address" = "Obteniendo dirección..."; -"sweep__loading_description" = "Por favor, espere mientras Bitkit busca fondos en direcciones no soportadas (Legacy, Nested SegWit y Taproot)."; -"sweep__looking_for_funds" = "BUSCANDO FONDOS..."; -"sweep__no_funds_description" = "Bitkit verificó los tipos de direcciones no soportadas y no encontró fondos para barrer."; -"sweep__no_funds_title" = "Sin fondos para barrer"; -"sweep__preparing" = "Preparando transacción..."; -"sweep__prompt_description" = "Bitkit encontró fondos en tipos de direcciones Bitcoin no soportadas. Barra para mover los fondos a su nuevo saldo de billetera."; -"sweep__prompt_headline" = "BARRER FONDOS\nANTIGUOS DE BITKIT"; -"sweep__prompt_sweep" = "Barrer"; -"sweep__prompt_title" = "Barrer fondos"; -"sweep__swipe" = "Deslice para barrer"; -"sweep__sweep_to_wallet" = "Barrer a la billetera"; -"sweep__title" = "Barrer fondos"; -"sweep__view_details" = "Ver detalles"; -"sweep__wallet_overview" = "Vista general de la billetera"; "wallet__activity_boost_fee" = "Tarifa de impulso"; "wallet__activity_boost_fee_description" = "Transacción entrante impulsada"; "wallet__activity_fee_prepaid" = "Tarifa (prepagada)"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index b9cdebbf..040d1140 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -669,7 +669,6 @@ "settings__adv__section_other" = "Otros"; "settings__adv__section_payments" = "Pagos"; "settings__adv__suggestions_reset" = "Restablecer sugerencias"; -"settings__adv__sweep_funds" = "Barrer Fondos"; "settings__adv__web_relay" = "Relé Web Slashtags"; "settings__backup__category_connection_receipts" = "Registro de Conexiones"; "settings__backup__category_connections" = "Conexiones"; @@ -917,34 +916,6 @@ "slashtags__profile_scan_to_add" = "Escanea para añadir {name}"; "slashtags__your_name" = "Tu nombre"; "slashtags__your_name_capital" = "Su Nombre"; -"sweep__amount" = "Cantidad a Recibir"; -"sweep__broadcasting" = "Emitiendo..."; -"sweep__complete_description" = "Sus fondos han sido barridos y se añadirán al saldo de su monedero."; -"sweep__complete_title" = "Barrido Completado"; -"sweep__confirm_title" = "Confirmar Barrido"; -"sweep__destination" = "Destino"; -"sweep__error_destination_address" = "Error al obtener la dirección de destino"; -"sweep__error_fee_rate_not_set" = "Tasa de comisión no establecida"; -"sweep__error_title" = "Error"; -"sweep__fee_total" = "{fee} sats de comisión total"; -"sweep__found_description" = "Bitkit encontró fondos en direcciones no compatibles (Legacy, Nested SegWit y Taproot)."; -"sweep__found_title" = "Fondos Encontrados"; -"sweep__funds_found" = "FONDOS ENCONTRADOS"; -"sweep__loading_address" = "Obteniendo dirección..."; -"sweep__loading_description" = "Por favor espere mientras Bitkit busca fondos en direcciones no compatibles (Legacy, Nested SegWit y Taproot)."; -"sweep__looking_for_funds" = "BUSCANDO FONDOS..."; -"sweep__no_funds_description" = "Bitkit comprobó los tipos de direcciones no compatibles y no encontró fondos para barrer."; -"sweep__no_funds_title" = "No Hay Fondos Para Barrer"; -"sweep__preparing" = "Preparando transacción..."; -"sweep__prompt_description" = "Bitkit encontró fondos en tipos de direcciones Bitcoin no compatibles. Barra para mover los fondos al nuevo saldo de su monedero."; -"sweep__prompt_headline" = "BARRER FONDOS\nANTIGUOS DE BITKIT"; -"sweep__prompt_sweep" = "Barrer"; -"sweep__prompt_title" = "Barrer Fondos"; -"sweep__sweep_to_wallet" = "Barrer al Monedero"; -"sweep__swipe" = "Deslizar para Barrer"; -"sweep__title" = "Barrer Fondos"; -"sweep__view_details" = "Ver Detalles"; -"sweep__wallet_overview" = "Vista General del Monedero"; "wallet__activity" = "Actividad"; "wallet__activity_address" = "Dirección"; "wallet__activity_all" = "Toda la actividad"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index ad289636..8d810972 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -770,7 +770,6 @@ "settings__adv__pp_contacts_switch" = "Permettre les paiements par contact*"; "settings__adv__address_viewer" = "Visualisateur d\'adresses"; "settings__adv__rescan" = "Rescanner les adresses"; -"settings__adv__sweep_funds" = "Balayer les fonds"; "settings__adv__suggestions_reset" = "Suggestions de réinitialisation"; "settings__adv__reset_title" = "Suggestions de réinitialisation ?"; "settings__adv__reset_desc" = "Êtes-vous sûr de vouloir réinitialiser les suggestions ? Elles réapparaîtront si vous les avez supprimées de l\'aperçu de votre portefeuille Bitkit."; @@ -851,34 +850,6 @@ "settings__wr__error_healthcheck" = "Échec du contrôle"; "settings__wr__url_updated_title" = "Mise à jour du Web Relay"; "settings__wr__url_updated_message" = "Connexion réussie à {url}"; -"sweep__amount" = "Montant à recevoir"; -"sweep__broadcasting" = "Diffusion en cours..."; -"sweep__complete_description" = "Vos fonds ont été balayés et seront ajoutés au solde de votre portefeuille."; -"sweep__complete_title" = "Balayage terminé"; -"sweep__confirm_title" = "Confirmer le balayage"; -"sweep__destination" = "Destination"; -"sweep__error_destination_address" = "Échec de l\'obtention de l\'adresse de destination"; -"sweep__error_fee_rate_not_set" = "Taux de frais non défini"; -"sweep__error_title" = "Erreur"; -"sweep__fee_total" = "{fee} sats de frais au total"; -"sweep__found_description" = "Bitkit a trouvé des fonds dans des adresses non prises en charge (Legacy, Nested SegWit et Taproot)."; -"sweep__found_title" = "Fonds trouvés"; -"sweep__funds_found" = "FONDS TROUVÉS"; -"sweep__loading_address" = "Obtention de l\'adresse..."; -"sweep__loading_description" = "Veuillez patienter pendant que Bitkit recherche des fonds dans des adresses non prises en charge (Legacy, Nested SegWit et Taproot)."; -"sweep__looking_for_funds" = "RECHERCHE DE FONDS..."; -"sweep__no_funds_description" = "Bitkit a vérifié les types d\'adresses non pris en charge et n\'a trouvé aucun fonds à balayer."; -"sweep__no_funds_title" = "Aucun fonds à balayer"; -"sweep__preparing" = "Préparation de la transaction..."; -"sweep__prompt_description" = "Bitkit a trouvé des fonds sur des types d\'adresses Bitcoin non pris en charge. Balayez pour transférer les fonds vers le solde de votre nouveau portefeuille."; -"sweep__prompt_headline" = "BALAYER LES ANCIENS\nFONDS BITKIT"; -"sweep__prompt_sweep" = "Balayer"; -"sweep__prompt_title" = "Balayer les fonds"; -"sweep__sweep_to_wallet" = "Balayer vers le portefeuille"; -"sweep__swipe" = "Glissez pour balayer"; -"sweep__title" = "Balayer les fonds"; -"sweep__view_details" = "Voir les détails"; -"sweep__wallet_overview" = "Aperçu du portefeuille"; "slashtags__your_name" = "Votre nom"; "slashtags__your_name_capital" = "Votre nom"; "slashtags__contact_name" = "Nom du contact"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 4469ddc1..29e68bcd 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -668,7 +668,6 @@ "settings__adv__section_other" = "Altro"; "settings__adv__section_payments" = "Pagamenti"; "settings__adv__suggestions_reset" = "Reimposta suggerimenti"; -"settings__adv__sweep_funds" = "Recupera Fondi"; "settings__adv__web_relay" = "Slashtags Web Relay"; "settings__advanced_title" = "Avanzate"; "settings__backup__category_connection_receipts" = "Ricevute di Connessione"; @@ -918,34 +917,6 @@ "slashtags__profile_scan_to_add" = "Scansiona per aggiungere {name}"; "slashtags__your_name" = "Tuo nome"; "slashtags__your_name_capital" = "Tuo Nome"; -"sweep__amount" = "Importo da Ricevere"; -"sweep__broadcasting" = "Trasmissione..."; -"sweep__complete_description" = "I tuoi fondi sono stati recuperati e verranno aggiunti al saldo del tuo portafoglio."; -"sweep__complete_title" = "Recupero Completato"; -"sweep__confirm_title" = "Conferma Recupero"; -"sweep__destination" = "Destinazione"; -"sweep__error_destination_address" = "Impossibile ottenere l'indirizzo di destinazione"; -"sweep__error_fee_rate_not_set" = "Commissione non impostata"; -"sweep__error_title" = "Errore"; -"sweep__fee_total" = "{fee} sats commissione totale"; -"sweep__found_description" = "Bitkit ha trovato fondi in indirizzi non supportati (Legacy, Nested SegWit e Taproot)."; -"sweep__found_title" = "Fondi Trovati"; -"sweep__funds_found" = "FONDI TROVATI"; -"sweep__loading_address" = "Ottenimento indirizzo..."; -"sweep__loading_description" = "Attendi mentre Bitkit cerca fondi in indirizzi non supportati (Legacy, Nested SegWit e Taproot)."; -"sweep__looking_for_funds" = "RICERCA FONDI IN CORSO..."; -"sweep__no_funds_description" = "Bitkit ha controllato i tipi di indirizzi non supportati e non ha trovato fondi da recuperare."; -"sweep__no_funds_title" = "Nessun Fondo da Recuperare"; -"sweep__preparing" = "Preparazione transazione..."; -"sweep__prompt_description" = "Bitkit ha trovato fondi su tipi di indirizzi Bitcoin non supportati. Recupera per spostare i fondi al nuovo saldo del tuo portafoglio."; -"sweep__prompt_headline" = "RECUPERA VECCHI\nFONDI BITKIT"; -"sweep__prompt_sweep" = "Recupera"; -"sweep__prompt_title" = "Recupera Fondi"; -"sweep__sweep_to_wallet" = "Recupera nel Portafoglio"; -"sweep__swipe" = "Scorri per Recuperare"; -"sweep__title" = "Recupera Fondi"; -"sweep__view_details" = "Visualizza Dettagli"; -"sweep__wallet_overview" = "Panoramica Portafoglio"; "wallet__activity" = "Attività"; "wallet__activity_address" = "Indirizzo"; "wallet__activity_all" = "Tutte le attività"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 70180c97..17b6f994 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -648,7 +648,6 @@ "settings__adv__section_other" = "Overige"; "settings__adv__section_payments" = "Betalingen"; "settings__adv__suggestions_reset" = "Herstel Suggesties"; -"settings__adv__sweep_funds" = "Geld Vegen"; "settings__adv__web_relay" = "Slashtags Web Relais"; "settings__addr__addr_change" = "Wisselgeld Adressen"; "settings__addr__addr_receiving" = "Ontvangstadressen"; @@ -918,34 +917,6 @@ "slashtags__profile_scan_to_add" = "Scan om {name} toe te voegen"; "slashtags__your_name" = "Uw naam"; "slashtags__your_name_capital" = "Uw Naam"; -"sweep__amount" = "Te Ontvangen Bedrag"; -"sweep__broadcasting" = "Aan het verzenden..."; -"sweep__complete_description" = "Uw geld is geveegd en wordt toegevoegd aan uw wallet saldo."; -"sweep__complete_title" = "Vegen Voltooid"; -"sweep__confirm_title" = "Bevestig Vegen"; -"sweep__destination" = "Bestemming"; -"sweep__error_destination_address" = "Kon bestemmingsadres niet ophalen"; -"sweep__error_fee_rate_not_set" = "Vergoedingstarief niet ingesteld"; -"sweep__error_title" = "Fout"; -"sweep__fee_total" = "{fee} sats totale vergoeding"; -"sweep__found_description" = "Bitkit heeft geld gevonden in niet-ondersteunde adressen (Legacy, Nested SegWit en Taproot)."; -"sweep__found_title" = "Geld Gevonden"; -"sweep__funds_found" = "GELD GEVONDEN"; -"sweep__loading_address" = "Adres ophalen..."; -"sweep__loading_description" = "Even geduld terwijl Bitkit zoekt naar geld in niet-ondersteunde adressen (Legacy, Nested SegWit en Taproot)."; -"sweep__looking_for_funds" = "ZOEKEN NAAR GELD..."; -"sweep__no_funds_description" = "Bitkit heeft niet-ondersteunde adrestypes gecontroleerd en geen geld gevonden om te vegen."; -"sweep__no_funds_title" = "Geen Geld Om Te Vegen"; -"sweep__preparing" = "Transactie voorbereiden..."; -"sweep__prompt_description" = "Bitkit heeft geld gevonden op niet-ondersteunde Bitcoin adrestypes. Veeg om het geld naar uw nieuwe wallet saldo te verplaatsen."; -"sweep__prompt_headline" = "VEEG OUDE\nBITKIT GELDEN"; -"sweep__prompt_sweep" = "Vegen"; -"sweep__prompt_title" = "Geld Vegen"; -"sweep__sweep_to_wallet" = "Vegen Naar Wallet"; -"sweep__swipe" = "Veeg om te Vegen"; -"sweep__title" = "Geld Vegen"; -"sweep__view_details" = "Bekijk Details"; -"sweep__wallet_overview" = "Wallet Overzicht"; "wallet__activity" = "Activiteit"; "wallet__activity_address" = "Adres"; "wallet__activity_all" = "Alle Activiteiten"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index fb9a7b34..a7859725 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1168,7 +1168,6 @@ "settings__adv__cs_branch_and_bound_description" = "Znajduje dokładne dopasowania kwot, aby zminimalizować resztę"; "settings__adv__cs_single_random_draw" = "Losowy wybór"; "settings__adv__cs_single_random_draw_description" = "Losowy wybór dla prywatności"; -"settings__adv__sweep_funds" = "Przenieś środki"; "settings__general__language" = "Język"; "settings__general__language_other" = "Język interfejsu"; "settings__general__language_title" = "Język"; @@ -1191,34 +1190,6 @@ "settings__notifications__settings__toggle" = "Otrzymuj płatności przy zamkniętym Bitkit"; "settings__rgs__error_peer" = "Błąd połączenia RGS"; "settings__rgs__server_error_description" = "Nie można połączyć się z serwerem Rapid-Gossip-Sync."; -"sweep__amount" = "Kwota do otrzymania"; -"sweep__broadcasting" = "Transmitowanie..."; -"sweep__complete_description" = "Twoje środki zostały przeniesione i zostaną dodane do salda portfela."; -"sweep__complete_title" = "Przenoszenie zakończone"; -"sweep__confirm_title" = "Potwierdź przeniesienie"; -"sweep__destination" = "Miejsce docelowe"; -"sweep__error_destination_address" = "Nie udało się uzyskać adresu docelowego"; -"sweep__error_fee_rate_not_set" = "Nie ustawiono stawki opłaty"; -"sweep__error_title" = "Błąd"; -"sweep__fee_total" = "{fee} satów łącznej opłaty"; -"sweep__found_description" = "Bitkit znalazł środki na nieobsługiwanych adresach (Legacy, Nested SegWit i Taproot)."; -"sweep__found_title" = "Znaleziono środki"; -"sweep__funds_found" = "ZNALEZIONO ŚRODKI"; -"sweep__loading_address" = "Pobieranie adresu..."; -"sweep__loading_description" = "Proszę czekać, Bitkit szuka środków na nieobsługiwanych adresach (Legacy, Nested SegWit i Taproot)."; -"sweep__looking_for_funds" = "SZUKANIE ŚRODKÓW..."; -"sweep__no_funds_description" = "Bitkit sprawdził nieobsługiwane typy adresów i nie znalazł środków do przeniesienia."; -"sweep__no_funds_title" = "Brak środków do przeniesienia"; -"sweep__preparing" = "Przygotowywanie transakcji..."; -"sweep__prompt_description" = "Bitkit znalazł środki na nieobsługiwanych typach adresów Bitcoin. Przenieś środki do salda nowego portfela."; -"sweep__prompt_headline" = "PRZENIEŚ STARE\nŚRODKI BITKIT"; -"sweep__prompt_sweep" = "Przenieś"; -"sweep__prompt_title" = "Przenieś środki"; -"sweep__sweep_to_wallet" = "Przenieś do portfela"; -"sweep__swipe" = "Przesuń, aby przenieść"; -"sweep__title" = "Przenieś środki"; -"sweep__view_details" = "Zobacz szczegóły"; -"sweep__wallet_overview" = "Przegląd portfela"; "wallet__activity_boost_fee" = "Opłata za przyśpieszenie"; "wallet__activity_boost_fee_description" = "Przyśpieszono transakcję przychodzącą"; "wallet__activity_fee_prepaid" = "Opłata (przedpłacona)"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index a61b42ee..489d4634 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1168,7 +1168,6 @@ "settings__adv__cs_branch_and_bound_description" = "Encontra correspondências exatas de valor para minimizar o troco"; "settings__adv__cs_single_random_draw" = "Seleção Aleatória Única"; "settings__adv__cs_single_random_draw_description" = "Seleção aleatória para privacidade"; -"settings__adv__sweep_funds" = "Varrer Fundos"; "settings__general__language" = "Idioma"; "settings__general__language_other" = "Idioma da interface"; "settings__general__language_title" = "Idioma"; @@ -1191,34 +1190,6 @@ "settings__notifications__settings__toggle" = "Receber pagamentos quando a Bitkit estiver fechada"; "settings__rgs__error_peer" = "Erro de Conexão RGS"; "settings__rgs__server_error_description" = "Não foi possível conectar ao servidor Rapid-Gossip-Sync."; -"sweep__amount" = "Valor a Receber"; -"sweep__broadcasting" = "Transmitindo..."; -"sweep__complete_description" = "Seus fundos foram varridos e serão adicionados ao saldo da sua carteira."; -"sweep__complete_title" = "Varredura Concluída"; -"sweep__confirm_title" = "Confirmar Varredura"; -"sweep__destination" = "Destino"; -"sweep__error_destination_address" = "Falha ao obter endereço de destino"; -"sweep__error_fee_rate_not_set" = "Taxa não definida"; -"sweep__error_title" = "Erro"; -"sweep__fee_total" = "{fee} sats de taxa total"; -"sweep__found_description" = "A Bitkit encontrou fundos em endereços não suportados (Legacy, Nested SegWit e Taproot)."; -"sweep__found_title" = "Fundos Encontrados"; -"sweep__funds_found" = "FUNDOS ENCONTRADOS"; -"sweep__loading_address" = "Obtendo endereço..."; -"sweep__loading_description" = "Por favor, aguarde enquanto a Bitkit procura fundos em endereços não suportados (Legacy, Nested SegWit e Taproot)."; -"sweep__looking_for_funds" = "PROCURANDO FUNDOS..."; -"sweep__no_funds_description" = "A Bitkit verificou tipos de endereço não suportados e não encontrou fundos para varrer."; -"sweep__no_funds_title" = "Nenhum Fundo Para Varrer"; -"sweep__preparing" = "Preparando transação..."; -"sweep__prompt_description" = "A Bitkit encontrou fundos em tipos de endereço Bitcoin não suportados. Varra para mover os fundos para o seu novo saldo de carteira."; -"sweep__prompt_headline" = "VARRER ANTIGOS\nFUNDOS BITKIT"; -"sweep__prompt_sweep" = "Varrer"; -"sweep__prompt_title" = "Varrer Fundos"; -"sweep__sweep_to_wallet" = "Varrer Para Carteira"; -"sweep__swipe" = "Deslize para Varrer"; -"sweep__title" = "Varrer Fundos"; -"sweep__view_details" = "Ver Detalhes"; -"sweep__wallet_overview" = "Visão Geral da Carteira"; "wallet__activity_boost_fee" = "Taxa de Aceleração"; "wallet__activity_boost_fee_description" = "Transação recebida acelerada"; "wallet__activity_fee_prepaid" = "Taxa (Pré-paga)"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 96861520..16d46a2d 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -668,7 +668,6 @@ "settings__adv__section_other" = "Outro"; "settings__adv__section_payments" = "Pagamentos"; "settings__adv__suggestions_reset" = "Repor Sugestões"; -"settings__adv__sweep_funds" = "Varrer Fundos"; "settings__adv__web_relay" = "Relay Web Slashtags"; "settings__advanced_title" = "Avançado"; "settings__backup__category_connection_receipts" = "Recibos de Ligações"; @@ -918,34 +917,6 @@ "slashtags__profile_scan_to_add" = "Digitaliza para adicionar {name}"; "slashtags__your_name" = "O teu nome"; "slashtags__your_name_capital" = "O Teu Nome"; -"sweep__amount" = "Valor a Receber"; -"sweep__broadcasting" = "A transmitir..."; -"sweep__complete_description" = "Os teus fundos foram varridos e serão adicionados ao saldo da tua carteira."; -"sweep__complete_title" = "Varrimento Completo"; -"sweep__confirm_title" = "Confirmar Varrimento"; -"sweep__destination" = "Destino"; -"sweep__error_destination_address" = "Falha ao obter endereço de destino"; -"sweep__error_fee_rate_not_set" = "Taxa de comissão não definida"; -"sweep__error_title" = "Erro"; -"sweep__fee_total" = "{fee} sats de taxa total"; -"sweep__found_description" = "Bitkit encontrou fundos em endereços não suportados (Legacy, Nested SegWit e Taproot)."; -"sweep__found_title" = "Fundos Encontrados"; -"sweep__funds_found" = "FUNDOS ENCONTRADOS"; -"sweep__loading_address" = "A obter endereço..."; -"sweep__loading_description" = "Por favor, aguarda enquanto Bitkit procura fundos em endereços não suportados (Legacy, Nested SegWit e Taproot)."; -"sweep__looking_for_funds" = "A PROCURAR FUNDOS..."; -"sweep__no_funds_description" = "Bitkit verificou tipos de endereços não suportados e não encontrou fundos para varrer."; -"sweep__no_funds_title" = "Sem Fundos Para Varrer"; -"sweep__preparing" = "A preparar transação..."; -"sweep__prompt_description" = "Bitkit encontrou fundos em tipos de endereços Bitcoin não suportados. Varre para mover os fundos para o teu novo saldo de carteira."; -"sweep__prompt_headline" = "VARRER FUNDOS\nBITKIT ANTIGOS"; -"sweep__prompt_sweep" = "Varrer"; -"sweep__prompt_title" = "Varrer Fundos"; -"sweep__sweep_to_wallet" = "Varrer Para Carteira"; -"sweep__swipe" = "Desliza para Varrer"; -"sweep__title" = "Varrer Fundos"; -"sweep__view_details" = "Ver Detalhes"; -"sweep__wallet_overview" = "Vista Geral da Carteira"; "wallet__activity" = "Atividade"; "wallet__activity_address" = "Endereço"; "wallet__activity_all" = "Toda a Atividade"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 0497f6f3..3a8cd42d 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1108,7 +1108,6 @@ "settings__adv__cs_single_random_draw" = "Случайный Выбор"; "settings__adv__cs_single_random_draw_description" = "Случайный выбор для конфиденциальности"; "settings__adv__rgs_server" = "Rapid-Gossip-Sync"; -"settings__adv__sweep_funds" = "Собрать Средства"; "settings__adv__web_relay" = "Slashtags Web Relay"; "settings__backup__category_connection_receipts" = "Квитанции Соединений"; "settings__backup__category_wallet" = "Ускорения и Переводы"; @@ -1153,34 +1152,6 @@ "slashtags__profile_delete_dialogue_title" = "Удалить Информацию Профиля?"; "slashtags__profile_delete_success_msg" = "Информация вашего профиля Bitkit была удалена."; "slashtags__profile_delete_success_title" = "Профиль Удалён"; -"sweep__amount" = "Сумма к Получению"; -"sweep__broadcasting" = "Отправка..."; -"sweep__complete_description" = "Ваши средства были собраны и будут добавлены к балансу вашего кошелька."; -"sweep__complete_title" = "Сбор Завершён"; -"sweep__confirm_title" = "Подтвердить Сбор"; -"sweep__destination" = "Назначение"; -"sweep__error_destination_address" = "Не удалось получить адрес назначения"; -"sweep__error_fee_rate_not_set" = "Ставка комиссии не установлена"; -"sweep__error_title" = "Ошибка"; -"sweep__fee_total" = "{fee} сатоши общая комиссия"; -"sweep__found_description" = "Bitkit нашёл средства на неподдерживаемых адресах (Legacy, Nested SegWit и Taproot)."; -"sweep__found_title" = "Найдены Средства"; -"sweep__funds_found" = "СРЕДСТВА НАЙДЕНЫ"; -"sweep__loading_address" = "Получение адреса..."; -"sweep__loading_description" = "Пожалуйста, подождите, пока Bitkit ищет средства на неподдерживаемых адресах (Legacy, Nested SegWit и Taproot)."; -"sweep__looking_for_funds" = "ПОИСК СРЕДСТВ..."; -"sweep__no_funds_description" = "Bitkit проверил неподдерживаемые типы адресов и не нашёл средств для сбора."; -"sweep__no_funds_title" = "Нет Средств Для Сбора"; -"sweep__preparing" = "Подготовка транзакции..."; -"sweep__prompt_description" = "Bitkit нашёл средства на неподдерживаемых типах адресов Bitcoin. Соберите, чтобы перевести средства на новый баланс кошелька."; -"sweep__prompt_headline" = "СОБРАТЬ СТАРЫЕ\nСРЕДСТВА BITKIT"; -"sweep__prompt_sweep" = "Собрать"; -"sweep__prompt_title" = "Собрать Средства"; -"sweep__sweep_to_wallet" = "Собрать в Кошелёк"; -"sweep__swipe" = "Проведите для Сбора"; -"sweep__title" = "Сбор Средств"; -"sweep__view_details" = "Посмотреть Детали"; -"sweep__wallet_overview" = "Обзор Кошелька"; "wallet__activity_boost_fee" = "Комиссия за Ускорение"; "wallet__activity_boost_fee_description" = "Ускорена входящая транзакция"; "wallet__activity_fee_prepaid" = "Комиссия (Предоплачена)"; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index dbb3077a..7f99b95f 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1204,7 +1204,7 @@ extension LightningService { } } } - + // MARK: - Helpers private static func parseAddressType(_ string: String?) -> LDKNode.AddressType { diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 165f96a7..26df74d5 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -84,11 +84,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/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 00b50ef6..e811dd6c 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 6165a340..00000000 --- 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/Views/Onboarding/WalletRestoreSuccess.swift b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift index 29d51b95..7ea1ac25 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,6 @@ struct WalletRestoreSuccess: View { // Mark backup as verified since user just restored with their phrase app.backupVerified = true wallet.isRestoringWallet = false - SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets) } .accessibilityIdentifier("GetStartedButton") } diff --git a/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift b/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift deleted file mode 100644 index aa5fa378..00000000 --- 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 28abdcca..00000000 --- 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 9bcb2a7e..00000000 --- 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 ee694fca..00000000 --- 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 62c9de9f..00000000 --- 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 3145ea36..00000000 --- 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) - } -} From 4f7a102fd5438bdf602f2264acc58860ac4b7add Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 11 Feb 2026 15:00:27 +0700 Subject: [PATCH 09/17] fixes --- Bitkit/Services/MigrationsService.swift | 11 ++++- Bitkit/ViewModels/SettingsViewModel.swift | 41 +++++++++---------- .../Advanced/AddressTypePreferenceView.swift | 4 +- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 1094417b..75cc0107 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -1224,7 +1224,16 @@ extension MigrationsService { Logger.info("Migrated selectedAddressType: \(selected)", context: "Migration") } - if let monitored = addressTypesToMonitor { + if var monitored = addressTypesToMonitor { + // BOLT 2 requires native witness scripts for channel shutdown/close outputs. + // Ensure at least one native witness type (NativeSegwit or Taproot) is monitored. + let nativeWitnessTypes = ["nativeSegwit", "taproot"] + let hasNativeWitness = monitored.contains(where: { nativeWitnessTypes.contains($0) }) + 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") diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 95b347d8..d64c3bb6 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -338,14 +338,14 @@ class SettingsViewModel: NSObject, ObservableObject { return false } - // If primary is Legacy, ensure at least one SegWit-compatible wallet remains enabled - // (Legacy UTXOs cannot be used for Lightning channel funding) - if selectedAddressType == .legacy { - let segwitTypes: [AddressScriptType] = [.nestedSegwit, .nativeSegwit, .taproot] - let remainingSegwit = current.filter { $0 != addressType && segwitTypes.contains($0) } - if remainingSegwit.isEmpty { - return false - } + // Ensure at least one native witness wallet (NativeSegwit or Taproot) remains enabled. + // BOLT 2 requires native witness scripts for channel shutdown/close outputs. + // When primary is Legacy or NestedSegwit, the node uses a loaded native witness + // wallet for all channel scripts. Without one, channels will fail. + let nativeWitnessTypes: [AddressScriptType] = [.nativeSegwit, .taproot] + let remainingNativeWitness = current.filter { $0 != addressType && nativeWitnessTypes.contains($0) } + if remainingNativeWitness.isEmpty { + return false } current.removeAll { $0 == addressType } @@ -379,20 +379,19 @@ class SettingsViewModel: NSObject, ObservableObject { addressTypesToMonitor = Self.allAddressTypes } - /// Check if disabling an address type would leave no SegWit wallets when Legacy is primary + /// Check if disabling an address type would leave no native witness wallets + /// BOLT 2 requires native witness scripts for channel shutdown/close outputs, + /// so at least one NativeSegwit or Taproot wallet must always remain enabled. /// - Parameter addressType: The address type to check - /// - Returns: True if this is the last SegWit wallet and Legacy is primary - func isLastRequiredSegwitWallet(_ addressType: AddressScriptType) -> Bool { - // Only applies when Legacy is the primary wallet - guard selectedAddressType == .legacy else { return false } - - // Only applies to SegWit-compatible types - let segwitTypes: [AddressScriptType] = [.nestedSegwit, .nativeSegwit, .taproot] - guard segwitTypes.contains(addressType) else { return false } - - // Check if disabling this would leave no SegWit wallets - let remainingSegwit = addressTypesToMonitor.filter { $0 != addressType && segwitTypes.contains($0) } - return remainingSegwit.isEmpty + /// - Returns: True if this is the last native witness wallet + func isLastRequiredNativeWitnessWallet(_ addressType: AddressScriptType) -> Bool { + // Only applies to native witness types + let nativeWitnessTypes: [AddressScriptType] = [.nativeSegwit, .taproot] + guard nativeWitnessTypes.contains(addressType) else { return false } + + // Check if disabling this would leave no native witness wallets + let remainingNativeWitness = addressTypesToMonitor.filter { $0 != addressType && nativeWitnessTypes.contains($0) } + return remainingNativeWitness.isEmpty } var selectedAddressType: AddressScriptType { diff --git a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift index 2be6cc1c..be561c2f 100644 --- a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -266,11 +266,11 @@ struct AddressTypePreferenceView: View { ) } else if !enabled { // Determine reason for failure - if settingsViewModel.isLastRequiredSegwitWallet(addressType) { + if settingsViewModel.isLastRequiredNativeWitnessWallet(addressType) { app.toast( type: .error, title: "Cannot Disable", - description: "At least one SegWit wallet is required for Lightning when using Legacy as primary." + description: "At least one Native SegWit or Taproot wallet is required for Lightning channels." ) } else { app.toast( From bacbc5b8a139d369f3d9cbd0071bfca951354af1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 11 Feb 2026 15:10:58 +0700 Subject: [PATCH 10/17] update ldk-node --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 7a4c6f2b..f79a9160 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -926,7 +926,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = af29894afa4b32ba7e506f321c09d200dc6ab8a2; + revision = 18b156d4a68b4fcf5a989cfe314d7f1c1290c703; }; }; 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 01b03e3f..d4a01d00 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "af29894afa4b32ba7e506f321c09d200dc6ab8a2" + "revision" : "18b156d4a68b4fcf5a989cfe314d7f1c1290c703" } }, { From c924da1f0e5da8ff4b13544556da579c5f81cf6e Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 12 Feb 2026 00:22:17 +0700 Subject: [PATCH 11/17] update ldk-node --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f79a9160..094b8eac 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -926,7 +926,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = 18b156d4a68b4fcf5a989cfe314d7f1c1290c703; + revision = efbed7c7d2d6d39eab0cf62bdcddd918dc42eeb6; }; }; 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 d4a01d00..71ba0790 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "18b156d4a68b4fcf5a989cfe314d7f1c1290c703" + "revision" : "efbed7c7d2d6d39eab0cf62bdcddd918dc42eeb6" } }, { From 036a86462344e1f3965e6f6ca75d9a0b396d6bcf Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 12 Feb 2026 07:23:53 +0700 Subject: [PATCH 12/17] update ldk node --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 094b8eac..8ec7a924 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -926,7 +926,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = revision; - revision = efbed7c7d2d6d39eab0cf62bdcddd918dc42eeb6; + revision = 4ef1a66b5390a09ab49dcb2b1e0d32d2ca5e890f; }; }; 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 71ba0790..044af810 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "efbed7c7d2d6d39eab0cf62bdcddd918dc42eeb6" + "revision" : "4ef1a66b5390a09ab49dcb2b1e0d32d2ca5e890f" } }, { From 27e310dd5dbabf258636122c357375ecba744105 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 12 Feb 2026 08:24:50 +0700 Subject: [PATCH 13/17] fix wipe and update comments --- Bitkit/Services/CoreService.swift | 3 +- Bitkit/Services/LightningService.swift | 14 +--- Bitkit/Services/MigrationsService.swift | 8 +-- Bitkit/Utilities/AppReset.swift | 3 + Bitkit/ViewModels/SettingsViewModel.swift | 71 +++++++++---------- .../Advanced/AddressTypeLoadingView.swift | 1 - .../Advanced/AddressTypePreferenceView.swift | 11 ++- .../Views/Transfer/FundManualAmountView.swift | 2 +- Bitkit/Views/Transfer/SpendingAmount.swift | 1 - 9 files changed, 45 insertions(+), 69 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 7cdf8b55..8830825c 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -24,7 +24,6 @@ class ActivityService { /// Maximum address index to search when current address exists private static let maxAddressSearchIndex: UInt32 = 1000 - /// Lock to prevent concurrent address searches private let addressSearchLock = NSLock() private var isSearchingAddresses = false @@ -894,7 +893,7 @@ class ActivityService { private func findReceivingAddress(for txid: String, value: UInt64, transactionDetails: BitkitCore.TransactionDetails? = nil) async throws -> String? { - // Prevent concurrent searches that could cause infinite loops + // Prevents concurrent searches (can cause infinite loop) addressSearchLock.lock() guard !isSearchingAddresses else { addressSearchLock.unlock() diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 7f99b95f..62f91e3c 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -81,11 +81,9 @@ class LightningService { ) config.includeUntrustedPendingInSpendable = true - // Set address type from user preference let selectedAddressType = Self.parseAddressType(UserDefaults.standard.string(forKey: "selectedAddressType")) config.addressType = selectedAddressType - // Set additional monitored address types (excluding the primary type) let monitoredTypesString = UserDefaults.standard.string(forKey: "addressTypesToMonitor") ?? "nativeSegwit" let monitoredTypes = monitoredTypesString.split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespaces) } @@ -735,10 +733,6 @@ extension LightningService { } } - /// Get balance for a specific address type - /// - Parameter addressType: The address type to check - /// - Returns: AddressTypeBalance with total and spendable sats - /// - Throws: AppError if node is not setup func getBalanceForAddressType(_ addressType: LDKNode.AddressType) async throws -> AddressTypeBalance { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -749,15 +743,11 @@ extension LightningService { } } - /// Get the total balance that can be used for channel funding (excludes Legacy/P2PKH UTXOs) - /// LDK channel funding requires witness-compatible UTXOs (NativeSegwit, NestedSegwit, Taproot) - /// - Returns: Total spendable sats from witness-compatible address types func getChannelFundableBalance() async throws -> UInt64 { guard let node else { throw AppError(serviceError: .nodeNotSetup) } - // Get monitored address types from UserDefaults let storedTypes = UserDefaults.standard.string(forKey: "addressTypesToMonitor") ?? "nativeSegwit" let typeStrings = storedTypes.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } let monitoredTypes: [LDKNode.AddressType] = typeStrings.compactMap { str in @@ -773,9 +763,8 @@ extension LightningService { var totalFundable: UInt64 = 0 for addressType in monitoredTypes { - // Skip Legacy (P2PKH) as it cannot be used for channel funding if addressType == .legacy { - continue + continue // Legacy UTXOs cannot fund channels } do { @@ -784,7 +773,6 @@ extension LightningService { } totalFundable += balance.spendableSats } catch { - // If we can't get balance for this type, log and continue Logger.warn("Failed to get balance for \(addressType) when calculating channel fundable balance: \(error)") } } diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 75cc0107..6110f458 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -881,7 +881,6 @@ extension MigrationsService { } } - /// Maps RN EAddressType enum values to iOS AddressScriptType string values private static let rnAddressTypeMapping: [String: String] = [ "p2pkh": "legacy", "p2sh": "nestedSegwit", @@ -907,7 +906,7 @@ extension MigrationsService { var selectedAddressType: String? var addressTypesToMonitor: [String]? - // Extract selectedAddressType from wallets.wallet0.addressType. + // 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] @@ -920,7 +919,7 @@ extension MigrationsService { } } - // Extract addressTypesToMonitor from top-level wallet state + // Top-level addressTypesToMonitor if let rnMonitoredTypes = walletDict["addressTypesToMonitor"] as? [String] { let iosTypes = rnMonitoredTypes.compactMap { Self.rnAddressTypeMapping[$0] } if !iosTypes.isEmpty { @@ -1225,10 +1224,9 @@ extension MigrationsService { } if var monitored = addressTypesToMonitor { - // BOLT 2 requires native witness scripts for channel shutdown/close outputs. - // Ensure at least one native witness type (NativeSegwit or Taproot) is monitored. 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") diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 30518167..883af659 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/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index d64c3bb6..05b27ec4 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -4,8 +4,7 @@ import LDKNode import SwiftUI import UserNotifications -/// Typealias for LDKNode.AddressType to avoid naming conflicts with local AddressType enums -/// used elsewhere in the app for UI purposes (e.g., receiving/change in AddressViewer). +// Avoids conflict with AddressViewer.AddressType typealias AddressScriptType = LDKNode.AddressType enum CoinSelectionMethod: String, CaseIterable { @@ -49,7 +48,6 @@ class SettingsViewModel: NSObject, ObservableObject { private let defaults = UserDefaults.standard - /// Flag to prevent concurrent address type changes private var isChangingAddressType = false private var observedKeys: Set = [] @@ -188,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 { @@ -252,14 +277,10 @@ class SettingsViewModel: NSObject, ObservableObject { // Address Type Settings @AppStorage("selectedAddressType") private var _selectedAddressType: String = "nativeSegwit" - // Monitored Address Types - stored as comma-separated string for @AppStorage compatibility - // Default to only Native Segwit, matching React Native behavior @AppStorage("addressTypesToMonitor") private var _addressTypesToMonitor: String = "nativeSegwit" - /// All available address types static let allAddressTypes: [AddressScriptType] = [.legacy, .nestedSegwit, .nativeSegwit, .taproot] - /// Convert address type to string for storage static func addressTypeToString(_ addressType: AddressScriptType) -> String { switch addressType { case .legacy: return "legacy" @@ -269,7 +290,6 @@ class SettingsViewModel: NSObject, ObservableObject { } } - /// Convert string to address type static func stringToAddressType(_ string: String) -> AddressScriptType? { switch string { case "legacy": return .legacy @@ -280,7 +300,6 @@ class SettingsViewModel: NSObject, ObservableObject { } } - /// Address types currently being monitored var addressTypesToMonitor: [AddressScriptType] { get { let strings = _addressTypesToMonitor.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } @@ -296,21 +315,11 @@ class SettingsViewModel: NSObject, ObservableObject { addressTypesToMonitor.contains(addressType) } - /// Check if an address type has balance - /// - Parameter addressType: The address type to check - /// - Returns: The balance in sats - /// - Throws: If unable to check balance func getBalanceForAddressType(_ addressType: AddressScriptType) async throws -> UInt64 { let balance = try await lightningService.getBalanceForAddressType(addressType) return balance.totalSats } - /// Enable or disable monitoring for an address type - /// - Parameters: - /// - addressType: The address type to toggle - /// - enabled: Whether to enable or disable monitoring - /// - wallet: Optional wallet view model to update UI state during restart - /// - Returns: True if the operation succeeded, false if it was prevented (e.g., type has balance) func setMonitoring(_ addressType: AddressScriptType, enabled: Bool, wallet: WalletViewModel? = nil) async -> Bool { guard !isChangingAddressType else { return false } @@ -325,23 +334,18 @@ class SettingsViewModel: NSObject, ObservableObject { addressTypesToMonitor = current } } else { - // Don't allow disabling if it's the currently selected type if addressType == selectedAddressType { return false } - // Check if address type has balance - don't allow disabling if it has funds - // Fail safely: if we can't verify balance, don't allow disabling do { let balance = try await getBalanceForAddressType(addressType) if balance > 0 { return false } } catch { + // Fail safely: block disable if balance check fails Logger.error("Failed to check balance for \(addressType), preventing disable: \(error)") return false } - // Ensure at least one native witness wallet (NativeSegwit or Taproot) remains enabled. - // BOLT 2 requires native witness scripts for channel shutdown/close outputs. - // When primary is Legacy or NestedSegwit, the node uses a loaded native witness - // wallet for all channel scripts. Without one, channels will fail. + // At least one native witness type required for Lightning let nativeWitnessTypes: [AddressScriptType] = [.nativeSegwit, .taproot] let remainingNativeWitness = current.filter { $0 != addressType && nativeWitnessTypes.contains($0) } if remainingNativeWitness.isEmpty { @@ -365,7 +369,6 @@ class SettingsViewModel: NSObject, ObservableObject { return true } - /// Add an address type to monitored types if not already present func ensureMonitoring(_ addressType: AddressScriptType) { if !addressTypesToMonitor.contains(addressType) { var current = addressTypesToMonitor @@ -374,29 +377,21 @@ class SettingsViewModel: NSObject, ObservableObject { } } - /// Set all address types as monitored (used during wallet restore) func monitorAllAddressTypes() { addressTypesToMonitor = Self.allAddressTypes } - /// Check if disabling an address type would leave no native witness wallets - /// BOLT 2 requires native witness scripts for channel shutdown/close outputs, - /// so at least one NativeSegwit or Taproot wallet must always remain enabled. - /// - Parameter addressType: The address type to check - /// - Returns: True if this is the last native witness wallet + /// True if disabling this would leave no native witness wallet (required for Lightning). func isLastRequiredNativeWitnessWallet(_ addressType: AddressScriptType) -> Bool { - // Only applies to native witness types let nativeWitnessTypes: [AddressScriptType] = [.nativeSegwit, .taproot] guard nativeWitnessTypes.contains(addressType) else { return false } - // Check if disabling this would leave no native witness wallets let remainingNativeWitness = addressTypesToMonitor.filter { $0 != addressType && nativeWitnessTypes.contains($0) } return remainingNativeWitness.isEmpty } var selectedAddressType: AddressScriptType { get { - // Parse the stored string value switch _selectedAddressType { case "legacy": return .legacy @@ -407,11 +402,10 @@ class SettingsViewModel: NSObject, ObservableObject { case "taproot": return .taproot default: - return .nativeSegwit // Default fallback + return .nativeSegwit } } set { - // Convert AddressScriptType to string for storage switch newValue { case .legacy: _selectedAddressType = "legacy" @@ -457,7 +451,6 @@ class SettingsViewModel: NSObject, ObservableObject { wallet?.syncState() } - /// Generate a new address for the specified type and update wallet properties private func generateAndUpdateAddress(addressType: AddressScriptType, wallet: WalletViewModel?) async { do { let newAddress = try await lightningService.newAddressForType(addressType) diff --git a/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift b/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift index 3718b388..eabc6b92 100644 --- a/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift +++ b/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift @@ -1,6 +1,5 @@ import SwiftUI -/// Loading view shown during address type or monitoring changes struct AddressTypeLoadingView: View { let targetAddressType: AddressScriptType? let isMonitoringChange: Bool diff --git a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift index be561c2f..5de83ab7 100644 --- a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -308,11 +308,9 @@ struct AddressTypePreferenceView: View { } } -/// Error thrown when operation times out private struct TimeoutError: Error {} -/// Executes an async operation with a timeout. Returns true if the operation timed out. -/// Note: If timeout occurs, the operation continues running in the background. +/// Returns true if operation timed out. private func withTimeout(seconds: UInt64, operation: @escaping () async -> some Any) async -> Bool { do { try await withThrowingTaskGroup(of: Void.self) { group in @@ -325,15 +323,14 @@ private func withTimeout(seconds: UInt64, operation: @escaping () async -> some throw TimeoutError() } - // Wait for first task to complete or throw try await group.next() group.cancelAll() } - return false // Operation completed + return false } catch is TimeoutError { - return true // Timeout + return true } catch { - return false // Other error, treat as completed + return false } } diff --git a/Bitkit/Views/Transfer/FundManualAmountView.swift b/Bitkit/Views/Transfer/FundManualAmountView.swift index 207c53a2..9456c498 100644 --- a/Bitkit/Views/Transfer/FundManualAmountView.swift +++ b/Bitkit/Views/Transfer/FundManualAmountView.swift @@ -32,7 +32,7 @@ struct FundManualAmountView: View { Spacer() HStack(alignment: .bottom) { - // Show channel fundable balance (excludes Legacy UTXOs which can't be used for channel funding) + // Excludes Legacy (not usable for channel funding) AvailableAmount(label: t("wallet__send_available"), amount: wallet.channelFundableBalanceSats) .onTapGesture { amountViewModel.updateFromSats(UInt64(wallet.channelFundableBalanceSats), currency: currency) diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index d434bb66..7104e2b8 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -80,7 +80,6 @@ struct SpendingAmount: View { await calculateMaxTransferAmount() } .onChange(of: wallet.spendableOnchainBalanceSats) { _ in - // Recalculate when balance changes (e.g., after receiving funds) Task { await calculateMaxTransferAmount() } From 38c255b114154e0d5e25ce6b13e7d80039f0799b Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 12 Feb 2026 08:59:42 +0700 Subject: [PATCH 14/17] fixes --- Bitkit/Services/LightningService.swift | 22 +++++++++----- Bitkit/ViewModels/SettingsViewModel.swift | 29 +++++++++++++++---- .../Advanced/AddressTypePreferenceView.swift | 18 ++++++++++-- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 62f91e3c..88b7141b 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -748,9 +748,11 @@ extension LightningService { throw AppError(serviceError: .nodeNotSetup) } - let storedTypes = UserDefaults.standard.string(forKey: "addressTypesToMonitor") ?? "nativeSegwit" - let typeStrings = storedTypes.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - let monitoredTypes: [LDKNode.AddressType] = typeStrings.compactMap { str in + let defaults = UserDefaults.standard + let selectedType = Self.parseAddressType(defaults.string(forKey: "selectedAddressType")) + let monitoredTypesString = defaults.string(forKey: "addressTypesToMonitor") ?? "nativeSegwit" + let monitoredTypeStrings = monitoredTypesString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + let monitoredTypes: [LDKNode.AddressType] = monitoredTypeStrings.compactMap { str in switch str { case "legacy": return .legacy case "nestedSegwit": return .nestedSegwit @@ -760,13 +762,17 @@ extension LightningService { } } - var totalFundable: UInt64 = 0 + var typesToSum = Set() + if selectedType != .legacy { + typesToSum.insert(selectedType) + } + for type in monitoredTypes where type != .legacy { + typesToSum.insert(type) + } - for addressType in monitoredTypes { - if addressType == .legacy { - continue // Legacy UTXOs cannot fund channels - } + var totalFundable: UInt64 = 0 + for addressType in typesToSum { do { let balance = try await ServiceQueue.background(.ldk) { try node.getBalanceForAddressType(addressType: addressType) diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 05b27ec4..f0bf86de 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -326,6 +326,7 @@ class SettingsViewModel: NSObject, ObservableObject { isChangingAddressType = true defer { isChangingAddressType = false } + let previousAddressTypesToMonitor = addressTypesToMonitor var current = addressTypesToMonitor if enabled { @@ -363,6 +364,9 @@ class SettingsViewModel: NSObject, ObservableObject { try await lightningService.sync() } catch { Logger.error("Failed to restart node after monitored types change: \(error)") + addressTypesToMonitor = previousAddressTypesToMonitor + UserDefaults.standard.synchronize() + return false } wallet?.syncState() @@ -419,17 +423,21 @@ class SettingsViewModel: NSObject, ObservableObject { } } - func updateAddressType(_ addressType: AddressScriptType, wallet: WalletViewModel? = nil) async { - guard !isChangingAddressType else { return } - guard addressType != selectedAddressType else { return } + 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) - // Clear cached address UserDefaults.standard.set("", forKey: "onchainAddress") UserDefaults.standard.set("", forKey: "bip21") UserDefaults.standard.synchronize() @@ -445,10 +453,21 @@ class SettingsViewModel: NSObject, ObservableObject { await generateAndUpdateAddress(addressType: addressType, wallet: wallet) } catch { Logger.error("Failed to restart node after address type change: \(error)") - await generateAndUpdateAddress(addressType: addressType, wallet: wallet) + selectedAddressType = previousSelectedAddressType + addressTypesToMonitor = previousAddressTypesToMonitor + UserDefaults.standard.set(previousOnchainAddress, forKey: "onchainAddress") + UserDefaults.standard.set(previousBip21, forKey: "bip21") + UserDefaults.standard.synchronize() + 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 { diff --git a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift index 5de83ab7..8f234713 100644 --- a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -188,15 +188,16 @@ struct AddressTypePreferenceView: View { showLoadingView = true loadingTask = Task { + var success = false let didTimeout = await withTimeout(seconds: timeoutSeconds) { - await settingsViewModel.updateAddressType(addressType, wallet: wallet) + success = await settingsViewModel.updateAddressType(addressType, wallet: wallet) } showLoadingView = false if didTimeout { app.toast(type: .error, title: "Timeout", description: "The operation took too long. Please try again.") - } else { + } else if success { Haptics.notify(.success) navigation.reset() app.toast( @@ -204,6 +205,12 @@ struct AddressTypePreferenceView: View { title: "Address Type Changed", description: "Now using \(addressType.localizedTitle) addresses." ) + } else { + app.toast( + type: .error, + title: "Failed", + description: "Could not change address type. Please try again." + ) } } } @@ -265,7 +272,6 @@ struct AddressTypePreferenceView: View { description: "Address monitoring settings applied." ) } else if !enabled { - // Determine reason for failure if settingsViewModel.isLastRequiredNativeWitnessWallet(addressType) { app.toast( type: .error, @@ -279,6 +285,12 @@ struct AddressTypePreferenceView: View { description: "\(addressType.localizedTitle) addresses have balance." ) } + } else { + app.toast( + type: .error, + title: "Failed", + description: "Could not update monitoring settings. Please try again." + ) } } } From 850a2ff99db9dbaec601da9872e035ac8c0522b2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 12 Feb 2026 09:01:52 +0700 Subject: [PATCH 15/17] extract texts --- .../Localization/en.lproj/Localizable.strings | 19 ++++++++ .../Advanced/AddressTypeLoadingView.swift | 8 ++-- .../Advanced/AddressTypePreferenceView.swift | 46 +++++++++++-------- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index e85f01b8..7e996406 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -740,6 +740,25 @@ "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_timeout_title" = "Timeout"; +"settings__adv__addr_type_timeout_desc" = "The operation took too long. Please try again."; +"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_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__addr_type_loading_nav_address" = "Address Type"; +"settings__adv__addr_type_loading_nav_monitoring" = "Address Monitoring"; +"settings__adv__addr_type_loading_headline" = "Switching to {type}"; +"settings__adv__addr_type_loading_updating" = "Updating Wallet"; +"settings__adv__addr_type_loading_desc" = "Please wait while the wallet restarts..."; "settings__adv__gap_limit" = "Address Gap Limit"; "settings__adv__coin_selection" = "Coin Selection"; "settings__adv__cs_method" = "Coin Selection Method"; diff --git a/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift b/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift index eabc6b92..8b694d28 100644 --- a/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift +++ b/Bitkit/Views/Settings/Advanced/AddressTypeLoadingView.swift @@ -5,18 +5,18 @@ struct AddressTypeLoadingView: View { let isMonitoringChange: Bool private var navTitle: String { - isMonitoringChange ? "Address Monitoring" : "Address Type" + isMonitoringChange ? t("settings__adv__addr_type_loading_nav_monitoring") : t("settings__adv__addr_type_loading_nav_address") } private var headline: String { if let addressType = targetAddressType, !isMonitoringChange { - return "Switching to \(addressType.localizedTitle)" + return t("settings__adv__addr_type_loading_headline", variables: ["type": addressType.localizedTitle]) } - return "Updating Wallet" + return t("settings__adv__addr_type_loading_updating") } private var description: String { - "Please wait while the wallet restarts..." + t("settings__adv__addr_type_loading_desc") } var body: some View { diff --git a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift index 8f234713..5349747b 100644 --- a/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -125,7 +125,7 @@ struct MonitoredAddressTypeToggle: View { VStack(alignment: .leading, spacing: 4) { BodyMText("\(addressType.localizedTitle) \(addressType.shortExample)", textColor: .textPrimary) if isSelectedType { - BodySText("Currently selected", textColor: .textSecondary) + BodySText(t("settings__adv__addr_type_currently_selected"), textColor: .textSecondary) } } Spacer() @@ -196,20 +196,27 @@ struct AddressTypePreferenceView: View { showLoadingView = false if didTimeout { - app.toast(type: .error, title: "Timeout", description: "The operation took too long. Please try again.") + app.toast( + type: .error, + title: t("settings__adv__addr_type_timeout_title"), + description: t("settings__adv__addr_type_timeout_desc") + ) } else if success { Haptics.notify(.success) navigation.reset() app.toast( type: .success, - title: "Address Type Changed", - description: "Now using \(addressType.localizedTitle) addresses." + title: t("settings__adv__addr_type_changed_title"), + description: t( + "settings__adv__addr_type_changed_desc", + variables: ["type": addressType.localizedTitle] + ) ) } else { app.toast( type: .error, - title: "Failed", - description: "Could not change address type. Please try again." + title: t("settings__adv__addr_type_failed_title"), + description: t("settings__adv__addr_type_change_failed_desc") ) } } @@ -221,7 +228,7 @@ struct AddressTypePreferenceView: View { if showDevSettings { VStack(alignment: .leading, spacing: 0) { HStack { - CaptionMText("Monitored Address Types") + CaptionMText(t("settings__adv__monitored_address_types")) Spacer() Button(action: { showMonitoredTypesNote.toggle() }) { Image(systemName: "info.circle") @@ -233,7 +240,7 @@ struct AddressTypePreferenceView: View { if showMonitoredTypesNote { BodySText( - "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.", + t("settings__adv__addr_type_monitored_note"), textColor: .textSecondary ) .padding(.bottom, 12) @@ -261,35 +268,38 @@ struct AddressTypePreferenceView: View { if didTimeout { app.toast( type: .error, - title: "Timeout", - description: "The operation took too long. Please try again." + title: t("settings__adv__addr_type_timeout_title"), + description: t("settings__adv__addr_type_timeout_desc") ) } else if success { Haptics.notify(.success) app.toast( type: .success, - title: "Settings Updated", - description: "Address monitoring settings applied." + 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: "Cannot Disable", - description: "At least one Native SegWit or Taproot wallet is required for Lightning channels." + 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: "Cannot Disable", - description: "\(addressType.localizedTitle) addresses have balance." + 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: "Failed", - description: "Could not update monitoring settings. Please try again." + title: t("settings__adv__addr_type_failed_title"), + description: t("settings__adv__addr_type_monitored_failed_desc") ) } } From 8e27f8a061c283bd72416b98737a360d8cb959b0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 12 Feb 2026 11:43:51 +0700 Subject: [PATCH 16/17] remove empty wallets from monitor list after restore --- Bitkit/ViewModels/AppViewModel.swift | 9 +++ Bitkit/ViewModels/SettingsViewModel.swift | 59 +++++++++++++++++++ .../Onboarding/WalletRestoreSuccess.swift | 3 + 3 files changed, 71 insertions(+) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 0eb93f71..b1e39c95 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -902,6 +902,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/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index f0bf86de..77cf05eb 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -385,6 +385,65 @@ class SettingsViewModel: NSObject, ObservableObject { addressTypesToMonitor = Self.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 } + + addressTypesToMonitor = newMonitored + UserDefaults.standard.synchronize() + + do { + try await lightningService.restart() + try await lightningService.sync() + Logger.info( + "Pruned empty address types after restore: \(newMonitored.map { Self.addressTypeToString($0) }.joined(separator: ","))", + context: "SettingsViewModel" + ) + } catch { + Logger.error("Failed to restart 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] diff --git a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift index 7ea1ac25..7371fcd9 100644 --- a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift +++ b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift @@ -35,6 +35,9 @@ struct WalletRestoreSuccess: View { // Mark backup as verified since user just restored with their phrase app.backupVerified = true wallet.isRestoringWallet = false + + // Prune empty address types on next syncCompleted + SettingsViewModel.shared.pendingRestoreAddressTypePrune = true } .accessibilityIdentifier("GetStartedButton") } From 1170e77fd10e8ca13a0d66f03ea9af5d31c0bf50 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 12 Feb 2026 14:32:22 +0700 Subject: [PATCH 17/17] add tests --- .github/workflows/integration-tests.yml | 1 + Bitkit/ViewModels/SettingsViewModel.swift | 9 +- BitkitTests/AddressTypeIntegrationTests.swift | 173 +++++++++++++++ BitkitTests/AddressTypeSettingsTests.swift | 202 ++++++++++++++++++ BitkitTests/RNMigrationAddressTypeTests.swift | 127 +++++++++++ 5 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 BitkitTests/AddressTypeIntegrationTests.swift create mode 100644 BitkitTests/AddressTypeSettingsTests.swift create mode 100644 BitkitTests/RNMigrationAddressTypeTests.swift diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4112aa06..849384d2 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/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 77cf05eb..f8d1ede8 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -300,10 +300,15 @@ class SettingsViewModel: NSObject, ObservableObject { } } + /// Parses a comma-separated string of address types, filtering invalid values. + static func parseAddressTypesString(_ string: String) -> [AddressScriptType] { + let strings = string.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + return strings.compactMap { stringToAddressType($0) } + } + var addressTypesToMonitor: [AddressScriptType] { get { - let strings = _addressTypesToMonitor.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - return strings.compactMap { Self.stringToAddressType($0) } + Self.parseAddressTypesString(_addressTypesToMonitor) } set { _addressTypesToMonitor = newValue.map { Self.addressTypeToString($0) }.joined(separator: ",") diff --git a/BitkitTests/AddressTypeIntegrationTests.swift b/BitkitTests/AddressTypeIntegrationTests.swift new file mode 100644 index 00000000..cab53174 --- /dev/null +++ b/BitkitTests/AddressTypeIntegrationTests.swift @@ -0,0 +1,173 @@ +import BitkitCore +import LDKNode +import XCTest + +@testable import Bitkit + +final class AddressTypeIntegrationTests: XCTestCase { + let walletIndex = 0 + let lightning = LightningService.shared + 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 { + 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) + 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 lightning.getBalanceForAddressType(.nativeSegwit) + XCTAssertGreaterThanOrEqual(balance.totalSats, 0) + Logger.test("Balance: \(balance.totalSats) sats", context: "AddressTypeIntegrationTests") + } + + func testGetChannelFundableBalance() async throws { + try await setupWalletAndNode() + + Logger.test("Getting channel fundable balance", context: "AddressTypeIntegrationTests") + let fundable = try await lightning.getChannelFundableBalance() + 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() + + // Add taproot via setMonitoring (handles restart internally so LDK creates taproot wallet) + 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() + + // Add taproot, then set taproot as selected; cannot disable selected type + 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 lightning.restart() + try await lightning.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 { SettingsViewModel.addressTypeToString($0) }.joined(separator: ","))", + context: "AddressTypeIntegrationTests" + ) + } +} diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift new file mode 100644 index 00000000..6b12a65a --- /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: - addressTypeToString + + func testAddressTypeToString() { + XCTAssertEqual(SettingsViewModel.addressTypeToString(.legacy), "legacy") + XCTAssertEqual(SettingsViewModel.addressTypeToString(.nestedSegwit), "nestedSegwit") + XCTAssertEqual(SettingsViewModel.addressTypeToString(.nativeSegwit), "nativeSegwit") + XCTAssertEqual(SettingsViewModel.addressTypeToString(.taproot), "taproot") + } + + // MARK: - stringToAddressType + + func testStringToAddressType() { + XCTAssertEqual(SettingsViewModel.stringToAddressType("legacy"), .legacy) + XCTAssertEqual(SettingsViewModel.stringToAddressType("nestedSegwit"), .nestedSegwit) + XCTAssertEqual(SettingsViewModel.stringToAddressType("nativeSegwit"), .nativeSegwit) + XCTAssertEqual(SettingsViewModel.stringToAddressType("taproot"), .taproot) + } + + func testStringToAddressTypeInvalidReturnsNil() { + XCTAssertNil(SettingsViewModel.stringToAddressType("invalid")) + XCTAssertNil(SettingsViewModel.stringToAddressType("")) + XCTAssertNil(SettingsViewModel.stringToAddressType("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 00000000..3eef7fa8 --- /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") + } +}