What changed, and why it matters
This commit adds a new integration test for the Lightning Network Daemon (LND). It checks that if a cooperative channel close transaction is replaced by a higher-fee version, then a blockchain reorganization occurs, and finally the original transaction gets confirmed, the software still recognizes the channel as properly closed. It also makes two small timing fixes in an unrelated test to make it more reliable. There is no production code change and no direct security fix.
No security action required. Treat as normal test coverage improvement. If reviewing a related bug, look for the production-code commit that this test was likely written to validate.
Security signals we found
New integration test covers reorg handling during RBF cooperative close
Test asserts that any valid spend of the funding output (including the original close tx) is accepted after reorg
No changes to consensus, wallet, or channel-state logic in production code
Two timing/race fixes in an unrelated test to account for asynchronous confirmation notifications
Evidence from the diff
The diff is entirely within LND’s integration test suite. It introduces testCoopCloseRBFWithReorg in itest/lnd_coop_close_rbf_test.go, registers it in itest/list_on_test.go, and adjusts testChannelFundingWithUnstableUtxos in itest/lnd_funding_test.go to mine empty blocks before expecting sweep transactions in the mempool. The new test exercises RBF cooperative close handling across a reorg where the initial close tx eventually confirms. No application logic is patched.
Changed components
itest/lnd_coop_close_rbf_test.goitest/list_on_test.goitest/lnd_funding_test.goInspect captured patch +194 / −2
diff --git a/itest/list_on_test.go b/itest/list_on_test.go
index a09c4c1..3dc3ac9 100644
--- a/itest/list_on_test.go
+++ b/itest/list_on_test.go
@@ -739,6 +739,10 @@ var allTestCases = []*lntest.TestCase{
Name: "rbf coop close disconnect",
TestFunc: testRBFCoopCloseDisconnect,
},
+ {
+ Name: "coop close rbf with reorg",
+ TestFunc: testCoopCloseRBFWithReorg,
+ },
{
Name: "bump fee low budget",
TestFunc: testBumpFeeLowBudget,
diff --git a/itest/lnd_coop_close_rbf_test.go b/itest/lnd_coop_close_rbf_test.go
index 5f8b15d..13e10c9 100644
--- a/itest/lnd_coop_close_rbf_test.go
+++ b/itest/lnd_coop_close_rbf_test.go
@@ -1,8 +1,13 @@
package itest
import (
+ "fmt"
+
"github.com/btcsuite/btcd/btcutil"
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
+ "github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest"
+ "github.com/lightningnetwork/lnd/lntest/wait"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
)
@@ -153,3 +158,172 @@ func testRBFCoopCloseDisconnect(ht *lntest.HarnessTest) {
// Disconnect Bob from Alice.
ht.DisconnectNodes(alice, bob)
}
+
+// testCoopCloseRBFWithReorg tests that when a cooperative close transaction
+// is reorganized out during confirmation waiting, the system properly handles
+// RBF replacements and re-registration for any spend of the funding output.
+func testCoopCloseRBFWithReorg(ht *lntest.HarnessTest) {
+ // Skip this test for neutrino backend as we can't trigger reorgs.
+ if ht.IsNeutrinoBackend() {
+ ht.Skipf("skipping reorg test for neutrino backend")
+ }
+
+ // Force cooperative close to require 3 confirmations for predictable
+ // testing.
+ const requiredConfs = 3
+ rbfCoopFlags := []string{
+ "--protocol.rbf-coop-close",
+ "--dev.force-channel-close-confs=3",
+ }
+
+ // Set the fee estimate to 1sat/vbyte to ensure our RBF attempts work.
+ ht.SetFeeEstimate(250)
+ ht.SetFeeEstimateWithConf(250, 6)
+
+ // Create two nodes with enough coins for a 50/50 channel.
+ cfgs := [][]string{rbfCoopFlags, rbfCoopFlags}
+ params := lntest.OpenChannelParams{
+ Amt: btcutil.Amount(10_000_000),
+ PushAmt: btcutil.Amount(5_000_000),
+ }
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob := nodes[0], nodes[1]
+ chanPoint := chanPoints[0]
+
+ // Initiate cooperative close with initial fee rate of 5 sat/vb.
+ initialFeeRate := chainfee.SatPerVByte(5)
+ _, aliceCloseUpdate := ht.CloseChannelAssertPending(
+ alice, chanPoint, false,
+ lntest.WithCoopCloseFeeRate(initialFeeRate),
+ lntest.WithLocalTxNotify(),
+ )
+
+ // Verify the initial close transaction is at the expected fee rate.
+ alicePendingUpdate := aliceCloseUpdate.GetClosePending()
+ require.NotNil(ht, aliceCloseUpdate)
+ require.Equal(
+ ht, int64(initialFeeRate), alicePendingUpdate.FeePerVbyte,
+ )
+
+ // Capture the initial close transaction from the mempool.
+ initialCloseTxid, err := chainhash.NewHash(alicePendingUpdate.Txid)
+ require.NoError(ht, err)
+ initialCloseTx := ht.AssertTxInMempool(*initialCloseTxid)
+
+ // Create first RBF replacement before any mining.
+ firstRbfFeeRate := chainfee.SatPerVByte(10)
+ _, firstRbfUpdate := ht.CloseChannelAssertPending(
+ bob, chanPoint, false,
+ lntest.WithCoopCloseFeeRate(firstRbfFeeRate),
+ lntest.WithLocalTxNotify(),
+ )
+
+ // Capture the first RBF transaction.
+ closePending := firstRbfUpdate.GetClosePending()
+ firstRbfTxid, err := chainhash.NewHash(closePending.Txid)
+ require.NoError(ht, err)
+ firstRbfTx := ht.AssertTxInMempool(*firstRbfTxid)
+
+ _, bestHeight := ht.GetBestBlock()
+ ht.Logf("Current block height: %d", bestHeight)
+
+ // Mine n-1 blocks (2 blocks when requiring 3 confirmations) with the
+ // first RBF transaction. This is just shy of full confirmation.
+ block1 := ht.Miner().MineBlockWithTxes(
+ []*btcutil.Tx{btcutil.NewTx(firstRbfTx)},
+ )
+
+ ht.Logf("Mined block %d with first RBF tx", bestHeight+1)
+
+ block2 := ht.MineEmptyBlocks(1)[0]
+
+ ht.Logf("Mined block %d", bestHeight+2)
+
+ ht.Logf("Re-orging two blocks to remove first RBF tx")
+
+ // Trigger a reorganization that removes the last 2 blocks. This is safe
+ // because we haven't reached full confirmation yet.
+ bestBlockHash := block2.Header.BlockHash()
+ require.NoError(
+ ht, ht.Miner().Client.InvalidateBlock(&bestBlockHash),
+ )
+ bestBlockHash = block1.Header.BlockHash()
+ require.NoError(
+ ht, ht.Miner().Client.InvalidateBlock(&bestBlockHash),
+ )
+
+ _, bestHeight = ht.GetBestBlock()
+ ht.Logf("Re-orged to block height: %d", bestHeight)
+
+ ht.Log("Mining blocks to surpass previous chain")
+
+ // Mine 2 empty blocks to trigger the reorg on the nodes.
+ ht.MineEmptyBlocks(2)
+
+ _, bestHeight = ht.GetBestBlock()
+ ht.Logf("Mined blocks to reach height: %d", bestHeight)
+
+ // Now, instead of mining the second RBF, mine the INITIAL transaction
+ // to test that the system can handle any valid spend of the funding
+ // output.
+ block := ht.Miner().MineBlockWithTxes(
+ []*btcutil.Tx{btcutil.NewTx(initialCloseTx)},
+ )
+ ht.AssertTxInBlock(block, *initialCloseTxid)
+
+ // Mine additional blocks to reach the required confirmations (3 total).
+ ht.MineEmptyBlocks(requiredConfs - 1)
+
+ // Both parties should see that the channel is now fully closed on chain
+ // with the expected closing txid.
+ expectedClosingTxid := initialCloseTxid.String()
+ err = wait.NoError(func() error {
+ req := &lnrpc.ClosedChannelsRequest{}
+ aliceClosedChans := alice.RPC.ClosedChannels(req)
+ bobClosedChans := bob.RPC.ClosedChannels(req)
+ if len(aliceClosedChans.Channels) != 1 {
+ return fmt.Errorf("alice: expected 1 closed "+
+ "chan, got %d", len(aliceClosedChans.Channels))
+ }
+ if len(bobClosedChans.Channels) != 1 {
+ return fmt.Errorf("bob: expected 1 closed chan, got %d",
+ len(bobClosedChans.Channels))
+ }
+
+ // Verify both Alice and Bob have the expected closing txid.
+ aliceClosedChan := aliceClosedChans.Channels[0]
+ if aliceClosedChan.ClosingTxHash != expectedClosingTxid {
+ return fmt.Errorf("alice: expected closing txid %s, "+
+ "got %s",
+ expectedClosingTxid,
+ aliceClosedChan.ClosingTxHash)
+ }
+ if aliceClosedChan.CloseType !=
+ lnrpc.ChannelCloseSummary_COOPERATIVE_CLOSE {
+
+ return fmt.Errorf("alice: expected cooperative "+
+ "close, got %v",
+ aliceClosedChan.CloseType)
+ }
+
+ bobClosedChan := bobClosedChans.Channels[0]
+ if bobClosedChan.ClosingTxHash != expectedClosingTxid {
+ return fmt.Errorf("bob: expected closing txid %s, "+
+ "got %s",
+ expectedClosingTxid,
+ bobClosedChan.ClosingTxHash)
+ }
+ if bobClosedChan.CloseType !=
+ lnrpc.ChannelCloseSummary_COOPERATIVE_CLOSE {
+
+ return fmt.Errorf("bob: expected cooperative "+
+ "close, got %v",
+ bobClosedChan.CloseType)
+ }
+
+ return nil
+ }, defaultTimeout)
+ require.NoError(ht, err)
+
+ ht.Logf("Successfully verified closing txid: %s", expectedClosingTxid)
+}
diff --git a/itest/lnd_funding_test.go b/itest/lnd_funding_test.go
index b6734e0..2c1daf5 100644
--- a/itest/lnd_funding_test.go
+++ b/itest/lnd_funding_test.go
@@ -1272,8 +1272,17 @@ func testChannelFundingWithUnstableUtxos(ht *lntest.HarnessTest) {
// Make sure Carol sees her to_remote output from the force close tx.
ht.AssertNumPendingSweeps(carol, 1)
- // We need to wait for carol initiating the sweep of the to_remote
- // output of chanPoint2.
+ // Wait for Carol's sweep transaction to appear in the mempool. Due to
+ // async confirmation notifications, there's a race between when the
+ // sweep is registered and when the sweeper processes the next block.
+ // The sweeper uses immediate=false, so it broadcasts on the next block
+ // after registration. Mine an empty block to trigger the broadcast.
+ ht.MineEmptyBlocks(1)
+
+ // Now the sweep should be in the mempool.
+ ht.AssertNumTxsInMempool(1)
+
+ // Now we should see the unconfirmed UTXO from the sweep.
utxo := ht.AssertNumUTXOsUnconfirmed(carol, 1)[0]
// We now try to open channel using the unconfirmed utxo.
@@ -1329,6 +1338,11 @@ func testChannelFundingWithUnstableUtxos(ht *lntest.HarnessTest) {
// Make sure Carol sees her to_remote output from the force close tx.
ht.AssertNumPendingSweeps(carol, 1)
+ // Mine an empty block to trigger the sweep broadcast (same fix as
+ // above).
+ ht.MineEmptyBlocks(1)
+ ht.AssertNumTxsInMempool(1)
+
// Wait for the to_remote sweep tx to show up in carol's wallet.
ht.AssertNumUTXOsUnconfirmed(carol, 1)
Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.