diff --git a/itest/list_on_test.go b/itest/list_on_test.go index e1160ed0f..07f2da997 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -678,6 +678,10 @@ var allTestCases = []*lntest.TestCase{ Name: "access perm", TestFunc: testAccessPerm, }, + { + Name: "rbf coop close", + TestFunc: testCoopCloseRbf, + }, } // appendPrefixed is used to add a prefix to each test name in the subtests diff --git a/itest/lnd_coop_close_rbf_test.go b/itest/lnd_coop_close_rbf_test.go new file mode 100644 index 000000000..870334648 --- /dev/null +++ b/itest/lnd_coop_close_rbf_test.go @@ -0,0 +1,133 @@ +package itest + +import ( + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +func testCoopCloseRbf(ht *lntest.HarnessTest) { + rbfCoopFlags := []string{"--protocol.rbf-coop-close"} + + // Set the fee estimate to 1sat/vbyte. This ensures that our manually + // initiated RBF attempts will always be successful. + ht.SetFeeEstimate(250) + ht.SetFeeEstimateWithConf(250, 6) + + // To kick things off, we'll create two new nodes, then fund them with + // enough coins to make a 50/50 channel. + cfgs := [][]string{rbfCoopFlags, rbfCoopFlags} + params := lntest.OpenChannelParams{ + Amt: btcutil.Amount(1000000), + PushAmt: btcutil.Amount(1000000 / 2), + } + chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params) + alice, bob := nodes[0], nodes[1] + chanPoint := chanPoints[0] + + // Now that both sides are active with a funded channel, we can kick + // off the test. + // + // To start, we'll have Alice try to close the channel, with a fee rate + // of 5 sat/byte. + aliceFeeRate := chainfee.SatPerVByte(5) + aliceCloseStream, aliceCloseUpdate := ht.CloseChannelAssertPending( + alice, chanPoint, false, + lntest.WithCoopCloseFeeRate(aliceFeeRate), + lntest.WithLocalTxNotify(), + ) + + // Confirm that this new update was at 5 sat/vb. + alicePendingUpdate := aliceCloseUpdate.GetClosePending() + require.NotNil(ht, aliceCloseUpdate) + require.Equal( + ht, int64(aliceFeeRate), alicePendingUpdate.FeePerVbyte, + ) + require.True(ht, alicePendingUpdate.LocalCloseTx) + + // Now, we'll have Bob attempt to RBF the close transaction with a + // higher fee rate, double that of Alice's. + bobFeeRate := aliceFeeRate * 2 + bobCloseStream, bobCloseUpdate := ht.CloseChannelAssertPending( + bob, chanPoint, false, lntest.WithCoopCloseFeeRate(bobFeeRate), + lntest.WithLocalTxNotify(), + ) + + // Confirm that this new update was at 10 sat/vb. + bobPendingUpdate := bobCloseUpdate.GetClosePending() + require.NotNil(ht, bobCloseUpdate) + require.Equal(ht, bobPendingUpdate.FeePerVbyte, int64(bobFeeRate)) + require.True(ht, bobPendingUpdate.LocalCloseTx) + + var err error + + // Alice should've also received a similar update that Bob has + // increased the closing fee rate to 10 sat/vb with his settled funds. + aliceCloseUpdate, err = ht.ReceiveCloseChannelUpdate(aliceCloseStream) + require.NoError(ht, err) + alicePendingUpdate = aliceCloseUpdate.GetClosePending() + require.NotNil(ht, aliceCloseUpdate) + require.Equal(ht, alicePendingUpdate.FeePerVbyte, int64(bobFeeRate)) + require.False(ht, alicePendingUpdate.LocalCloseTx) + + // We'll now attempt to make a fee update that increases Alice's fee + // rate by 6 sat/vb, which should be rejected as it is too small of an + // increase for the RBF rules. The RPC API however will return the new + // fee. We'll skip the mempool check here as it won't make it in. + aliceRejectedFeeRate := aliceFeeRate + 1 + _, aliceCloseUpdate = ht.CloseChannelAssertPending( + alice, chanPoint, false, + lntest.WithCoopCloseFeeRate(aliceRejectedFeeRate), + lntest.WithLocalTxNotify(), lntest.WithSkipMempoolCheck(), + ) + alicePendingUpdate = aliceCloseUpdate.GetClosePending() + require.NotNil(ht, aliceCloseUpdate) + require.Equal( + ht, alicePendingUpdate.FeePerVbyte, + int64(aliceRejectedFeeRate), + ) + require.True(ht, alicePendingUpdate.LocalCloseTx) + + _, err = ht.ReceiveCloseChannelUpdate(bobCloseStream) + require.NoError(ht, err) + + // We'll now attempt a fee update that we can't actually pay for. This + // will actually show up as an error to the remote party. + aliceRejectedFeeRate = 100_000 + _, _ = ht.CloseChannelAssertPending( + alice, chanPoint, false, + lntest.WithCoopCloseFeeRate(aliceRejectedFeeRate), + lntest.WithLocalTxNotify(), + lntest.WithExpectedErrString("cannot pay for fee"), + ) + + // At this point, we'll have Alice+Bob reconnect so we can ensure that + // we can continue to do RBF bumps even after a reconnection. + ht.DisconnectNodes(alice, bob) + ht.ConnectNodes(alice, bob) + + // Next, we'll have Alice double that fee rate again to 20 sat/vb. + aliceFeeRate = bobFeeRate * 2 + aliceCloseStream, aliceCloseUpdate = ht.CloseChannelAssertPending( + alice, chanPoint, false, + lntest.WithCoopCloseFeeRate(aliceFeeRate), + lntest.WithLocalTxNotify(), + ) + + alicePendingUpdate = aliceCloseUpdate.GetClosePending() + require.NotNil(ht, aliceCloseUpdate) + require.Equal( + ht, alicePendingUpdate.FeePerVbyte, int64(aliceFeeRate), + ) + require.True(ht, alicePendingUpdate.LocalCloseTx) + + // To conclude, we'll mine a block which should now confirm Alice's + // version of the coop close transaction. + block := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + + // Both Alice and Bob should trigger a final close update to signal the + // closing transaction has confirmed. + aliceClosingTxid := ht.WaitForChannelCloseEvent(aliceCloseStream) + ht.AssertTxInBlock(block, aliceClosingTxid) +} diff --git a/itest/lnd_coop_close_with_htlcs_test.go b/itest/lnd_coop_close_with_htlcs_test.go index 0fc75e3a8..02cfc3eac 100644 --- a/itest/lnd_coop_close_with_htlcs_test.go +++ b/itest/lnd_coop_close_with_htlcs_test.go @@ -66,7 +66,6 @@ func testCoopCloseWithHtlcs(ht *lntest.HarnessTest) { // channel party initiates a channel shutdown while an HTLC is still pending on // the channel. func coopCloseWithHTLCs(ht *lntest.HarnessTest, alice, bob *node.HarnessNode) { - ht.ConnectNodes(alice, bob) // Here we set up a channel between Alice and Bob, beginning with a diff --git a/itest/lnd_sweep_test.go b/itest/lnd_sweep_test.go index afc237713..83cde166a 100644 --- a/itest/lnd_sweep_test.go +++ b/itest/lnd_sweep_test.go @@ -1979,6 +1979,7 @@ func testBumpForceCloseFee(ht *lntest.HarnessTest) { closeTxid, err := chainhash.NewHash(pendingClose.Txid) require.NoError(ht, err) closingTx := ht.AssertTxInMempool(*closeTxid) + require.NotNil(ht, closingTx) // The default commitment fee for anchor channels is capped at 2500 // sat/kw but there might be some inaccuracies because of the witness diff --git a/lntest/harness.go b/lntest/harness.go index f7a8eec65..ffbedea9f 100644 --- a/lntest/harness.go +++ b/lntest/harness.go @@ -1211,6 +1211,19 @@ func (h *HarnessTest) OpenChannelAssertErr(srcNode, destNode *node.HarnessNode, // closeChannelOpts holds the options for closing a channel. type closeChannelOpts struct { feeRate fn.Option[chainfee.SatPerVByte] + + // localTxOnly is a boolean indicating if we should only attempt to + // consume close pending notifications for the local transaction. + localTxOnly bool + + // skipMempoolCheck is a boolean indicating if we should skip the normal + // mempool check after a coop close. + skipMempoolCheck bool + + // errString is an expected error. If this is non-blank, then we'll + // assert that the coop close wasn't possible, and returns an error that + // contains this err string. + errString string } // CloseChanOpt is a functional option to modify the way we close a channel. @@ -1224,6 +1237,32 @@ func WithCoopCloseFeeRate(rate chainfee.SatPerVByte) CloseChanOpt { } } +// WithLocalTxNotify is a functional option to indicate that we should only +// notify for the local txn. This is useful for the RBF coop close type, as +// it'll notify for both local and remote txns. +func WithLocalTxNotify() CloseChanOpt { + return func(o *closeChannelOpts) { + o.localTxOnly = true + } +} + +// WithSkipMempoolCheck is a functional option to indicate that we should skip +// the mempool check. This can be used when a coop close iteration may not +// result in a newly broadcast transaction. +func WithSkipMempoolCheck() CloseChanOpt { + return func(o *closeChannelOpts) { + o.skipMempoolCheck = true + } +} + +// WithExpectedErrString is a functional option that can be used to assert that +// an error occurs during the coop close process. +func WithExpectedErrString(errString string) CloseChanOpt { + return func(o *closeChannelOpts) { + o.errString = errString + } +} + // defaultCloseOpts returns the set of default close options. func defaultCloseOpts() *closeChannelOpts { return &closeChannelOpts{} @@ -1267,23 +1306,49 @@ func (h *HarnessTest) CloseChannelAssertPending(hn *node.HarnessNode, _, err = h.ReceiveCloseChannelUpdate(stream) require.NoError(h, err, "close channel update got error: %v", err) - event, err = h.ReceiveCloseChannelUpdate(stream) - if err != nil { - h.Logf("Test: %s, close channel got error: %v", - h.manager.currentTestCase, err) + var closeTxid *chainhash.Hash + for { + event, err = h.ReceiveCloseChannelUpdate(stream) + if err != nil { + h.Logf("Test: %s, close channel got error: %v", + h.manager.currentTestCase, err) + } + if err != nil && closeOpts.errString == "" { + require.NoError(h, err, "retry closing channel failed") + } else if err != nil && closeOpts.errString != "" { + require.ErrorContains(h, err, closeOpts.errString) + return nil, nil + } + + pendingClose, ok := event.Update.(*lnrpc.CloseStatusUpdate_ClosePending) //nolint:ll + require.Truef(h, ok, "expected channel close "+ + "update, instead got %v", pendingClose) + + if !pendingClose.ClosePending.LocalCloseTx && + closeOpts.localTxOnly { + + continue + } + + notifyRate := pendingClose.ClosePending.FeePerVbyte + if closeOpts.localTxOnly && + notifyRate != int64(closeReq.SatPerVbyte) { + continue + } + + closeTxid, err = chainhash.NewHash( + pendingClose.ClosePending.Txid, + ) + require.NoErrorf(h, err, "unable to decode closeTxid: %v", + pendingClose.ClosePending.Txid) + + break } - require.NoError(h, err, "retry closing channel failed") - pendingClose, ok := event.Update.(*lnrpc.CloseStatusUpdate_ClosePending) - require.Truef(h, ok, "expected channel close update, instead got %v", - pendingClose) - - closeTxid, err := chainhash.NewHash(pendingClose.ClosePending.Txid) - require.NoErrorf(h, err, "unable to decode closeTxid: %v", - pendingClose.ClosePending.Txid) - - // Assert the closing tx is in the mempool. - h.miner.AssertTxInMempool(*closeTxid) + if !closeOpts.skipMempoolCheck { + // Assert the closing tx is in the mempool. + h.miner.AssertTxInMempool(*closeTxid) + } return stream, event }