lnwallet/chancloser: fix local session nonce rotation bug
What changed, and why it matters
This commit fixes a bug in LND's cooperative channel-closing code for Taproot (MuSig2) channels. The wrong order of operations meant the wallet sometimes tried to create a closing signature before it had loaded the remote party's latest nonce, which could cause the cooperative close to fail with a 'final signature is invalid' error. The patch reorders the steps so the nonce is loaded first, and renames a helper function to make clear which nonce it is handling. A new test was added to enforce the correct order.
Reviewers should confirm that the nonce used to initialize the local MuSig2 session is always the previously committed remote closee nonce, not the freshly received NextCloseeNonce, and that the regression test covers both the success path and any failure/rollback paths. Consider whether other state transitions (e.g., RemoteOfferSent) have the same ordering assumption.
Security signals we found
Incorrect nonce ordering in MuSig2 cooperative close signature path
Potential 'final signature is invalid' failure during cooperative close
State-machine transition bug in RBF cooperative channel close
Regression test added to enforce correct initialization order
Evidence from the diff
In lnwallet/chancloser/rbf_coop_transitions.go, the LocalOfferSent state’s handling of LocalSigReceived was calling ProposalClosingOpts() before ensuring the local MuSig2 session had been initialized with the remote party’s current closee nonce. The fix moves the nonce initialization (via initLocalMusigCloseeNonce using l.NonceState.RemoteCloseeNonce) ahead of ProposalClosingOpts(), and only afterwards updates NonceState.RemoteCloseeNonce with the NextCloseeNonce from the incoming ClosingSig for future RBF rounds. The patch also removes an earlier, misplaced nonce extraction in ClosingNegotiation.updateAndValidateCloseTerms and renames initRemoteMusigCloseeNonce to initRemoteMusigCloserNonce to reflect that it initializes the remote’s closer nonce, not closee nonce. A regression test (TestLocalOfferSentNonceInitOrder) uses a strict mock to verify the ordering.
Changed components
lnwallet/chancloser/rbf_coop_transitions.golnwallet/chancloser/rbf_coop_test.goLocalOfferSent state machine transitionMuSig2/Taproot cooperative channel closeInspect captured patch +191 / −26
diff --git a/lnwallet/chancloser/rbf_coop_test.go b/lnwallet/chancloser/rbf_coop_test.go
index 5cd4d7a..563f407 100644
--- a/lnwallet/chancloser/rbf_coop_test.go
+++ b/lnwallet/chancloser/rbf_coop_test.go
@@ -3117,3 +3117,163 @@ func TestProcessRemoteTaprootSigWithSignerNonce(t *testing.T) {
"musig session should be updated with JIT closer nonce",
)
}
+
+// strictNonceMusigSession is a mock that enforces the correct ordering:
+// InitRemoteNonce must be called before ProposalClosingOpts.
+type strictNonceMusigSession struct {
+ remoteNonceInited bool
+ remoteNonce musig2.Nonces
+
+ proposalOptsCalledBeforeInit bool
+}
+
+func newStrictNonceMusigSession() *strictNonceMusigSession {
+ return &strictNonceMusigSession{}
+}
+
+func (m *strictNonceMusigSession) ProposalClosingOpts() ([]lnwallet.ChanCloseOpt,
+ error) {
+
+ // Track if ProposalClosingOpts was called before InitRemoteNonce.
+ if !m.remoteNonceInited {
+ m.proposalOptsCalledBeforeInit = true
+ return nil, fmt.Errorf("ProposalClosingOpts called before " +
+ "InitRemoteNonce")
+ }
+
+ return nil, nil
+}
+
+func (m *strictNonceMusigSession) CombineClosingOpts(localSig,
+ remoteSig lnwire.PartialSig,
+) (input.Signature, input.Signature, []lnwallet.ChanCloseOpt, error) {
+
+ return &lnwallet.MusigPartialSig{}, &lnwallet.MusigPartialSig{}, nil,
+ nil
+}
+
+func (m *strictNonceMusigSession) InitRemoteNonce(nonce *musig2.Nonces) {
+ m.remoteNonceInited = true
+ m.remoteNonce = *nonce
+}
+
+func (m *strictNonceMusigSession) ClosingNonce() (*musig2.Nonces, error) {
+ return &musig2.Nonces{
+ PubNonce: [66]byte{1, 2, 3},
+ }, nil
+}
+
+// TestLocalOfferSentNonceInitOrder verifies that when processing a
+// LocalSigReceived event in the LocalOfferSent state, the NextCloseeNonce from
+// ClosingSig is properly initialized via initLocalMusigCloseeNonce BEFORE
+// calling ProposalClosingOpts. This is critical for taproot channels because
+// ProposalClosingOpts requires the remote nonce to be set to create a valid
+// MuSig2 session.
+//
+// This test catches the bug where ProposalClosingOpts was called before the
+// nonce was initialized, causing "final signature is invalid" errors during
+// cooperative close.
+func TestLocalOfferSentNonceInitOrder(t *testing.T) {
+ t.Parallel()
+
+ // Create a strict mock that will fail if ProposalClosingOpts is called
+ // before InitRemoteNonce.
+ strictLocalMusig := newStrictNonceMusigSession()
+
+ // The remote's closee nonce from shutdown - this is what should be used
+ // for the current transaction.
+ remoteCloseeNonceFromShutdown := lnwire.Musig2Nonce{4, 5, 6}
+
+ // Set up the environment with the strict mock.
+ closeTerms := &CloseChannelTerms{
+ ShutdownScripts: ShutdownScripts{
+ LocalDeliveryScript: localAddr,
+ RemoteDeliveryScript: remoteAddr,
+ },
+ NonceState: NonceState{
+ LocalCloseeNonce: fn.Some(lnwire.Musig2Nonce{1, 2, 3}),
+ RemoteCloseeNonce: fn.Some(remoteCloseeNonceFromShutdown),
+ },
+ ShutdownBalances: ShutdownBalances{
+ LocalBalance: lnwire.NewMSatFromSatoshis(500_000),
+ RemoteBalance: lnwire.NewMSatFromSatoshis(500_000),
+ },
+ }
+
+ // Create the LocalOfferSent state that we'll be testing.
+ localOfferSent := &LocalOfferSent{
+ CloseChannelTerms: closeTerms,
+ ProposedFee: btcutil.Amount(1000),
+ ProposedFeeRate: chainfee.FeePerKwFloor.FeePerVByte(),
+ LocalSig: localSchnorrSig,
+ }
+
+ // The environment needs LocalMusigSession set for taproot path.
+ // IsTaproot() returns true when LocalMusigSession is non-nil.
+ env := &Environment{
+ ChanPoint: randOutPoint(t),
+ LocalMusigSession: strictLocalMusig,
+ }
+
+ // Create a LocalSigReceived event with NextCloseeNonce.
+ // This simulates receiving a ClosingSig from the remote party.
+ nextCloseeNonce := lnwire.Musig2Nonce{10, 11, 12}
+ localSigEvent := &LocalSigReceived{
+ SigMsg: lnwire.ClosingSig{
+ CloserScript: localAddr,
+ CloseeScript: remoteAddr,
+ FeeSatoshis: btcutil.Amount(1000),
+ LockTime: 1,
+ TaprootPartialSigs: lnwire.TaprootPartialSigs{
+ CloserAndClosee: newPartialSigTlv[tlv.TlvType7](
+ lnwire.PartialSig{
+ Sig: btcec.ModNScalar{},
+ },
+ ),
+ },
+ NextCloseeNonce: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType22](nextCloseeNonce),
+ ),
+ },
+ }
+
+ // Process the event. If the fix is correct, InitRemoteNonce will be
+ // called before ProposalClosingOpts.
+ //
+ // We expect a panic or error from the later code because we don't have
+ // a full environment (missing CloseSigner, etc). We use recover to
+ // catch any panics and still check our assertions.
+ func() {
+ defer func() {
+ // Recover from any panic - we just want to check that
+ // ProposalClosingOpts was called in the right order.
+ _ = recover()
+ }()
+
+ _, _ = localOfferSent.ProcessEvent(localSigEvent, env)
+ }()
+
+ // The critical assertion: ProposalClosingOpts should NOT have been
+ // called before InitRemoteNonce.
+ require.False(
+ t, strictLocalMusig.proposalOptsCalledBeforeInit,
+ "ProposalClosingOpts was called before InitRemoteNonce - "+
+ "this would cause 'final signature is invalid' errors",
+ )
+
+ // Also verify that InitRemoteNonce was actually called.
+ require.True(
+ t, strictLocalMusig.remoteNonceInited,
+ "InitRemoteNonce should have been called",
+ )
+
+ // And that it was called with the correct nonce from NonceState
+ // (established during shutdown), NOT the NextCloseeNonce from
+ // ClosingSig.
+ require.Equal(
+ t, musig2.Nonces{PubNonce: remoteCloseeNonceFromShutdown},
+ strictLocalMusig.remoteNonce,
+ "InitRemoteNonce should be called with RemoteCloseeNonce from "+
+ "NonceState (not NextCloseeNonce from ClosingSig)",
+ )
+}
diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go
index eda45d3..bbbe8f0 100644
--- a/lnwallet/chancloser/rbf_coop_transitions.go
+++ b/lnwallet/chancloser/rbf_coop_transitions.go
@@ -155,15 +155,17 @@ func initLocalMusigCloseeNonce(env *Environment,
}
}
-// initRemoteMusigCloseeNonce initializes the RemoteMusigSession with our local
-// closee nonce. This is used when we act as the closee to sign counter offers.
-func initRemoteMusigCloseeNonce(env *Environment,
- localCloseeNonce fn.Option[lnwire.Musig2Nonce]) {
+// initRemoteMusigCloserNonce initializes the RemoteMusigSession with the
+// remote party's closer nonce. This is called when we receive ClosingComplete
+// and we're acting as closee. The nonce passed in is the remote's JIT closer
+// nonce from their ClosingComplete message.
+func initRemoteMusigCloserNonce(env *Environment,
+ remoteCloserNonce fn.Option[lnwire.Musig2Nonce]) {
if env.RemoteMusigSession != nil {
- localCloseeNonce.WhenSome(func(nonce lnwire.Musig2Nonce) {
- localMusigNonce := musig2.Nonces{PubNonce: nonce}
- env.RemoteMusigSession.InitRemoteNonce(&localMusigNonce)
+ remoteCloserNonce.WhenSome(func(nonce lnwire.Musig2Nonce) {
+ remoteMusigNonce := musig2.Nonces{PubNonce: nonce}
+ env.RemoteMusigSession.InitRemoteNonce(&remoteMusigNonce)
})
}
}
@@ -942,17 +944,6 @@ func (c *ClosingNegotiation) updateAndValidateCloseTerms(event ProtocolEvent,
return err
}
- // For taproot channels, extract the NextCloseeNonce from
- // ClosingSig if present. This will be used for the next RBF
- // iteration when we act as closer. This is their new closee
- // nonce.
- _, nextCloseeNonce := validateAndExtractSigAndNonce(
- msg.SigMsg, isTaproot,
- )
- nextCloseeNonce.WhenSome(func(nonce lnwire.Musig2Nonce) {
- c.NonceState.RemoteCloseeNonce = fn.Some(nonce)
- })
-
return nil
}
@@ -1498,8 +1489,9 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent, env *Environment,
// validate the signature from the remote party. If valid, then we can
// broadcast the transaction, and transition to the ClosePending state.
case *LocalSigReceived:
- // Extract and validate that only one sig field is set.
- sigResult, _ := validateAndExtractSigAndNonce(
+ // Extract and validate that only one sig field is set. For
+ // taproot channels, we also extract the NextCloseeNonce.
+ sigResult, nextCloseeNonce := validateAndExtractSigAndNonce(
msg.SigMsg, env.IsTaproot(),
)
sig, err := sigResult.Unpack()
@@ -1513,15 +1505,28 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent, env *Environment,
lnwallet.WithCustomPayer(lntypes.Local),
)
- // For taproot channels, we'll make sure to add the musig
- // options before calling prepareClosingSignatures.
+ // For taproot channels, we need to initialize the remote's
+ // closee nonce BEFORE calling ProposalClosingOpts. We use the
+ // nonce from NonceState (set during shutdown or from prior
+ // ClosingSig's NextCloseeNonce).
if env.IsTaproot() {
+ // Initialize the remote nonce from our stored state.
+ // This is the nonce the remote party committed to in
+ // their shutdown message (or their previous ClosingSig).
+ initLocalMusigCloseeNonce(env, l.NonceState.RemoteCloseeNonce)
+
+ // Now that the nonce is initialized, we can safely get
+ // the musig closing options.
musigOpts, err := env.LocalMusigSession.ProposalClosingOpts()
if err != nil {
return nil, fmt.Errorf("failed to get musig "+
"closing opts: %w", err)
}
closeOpts = append(closeOpts, musigOpts...)
+
+ // Update NonceState with the new nonce from ClosingSig
+ // for potential future RBF iterations.
+ l.NonceState.RemoteCloseeNonce = nextCloseeNonce
}
localSig, remoteSig, err := prepareClosingSignatures(
@@ -1588,7 +1593,7 @@ func processRemoteTaprootSig(env *Environment, msg lnwire.ClosingComplete,
// Initialize the RemoteMusigSession with their JIT closer nonce. We
// already added our local nonce either during shutdown, or with our
// last ClosingSig message.
- initRemoteMusigCloseeNonce(env, jitNonce)
+ initRemoteMusigCloserNonce(env, jitNonce)
remotePartialSig, err := selectTaprootPartialSigWithNonce(
msg.TaprootClosingSigs, noClosee,
@@ -1969,10 +1974,10 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
}
}
- chancloserLog.Infof("responding to close w/ local_addr=%x, "+
- "remote_addr=%x, fee=%v",
+ chancloserLog.Infof("RemoteCloseStart: responding to close w/ "+
+ "local_addr=%x, remote_addr=%x, fee=%v, locktime=%v",
l.LocalDeliveryScript[:], l.RemoteDeliveryScript[:],
- msg.SigMsg.FeeSatoshis)
+ msg.SigMsg.FeeSatoshis, msg.SigMsg.LockTime)
// Now that we have the remote sig, we'll sign the version they
// signed, then attempt to complete the cooperative close
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.