From 9d6217be7283297d665ad38b4fefa2828258a963 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Jan 2026 13:33:11 -0500 Subject: [PATCH] fix: prevent force close channels of trusted peers --- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Services/LightningService.swift | 23 ++++++ Bitkit/ViewModels/TransferViewModel.swift | 46 +++++++++--- .../CloseConnectionConfirmation.swift | 73 +++++++++++++------ Bitkit/Views/Sheets/ForceTransferSheet.swift | 12 +-- .../Views/Transfer/SavingsProgressView.swift | 35 +++++++-- 6 files changed, 146 insertions(+), 44 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 84df6832..57233572 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -254,6 +254,7 @@ "lightning__force_init_msg" = "Your funds will be accessible in ±14 days."; "lightning__force_failed_title" = "Force Transfer Failed"; "lightning__force_failed_msg" = "Unable to transfer your funds back to savings. Please try again."; +"lightning__force_channels_skipped" = "Some connections could not be closed."; "lightning__channel_opened_title" = "Spending Balance Ready"; "lightning__channel_opened_msg" = "You can now pay anyone, anywhere, instantly."; "lightning__order_given_up_title" = "Instant Payments Setup Failed"; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 99279c94..6b294a48 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -503,6 +503,17 @@ class LightningService { Logger.debug("closeChannel called to channel=\(channel), force=\(force)", context: "LightningService") + // Prevent force closing channels with trusted peers (LSP nodes) + if force { + let trustedPeerIds = Set(getLspPeerNodeIds()) + if trustedPeerIds.contains(channel.counterpartyNodeId.description) { + throw AppError( + message: "Cannot force close channel with trusted peer", + debugMessage: "Force close is disabled for Blocktank LSP channels. Please use cooperative close instead." + ) + } + } + return try await closeChannel( userChannelId: channel.userChannelId, counterpartyNodeId: channel.counterpartyNodeId, @@ -691,6 +702,18 @@ extension LightningService { !lspNodeIds.contains(channel.counterpartyNodeId) } } + + /// Separates channels into trusted (LSP) and non-trusted peers + func separateTrustedChannels(_ channels: [ChannelDetails]) -> (trusted: [ChannelDetails], nonTrusted: [ChannelDetails]) { + let trustedPeerIds = Set(getLspPeerNodeIds()) + let trusted = channels.filter { channel in + trustedPeerIds.contains(channel.counterpartyNodeId.description) + } + let nonTrusted = channels.filter { channel in + !trustedPeerIds.contains(channel.counterpartyNodeId.description) + } + return (trusted: trusted, nonTrusted: nonTrusted) + } } // MARK: Events diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 0527b251..9c2d7fe6 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -22,6 +22,7 @@ class TransferViewModel: ObservableObject { @Published var transferValues = TransferValues() @Published var selectedChannelIds: [String] = [] @Published var channelsToClose: [ChannelDetails] = [] + @Published var transferUnavailable = false private let coreService: CoreService private let lightningService: LightningService @@ -598,26 +599,50 @@ class TransferViewModel: ObservableObject { try? await Task.sleep(nanoseconds: UInt64(retryInterval * 1_000_000_000)) } - Logger.info("Giving up on coop close. Showing force transfer UI.") + Logger.info("Giving up on coop close. Checking if force close is possible.") - // Show force transfer sheet - sheetViewModel.showSheet(.forceTransfer) + // Check if any channels can be force closed (filter out trusted peers) + let (_, nonTrustedChannels) = lightningService.separateTrustedChannels(channelsToClose) + + if !nonTrustedChannels.isEmpty { + sheetViewModel.showSheet(.forceTransfer) + } else { + Logger.warn("All channels are with trusted peers. Cannot force close.") + channelsToClose.removeAll() + transferUnavailable = true + } } } /// Force close all channels that failed to cooperatively close - func forceCloseChannel() async throws { + /// Returns the number of trusted peer channels that were skipped + func forceCloseChannel() async throws -> Int { guard !channelsToClose.isEmpty else { Logger.warn("No channels to force close") - return + return 0 + } + + // Filter out trusted peer channels (cannot force close LSP channels) + let (trustedChannels, nonTrustedChannels) = lightningService.separateTrustedChannels(channelsToClose) + + if !trustedChannels.isEmpty { + Logger.warn("Skipping \(trustedChannels.count) trusted peer channel(s)") } - Logger.info("Force closing \(channelsToClose.count) channel(s)") + guard !nonTrustedChannels.isEmpty else { + channelsToClose.removeAll() + throw AppError( + message: "Cannot force close channels with trusted peer", + debugMessage: "All channels are with trusted peers (LSP). Force close is disabled." + ) + } + + Logger.info("Force closing \(nonTrustedChannels.count) channel(s)") var errors: [(channelId: String, error: Error)] = [] var successfulChannels: [ChannelDetails] = [] - for channel in channelsToClose { + for channel in nonTrustedChannels { do { // Force close the channel first try await lightningService.closeChannel( @@ -649,9 +674,10 @@ class TransferViewModel: ObservableObject { } } - // Remove successfully closed channels from the list + // Remove successfully closed channels and trusted peer channels from the list channelsToClose.removeAll { channel in - successfulChannels.contains { $0.channelId == channel.channelId } + successfulChannels.contains { $0.channelId == channel.channelId } || + trustedChannels.contains { $0.channelId == channel.channelId } } try? await transferService.syncTransferStates() @@ -664,6 +690,8 @@ class TransferViewModel: ObservableObject { debugMessage: errorMessages ) } + + return trustedChannels.count } } diff --git a/Bitkit/Views/Settings/Advanced/CloseConnectionConfirmation.swift b/Bitkit/Views/Settings/Advanced/CloseConnectionConfirmation.swift index c3024353..11ee63fe 100644 --- a/Bitkit/Views/Settings/Advanced/CloseConnectionConfirmation.swift +++ b/Bitkit/Views/Settings/Advanced/CloseConnectionConfirmation.swift @@ -66,43 +66,68 @@ struct CloseConnectionConfirmation: View { ) } } else { - // Failed to close - store failed channels and show force close dialog + // Failed to close - check if we can force close DispatchQueue.main.async { dismiss() - // Show error toast - app.toast( - type: .error, - title: t("lightning__close_error"), - description: t("lightning__close_error_msg") - ) - - // Store the failed channels for force close - transfer.channelsToClose = failedChannels - - // Show force transfer sheet - sheets.showSheet(.forceTransfer) + // Check if failed channels are trusted peers (cannot force close) + let (_, nonTrustedFailedChannels) = LightningService.shared.separateTrustedChannels(failedChannels) + + if !nonTrustedFailedChannels.isEmpty { + // Show error toast + app.toast( + type: .error, + title: t("lightning__close_error"), + description: t("lightning__close_error_msg") + ) + + // Store the failed non-trusted channels for force close + transfer.channelsToClose = nonTrustedFailedChannels + + // Show force transfer sheet + sheets.showSheet(.forceTransfer) + } else { + // All failed channels are trusted peers - cannot force close + app.toast( + type: .error, + title: t("lightning__close_error"), + description: t("lightning__close_error_msg") + ) + } } } } catch { Logger.error("Failed to close channel: \(error)") - // On error, also offer force close option + // On error, check if we can force close DispatchQueue.main.async { dismiss() - // Show error toast - app.toast( - type: .error, - title: t("lightning__close_error"), - description: error.localizedDescription - ) + // Check if channel is a trusted peer (cannot force close) + let (trustedChannels, _) = LightningService.shared.separateTrustedChannels([channel]) + let isTrustedPeer = !trustedChannels.isEmpty - // Store the channel for force close - transfer.channelsToClose = [channel] + if isTrustedPeer { + // Cannot force close trusted peer channel + app.toast( + type: .error, + title: t("lightning__close_error"), + description: t("lightning__close_error_msg") + ) + } else { + // Show error toast + app.toast( + type: .error, + title: t("lightning__close_error"), + description: error.localizedDescription + ) + + // Store the channel for force close + transfer.channelsToClose = [channel] - // Show force transfer sheet - sheets.showSheet(.forceTransfer) + // Show force transfer sheet + sheets.showSheet(.forceTransfer) + } } } diff --git a/Bitkit/Views/Sheets/ForceTransferSheet.swift b/Bitkit/Views/Sheets/ForceTransferSheet.swift index ccd4dce1..9fba2250 100644 --- a/Bitkit/Views/Sheets/ForceTransferSheet.swift +++ b/Bitkit/Views/Sheets/ForceTransferSheet.swift @@ -40,13 +40,13 @@ struct ForceTransferSheet: View { Task { @MainActor in do { - try await transfer.forceCloseChannel() + let skippedCount = try await transfer.forceCloseChannel() sheets.hideSheet() - app.toast( - type: .success, - title: t("lightning__force_init_title"), - description: t("lightning__force_init_msg") - ) + + let description = skippedCount > 0 + ? "\(t("lightning__force_init_msg")) \(t("lightning__force_channels_skipped"))" + : t("lightning__force_init_msg") + app.toast(type: .success, title: t("lightning__force_init_title"), description: description) } catch { Logger.error("Force transfer failed", context: error.localizedDescription) app.toast( diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index e119ae27..99aed9fd 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -122,6 +122,7 @@ struct SavingsProgressContentView: View { struct SavingsProgressView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var transfer: TransferViewModel + @EnvironmentObject var navigation: NavigationViewModel @State private var progressState: SavingsProgressState = .inProgress var body: some View { @@ -143,12 +144,26 @@ struct SavingsProgressView: View { progressState = .success } } else { - withAnimation { - progressState = .failed + // Check if any channels can be retried (filter out trusted peers) + let (_, nonTrustedChannels) = LightningService.shared.separateTrustedChannels(channelsFailedToCoopClose) + + if nonTrustedChannels.isEmpty { + // All channels are trusted peers - show error and navigate back + UIApplication.shared.isIdleTimerDisabled = false + app.toast( + type: .error, + title: t("lightning__close_error"), + description: t("lightning__close_error_msg") + ) + navigation.reset() + } else { + withAnimation { + progressState = .failed + } + + // Start retrying the cooperative close for non-trusted channels + transfer.startCoopCloseRetries(channels: nonTrustedChannels) } - - // Start retrying the cooperative close - transfer.startCoopCloseRetries(channels: channelsFailedToCoopClose) } } catch { app.toast(error) @@ -158,6 +173,16 @@ struct SavingsProgressView: View { // Ensure we re-enable screen timeout when view disappears UIApplication.shared.isIdleTimerDisabled = false } + .onChange(of: transfer.transferUnavailable) { unavailable in + if unavailable { + transfer.transferUnavailable = false + app.toast( + type: .error, + title: t("lightning__close_error"), + description: t("lightning__close_error_msg") + ) + } + } } }