What changed, and why it matters
This commit fixes a bug in LND's cooperative channel-closing code for Taproot channels. When two parties try to close a Lightning channel together, the code accidentally used the same internal helper for both sides and tried to create a signature before the other side's one-time 'nonce' had been applied. The fix creates two separate helpers and reorders the steps so the nonce is applied before signing. The commit message and new test say the bug was hidden in normal use because a shared object masked the ordering problem, and it only surfaced during interoperability testing.
Treat as a security-relevant correctness fix and include in release notes. Users running Taproot channels and the new RBF cooperative close path should upgrade. Review whether the shared-session bug could have led to invalid signatures, failed closes, or protocol-stalling edge cases in earlier versions.
Security signals we found
Incorrect nonce ordering in MuSig2 cooperative close signature generation
Shared mutable state between local and remote signing sessions masked the ordering issue
Regression test added to prevent reintroduction
Affects Taproot/RBF cooperative channel close flow only
Evidence from the diff
In peer/brontide.go, initRbfChanCloser previously assigned a single NewMusigChanCloser instance to both env.LocalMusigSession and env.RemoteMusigSession. In lnwallet/chancloser/rbf_coop_transitions.go, RemoteCloseStart.ProcessEvent called ProposalClosingOpts (which may need the remote nonce) before processRemoteTaprootSig initialized that nonce. Because the sessions were the same object, the nonce set through one session was visible to the other, hiding the ordering bug. The fix creates two independent MusigChanCloser instances and reorders ProcessEvent so processRemoteTaprootSig runs before ProposalClosingOpts. A regression test (peer/musig_nonce_order_test.go) reproduces the failure when sessions are separate.
Changed components
peer/brontide.golnwallet/chancloser/rbf_coop_transitions.goTaproot cooperative channel close (RBF)MuSig2 nonce/session handlingInspect captured patch +307 / −14
diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go
index 6513ee1..eda45d3 100644
--- a/lnwallet/chancloser/rbf_coop_transitions.go
+++ b/lnwallet/chancloser/rbf_coop_transitions.go
@@ -54,7 +54,7 @@ func sendShutdownEvents(chanID lnwire.ChannelID, chanPoint wire.OutPoint,
// nonce - the nonce the remote party will use when they act as closer.
if env.IsTaproot() {
// If closee nonce not provided, generate one now. Note how we
- // generate it usingt he RemoteMusigSession, as that'll set our
+ // generate it using the RemoteMusigSession, as that'll set our
// localNonce, we'll receive their remoteNonce for this session
// once we get their ClosingComplete message.
if localCloseeNonce.IsNone() {
@@ -1342,7 +1342,7 @@ func createClosingSigMessage(env *Environment, wireSig lnwire.Sig,
)
}
- // Generate our next closee nonce for the next RBF iteration
+ // Generate our next closee nonce for the next RBF iteration.
// This is the nonce the closer should use for our closee
// signature in the next RBF round. We always include this since
// RBF could occur.
@@ -1938,9 +1938,9 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
var remoteSig input.Signature
- // For taproot channels, add MusigSession options if available
+ // For taproot channels, add MusigSession options if available.
// When we're the closee (sending closing_sig), we use
- // RemoteMusigSession
+ // RemoteMusigSession.
switch {
case env.IsTaproot():
// First, process the remote taproot signature which
@@ -2005,7 +2005,6 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
lnutils.SpewLogClosure(closeTx),
)
- // Create the ClosingSig response message
closingSigMsg, err := createClosingSigMessage(
env, wireSig, localSig, l.LocalDeliveryScript,
l.RemoteDeliveryScript, msg.SigMsg.FeeSatoshis,
diff --git a/peer/brontide.go b/peer/brontide.go
index e93a8d6..257f724 100644
--- a/peer/brontide.go
+++ b/peer/brontide.go
@@ -1308,7 +1308,6 @@ func (p *Brontide) loadActiveChannels(chans []*channeldb.OpenChannel) (
return nil, err
}
-
var (
shutdownMsg fn.Option[lnwire.Shutdown]
shutdownInfoErr error
@@ -3425,7 +3424,6 @@ func chooseDeliveryScript(upfront, requested lnwire.DeliveryAddress,
func (p *Brontide) restartCoopClose(lnChan *lnwallet.LightningChannel) (
*lnwire.Shutdown, error) {
-
// If this channel has status ChanStatusCoopBroadcasted and does not
// have a closing transaction, then the cooperative close process was
// started but never finished. We'll re-create the chanCloser state
@@ -4022,12 +4020,12 @@ func (p *Brontide) initRbfChanCloser(
),
}
- // For taproot channels, we need to set both LocalMusigSession and
- // RemoteMusigSession to handle nonce exchange during RBF cooperative close.
+ // For taproot channels, we need to set both LocalMusigSession and
+ // RemoteMusigSession to handle nonce exchange during RBF cooperative
+ // close.
if channel.ChanType().IsTaproot() {
- musigCloser := NewMusigChanCloser(channel)
- env.LocalMusigSession = musigCloser
- env.RemoteMusigSession = musigCloser
+ env.LocalMusigSession = NewMusigChanCloser(channel)
+ env.RemoteMusigSession = NewMusigChanCloser(channel)
}
spendEvent := protofsm.RegisterSpend[chancloser.ProtocolEvent]{
@@ -4342,7 +4340,6 @@ func (p *Brontide) handleLocalCloseReq(req *htlcswitch.ChanClose) {
return
}
-
switch req.CloseType {
// A type of CloseRegular indicates that the user has opted to close
// out this channel on-chain, so we execute the cooperative channel
@@ -5373,7 +5370,6 @@ func (p *Brontide) addActiveChannel(c *lnpeer.NewChannel) error {
"peer", chanPoint)
}
-
// We're using the old co-op close, so we don't need to init the new RBF
// chan closer.
if !p.rbfCoopCloseAllowed() {
diff --git a/peer/musig_nonce_order_test.go b/peer/musig_nonce_order_test.go
new file mode 100644
index 0000000..19f0ebd
--- /dev/null
+++ b/peer/musig_nonce_order_test.go
@@ -0,0 +1,298 @@
+package peer
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
+ "github.com/btcsuite/btcd/btcutil"
+ "github.com/btcsuite/btcd/chaincfg"
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
+ "github.com/btcsuite/btcd/txscript"
+ "github.com/btcsuite/btcd/wire"
+ "github.com/lightningnetwork/lnd/channeldb"
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/lightningnetwork/lnd/input"
+ "github.com/lightningnetwork/lnd/lnwallet"
+ "github.com/lightningnetwork/lnd/lnwallet/chainfee"
+ "github.com/lightningnetwork/lnd/lnwallet/chancloser"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+// TestRemoteCloseStartTaprootIntegration tests the full flow of
+// RemoteCloseStart handling a ClosingComplete message with taproot signatures.
+//
+// This is a regression test for a bug where the remote nonce was not properly
+// created before sending over our signature.
+func TestRemoteCloseStartTaprootIntegration(t *testing.T) {
+ t.Parallel()
+
+ chanType := channeldb.SingleFunderTweaklessBit |
+ channeldb.AnchorOutputsBit | channeldb.SimpleTaprootFeatureBit
+
+ aliceChan, bobChan, err := lnwallet.CreateTestChannels(t, chanType)
+ require.NoError(t, err)
+
+ // Create TWO SEPARATE MusigChanCloser instances. This is the key to
+ // exposing the bug - in production where the issue existed, they share one.
+ localSession := NewMusigChanCloser(aliceChan)
+ remoteSession := NewMusigChanCloser(bobChan)
+
+ // Initialize local session with nonces (simulating LocalCloseStart path
+ // during shutdown exchange).
+ _, err = localSession.ClosingNonce()
+ require.NoError(t, err)
+
+ // Give local session the remote's closee nonce.
+ remoteCloseeNonce, err := musig2.GenNonces(
+ musig2.WithPublicKey(
+ bobChan.State().LocalChanCfg.MultiSigKey.PubKey,
+ ),
+ )
+ require.NoError(t, err)
+ localSession.InitRemoteNonce(remoteCloseeNonce)
+
+ // For remoteSession, generate the Local nonce (simulating what would
+ // happen during the shutdown exchange when we act as closee).
+ _, err = remoteSession.ClosingNonce()
+ require.NoError(t, err)
+
+ // NOTE: remoteSession has its local nonce but NO remote nonce yet.
+ // This is the setup that exposes the bug. In production, both
+ // sessions point to the same object, so nonces set via localSession
+ // would also be visible to remoteSession. Here they are separate.
+ //
+ // The bug was that ProcessEvent calls ProposalClosingOpts() BEFORE
+ // processRemoteTaprootSig() which would initialize the remote nonce.
+
+ // Make some fake shutdown scripts for both sides.
+ localDeliveryScript := bytes.Repeat([]byte{0x01}, 34)
+ localDeliveryScript[0] = txscript.OP_1
+ localDeliveryScript[1] = txscript.OP_DATA_32
+
+ remoteDeliveryScript := bytes.Repeat([]byte{0x02}, 34)
+ remoteDeliveryScript[0] = txscript.OP_1
+ remoteDeliveryScript[1] = txscript.OP_DATA_32
+
+ // Create a minimal MusigPartialSig for the mock close signer.
+ partialSig := musig2.NewPartialSignature(
+ new(btcec.ModNScalar), new(btcec.PublicKey),
+ )
+ musigSig := lnwallet.NewMusigPartialSig(
+ &partialSig,
+ lnwire.Musig2Nonce{},
+ lnwire.Musig2Nonce{},
+ nil,
+ fn.None[chainhash.Hash](),
+ )
+
+ // Create mocks and other set up configs.
+ closeSigner := &mockCloseSigner{}
+ closeSigner.On(
+ "CreateCloseProposal", mock.Anything, mock.Anything,
+ mock.Anything, mock.Anything,
+ ).Return(
+ input.Signature(musigSig), wire.NewMsgTx(2),
+ btcutil.Amount(1000), nil,
+ )
+ closeSigner.On(
+ "CompleteCooperativeClose", mock.Anything, mock.Anything,
+ mock.Anything, mock.Anything, mock.Anything, mock.Anything,
+ ).Return(wire.NewMsgTx(2), btcutil.Amount(0), nil)
+
+ feeEstimator := &mockCoopFeeEstimator{}
+ feeEstimator.On(
+ "EstimateFee", mock.Anything, mock.Anything,
+ mock.Anything, mock.Anything,
+ ).Return(btcutil.Amount(1000))
+
+ chanObserver := &mockChanObserver{}
+ chanObserver.On("MarkCoopBroadcasted", mock.Anything, mock.Anything).
+ Return(nil)
+ chanObserver.On("FinalBalances").Return(
+ fn.None[chancloser.ShutdownBalances](),
+ )
+
+ peerPub := bobChan.State().IdentityPub
+ env := chancloser.Environment{
+ ChainParams: chaincfg.RegressionNetParams,
+ ChanPeer: *peerPub,
+ ChanPoint: aliceChan.ChannelPoint(),
+ ChanID: lnwire.NewChanIDFromOutPoint(
+ aliceChan.ChannelPoint(),
+ ),
+ ChanType: chanType,
+ DefaultFeeRate: chainfee.SatPerVByte(10),
+ FeeEstimator: feeEstimator,
+ ChanObserver: chanObserver,
+ CloseSigner: closeSigner,
+ LocalMusigSession: localSession,
+ RemoteMusigSession: remoteSession,
+ }
+ localBalance := lnwire.NewMSatFromSatoshis(btcutil.Amount(500000000))
+ remoteBalance := lnwire.NewMSatFromSatoshis(btcutil.Amount(500000000))
+
+ // Create RemoteCloseStart state, this is where the state machine will
+ // start from.
+ state := &chancloser.RemoteCloseStart{
+ CloseChannelTerms: &chancloser.CloseChannelTerms{
+ ShutdownScripts: chancloser.ShutdownScripts{
+ LocalDeliveryScript: localDeliveryScript,
+ RemoteDeliveryScript: remoteDeliveryScript,
+ },
+ ShutdownBalances: chancloser.ShutdownBalances{
+ LocalBalance: localBalance,
+ RemoteBalance: remoteBalance,
+ },
+ },
+ }
+
+ // Generate a valid JIT nonce for the ClosingComplete message.
+ // This simulates the remote party's closer nonce.
+ jitNonce, err := musig2.GenNonces(
+ musig2.WithPublicKey(
+ aliceChan.State().LocalChanCfg.MultiSigKey.PubKey,
+ ),
+ )
+ require.NoError(t, err)
+
+ var dummySig btcec.ModNScalar
+ dummySig.SetInt(12345)
+ partialSigWithNonce := lnwire.PartialSigWithNonce{
+ PartialSig: lnwire.NewPartialSig(dummySig),
+ Nonce: jitNonce.PubNonce,
+ }
+
+ // Create OfferReceivedEvent with taproot sig (ClosingComplete). Since
+ // both local and remote balances are above dust, we need the
+ // CloserAndClosee variant.
+ closingComplete := lnwire.ClosingComplete{
+ ChannelID: env.ChanID,
+ CloserScript: remoteDeliveryScript,
+ CloseeScript: localDeliveryScript,
+ FeeSatoshis: btcutil.Amount(1000),
+ LockTime: 0,
+ TaprootClosingSigs: lnwire.TaprootClosingSigs{
+ CloserAndClosee: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType7](
+ partialSigWithNonce,
+ ),
+ ),
+ },
+ }
+
+ event := &chancloser.OfferReceivedEvent{
+ SigMsg: closingComplete,
+ }
+
+ // Call ProcessEvent. Before the bug fix, this will fail with: "failed
+ // to get musig closing opts: remote nonce not generated"
+ //
+ // This is because ProposalClosingOpts() is called on line 1932 BEFORE
+ // processRemoteTaprootSig() initializes the nonce on line 1943.
+ //
+ // After the fix (swapping the order), this should succeed.
+ _, err = state.ProcessEvent(event, &env)
+
+ require.NoError(
+ t, err, "ProcessEvent should not fail - if it fails "+
+ "with 'remote nonce not generated', the bug still "+
+ "exists",
+ )
+}
+
+type mockCloseSigner struct {
+ mock.Mock
+}
+
+func (m *mockCloseSigner) CreateCloseProposal(
+ proposedFee btcutil.Amount, localDeliveryScript,
+ remoteDeliveryScript []byte, closeOpt ...lnwallet.ChanCloseOpt,
+) (input.Signature, *wire.MsgTx, btcutil.Amount, error) {
+
+ args := m.Called(proposedFee, localDeliveryScript,
+ remoteDeliveryScript, closeOpt)
+
+ return args.Get(0).(input.Signature), args.Get(1).(*wire.MsgTx),
+ args.Get(2).(btcutil.Amount), args.Error(3)
+}
+
+func (m *mockCloseSigner) CompleteCooperativeClose(
+ localSig, remoteSig input.Signature,
+ localDeliveryScript, remoteDeliveryScript []byte,
+ proposedFee btcutil.Amount, closeOpts ...lnwallet.ChanCloseOpt,
+) (*wire.MsgTx, btcutil.Amount, error) {
+
+ args := m.Called(localSig, remoteSig, localDeliveryScript,
+ remoteDeliveryScript, proposedFee, closeOpts)
+
+ return args.Get(0).(*wire.MsgTx), args.Get(1).(btcutil.Amount),
+ args.Error(2)
+}
+
+type mockCoopFeeEstimator struct {
+ mock.Mock
+}
+
+func (m *mockCoopFeeEstimator) EstimateFee(
+ chanType channeldb.ChannelType, localTxOut, remoteTxOut *wire.TxOut,
+ idealFeeRate chainfee.SatPerKWeight) btcutil.Amount {
+
+ args := m.Called(chanType, localTxOut, remoteTxOut, idealFeeRate)
+
+ return args.Get(0).(btcutil.Amount)
+}
+
+type mockChanObserver struct {
+ mock.Mock
+}
+
+func (m *mockChanObserver) NoDanglingUpdates() bool {
+ args := m.Called()
+
+ return args.Bool(0)
+}
+
+func (m *mockChanObserver) DisableIncomingAdds() error {
+ args := m.Called()
+
+ return args.Error(0)
+}
+
+func (m *mockChanObserver) DisableOutgoingAdds() error {
+ args := m.Called()
+
+ return args.Error(0)
+}
+
+func (m *mockChanObserver) DisableChannel() error {
+ args := m.Called()
+
+ return args.Error(0)
+}
+
+func (m *mockChanObserver) MarkCoopBroadcasted(tx *wire.MsgTx,
+ local bool) error {
+
+ args := m.Called(tx, local)
+
+ return args.Error(0)
+}
+
+func (m *mockChanObserver) MarkShutdownSent(deliveryAddr []byte,
+ isInitiator bool) error {
+
+ args := m.Called(deliveryAddr, isInitiator)
+
+ return args.Error(0)
+}
+
+func (m *mockChanObserver) FinalBalances() fn.Option[chancloser.ShutdownBalances] {
+ args := m.Called()
+
+ return args.Get(0).(fn.Option[chancloser.ShutdownBalances])
+}
Why this scored 59/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.