diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index af446d36..18c3d06b 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -69,17 +69,18 @@ enum Env { static var electrumServerUrl: String { if isE2E { - return "127.0.0.1:60001" + return "tcp://127.0.0.1:60001" } + switch network { - case .regtest: - return "34.65.252.32:18483" case .bitcoin: - return "35.187.18.233:18484" - case .testnet: - fatalError("Testnet network not implemented") + return "ssl://35.187.18.233:8900" case .signet: fatalError("Signet network not implemented") + case .testnet: + return "ssl://electrum.blockstream.info:60002" + case .regtest: + return "tcp://34.65.252.32:18483" } } diff --git a/Bitkit/Models/ElectrumServer.swift b/Bitkit/Models/ElectrumServer.swift index 1e312551..cc7be429 100644 --- a/Bitkit/Models/ElectrumServer.swift +++ b/Bitkit/Models/ElectrumServer.swift @@ -14,6 +14,12 @@ struct ElectrumServer: Equatable, Codable { return "\(host):\(port)" } + /// Returns the full URL with protocol prefix (tcp:// or ssl://) + var fullUrl: String { + let protocolPrefix = protocolType == .ssl ? "ssl://" : "tcp://" + return "\(protocolPrefix)\(host):\(port)" + } + var portString: String { return String(port) } diff --git a/Bitkit/Services/ElectrumConfigService.swift b/Bitkit/Services/ElectrumConfigService.swift index 658d1ebb..a55f9c77 100644 --- a/Bitkit/Services/ElectrumConfigService.swift +++ b/Bitkit/Services/ElectrumConfigService.swift @@ -28,7 +28,14 @@ class ElectrumConfigService { /// Gets the default server parsed from Env.electrumServerUrl func getDefaultServer() -> ElectrumServer { let defaultServerUrl = Env.electrumServerUrl - let components = defaultServerUrl.split(separator: ":") + + guard defaultServerUrl.hasPrefix("tcp://") || defaultServerUrl.hasPrefix("ssl://") else { + fatalError("Invalid default Electrum server URL format: \(defaultServerUrl). Expected tcp:// or ssl:// prefix.") + } + + let protocolType: ElectrumProtocol = defaultServerUrl.hasPrefix("ssl://") ? .ssl : .tcp + let urlWithoutProtocol = String(defaultServerUrl.dropFirst(6)) // Remove "ssl://" or "tcp://" + let components = urlWithoutProtocol.split(separator: ":") guard components.count >= 2 else { fatalError("Invalid default Electrum server URL: \(defaultServerUrl)") @@ -36,7 +43,6 @@ class ElectrumConfigService { let host = String(components[0]) let port = String(components[1]) - let protocolType = getProtocolForPort(port) return ElectrumServer(host: host, portString: port, protocolType: protocolType) } @@ -45,7 +51,7 @@ class ElectrumConfigService { func saveServerConfig(_ server: ElectrumServer) { do { electrumServerData = try JSONEncoder().encode(server) - Logger.info("Saved Electrum server config: \(server.url) (\(server.protocolType.rawValue))") + Logger.info("Saved Electrum server config: \(server.fullUrl)") } catch { Logger.error(error, context: "Failed to encode Electrum server config") } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index a8732f47..95f16369 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -119,10 +119,52 @@ class LightningService { } // Restart the node with the current configuration - try await setup(walletIndex: currentWalletIndex, electrumServerUrl: electrumServerUrl, rgsServerUrl: rgsServerUrl) - try await start() + do { + try await setup(walletIndex: currentWalletIndex, electrumServerUrl: electrumServerUrl, rgsServerUrl: rgsServerUrl) + try await start() + Logger.info("Node restarted successfully") + } catch { + Logger.warn("Failed ldk-node config change, attempting recovery…") + // Attempt to restart with previous config + // If recovery fails, log it but still throw the original error + do { + try await restartWithPreviousConfig() + } catch { + Logger.error("Recovery attempt also failed: \(error)") + } + // Always re-throw the original error that caused the restart failure + throw error + } + } + + /// Restarts the node with the previous stored configuration (recovery method) + /// This is called when a config change fails to restore the node to a working state + private func restartWithPreviousConfig() async throws { + Logger.debug("Stopping node for recovery attempt") + + // Stop the current node if it exists + if node != nil { + do { + try await stop() + } catch { + Logger.error("Failed to stop node during recovery: \(error)") + // Clear the node reference anyway + node = nil + try? StateLocker.unlock(.lightning) + } + } + + Logger.debug("Starting node with previous config for recovery") - Logger.info("Node restarted successfully") + do { + // Restart with nil URLs to use stored/default configuration + try await setup(walletIndex: currentWalletIndex, electrumServerUrl: nil, rgsServerUrl: nil) + try await start() + Logger.debug("Successfully started node with previous config") + } catch { + Logger.error("Failed starting node with previous config: \(error)") + throw error + } } /// Pass onEvent when being used in the background to listen for payments, channels, closes, etc diff --git a/Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift b/Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift index 3f0624fd..32c7cb0a 100644 --- a/Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift +++ b/Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift @@ -32,36 +32,60 @@ extension SettingsViewModel { return (success: false, host: host, port: port, errorMessage: validationError) } - // Update current server config immediately after validation - electrumCurrentServer = ElectrumServer( + // Create server config (don't save yet - only save after successful connection) + let serverConfig = ElectrumServer( host: host, portString: port, protocolType: electrumSelectedProtocol ) do { - // Save the configuration to settings - electrumConfigService.saveServerConfig(electrumCurrentServer) - // Restart the Lightning node with the new Electrum server let currentRgsUrl = rgsConfigService.getCurrentServerUrl() try await lightningService.restart( - electrumServerUrl: electrumCurrentServer.url, + electrumServerUrl: serverConfig.fullUrl, rgsServerUrl: currentRgsUrl.isEmpty ? nil : currentRgsUrl ) + // Wait a bit for the connection to establish and verify it's actually working + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + + // Verify the node is actually running and connected + guard let status = lightningService.status, status.isRunning else { + electrumIsLoading = false + Logger.error("Electrum connection failed: Node is not running after restart") + + // Reload form and connection status from actual current server (node may have fallen back to previous server) + let actualServer = electrumConfigService.getCurrentServer() + updateForm(with: actualServer) + electrumCurrentServer = actualServer + // Check if node is actually running with the previous server + electrumIsConnected = lightningService.status?.isRunning == true + + return (success: false, host: host, port: port, errorMessage: t("settings__es__server_error_description")) + } + + // Only save the configuration after successful connection validation + electrumConfigService.saveServerConfig(serverConfig) + electrumCurrentServer = serverConfig electrumIsConnected = true electrumIsLoading = false - Logger.info("Successfully connected to Electrum server: \(electrumCurrentServer.url)") + Logger.info("Successfully connected to Electrum server: \(serverConfig.fullUrl)") return (success: true, host: host, port: port, errorMessage: nil) } catch { - electrumIsConnected = false electrumIsLoading = false Logger.error(error, context: "Failed to connect to Electrum server") + // Reload form and connection status from actual current server (node may have fallen back to previous server) + let actualServer = electrumConfigService.getCurrentServer() + updateForm(with: actualServer) + electrumCurrentServer = actualServer + // Check if node is actually running with the previous server + electrumIsConnected = lightningService.status?.isRunning == true + return (success: false, host: host, port: port, errorMessage: nil) } } @@ -151,6 +175,20 @@ extension SettingsViewModel { } private func parseElectrumScanData(_ data: String) -> ElectrumServer? { + // Handle URLs with tcp:// or ssl:// prefix + if data.hasPrefix("tcp://") || data.hasPrefix("ssl://") { + let protocolType: ElectrumProtocol = data.hasPrefix("ssl://") ? .ssl : .tcp + let urlWithoutProtocol = String(data.dropFirst(6)) // Remove "ssl://" or "tcp://" + let components = urlWithoutProtocol.split(separator: ":") + + guard components.count >= 2 else { return nil } + + let host = String(components[0]) + let port = String(components[1]) + + return ElectrumServer(host: host, portString: port, protocolType: protocolType) + } + // Handle plain format: host:port or host:port:s (Umbrel format) if !data.hasPrefix("http://") && !data.hasPrefix("https://") { let parts = data.split(separator: ":") diff --git a/Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift b/Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift index e7fc6945..de112324 100644 --- a/Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift +++ b/Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift @@ -26,7 +26,7 @@ extension SettingsViewModel { rgsConfigService.saveServerUrl(url) // Restart the Lightning node with the new RGS server - let currentElectrumUrl = electrumConfigService.getCurrentServer().url + let currentElectrumUrl = electrumConfigService.getCurrentServer().fullUrl try await lightningService.restart(electrumServerUrl: currentElectrumUrl, rgsServerUrl: url.isEmpty ? nil : url) rgsIsLoading = false diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index bc3541ba..20d8eaff 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -342,9 +342,7 @@ class SettingsViewModel: NSObject, ObservableObject { } } - let electrumServer = electrumConfigService.getCurrentServer() - let protocolPrefix = electrumServer.protocolType == .ssl ? "ssl://" : "tcp://" - let electrumServerUrl = "\(protocolPrefix)\(electrumServer.url)" + let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl if !electrumServerUrl.isEmpty { dict["electrumServer"] = electrumServerUrl } let rgsServerUrl = rgsConfigService.getCurrentServerUrl() @@ -375,7 +373,7 @@ class SettingsViewModel: NSObject, ObservableObject { } if urlString.hasPrefix("tcp://") || urlString.hasPrefix("ssl://") { - let withoutProtocol = urlString.replacingOccurrences(of: "tcp://", with: "").replacingOccurrences(of: "ssl://", with: "") + let withoutProtocol = String(urlString.dropFirst(6)) // Remove "ssl://" or "tcp://" let parts = withoutProtocol.split(separator: ":") guard parts.count >= 2 else { return nil } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 3a4af3d8..921d4c4d 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -100,7 +100,7 @@ class WalletViewModel: ObservableObject { syncState() do { - let electrumServerUrl = electrumConfigService.getCurrentServer().url + let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl let rgsServerUrl = rgsConfigService.getCurrentServerUrl() try await lightningService.setup( walletIndex: walletIndex, @@ -472,10 +472,19 @@ class WalletViewModel: ObservableObject { syncBalances() } - /// Sync node status and ID only + /// Sync node status, ID and lifecycle state private func syncNodeStatus() { nodeStatus = lightningService.status nodeId = lightningService.nodeId + + // Sync lifecycle state based on service status + if let status = lightningService.status { + if status.isRunning && nodeLifecycleState != .running { + nodeLifecycleState = .running + } else if !status.isRunning && nodeLifecycleState == .running { + nodeLifecycleState = .stopped + } + } } /// Sync channels and peers only diff --git a/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift b/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift index 2d1100d1..1627e223 100644 --- a/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift +++ b/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift @@ -4,6 +4,7 @@ struct ElectrumSettingsScreen: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var wallet: WalletViewModel @FocusState private var isTextFieldFocused: Bool @@ -100,12 +101,17 @@ struct ElectrumSettingsScreen: View { } .accessibilityIdentifier("ConnectToHost") } - .buttonBottomPadding(isFocused: isTextFieldFocused) + .bottomSafeAreaPadding() } .frame(minHeight: geometry.size.height) - .bottomSafeAreaPadding() + .contentShape(Rectangle()) + .onTapGesture { + isTextFieldFocused = false + } } + .scrollDismissesKeyboard(.interactively) } + .ignoresSafeArea(.keyboard, edges: .bottom) } .navigationBarHidden(true) .padding(.horizontal, 16) @@ -117,6 +123,8 @@ struct ElectrumSettingsScreen: View { private func onConnect() { Task { let result = await settings.connectToElectrumServer() + // Sync wallet state to update node lifecycle state for app status + wallet.syncState() showToast(result.success, result.host, result.port, result.errorMessage) } } @@ -124,6 +132,8 @@ struct ElectrumSettingsScreen: View { private func onReset() { Task { let result = await settings.resetElectrumToDefault() + // Sync wallet state to update node lifecycle state for app status + wallet.syncState() showToast(result.success, result.host, result.port, result.errorMessage) } }