Skip to content

Conversation

@jvsena42
Copy link
Member

@jvsena42 jvsena42 commented Nov 6, 2025

Description

This Pr implements force close UI and logic

Linked Issues/Tasks

Roadmap

Reference

Screenshot / Video

Test flow 1 - Close single channel:

  1. Open 2 channels with Polar
  2. Stop play node
  3. Go to connections -> close connection
  4. Should display and error and the force transfer channel
  5. Confirm force close
  6. Should subtract from spending immediately and show the incoming amount in savings
  7. Mine 144 blocks
  8. Should update savings balance and total balance
Simulator.Screen.Recording.-.iPhone.16.-.2025-11-07.at.20.24.17.mp4

Test flow 2 - Close all channels:

  1. In TransferViewModel.swift:35-36 use this to mock a total coop failure :
        func closeSelectedChannels() async throws -> [ChannelDetails] {
        return channelsToClose // todo mock a total channel closing failure
        // return try await closeChannels(channels: channelsToClose)
    }
    

And this to skip the retry process:

               func startCoopCloseRetries(channels: [ChannelDetails], startTime: Date = Date()) {
        channelsToClose = channels
        coopCloseRetryTask?.cancel()

        coopCloseRetryTask = Task { @MainActor [weak self] in
            guard let self else { return }
            //Skips the retry and triggers the force close sheet
            // let giveUpTime = startTime.addingTimeInterval(giveUpInterval)

            // while !Task.isCancelled && Date() < giveUpTime {
            //     Logger.info("Trying coop close...")
            //     do {
            //         let channelsFailedToCoopClose = try await closeChannels(channels: channelsToClose)

            //         if channelsFailedToCoopClose.isEmpty {
            //             channelsToClose = []
            //             Logger.info("Coop close success.")

            //             // Final sync after successful closure
            //             try? await transferService.syncTransferStates()

            //             return
            //         } else {
            //             channelsToClose = channelsFailedToCoopClose
            //             Logger.info("Coop close failed: \(channelsFailedToCoopClose.map(\.channelId))")
            //         }
            //     } catch {
            //         Logger.error("Error during coop close retry", context: error.localizedDescription)
            //     }

            //     try? await Task.sleep(nanoseconds: UInt64(retryInterval * 1_000_000_000))
            // }

            Logger.info("Giving up on coop close. Showing force transfer UI.")

            // Show force transfer sheet
            sheetViewModel.showSheet(.forceTransfer)
        }
    }
  1. Open a channel with Polar
  2. Mine 1 block
  3. Move funds to savings
  4. Expected: display error message and trigger force close sheet
  5. Confirm force close
  6. Should subtract from spending immediately and show the incoming amount in savings
  7. Mine 144 blocks
  8. Should update savings balance and total balance
    (OBS: tried to reproduce this with Bloktank but the force transaction hadn't been confirmed even after 200 blocks. It probably has a different lock time)
force.close.simulation.mp4

@jvsena42 jvsena42 self-assigned this Nov 6, 2025
@jvsena42 jvsena42 requested a review from Copilot November 6, 2025 17:19
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements the force close UI and logic for Lightning channels that fail to cooperatively close. When cooperative channel closures fail after repeated retries (30 minutes), users are presented with a force transfer sheet that allows them to forcefully close the channels.

Key Changes:

  • Added force transfer sheet UI component with user confirmation flow
  • Implemented force close logic in TransferViewModel with proper error handling
  • Extended LightningService to support both cooperative and force channel closures
  • Integrated force transfer flow into the app's sheet management system

Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
ForceTransferSheet.swift New sheet component providing UI for force close confirmation with loading states and toast notifications
TransferViewModel.swift Added force close functionality and sheet integration for failed cooperative closures
SheetViewModel.swift Added force transfer sheet configuration and management
LightningService.swift Extended closeChannel method to support force close with optional reason parameter
MainNavView.swift Registered force transfer sheet in the app's sheet presentation system
AppScene.swift Injected SheetViewModel dependency into TransferViewModel initialization

@jvsena42 jvsena42 marked this pull request as ready for review November 6, 2025 18:18
@jvsena42 jvsena42 enabled auto-merge November 6, 2025 21:57
@jvsena42
Copy link
Member Author

jvsena42 commented Nov 6, 2025

Still trying to setup the test environment, but you can do the review already, if you want

Copy link
Contributor

@ovitrif ovitrif left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code reviewed 🎉

LGTM, for test env please use setup described in my android PR:
synonymdev/bitkit-android#414

It was a reliable way to test this fully controllable for me, back then

Will also test tomorrow using same setup

@ovitrif
Copy link
Contributor

ovitrif commented Nov 7, 2025

to test we need to:

  • either add 'disconnect peer' dev settings option
  • disable coop close with comments, to force a 'force close'

@jvsena42
Copy link
Member Author

jvsena42 commented Nov 7, 2025

Added force close to individual channel close too

@jvsena42
Copy link
Member Author

jvsena42 commented Nov 8, 2025

to test we need to:

  • either add 'disconnect peer' dev settings option
  • disable coop close with comments, to force a 'force close'

To don't extend the PR too much, I commented the coop code to enforce the force flow. The disconnect peer feature will be added on node info screen polish

@jvsena42 jvsena42 marked this pull request as draft November 8, 2025 11:00
auto-merge was automatically disabled November 8, 2025 11:00

Pull request was converted to draft

@jvsena42 jvsena42 marked this pull request as ready for review November 9, 2025 09:48
@jvsena42
Copy link
Member Author

jvsena42 commented Nov 9, 2025

Test videos Updated

@jvsena42 jvsena42 requested a review from ovitrif November 9, 2025 09:49
@ben-kaufman
Copy link
Contributor

The code looks good, but when testing after mining 144 block it still doesn't show the funds back in savings for me

@jvsena42
Copy link
Member Author

jvsena42 commented Nov 10, 2025

The code looks good, but when testing after mining 144 block it still doesn't show the funds back in savings for me

Found this when connected to Blocktank but it worked when connected to an external channel. l still couldn't find the reason. I'll test the android behavior, maybe block tank has a different lock time

@ovitrif
Copy link
Contributor

ovitrif commented Nov 10, 2025

The code looks good, but when testing after mining 144 block it still doesn't show the funds back in savings for me

Found this when connected to Blocktank but it worked when connected to an external channel. l still couldn't find the reason. I'll test the android behavior, maybe block tank has a different lock time

You should be able to see block height when funds are available in Settings > Advanced> Lightning Node > scroll to bottom to check Balances

It's the number in parenthesis

@ben-kaufman
Copy link
Contributor

Screenshot 2025-11-10 at 11 57 48 AM

I see the channel and amount but not block height

@ovitrif
Copy link
Contributor

ovitrif commented Nov 10, 2025

Screenshot 2025-11-10 at 11 57 48 AM

I see the channel and amount but not block height

Mine at least 1 more block when you see this.

@ben-kaufman
Copy link
Contributor

I mined many blocks but still the same.

@ovitrif
Copy link
Contributor

ovitrif commented Nov 10, 2025

I mined many blocks but still the same.

Then the channel didn't close at all ever :)

@jvsena42 jvsena42 marked this pull request as draft November 10, 2025 21:05
@jvsena42
Copy link
Member Author

Set as draft again to investigate

@jvsena42
Copy link
Member Author

Update:
Android has the same behavior

const val RETRY_INTERVAL_MS = 1 * 1000L // 1 second in ms
const val GIVE_UP_MS = 5 * 1000L // 5 seconds in ms

 suspend fun closeSelectedChannels() = channelsToClose

    private suspend fun closeChannels(channels: List<ChannelDetails>): List<ChannelDetails> {
        // val channelsFailedToClose = coroutineScope {
        //     channels.map { channel ->
        //         async {
        //             lightningRepo.closeChannel(channel)
        //                 .onSuccess {
        //                     transferRepo.createTransfer(
        //                         type = TransferType.COOP_CLOSE,
        //                         amountSats = channel.amountOnClose.toLong(),
        //                         channelId = channel.channelId,
        //                         fundingTxId = channel.fundingTxo?.txid,
        //                     )
        //                 }
        //                 .fold(
        //                     onSuccess = { null },
        //                     onFailure = { channel }
        //                 )
        //         }
        //     }.awaitAll()
        // }.filterNotNull()

        return channels
    }
2025-11-11 07:21:45.112  4126-4216  APP                     to.bitkit.dev                        V  2025-11-11 10:21:45.112 VERBOSE [DeriveBalanceStateUseCase.kt:52] Active transfers at block height=30597: [{"id":"76321cef-c558-48be-bc4c-948a567a1ccf","type":"FORCE_CLOSE","amountSats":99718,"channelId":"02df2338f482b1461ce7f0b5a84cf96db0530da35fa2e0d232c275357b13e2f3","fundingTxId":"f3e2137b3575c232d2e0a25fa30d53b06df94ca8b5f0e71c46b182f43823df02","lspOrderId":null,"isSettled":false,"createdAt":1762856099,"settledAt":null}] - DeriveBalanceStateUseCase

2025-11-11 07:14:58.905  4126-4426  LDK                     to.bitkit.dev                        V  2025-11-11 10:14:58.905 TRACE   [lightning::chain::channelmonitor:3228] Updating ChannelMonitor: channel force closed, should broadcast: false

2025-11-11 07:14:59.382  4126-4767  APP                     to.bitkit.dev                        I  2025-11-11 10:14:59.382 INFO    [LightningService.kt:391] Channel close initiated (force=true): '02df2338f482b1461ce7f0b5a84cf96db0530da35fa2e0d232c275357b13e2f3' - LightningService

2025-11-11 07:14:59.385  4126-4724  LDK                     to.bitkit.dev                        I  2025-11-11 10:14:59.385 INFO    [ldk_node::event:1386] Channel 02df2338f482b1461ce7f0b5a84cf96db0530da35fa2e0d232c275357b13e2f3 closed due to: Channel closed because user force-closed the channel and elected not to broadcast the latest transaction

2025-11-11 07:14:59.565  4126-4126  APP                     to.bitkit.dev                        I  2025-11-11 10:14:59.565 INFO    [TransferViewModel.kt:558] Force close initiated successfully for all channels - TransferViewModel

2025-11-11 07:14:59.851  4126-4218  APP                     to.bitkit.dev                        I  2025-11-11 10:14:59.851 INFO    [LightningService.kt:757] ⛔ Channel closed: channelId: 02df2338f482b1461ce7f0b5a84cf96db0530da35fa2e0d232c275357b13e2f3 userChannelId: 163465531154917101728461856221663435835 counterpartyNodeId: 028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc reason: HolderForceClosed(broadcastedLatestTxn=false)
image

@jvsena42
Copy link
Member Author

jvsena42 commented Nov 11, 2025

What I got so far:

  • Blocktank node is a trustedLnPeers
  • Trusted peer channels use anchor outputs
  • Anchor outputs have a different outputs scripts with different CSV conditions
DEBUG: Current block height: 31732
WARN⚠️: 🔑 CLAIMABLE HEIGHT: 0 ← Output becomes spendable at this block
WARN⚠️: ⚠️ Claimable height is 0 - this may indicate LDK hasn't calculated the timelock yet

LDK has claimableHeight = 0, which means LDK is failing to calculate when this force-close output becomes spendable. This is why the funds are stuck indefinitely.

External node channels are not on trusted list so they use a non-anchor channel format. That is why it worked on the tests

Another strange this is that transactionName = 0
It suggests LDK might be completely missing the force-close transaction details.
It would be helpful is I could see the commitment transaction in the mempool

WARN⚠️:   📄 TRANSACTION NAME/ID: 0
WARN⚠️:   🔑 CLAIMABLE HEIGHT: 0 ← Output becomes spendable at this block
WARN⚠️:   ⚠️ Claimable height is 0 - this may indicate LDK hasn't calculated the timelock yet
WARN⚠️:   🔍 ANCHOR CHANNEL BUG: LDK cannot determine when CSV timelock expires

    func sync() async throws {
        guard let node else {
            throw AppError(serviceError: .nodeNotSetup)
        }

        Logger.debug("Syncing LDK...")
        try await ServiceQueue.background(.ldk) {
            try node.syncWallets()
            // try? self.setMaxDustHtlcExposureForCurrentChannels()
        }
        Logger.info("LDK synced")

        await refreshChannelCache()

        // Log detailed balance information after sync
        let balanceDetails = node.listBalances()
        let nodeStatus = node.status()
        let currentHeight = nodeStatus.currentBestBlock.height

        Logger.debug("=== LDK Balance Details After Sync ===")
        Logger.debug("Current block height: \(currentHeight)")
        Logger.debug("Total onchain: \(balanceDetails.totalOnchainBalanceSats) sats")
        Logger.debug("Total lightning: \(balanceDetails.totalLightningBalanceSats) sats")
        Logger.debug("Lightning balances count: \(balanceDetails.lightningBalances.count)")

        for (index, balance) in balanceDetails.lightningBalances.enumerated() {
            Logger.debug("Lightning Balance #\(index + 1):")

            switch balance {
            case let .claimableOnChannelClose(
                channelId,
                counterpartyNodeId,
                amountSat,
                transactionName,
                confirmationHeight,
                claimableHeight,
                paymentHash,
                paymentPreimage
            ):
                Logger.warn("  Type: ClaimableOnChannelClose")
                Logger.warn("  ⚠️ This is a FORCE-CLOSED channel balance waiting to be claimed!")
                Logger.debug("  Channel ID: \(channelId)")
                Logger.debug("  Amount: \(amountSat) sats")
                Logger.warn("  📄 TRANSACTION NAME/ID: \(transactionName)")
                Logger.debug("  Confirmation height: \(confirmationHeight)")
                Logger.warn("  🔑 CLAIMABLE HEIGHT: \(claimableHeight) ← Output becomes spendable at this block")

                // Calculate blocks remaining until claimable
                let currentHeightUInt64 = UInt64(currentHeight)
                if claimableHeight > currentHeightUInt64 {
                    let blocksRemaining = claimableHeight - currentHeightUInt64
                    Logger.warn("  ⏰ Blocks until claimable: \(blocksRemaining) (need to mine \(blocksRemaining) more blocks)")
                } else if claimableHeight > 0 {
                    Logger.warn("  ✅ Output is NOW CLAIMABLE (claimable height \(claimableHeight) <= current height \(currentHeight))")
                    Logger.warn("  🚨 LDK should sweep this automatically - if it hasn't, there may be an issue")
                } else {
                    Logger.warn("  ⚠️ Claimable height is 0 - this may indicate LDK hasn't calculated the timelock yet")
                    Logger.warn("  🔍 ANCHOR CHANNEL BUG: LDK cannot determine when CSV timelock expires")
                    Logger.warn("  💡 This is a known issue with anchor channels - funds are stuck until LDK is fixed or manual intervention")
                }

                if paymentHash != 0 {
                    Logger.debug("  Payment hash: \(paymentHash)")
                }
                if paymentPreimage != 0 {
                    Logger.debug("  Payment preimage: \(paymentPreimage)")
                }
                Logger.debug("  Counterparty: \(counterpartyNodeId)")

            case let .claimableAwaitingConfirmations(channelId, counterpartyNodeId, amountSat, confirmationHeight, transactionName):
                Logger.info("  Type: ClaimableAwaitingConfirmations")
                Logger.info("  ⏳ Sweep transaction is pending, waiting for confirmations")
                Logger.debug("  Transaction: \(transactionName)")
                Logger.debug("  Confirmation height: \(confirmationHeight)")
                Logger.debug("  Counterparty: \(counterpartyNodeId)")

            case .contentiousClaimable:
                Logger.warn("  Type: ContentiousClaimable")
            case .maybeTimeoutClaimableHtlc:
                Logger.debug("  Type: MaybeTimeoutClaimableHTLC")
            case .maybePreimageClaimableHtlc:
                Logger.debug("  Type: MaybePreimageClaimableHTLC")
            case .counterpartyRevokedOutputClaimable:
                Logger.warn("  Type: CounterpartyRevokedOutputClaimable")
            }
        }
        Logger.debug("=====================================")

        // Emit state change with sync timestamp from node status
        if let latestSyncTimestamp = nodeStatus.latestLightningWalletSyncTimestamp {
            let syncTimestamp = UInt64(latestSyncTimestamp)
            syncStatusChangedSubject.send(syncTimestamp)
        } else {
            let syncTimestamp = UInt64(Date().timeIntervalSince1970)
            syncStatusChangedSubject.send(syncTimestamp)
        }
    }

@jvsena42
Copy link
Member Author

@ovitrif I suggest merge this branch, because it has the same behavior of android, and investigate the issue in another

@jvsena42 jvsena42 marked this pull request as ready for review November 11, 2025 13:05
@ovitrif
Copy link
Contributor

ovitrif commented Nov 11, 2025

@ovitrif I suggest merge this branch, because it has the same behavior of android, and investigate the issue in another

@jvsena42
Was the feature tested otherwise in a "real world" scenario?
By force-closing channel to local LND peer, for example through bitkit-docker?!

If not, my plan was to test this myself before merging ;)

@jvsena42
Copy link
Member Author

@ovitrif I suggest merge this branch, because it has the same behavior of android, and investigate the issue in another

@jvsena42 Was the feature tested otherwise in a "real world" scenario? By force-closing channel to local LND peer, for example through bitkit-docker?!

If not, my plan was to test this myself before merging ;)

Only tested with a Polar node, but if necessary I can add the disconnect channel feature to test with bitkit-docker

@ovitrif
Copy link
Contributor

ovitrif commented Nov 12, 2025

Only tested with a Polar node, but if necessary I can add the disconnect channel feature to test with bitkit-docker

Polar test is fine as well, but since this PR is about force close, we need someone else than the author to review force close :)

I will do it, can't be that hard ^^

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants