Skip to content

Commit 7271b55

Browse files
authored
Merge pull request #207 from synonymdev/feat/force-close
feat: Force Close
2 parents ea0267b + 1fba8cc commit 7271b55

File tree

9 files changed

+291
-44
lines changed

9 files changed

+291
-44
lines changed

Bitkit/AppScene.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ struct AppScene: View {
5050
_currency = StateObject(wrappedValue: CurrencyViewModel())
5151
_blocktank = StateObject(wrappedValue: BlocktankViewModel())
5252
_activity = StateObject(wrappedValue: ActivityListViewModel(transferService: transferService))
53-
_transfer = StateObject(wrappedValue: TransferViewModel(transferService: transferService))
53+
_transfer = StateObject(wrappedValue: TransferViewModel(transferService: transferService, sheetViewModel: sheetViewModel))
5454
_widgets = StateObject(wrappedValue: WidgetsViewModel())
5555
_settings = StateObject(wrappedValue: SettingsViewModel.shared)
5656

Bitkit/Components/NodeStateView.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ struct IdentifiableLightningBalance: Identifiable {
2929

3030
struct NodeStateView: View {
3131
@EnvironmentObject var wallet: WalletViewModel
32+
@EnvironmentObject var app: AppViewModel
3233

3334
@State private var closingChannels: [String] = []
3435

@@ -118,7 +119,29 @@ struct NodeStateView: View {
118119
Text("\(peer.nodeId)@\(peer.address)")
119120
.font(.caption)
120121
Spacer()
121-
Text(peer.isConnected ? "" : "")
122+
123+
Button {
124+
Task { @MainActor in
125+
do {
126+
try await wallet.disconnectPeer(peer)
127+
app.toast(
128+
type: .info,
129+
title: tTodo("success"),
130+
description: tTodo("Peer disconnected.")
131+
)
132+
} catch {
133+
app.toast(
134+
type: .error,
135+
title: "error_occured",
136+
description: error.localizedDescription
137+
)
138+
}
139+
}
140+
} label: {
141+
Image(systemName: "minus.circle")
142+
.foregroundColor(.redAccent)
143+
}
144+
.buttonStyle(.plain)
122145
}
123146
}
124147
}

Bitkit/MainNavView.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ struct MainNavView: View {
139139
) {
140140
config in SendSheet(config: config)
141141
}
142+
.sheet(
143+
item: $sheets.forceTransferSheetItem,
144+
onDismiss: {
145+
sheets.hideSheet()
146+
}
147+
) {
148+
config in ForceTransferSheet(config: config)
149+
}
142150
.accentColor(.white)
143151
.overlay {
144152
TabBar()

Bitkit/Services/LightningService.swift

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -406,29 +406,60 @@ class LightningService {
406406
}
407407
}
408408

409-
func closeChannel(userChannelId: ChannelId, counterpartyNodeId: PublicKey) async throws {
409+
func closeChannel(userChannelId: ChannelId, counterpartyNodeId: PublicKey, force: Bool = false, forceCloseReason: String? = nil) async throws {
410410
guard let node else {
411411
throw AppError(serviceError: .nodeNotStarted)
412412
}
413413

414414
return try await ServiceQueue.background(.ldk) {
415-
try node.closeChannel(
416-
userChannelId: userChannelId,
417-
counterpartyNodeId: counterpartyNodeId
418-
)
415+
Logger.debug("Initiating channel close (force=\(force)): userChannelId=\(userChannelId)", context: "LightningService")
416+
417+
if force {
418+
try node.forceCloseChannel(
419+
userChannelId: userChannelId,
420+
counterpartyNodeId: counterpartyNodeId,
421+
reason: forceCloseReason ?? ""
422+
)
423+
} else {
424+
try node.closeChannel(
425+
userChannelId: userChannelId,
426+
counterpartyNodeId: counterpartyNodeId
427+
)
428+
}
419429
}
420430
}
421431

422-
func closeChannel(_ channel: ChannelDetails) async throws {
432+
func closeChannel(_ channel: ChannelDetails, force: Bool = false, forceCloseReason: String? = nil) async throws {
423433
guard let node else {
424434
throw AppError(serviceError: .nodeNotStarted)
425435
}
426436

427-
return try await ServiceQueue.background(.ldk) {
428-
try node.closeChannel(
429-
userChannelId: channel.userChannelId,
430-
counterpartyNodeId: channel.counterpartyNodeId
431-
)
437+
Logger.debug("closeChannel called to channel=\(channel), force=\(force)", context: "LightningService")
438+
439+
return try await closeChannel(
440+
userChannelId: channel.userChannelId,
441+
counterpartyNodeId: channel.counterpartyNodeId,
442+
force: force,
443+
forceCloseReason: forceCloseReason
444+
)
445+
}
446+
447+
func disconnectPeer(peer: PeerDetails) async throws {
448+
guard let node else {
449+
throw AppError(serviceError: .nodeNotSetup)
450+
}
451+
452+
let uri = "\(peer.nodeId)@\(peer.address)"
453+
Logger.debug("Disconnecting peer: \(uri)")
454+
455+
do {
456+
try await ServiceQueue.background(.ldk) {
457+
try node.disconnect(nodeId: peer.nodeId)
458+
}
459+
Logger.info("Peer disconnected: \(uri)")
460+
} catch {
461+
Logger.warn("Peer disconnect error: \(uri)")
462+
throw error
432463
}
433464
}
434465

Bitkit/ViewModels/SheetViewModel.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ enum SheetID: String, CaseIterable {
55
case appUpdate
66
case backup
77
case boost
8+
case forceTransfer
89
case forgotPin
910
case gift
1011
case highBalance
@@ -287,4 +288,16 @@ class SheetViewModel: ObservableObject {
287288
}
288289
}
289290
}
291+
292+
var forceTransferSheetItem: ForceTransferSheetItem? {
293+
get {
294+
guard let config = activeSheetConfiguration, config.id == .forceTransfer else { return nil }
295+
return ForceTransferSheetItem()
296+
}
297+
set {
298+
if newValue == nil {
299+
activeSheetConfiguration = nil
300+
}
301+
}
302+
}
290303
}

Bitkit/ViewModels/TransferViewModel.swift

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class TransferViewModel: ObservableObject {
2727
private let lightningService: LightningService
2828
private let currencyService: CurrencyService
2929
private let transferService: TransferService
30+
private let sheetViewModel: SheetViewModel
3031

3132
private var refreshTimer: Timer?
3233
private var refreshTask: Task<Void, Never>?
@@ -39,19 +40,22 @@ class TransferViewModel: ObservableObject {
3940
coreService: CoreService = .shared,
4041
lightningService: LightningService = .shared,
4142
currencyService: CurrencyService = .shared,
42-
transferService: TransferService
43+
transferService: TransferService,
44+
sheetViewModel: SheetViewModel
4345
) {
4446
self.coreService = coreService
4547
self.lightningService = lightningService
4648
self.currencyService = currencyService
4749
self.transferService = transferService
50+
self.sheetViewModel = sheetViewModel
4851
}
4952

5053
/// Convenience initializer for testing and previews
5154
convenience init(
5255
coreService: CoreService = .shared,
5356
lightningService: LightningService = .shared,
54-
currencyService: CurrencyService = .shared
57+
currencyService: CurrencyService = .shared,
58+
sheetViewModel: SheetViewModel = SheetViewModel()
5559
) {
5660
let transferService = TransferService(
5761
lightningService: lightningService,
@@ -61,7 +65,8 @@ class TransferViewModel: ObservableObject {
6165
coreService: coreService,
6266
lightningService: lightningService,
6367
currencyService: currencyService,
64-
transferService: transferService
68+
transferService: transferService,
69+
sheetViewModel: sheetViewModel
6570
)
6671
}
6772

@@ -529,22 +534,9 @@ class TransferViewModel: ObservableObject {
529534

530535
func closeChannels(channels: [ChannelDetails]) async throws -> [ChannelDetails] {
531536
var failedChannels: [ChannelDetails] = []
537+
var successfulChannels: [ChannelDetails] = []
532538

533-
// Create transfer tracking records for each channel being closed
534-
for channel in channels {
535-
do {
536-
let transferId = try await transferService.createTransfer(
537-
type: .toSavings,
538-
amountSats: channel.amountOnClose,
539-
channelId: channel.channelId.description
540-
)
541-
Logger.info("Created transfer tracking record for channel closure: \(transferId)", context: "TransferViewModel")
542-
} catch {
543-
Logger.error("Failed to create transfer tracking record for channel: \(channel.channelId)", context: error.localizedDescription)
544-
// Continue with closure even if tracking fails
545-
}
546-
}
547-
539+
// Close channels in parallel and track which ones succeeded
548540
try await withThrowingTaskGroup(of: ChannelDetails?.self) { group in
549541
for channel in channels {
550542
group.addTask {
@@ -566,6 +558,26 @@ class TransferViewModel: ObservableObject {
566558
}
567559
}
568560

561+
// Determine which channels closed successfully
562+
successfulChannels = channels.filter { channel in
563+
!failedChannels.contains { $0.channelId == channel.channelId }
564+
}
565+
566+
// Create transfer tracking records only for successfully closed channels
567+
for channel in successfulChannels {
568+
do {
569+
let transferId = try await transferService.createTransfer(
570+
type: .toSavings,
571+
amountSats: channel.amountOnClose,
572+
channelId: channel.channelId.description
573+
)
574+
Logger.info("Created transfer tracking record for channel closure: \(transferId)", context: "TransferViewModel")
575+
} catch {
576+
Logger.error("Failed to create transfer tracking record for channel: \(channel.channelId)", context: error.localizedDescription)
577+
// Don't fail the entire operation - the channel is already closed
578+
}
579+
}
580+
569581
// Sync transfer states after attempting closures
570582
try? await transferService.syncTransferStates()
571583

@@ -605,8 +617,71 @@ class TransferViewModel: ObservableObject {
605617
try? await Task.sleep(nanoseconds: UInt64(retryInterval * 1_000_000_000))
606618
}
607619

608-
Logger.info("Giving up on coop close.")
609-
// TODO: Show force transfer UI
620+
Logger.info("Giving up on coop close. Showing force transfer UI.")
621+
622+
// Show force transfer sheet
623+
sheetViewModel.showSheet(.forceTransfer)
624+
}
625+
}
626+
627+
/// Force close all channels that failed to cooperatively close
628+
func forceCloseChannel() async throws {
629+
guard !channelsToClose.isEmpty else {
630+
Logger.warn("No channels to force close")
631+
return
632+
}
633+
634+
Logger.info("Force closing \(channelsToClose.count) channel(s)")
635+
636+
var errors: [(channelId: String, error: Error)] = []
637+
var successfulChannels: [ChannelDetails] = []
638+
639+
for channel in channelsToClose {
640+
do {
641+
// Force close the channel first
642+
try await lightningService.closeChannel(
643+
channel,
644+
force: true,
645+
forceCloseReason: "User requested force close after cooperative close failed"
646+
)
647+
Logger.info("Successfully initiated force close for channel: \(channel.channelId)")
648+
successfulChannels.append(channel)
649+
650+
// Only create transfer tracking record if force close succeeded
651+
do {
652+
let transferId = try await transferService.createTransfer(
653+
type: .toSavings,
654+
amountSats: channel.amountOnClose,
655+
channelId: channel.channelId.description
656+
)
657+
Logger.info("Created transfer tracking record for force channel closure: \(transferId)", context: "TransferViewModel")
658+
} catch {
659+
Logger.error(
660+
"Failed to create transfer tracking record for force-closed channel: \(channel.channelId)",
661+
context: error.localizedDescription
662+
)
663+
// Don't fail the entire operation - the channel is already force-closed
664+
}
665+
} catch {
666+
Logger.error("Failed to force close channel: \(channel.channelId)", context: error.localizedDescription)
667+
errors.append((channelId: channel.channelId, error: error))
668+
}
669+
}
670+
671+
// Remove successfully closed channels from the list
672+
channelsToClose.removeAll { channel in
673+
successfulChannels.contains { $0.channelId == channel.channelId }
674+
}
675+
676+
try? await transferService.syncTransferStates()
677+
678+
// If any errors occurred, throw an aggregated error
679+
if !errors.isEmpty {
680+
let errorMessages = errors.map { "\($0.channelId): \($0.error.localizedDescription)" }.joined(separator: ", ")
681+
throw AppError(
682+
message: "Failed to force close \(errors.count) of \(errors.count + successfulChannels.count) channel(s)",
683+
debugMessage: errorMessages
684+
)
610685
}
611686
}
612687
}

Bitkit/ViewModels/WalletViewModel.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,8 +432,18 @@ class WalletViewModel: ObservableObject {
432432
}
433433
}
434434

435-
func closeChannel(_ channel: ChannelDetails) async throws {
436-
try await lightningService.closeChannel(userChannelId: channel.userChannelId, counterpartyNodeId: channel.counterpartyNodeId)
435+
func closeChannel(_ channel: ChannelDetails, force: Bool = false, forceCloseReason: String? = nil) async throws {
436+
try await lightningService.closeChannel(
437+
userChannelId: channel.userChannelId,
438+
counterpartyNodeId: channel.counterpartyNodeId,
439+
force: force,
440+
forceCloseReason: forceCloseReason
441+
)
442+
syncState()
443+
}
444+
445+
func disconnectPeer(_ peer: PeerDetails) async throws {
446+
try await lightningService.disconnectPeer(peer: peer)
437447
syncState()
438448
}
439449

0 commit comments

Comments
 (0)