Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions Bitkit/Constants/Env.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down
6 changes: 6 additions & 0 deletions Bitkit/Models/ElectrumServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
12 changes: 9 additions & 3 deletions Bitkit/Services/ElectrumConfigService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,21 @@ 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)")
}

let host = String(components[0])
let port = String(components[1])
let protocolType = getProtocolForPort(port)

return ElectrumServer(host: host, portString: port, protocolType: protocolType)
}
Expand All @@ -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")
}
Expand Down
48 changes: 45 additions & 3 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,52 @@
}

// 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
Expand Down Expand Up @@ -426,7 +468,7 @@
}

func closeChannel(_ channel: ChannelDetails, force: Bool = false, forceCloseReason: String? = nil) async throws {
guard let node else {

Check warning on line 471 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

value 'node' was defined but never used; consider replacing with boolean test

Check warning on line 471 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

value 'node' was defined but never used; consider replacing with boolean test
throw AppError(serviceError: .nodeNotStarted)
}

Expand Down Expand Up @@ -629,7 +671,7 @@
onEvent?(event)

switch event {
case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, feePaidMsat):

Check warning on line 674 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'paymentPreimage' was never used; consider replacing with '_' or removing it

Check warning on line 674 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'paymentPreimage' was never used; consider replacing with '_' or removing it
Logger.info("✅ Payment successful: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) feePaidMsat: \(feePaidMsat ?? 0)")
Task {
let hash = paymentId ?? paymentHash
Expand All @@ -654,7 +696,7 @@
Logger.warn("No paymentId or paymentHash available for failed payment", context: "LightningService")
}
}
case let .paymentReceived(paymentId, paymentHash, amountMsat, feePaidMsat):

Check warning on line 699 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'feePaidMsat' was never used; consider replacing with '_' or removing it

Check warning on line 699 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'feePaidMsat' was never used; consider replacing with '_' or removing it
Logger.info("🤑 Payment received: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) amountMsat: \(amountMsat)")
Task {
let hash = paymentId ?? paymentHash
Expand All @@ -664,7 +706,7 @@
Logger.error("Failed to handle payment received for \(hash): \(error)", context: "LightningService")
}
}
case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, claimDeadline, customRecords):

Check warning on line 709 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'customRecords' was never used; consider replacing with '_' or removing it

Check warning on line 709 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'claimDeadline' was never used; consider replacing with '_' or removing it

Check warning on line 709 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'customRecords' was never used; consider replacing with '_' or removing it

Check warning on line 709 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'claimDeadline' was never used; consider replacing with '_' or removing it
Logger.info(
"🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)"
)
Expand Down Expand Up @@ -693,7 +735,7 @@

if let channel {
await registerClosedChannel(channel: channel, reason: reasonString)
await MainActor.run {

Check warning on line 738 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

result of call to 'run(resultType:body:)' is unused

Check warning on line 738 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

result of call to 'run(resultType:body:)' is unused
channelCache.removeValue(forKey: channelIdString)
}
} else {
Expand All @@ -716,7 +758,7 @@
Logger.error("Failed to handle transaction received for \(txid): \(error)", context: "LightningService")
}
}
case let .onchainTransactionConfirmed(txid, blockHash, blockHeight, confirmationTime, details):

Check warning on line 761 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'confirmationTime' was never used; consider replacing with '_' or removing it

Check warning on line 761 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'blockHash' was never used; consider replacing with '_' or removing it

Check warning on line 761 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'confirmationTime' was never used; consider replacing with '_' or removing it

Check warning on line 761 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'blockHash' was never used; consider replacing with '_' or removing it
Logger.info("✅ Onchain transaction confirmed: txid=\(txid) blockHeight=\(blockHeight) amountSats=\(details.amountSats)")
Task {
do {
Expand Down Expand Up @@ -770,7 +812,7 @@

// MARK: Balance Events

case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, oldTotalOnchain, newTotalOnchain, oldLightning, newLightning):

Check warning on line 815 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'newTotalOnchain' was never used; consider replacing with '_' or removing it

Check warning on line 815 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'oldTotalOnchain' was never used; consider replacing with '_' or removing it

Check warning on line 815 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'newTotalOnchain' was never used; consider replacing with '_' or removing it

Check warning on line 815 in Bitkit/Services/LightningService.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

immutable value 'oldTotalOnchain' was never used; consider replacing with '_' or removing it
Logger
.info("💰 Balance changed: onchain=\(oldSpendableOnchain)->\(newSpendableOnchain) lightning=\(oldLightning)->\(newLightning)")
}
Expand Down
54 changes: 46 additions & 8 deletions Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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: ":")
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions Bitkit/ViewModels/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 }

Expand Down
13 changes: 11 additions & 2 deletions Bitkit/ViewModels/WalletViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -117,13 +123,17 @@ 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)
}
}

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)
}
}
Expand Down
Loading