Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7b65745
lnwire: add taproot signatures support to closing_complete message
Roasbeef Jul 10, 2025
19c51fb
lnwire: add taproot partial signatures support to closing_sig message
Roasbeef Jul 10, 2025
63236cf
lnwire: add shutdown nonce support for taproot channels
Roasbeef Jul 10, 2025
6d90c62
lnwire: update test message with taproot signature fields
Roasbeef Jul 10, 2025
afa5aad
chancloser: add taproot channel infrastructure and nonce state manage…
Roasbeef Jul 10, 2025
fa9a445
chancloser: implement taproot cooperative close state transitions
Roasbeef Jul 10, 2025
f577317
chancloser: add taproot test infrastructure and test cases
Roasbeef Jul 10, 2025
e2a0c22
chancloser: update test utilities and message mapping for taproot
Roasbeef Jul 10, 2025
d93feee
itest: add taproot RBF cooperative close integration tests
Roasbeef Jul 10, 2025
faf5d30
multi: wire taproot RBF support throughout the stack
Roasbeef Jul 10, 2025
e559c83
chancloser: update RBF close documentation for taproot support
Roasbeef Jul 10, 2025
f2005cc
lnwallet/chancloser: revamp sig type parsing to be spec compliant
Roasbeef Nov 12, 2025
d9ba98d
fixup! itest: add taproot RBF cooperative close integration tests
Roasbeef Dec 9, 2025
8312f8e
fixup! chancloser: add taproot test infrastructure and test cases
Roasbeef Dec 9, 2025
7fa6395
multi: fix nonce handling bug
Roasbeef Dec 9, 2025
69410d0
chancloser: update RBF close to match current flow
Roasbeef Jan 3, 2026
591a9a2
lnwallet/chancloser: fix local session nonce rotation bug
Roasbeef Jan 3, 2026
10ab643
lnwallet/chancloser: fix priority ordering for rbf sig parsing
Roasbeef Jan 3, 2026
d1958a8
lnwallet/chancloser: fix rbf close docs
Roasbeef Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 104 additions & 50 deletions itest/lnd_coop_close_rbf_test.go
Original file line number Diff line number Diff line change
@@ -1,101 +1,96 @@
package itest

import (
"testing"

"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lntest/node"
"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)
// rbfTestCase encapsulates the parameters and logic for a single RBF coop close test run.
func runRbfCoopCloseTest(st *lntest.HarnessTest, alice, bob *node.HarnessNode,
chanPoint *lnrpc.ChannelPoint, isTaproot bool) {

// 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(
aliceCloseStream, aliceCloseUpdate := st.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.NotNil(st, aliceCloseUpdate)
require.Equal(
ht, int64(aliceFeeRate), alicePendingUpdate.FeePerVbyte,
st, int64(aliceFeeRate), alicePendingUpdate.FeePerVbyte,
)
require.True(ht, alicePendingUpdate.LocalCloseTx)
require.True(st, 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(
bobCloseStream, bobCloseUpdate := st.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)
require.NotNil(st, bobCloseUpdate)
require.Equal(st, bobPendingUpdate.FeePerVbyte, int64(bobFeeRate))
require.True(st, 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)
aliceCloseUpdate, err = st.ReceiveCloseChannelUpdate(aliceCloseStream)
require.NoError(st, err)
alicePendingUpdate = aliceCloseUpdate.GetClosePending()
require.NotNil(ht, aliceCloseUpdate)
require.Equal(ht, alicePendingUpdate.FeePerVbyte, int64(bobFeeRate))
require.False(ht, alicePendingUpdate.LocalCloseTx)
require.NotNil(st, aliceCloseUpdate)

// For taproot channels, due to different witness sizes, the fee per vbyte
// might be slightly different due to rounding when converting between
// absolute fee and fee per vbyte.
if isTaproot {
// Allow for a small difference in fee calculation for taproot
require.InDelta(st, int64(bobFeeRate), alicePendingUpdate.FeePerVbyte, 1)
} else {
require.Equal(st, alicePendingUpdate.FeePerVbyte, int64(bobFeeRate))
}
require.False(st, 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(
_, aliceCloseUpdate = st.CloseChannelAssertPending(
alice, chanPoint, false,
lntest.WithCoopCloseFeeRate(aliceRejectedFeeRate),
lntest.WithLocalTxNotify(), lntest.WithSkipMempoolCheck(),
)
alicePendingUpdate = aliceCloseUpdate.GetClosePending()
require.NotNil(ht, aliceCloseUpdate)
require.NotNil(st, aliceCloseUpdate)
require.Equal(
ht, alicePendingUpdate.FeePerVbyte,
st, alicePendingUpdate.FeePerVbyte,
int64(aliceRejectedFeeRate),
)
require.True(ht, alicePendingUpdate.LocalCloseTx)
require.True(st, alicePendingUpdate.LocalCloseTx)

_, err = ht.ReceiveCloseChannelUpdate(bobCloseStream)
require.NoError(ht, err)
_, err = st.ReceiveCloseChannelUpdate(bobCloseStream)
require.NoError(st, 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(
_, _ = st.CloseChannelAssertPending(
alice, chanPoint, false,
lntest.WithCoopCloseFeeRate(aliceRejectedFeeRate),
lntest.WithLocalTxNotify(),
Expand All @@ -104,32 +99,91 @@ func testCoopCloseRbf(ht *lntest.HarnessTest) {

// 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)
st.DisconnectNodes(alice, bob)
st.ConnectNodes(alice, bob)

// Next, we'll have Alice double that fee rate again to 20 sat/vb.
aliceFeeRate = bobFeeRate * 2
aliceCloseStream, aliceCloseUpdate = ht.CloseChannelAssertPending(
aliceCloseStream, aliceCloseUpdate = st.CloseChannelAssertPending(
alice, chanPoint, false,
lntest.WithCoopCloseFeeRate(aliceFeeRate),
lntest.WithLocalTxNotify(),
)

alicePendingUpdate = aliceCloseUpdate.GetClosePending()
require.NotNil(ht, aliceCloseUpdate)
require.NotNil(st, aliceCloseUpdate)
require.Equal(
ht, alicePendingUpdate.FeePerVbyte, int64(aliceFeeRate),
st, alicePendingUpdate.FeePerVbyte, int64(aliceFeeRate),
)
require.True(ht, alicePendingUpdate.LocalCloseTx)
require.True(st, 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]
block := st.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)
aliceClosingTxid := st.WaitForChannelCloseEvent(aliceCloseStream)
st.AssertTxInBlock(block, aliceClosingTxid)
}

func testCoopCloseRbf(ht *lntest.HarnessTest) {
// Test with different channel types including taproot
channelTypes := []struct {
name string
commitType lnrpc.CommitmentType
}{
{
name: "anchors",
commitType: lnrpc.CommitmentType_ANCHORS,
},
{
name: "taproot",
commitType: lnrpc.CommitmentType_SIMPLE_TAPROOT,
},
}

for _, chanType := range channelTypes {
chanType := chanType
ht.Run(chanType.name, func(t1 *testing.T) {
st := ht.Subtest(t1)
// Set the fee estimate to 1sat/vbyte. This ensures that
// our manually initiated RBF attempts will always be
// successful.
st.SetFeeEstimate(250)
st.SetFeeEstimateWithConf(250, 6)

// Build node config with commitment type args and RBF
// flag.
baseArgs := lntest.NodeArgsForCommitType(chanType.commitType)
nodeArgs := append(baseArgs, "--protocol.rbf-coop-close")
cfgs := [][]string{nodeArgs, nodeArgs}

// For taproot channels, we need to make them private.
isTaproot := chanType.commitType ==
lnrpc.CommitmentType_SIMPLE_TAPROOT

params := lntest.OpenChannelParams{
Amt: btcutil.Amount(1000000),
PushAmt: btcutil.Amount(1000000 / 2),
CommitmentType: chanType.commitType,
Private: isTaproot,
}

// Create network with Alice -> Bob channel, then use
// that to run the RBF coop close test.
chanPoints, nodes := st.CreateSimpleNetwork(
cfgs, params,
)
alice, bob := nodes[0], nodes[1]
chanPoint := chanPoints[0]

runRbfCoopCloseTest(st, alice, bob, chanPoint, isTaproot)

st.Shutdown(alice)
st.Shutdown(bob)
})
}
}

// testRBFCoopCloseDisconnect tests that when a node disconnects that the node
Expand Down
2 changes: 1 addition & 1 deletion lncfg/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ type ProtocolOptions struct {

// RbfCoopClose should be set if we want to signal that we support for
// the new experimental RBF coop close feature.
RbfCoopClose bool `long:"rbf-coop-close" description:"if set, then lnd will signal that it supports the new RBF based coop close protocol, taproot channels are not supported"`
RbfCoopClose bool `long:"rbf-coop-close" description:"if set, then lnd will signal that it supports the new RBF based coop close protocol"`

// NoAnchors should be set if we don't want to support opening or accepting
// channels having the anchor commitment type.
Expand Down
12 changes: 9 additions & 3 deletions lnwallet/chancloser/chancloser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ func newMockTaprootChan(t *testing.T, initiator bool) *mockChannel {
}

type mockMusigSession struct {
remoteNonceInited bool
remoteNonce musig2.Nonces
}

func newMockMusigSession() *mockMusigSession {
Expand All @@ -293,11 +295,15 @@ func (m *mockMusigSession) CombineClosingOpts(localSig,
nil
}

func (m *mockMusigSession) ClosingNonce() (*musig2.Nonces, error) {
return &musig2.Nonces{}, nil
func (m *mockMusigSession) InitRemoteNonce(nonce *musig2.Nonces) {
m.remoteNonceInited = true
m.remoteNonce = *nonce
}

func (m *mockMusigSession) InitRemoteNonce(nonce *musig2.Nonces) {
func (m *mockMusigSession) ClosingNonce() (*musig2.Nonces, error) {
return &musig2.Nonces{
PubNonce: [66]byte{1, 2, 3},
}, nil
}

type mockCoopFeeEstimator struct {
Expand Down
Loading
Loading