Different Ways to Close a Lightning Payment Channel

Mabel Oza
Coinmonks
15 min readMar 24, 2023

--

In the lightning network, payment channels can close in 3 different ways, and they're good, bad, and ugly. The good is a "collaborative close" when both parties work together to close the channel. The bad is a "force close" when one party can't close the channel. The ugly is a "dispute close" when there's a force close, but the other party comes back and disputes the close initiated by the other party.

😃 Collaborative Close — Good

  1. Negotiate the final channel balance
  2. Initiate & Exchange Close Requests
  3. Sign, Exchange & Broadcast Close Requests to the Network
  4. Wait for Confirmations on the Network
  5. Remove the Channel

💻 Code to do a Collaborative Close

📜 Example of a Collaborative Close Transaction

😞 Force Close — Bad

  1. Initiate, Sign, and Broadcast the Force Close Request
  2. Wait for the Force Close to Confirm
  3. Remove the Channel

💻 Code to do a Force Close

📜 Example Forced Close Transaction

😡 Disputed Close — Ugly

💻 Code of a Dispute Close Transaction

🗼Watch Tower

📚Hooked? Dive into the Rabbit Hole

😃 Collaborative Close

A collaborative close in the Lightning Network is a channel closure where both parties agree to close the channel. With a collaborative close, both parties sign off on closing the payment channel and get their funds back immediately. Below are the steps to a collaborative close.

1. Negotiate the Final Channel Balance

Before initiating the channel closing, the parties should agree on the final balance. This can be done by exchanging commitment transactions until they reach a final balance that they both agree on.

2. Initiate Close Channel Requests & Exchange Requests

Each party must initialize a close channel request in a collaborative close. Before the request is ever signed and broadcasted to the network, it must be exchanged to ensure everyone is on the right page.

An initialize request in LND implementation involves the following details:

  • Channel Point: a unique identifier for a payment channel established between two parties. It consists of the channel funding transaction hash and the index of the transaction output that funds the channel.
  • Target Confirmations: How many blocks need to be mined after the close txn to consider the txn done?
  • Sats Per Byte: This is the Bitcoin fee rate the party is willing to pay on the close transaction.
  • Force: Since this is not a force close, force is set to false.
  • Delivery Address: Specifies an address to receive the funds from the closed channel. If left empty, the funds will still be split between the two parties according to their channel balance, but any unspendable funds will go to the node that initiated (Alice) the channel closure request.

Below is the code (using the LND go implementation) to initiate the close:

// Initiate the cooperative channel close.
closeReq := &lnrpc.CloseChannelRequest{
ChannelPoint: channelPoint,
TargetConf: 0, // Wait for the next block.
SatPerByte: 1, // Set the fee rate.
Force: false, // Initiate a cooperative closure.
DeliveryAddress: "your_bitcoin_address", // Optional.
}
closeResp, err := client.CloseChannel(ctx, closeReq)
if err != nil {
log.Fatal(err)
}
log.Printf("Close channel response: %v", closeResp)
closeChannelStream, err := client.CloseChannel(context.Background(), closeChannelReq)
if err != nil {
log.Fatalf("could not initiate close: %v", err)
}

Below is the code (using the LND go implementation) to receive the close request from the remote (other) party:

// Receive close request from remote party.
closeRequest, err := closeChannelStream.Recv()
if err != nil {
log.Fatalf("could not receive close request: %v", err)
}

3. Sign, Exchange & Broadcast Requests to the Network

Now that both parties agree on the close request, they can sign and broadcast their requests.

Below is the code (using the LND go implementation) to sign the request, send it to the remote (other) party, and receive the signed request from the remote (other) party.

// Sign the close request.
signedCloseReq, err := client.SignMessage(context.Background(), &lnrpc.SignMessageRequest{
Msg: closeRequest.SignedClosures[0].TxidBytes,
})
if err != nil {
log.Fatalf("could not sign close request: %v", err)
}
// Send the signed close request to the remote party.
_, err = closeChannelStream.Send(&lnrpc.CloseStatusUpdate{
Update: &lnrpc.CloseStatusUpdate_SignedClosingTx{
SignedClosingTx: signedCloseReq.Signature,
},
})
if err != nil {
log.Fatalf("could not send signed close request: %v", err)
}
// Receive the fully signed close request from the remote party.
closeRequest, err = closeChannelStream.Recv()
if err != nil {
log.Fatalf("could not receive fully signed close request: %v", err)
}

Below is the code (using the LND go implementation) to broadcast the close channel transaction to the Bitcoin network.

// Broadcast the close transaction to the Bitcoin network.
_, err = client.SendRawTransaction(context.Background(), &lnrpc.SendRawTransactionRequest{
TxHex: closeRequest.SignedClosures[0].TxidBytes,
})
if err != nil {
log.Fatalf("could not broadcast close transaction: %v", err)
}

4. Wait ⏰ for Confirmations on the Network

Wait for the TargetConf stated during the initiation, these are the number of blocks that need to be added after the close transaction is committed to the Bitcoin network (in this example, Alice is waiting for 6 blocks).

Below is the code (using the LND go implementation) to wait for the confirmations.

// Wait for the channel to be closed and remove it from local storage.
waitCloseReq := &lnrpc.WaitForFinishedRequest{
ChannelPoint: channelPoint,
}
_, err = client.WaitForChannelClosed(context.Background(), waitCloseReq)
if err != nil {
log.Fatalf("could not wait for channel to close: %v", err)
}

5. Remove the Channel

Most implementations handle the channel removal on the network automatically. But even though the channel is removed from the network, the channel's information will still exist in the network.

You may want to remove a channel from your Lightening node's storage to free up disk space, improve your node's performance, or reduce your configuration's complexity.

Below is the code (using the LND go implementation) to remove the channel from local storage.

_, err = client.RemoveChannel(context.Background(), &lnrpc.RemoveChannelRequest{
ChannelPoint: channelPoint,
})
if err != nil {
log.Fatalf("could not remove channel: %v", err)
}

Warning — Loss of Information:

Removing the channel state from your Lightning Network node will permanently delete all information associated with the closed channel, including the transaction history and any funds remaining in the channel.

This is why it’s important to make sure that the channel has been fully closed and settled on the Bitcoin network before removing the channel state from your node.

Warning — Wait for Pending HTLC’s

Wait for any pending HTLCs to expire: If there were any pending HTLCs (hash time-locked contracts) on the channel at closing, you should wait for them to expire before removing the channel state from your node.

This is because HTLCs have a time-out period during which the counterparty can redeem them. Once this time-out period has expired, the HTLCs will be resolved on-chain, and the remaining funds will be returned to the appropriate parties. Once all pending HTLCs have expired and been resolved, you can safely remove the channel state from your node.

Example Code to do a Collaborative Close

package main

import (
"context"
"fmt"
"github.com/lightningnetwork/lnd/lnrpc"
"google.golang.org/grpc"
"log"
)
func main() {
// Set up a connection to the lnd instance running locally.
conn, err := grpc.Dial("localhost:10009", grpc.WithInsecure())
if err != nil {
log.Fatalf("could not connect to lnd: %v", err)
}
defer conn.Close()
client := lnrpc.NewLightningClient(conn)
// Set the channel point you want to close.
channelPoint := &lnrpc.ChannelPoint{
FundingTxidStr: "funding_transaction_id",
OutputIndex: 0,
}
// Start a cooperative close for the channel.
closeChannelReq := &lnrpc.CloseChannelRequest{
ChannelPoint: channelPoint,
TargetConf: 6,
SatPerByte: 1,
DeliveryAddr: "", // Optional, if not provided the closing funds go to the channel initiator.
Force: false,
}
closeReq := &lnrpc.CloseChannelRequest{
ChannelPoint: channelPoint,
TargetConf: 0, // Wait for the next block.
SatPerByte: 1, // Set the fee rate.
Force: false, // Initiate a cooperative closure.
DeliveryAddress: "your_bitcoin_address", // Optional.
}
closeChannelStream, err := client.CloseChannel(context.Background(), closeChannelReq)
if err != nil {
log.Fatalf("could not initiate close: %v", err)
}
// Receive close request from remote party.
closeRequest, err := closeChannelStream.Recv()
if err != nil {
log.Fatalf("could not receive close request: %v", err)
}
// Sign the close request.
signedCloseReq, err := client.SignMessage(context.Background(), &lnrpc.SignMessageRequest{
Msg: closeRequest.SignedClosures[0].TxidBytes,
})
if err != nil {
log.Fatalf("could not sign close request: %v", err)
}
// Send the signed close request to the remote party.
_, err = closeChannelStream.Send(&lnrpc.CloseStatusUpdate{
Update: &lnrpc.CloseStatusUpdate_SignedClosingTx{
SignedClosingTx: signedCloseReq.Signature,
},
})
if err != nil {
log.Fatalf("could not send signed close request: %v", err)
}
// Receive the fully signed close request from the remote party.
closeRequest, err = closeChannelStream.Recv()
if err != nil {
log.Fatalf("could not receive fully signed close request: %v", err)
}
// Broadcast the close transaction to the Bitcoin network.
_, err = client.SendRawTransaction(context.Background(), &lnrpc.SendRawTransactionRequest{
TxHex: closeRequest.SignedClosures[0].TxidBytes,
})
if err != nil {
log.Fatalf("could not broadcast close transaction: %v", err)
}
// Wait for the channel to be closed and remove it from local storage.
waitCloseReq := &lnrpc.WaitForFinishedRequest{
ChannelPoint: channelPoint,
}
_, err = client.WaitForChannelClosed(context.Background(), waitCloseReq)
if err != nil {
log.Fatalf("could not wait for channel to close: %v", err)
}
_, err = client.RemoveChannel(context.Background(), &lnrpc.RemoveChannelRequest{
ChannelPoint: channelPoint,
})
if err != nil {
log.Fatalf("could not remove channel: %v", err)
}
fmt.Println("Channel closed.")
}

Example of a Collaborative Close Transaction

Transaction a3b23982331300ef8d23bd90b64e939758835515642a65ba64234bf2cae2ac7a is an example of a collaborative closure.

https://mempool.space/tx/a3b23982331300ef8d23bd90b64e939758835515642a65ba64234bf2cae2ac7a

Force Close

A force close happens when one of the parties is offline, so the other party unilaterally forces the channel to close. There are fewer steps in a force close because there aren't exchanges between parties since it's a one-person show.

1. Initiate, Sign, and Broadcast the Force Close

Below is the code (using the LND go implementation) to create the force close request. Notice that now the Force the field is marked true.

// Create the force close channel request
request := &lnrpc.CloseChannelRequest{
ChannelPoint: channelPoint,
Force: true,
TargetConf: 6,
SatPerByte: 10,
DeliveryAddress: "",
ForceTargetChanId: 0,
Anchors: false,
}

Below is the code (using the LND go implementation) to initiate the force close request. We are using the client to broadcast the close request here.

// Initiate the force close request
response, err := client.CloseChannel(context.Background(), request)
if err != nil {
log.Fatalf("could not initiate force close: %v", err)
}
log.Printf("force close initiated: %v", response)

2. Wait ⏰ for the Force Close to Confirm

With force close, the party initiating the close has a waiting period (time lock), giving the counterparty enough time to respond.

Local and Remote Force Close

There are two types of forced closures, remote and local. It depends on who is initiating the forced closure. If you are initiating the forced closure, you are dealing with a local force close, and if you aren’t initiating the forced closure, then you are dealing with a remote force close.

This time lock prevents the funds from being spent for a certain period. This time lock is known as the CSV (Check Sequence Verify) value, and it ensures that both parties can dispute the force close and prevent fraud.

Below is the code (using the LND go implementation) to enforce the time-lock/CSV.

// Wait for the force close to be confirmed
time.Sleep(10 * time.Minute)

In the Lightning Network, security is enforced, and honesty is incentivized using delays. The Lightening Network has two types of delays time-lock and revocation delays. If you want to dive deeper into those concepts, check out the article below:

3. Remove the Channel

After the time-lock expires, the channel closure is complete, and now we can remove the channel from local storage.

Below is the code (using the LND go implementation) to remove the channel from local storage.

// Remove the closed channel from the node's local storage
_, err = client.RemoveChannel(context.Background(), &lnrpc.RemoveChannelRequest{
ChannelPoint: channelPoint,
})
if err != nil {
log.Fatalf("could not remove channel: %v", err)
}

Example Code to do a Force Close

package main

import (
"context"
"log"
"time"
"github.com/lightningnetwork/lnd/lnrpc"
"google.golang.org/grpc"
)
func main() {
// Create a connection to the LND instance
conn, err := grpc.Dial("localhost:10009", grpc.WithInsecure())
if err != nil {
log.Fatalf("could not connect to LND: %v", err)
}
defer conn.Close()
// Create a new LND client
client := lnrpc.NewLightningClient(conn)
// Define the channel point to be closed
channelPoint := &lnrpc.ChannelPoint{
FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
FundingTxidStr: "FUNDING_TXID",
},
OutputIndex: OUTPUT_INDEX,
}
// Create the force close channel request
request := &lnrpc.CloseChannelRequest{
ChannelPoint: channelPoint,
Force: true,
TargetConf: 6,
SatPerByte: 10,
DeliveryAddress: "",
ForceTargetChanId: 0,
Anchors: false,
}
// Initiate the force close request
response, err := client.CloseChannel(context.Background(), request)
if err != nil {
log.Fatalf("could not initiate force close: %v", err)
}
log.Printf("force close initiated: %v", response)
// Wait for the force close to be confirmed
time.Sleep(10 * time.Minute)
// Remove the closed channel from the node's local storage
_, err = client.RemoveChannel(context.Background(), &lnrpc.RemoveChannelRequest{
ChannelPoint: channelPoint,
})
if err != nil {
log.Fatalf("could not remove channel: %v", err)
}
log.Println("force close complete")
}

Example Forced Close Transaction

Transaction f9b48496a3b16693460a447a224b102d4fb3d38bb9473dc865fb5ce6e71761bd is an example of forced closure. There are key features

https://mempool.space/tx/f9b48496a3b16693460a447a224b102d4fb3d38bb9473dc865fb5ce6e71761bd

If you dig a little deeper and look at the lightning channel details, you can see that Bitcoin Revolution was the one that opened and closed the channel (704410x953x0) unilaterally. Also, notice that the fees are considerably higher than a collaborative close. This transaction has a $0.67 fee, while the collaborative close (mentioned earlier) has a $0.06 fee. Force closes have higher fees because they use a time-critical pre-signed transaction.

https://mempool.space/lightning/channel/774506985784147968

Disputed Close

Previously in the force close, Alice had to wait (time-lock/CSV) to give the other party a chance to come back on and dispute. In this scenario, the other party returned online within the wait time (time-lock/CSV) and disputed the force close.

In this example, Bob noticed that Alice made a force close request to the network. Luckily, Bob has the wait time (time-lock/CSV) ⏰ to dispute her close.

How would Bob dispute Alice's force close?

Bob needs to publish the most recent state of the channel before the time lock expires. If Bob publishes the most recent state within the time-lock wait time, he can publish a justice transaction 🏛 👩‍ ⚖. The justice transaction 🏛 👩‍ ⚖ will allow Bob to take everything.

What are justice transactions?

Justice transactions 🏛 👩‍ ⚖ use game theory to secure the lightning network, and if you're dishonest, you can lose it all, so the incentive of being dishonest must outweigh the risk of losing all your funds. BitMEX published research on the effectiveness of justice transactions.

When does 🏛 👩‍ ⚖ justice transactions happen?

After the breach close transaction is confirmed on the Bitcoin network, the channel will enter into the “Pending Force Close” state.

At this point, the justice transaction will be broadcasted by the LND node to claim the channel’s funds and penalize the counterparty.

Example Code of a Dispute Close Transaction

The code below walks through the steps Bob needs to take using the LND implementation in Go.

import (
"context"
"log"
"github.com/lightningnetwork/lnd/lnrpc"
"google.golang.org/grpc"
)
func main() {
// Create a connection to the LND instance
conn, err := grpc.Dial("localhost:10009", grpc.WithInsecure())
if err != nil {
log.Fatalf("could not connect to LND: %v", err)
}
defer conn.Close()
// Create a new LND client
client := lnrpc.NewLightningClient(conn)
// Define the channel point to be disputed
channelPoint := &lnrpc.ChannelPoint{
FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
FundingTxidStr: "FUNDING_TXID",
},
OutputIndex: OUTPUT_INDEX,
}
// Create the breach close transaction request
request := &lnrpc.BreachCloseChannelRequest{
ChannelPoint: channelPoint,
SweepAddress: "YOUR_SWEEP_ADDRESS",
}
// Generate the breach close transaction
response, err := client.BreachCloseChannel(context.Background(), request)
if err != nil {
log.Fatalf("could not generate breach close transaction: %v", err)
}
log.Printf("breach close transaction generated: %v", response)
// Broadcast the breach close transaction to the network
_, err = client.PublishTransaction(context.Background(), &lnrpc.PublishTransactionRequest{
Transaction: response.SignedTransaction,
})
if err != nil {
log.Fatalf("could not publish transaction: %v", err)
}
log.Println("breach close transaction published to network")
// Wait for the breach close transaction to be confirmed
// TODO: implement waiting code
log.Println("dispute complete")
}

🗼Watch Tower

In the scenario described above, Bob had to check the status of his payment channel continuously, or it might have been luck that he caught Alice the one time he checked his payment channel.

In most scenarios, it's advisable to use a watch tower to automate the monitoring of your payment channel and, in the case of fraudulence, publish the most recent state and a justice transaction. The watchtower does everything Bob was doing in the previous scenario.

🗼What is a Watchtower?

Watchtower is a lightning node that watches the chain on behalf of a 3rd party that can send justice transactions when it detects a breach.

🗼How the Watchtower knows it should dispute the close?

Every time the channel’s state is updated (Bob and Alice transact on the channel), Alice and Bob contact the watchtower with a status update.

The status update has a fixed-size encrypted blobs and is only able to decrypt and publish the justice transaction after the offending party has broadcast a revoked commitment state.

🗼Could the Watchtower eavesdrop?

Client communications with a watchtower are encrypted and authenticated using ephemeral (temporary) keypairs, mitigating the amount of tracking the watchtower can perform on its clients using long-term identifiers.

Below is code using LND implementation in Go to implement a watchtower.

import (
"github.com/lightningnetwork/lnd/watchtower/client"
)
// create a client config with the necessary parameters
config := client.Config{
DialAddr: "localhost:9911",
PubKey: pubkey,
PrivKey: privkey,
ChainHash: chainhash,
DB: db,
SessionBackup: sessionBackup,
SweepFeeRate: sweepFeeRate,
RewardAddress: addr,
Resolver: resolver,
Net: net,
EpochRegistrar: epochRegistrar,
DebugLogger: logger,
ErrorLogger: logger,
}
// create a new watchtower client
watchtowerClient, err := client.New(config)
if err != nil {
log.Fatalf("unable to create watchtower client: %v", err)
}
// create a new breach observer to watch for channel breaches
observer := watchtower.NewBreachObserver(watchtower.BreachObserverConfig{
Backend: backend,
ChainHash: chainhash,
})
// start the observer and begin watching for breaches
if err := observer.Start(); err != nil {
log.Fatalf("unable to start observer: %v", err)
}

In a Trusting World, Would We Only Use Collaborative Closes?

Photo by Duy Pham on Unsplash

Most likely, you will deal with forced closes. Collaborative closes require both nodes to always be up and running, and synchronization between the two nodes after transactions are complete.

Constant Node Uptime is Unrealistic

With node issues, network, and power outages, node uptime is a major issue. This isn't just a Bitcoin or Blockchain node issue but a major issue in tech; even giants like Amazon and Google cloud providers face these issues.

Channel Nodes are Not Always in Communication

There's a chance that one of the nodes in a payment channel goes MIA (missing in action). They may want their funds but forgot to close the channel or abandoned their node altogether. Think about all those unclaimed funds in your Venmo account. I know I am not the only one letting them sit there. Even though the other party may be happy to let their funds sit in the channel, the other party may have a greater urgency to close.

📚Hooked? Dive into the Rabbit Hole

BOLT Rules on Closing Transactions

Bitcoin Lightning Transactions & Protocol Deep Dive

Ligthning Engineering

L.N. Guide

Raphael Osaze Eyerin Lightening Payment Channel

The Bitcoin Manual

BOLT to identify Justice Transactions

How to Set up a Watchtower for an Umbrel Bitcoin Lightening Node

LND Watchtower

Lightening Network Watchtower

Decred Documentation on Watchtowers

New to trading? Try crypto trading bots or copy trading on best crypto exchanges

Join Coinmonks Telegram Channel and Youtube Channel get daily Crypto News

Also, Read

--

--

Mabel Oza
Coinmonks

Making the financial world more secure, accessible, and transparent.