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..d77121ff 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" : "2281589d699cb6f821f1ad720435c8110cf1fa7c" } }, { diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 3e0fe2a1..56435ba1 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 75fbabb5..dd1a9478 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,63 @@ 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) + } + } + + /// 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) @@ -1105,4 +1182,15 @@ extension LightningService { return feeSat } } + + // 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 ceeaafec..530f0cd4 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..0145ee7f 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,232 @@ 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 } + + // 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 + } + + 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 + } + + /// 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 + 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 5bed14fc..bef21c73 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 = "" @@ -560,10 +561,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 @@ -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/Onboarding/RestoreWalletView.swift b/Bitkit/Views/Onboarding/RestoreWalletView.swift index 7123f062..9e2b8c97 100644 --- a/Bitkit/Views/Onboarding/RestoreWalletView.swift +++ b/Bitkit/Views/Onboarding/RestoreWalletView.swift @@ -252,6 +252,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..b77b6dc2 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift @@ -0,0 +1,341 @@ +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 { + // 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." + ) + } + } + } + } + } + } + } + } + + 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/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) } } } 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 {