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
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
28 changes: 28 additions & 0 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,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,
Expand Down Expand Up @@ -652,6 +663,23 @@ extension LightningService {
try node.getAddressBalance(addressStr: address)
}
}

/// Returns LSP (Blocktank) peer node IDs
func getLspPeerNodeIds() -> [String] {
return Env.trustedLnPeers.map(\.nodeId)
}

/// 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
Expand Down
46 changes: 37 additions & 9 deletions Bitkit/ViewModels/TransferViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -664,6 +690,8 @@ class TransferViewModel: ObservableObject {
debugMessage: errorMessages
)
}

return trustedChannels.count
}
}

Expand Down
73 changes: 49 additions & 24 deletions Bitkit/Views/Settings/Advanced/CloseConnectionConfirmation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
12 changes: 6 additions & 6 deletions Bitkit/Views/Sheets/ForceTransferSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
35 changes: 30 additions & 5 deletions Bitkit/Views/Transfer/SavingsProgressView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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")
)
}
}
}
}

Expand Down
Loading