lnwire: add taproot signatures support to closing_complete message
What changed, and why it matters
This commit adds support for taproot (MuSig2) cooperative channel closing in LND's RBF close protocol. It introduces new wire message fields for taproot partial signatures and nonces, updates the state machine to handle both ECDSA and taproot paths, and adds tests. There is no direct evidence in the commit message or diff that this fixes a known security vulnerability; it appears to be a feature implementation for taproot channel compatibility.
Treat as a significant feature commit rather than an urgent security patch. Reviewers should focus on correctness of MuSig2 nonce handling, signature type validation, and backward compatibility with non-taproot channels. Run the new taproot RBF close tests and consider fuzzing the wire encoding/decoding for the new TLV fields.
Security signals we found
New wire protocol fields for taproot partial signatures and nonces
State machine now validates signature type matches channel type (taproot vs ECDSA)
Taproot shutdown messages missing required nonce are rejected
MuSig2 session initialization and nonce rotation added to cooperative close flow
Extensive test coverage added for taproot close paths
Evidence from the diff
The patch extends lnwire messages (ClosingComplete, ClosingSig, Shutdown) with taproot-specific TLV fields: TaprootClosingSigs using PartialSigWithNonce, TaprootPartialSigs, NextCloseeNonce, and ShutdownNonce. The chancloser state machine gains IsTaproot detection, Local/Remote MusigSession management, NonceState tracking, and helpers to encode/extract/validate signatures for both ECDSA and taproot paths. Validation rejects taproot shutdown messages missing nonces and mismatched signature types. The change is large (+2401/-474) and touches core close logic, but the supplied materials do not describe it as a security fix.
Changed components
lnwire/closing_complete.golnwire/closing_sig.golnwire/shutdown.golnwire/test_message.golnwallet/chancloser/rbf_coop_transitions.golnwallet/chancloser/rbf_coop_states.golnwallet/chancloser/rbf_coop_msg_mapper.golnwallet/chancloser/rbf_coop_test.golnwallet/chancloser/chancloser_test.goInspect captured patch +2401 / −474
diff --git a/lnwallet/chancloser/chancloser_test.go b/lnwallet/chancloser/chancloser_test.go
index 0f16356..850a162 100644
--- a/lnwallet/chancloser/chancloser_test.go
+++ b/lnwallet/chancloser/chancloser_test.go
@@ -273,6 +273,8 @@ func newMockTaprootChan(t *testing.T, initiator bool) *mockChannel {
}
type mockMusigSession struct {
+ remoteNonceInited bool
+ remoteNonce musig2.Nonces
}
func newMockMusigSession() *mockMusigSession {
@@ -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 {
diff --git a/lnwallet/chancloser/rbf_coop_msg_mapper.go b/lnwallet/chancloser/rbf_coop_msg_mapper.go
index a66cf78..1141e36 100644
--- a/lnwallet/chancloser/rbf_coop_msg_mapper.go
+++ b/lnwallet/chancloser/rbf_coop_msg_mapper.go
@@ -58,9 +58,15 @@ func (r *RbfMsgMapper) MapMsg(wireMsg msgmux.PeerMsg) fn.Option[ProtocolEvent] {
return fn.None[ProtocolEvent]()
}
+ var remoteShutdownNonce fn.Option[lnwire.Musig2Nonce]
+ msg.ShutdownNonce.WhenSomeV(func(nonce lnwire.Musig2Nonce) {
+ remoteShutdownNonce = fn.Some(nonce)
+ })
+
return someEvent(&ShutdownReceived{
- BlockHeight: r.blockHeight,
- ShutdownScript: msg.Address,
+ BlockHeight: r.blockHeight,
+ ShutdownScript: msg.Address,
+ RemoteShutdownNonce: remoteShutdownNonce,
})
case *lnwire.ClosingComplete:
diff --git a/lnwallet/chancloser/rbf_coop_states.go b/lnwallet/chancloser/rbf_coop_states.go
index 8c7a265..4cdd583 100644
--- a/lnwallet/chancloser/rbf_coop_states.go
+++ b/lnwallet/chancloser/rbf_coop_states.go
@@ -54,6 +54,11 @@ var (
// ClosingComplete message that doesn't carry our last local script
// sent.
ErrWrongLocalScript = fmt.Errorf("wrong local script")
+
+ // ErrTaprootShutdownNonceMissing is returned when a taproot channel
+ // receives a shutdown message without the required nonce.
+ ErrTaprootShutdownNonceMissing = fmt.Errorf("shutdown nonce " +
+ "required for taproot channel RBF flow")
)
// ProtocolEvent is a special interface used to create the equivalent of a
@@ -101,6 +106,11 @@ type SendShutdown struct {
// IdealFeeRate is the ideal fee rate we'd like to use for the closing
// attempt.
IdealFeeRate chainfee.SatPerVByte
+
+ // CloseeNonce is the nonce we'll send in the shutdown message. The
+ // remote party will use this when they create their closing transaction
+ // (when they act as closer). Only present for taproot channels.
+ CloseeNonce fn.Option[lnwire.Musig2Nonce]
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
@@ -121,6 +131,11 @@ type ShutdownReceived struct {
// received. This is used for channel leases to determine if a co-op
// close can occur.
BlockHeight uint32
+
+ // RemoteShutdownNonce is the closee nonce from the remote party's
+ // shutdown message. We'll use this when signing our closing transaction
+ // (when we act as closer). Only present for taproot channels.
+ RemoteShutdownNonce fn.Option[lnwire.Musig2Nonce]
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
@@ -338,6 +353,16 @@ type Environment struct {
// we'll be signing can only be determined once the channel has been
// flushed.
CloseSigner CloseSigner
+
+ // LocalMusigSession is the MuSig2 session used when we're creating our
+ // own closing transaction (acting as the closer) in the RBF flow. This
+ // is optional and only used for taproot channels.
+ LocalMusigSession MusigSession
+
+ // RemoteMusigSession is the MuSig2 session used when we're creating the
+ // remote party's closing transaction (acting as the closee) in the RBF
+ // flow. This is optional and only used for taproot channels.
+ RemoteMusigSession MusigSession
}
// Name returns the name of the environment. This is used to uniquely identify
@@ -347,6 +372,12 @@ func (e *Environment) Name() string {
return fmt.Sprintf("rbf_chan_closer(%v)", e.ChanPoint)
}
+// IsTaproot returns true if this is a taproot channel. A channel is considered
+// taproot if either the LocalMusigSession or RemoteMusigSession is set.
+func (e *Environment) IsTaproot() bool {
+ return e.LocalMusigSession != nil || e.RemoteMusigSession != nil
+}
+
// CloseStateTransition is the StateTransition type specific to the coop close
// state machine.
//
@@ -459,6 +490,10 @@ type ShutdownPending struct {
// before we received their shutdown message. We'll stash it to process
// later.
EarlyRemoteOffer fn.Option[OfferReceivedEvent]
+
+ // NonceState tracks the nonces exchanged during shutdown for taproot
+ // channels.
+ NonceState NonceState
}
// String returns the name of the state for ShutdownPending.
@@ -499,6 +534,10 @@ type ChannelFlushing struct {
// transaction. Once the channel has been flushed, we'll use this as
// our target fee rate.
IdealFeeRate fn.Option[chainfee.SatPerVByte]
+
+ // NonceState tracks the nonces exchanged during shutdown for taproot
+ // channels.
+ NonceState NonceState
}
// String returns the name of the state for ChannelFlushing.
@@ -609,6 +648,20 @@ func (e *ErrStateCantPayForFee) String() string {
"attempted_fee=%v)", e.localBalance, e.attemptedFee)
}
+// NonceState stores the nonces for taproot channel closing using the simplified
+// JIT (just-in-time) nonce pattern. With this pattern, shutdown messages only
+// contain the sender's closee nonce, and subsequent nonces are sent alongside
+// signatures in PartialSigWithNonce fields.
+type NonceState struct {
+ // LocalCloseeNonce is the nonce we sent in our shutdown message.
+ // The remote party will use this when they act as closer.
+ LocalCloseeNonce fn.Option[lnwire.Musig2Nonce]
+
+ // RemoteCloseeNonce is the nonce from the remote party's shutdown
+ // message. We'll use this when we act as closer.
+ RemoteCloseeNonce fn.Option[lnwire.Musig2Nonce]
+}
+
// CloseChannelTerms is a set of terms that we'll use to close the channel. This
// includes the balances of the channel, and the scripts we'll use to send each
// party's funds to.
@@ -616,6 +669,9 @@ type CloseChannelTerms struct {
ShutdownScripts
ShutdownBalances
+
+ // NonceState tracks nonces for taproot channels across RBF iterations.
+ NonceState NonceState
}
// DeriveCloseTxOuts takes the close terms, and returns the local and remote tx
diff --git a/lnwallet/chancloser/rbf_coop_test.go b/lnwallet/chancloser/rbf_coop_test.go
index 488e41e..644ba30 100644
--- a/lnwallet/chancloser/rbf_coop_test.go
+++ b/lnwallet/chancloser/rbf_coop_test.go
@@ -12,8 +12,10 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
+ "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/mempool"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
@@ -21,6 +23,7 @@ import (
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lntest/wait"
"github.com/lightningnetwork/lnd/lntypes"
+ "github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/protofsm"
@@ -49,18 +52,22 @@ var (
remoteSigBytes = fromHex("304502210082235e21a2300022738dabb8e1bbd9d1" +
"9cfb1e7ab8c30a23b0afbb8d178abcf3022024bf68e256c534ddfaf966b" +
"f908deb944305596f7bdcc38d69acad7f9c868724")
- remoteSig = sigMustParse(remoteSigBytes)
- remoteWireSig = mustWireSig(&remoteSig)
- remoteSigRecordType3 = newSigTlv[tlv.TlvType3](remoteWireSig)
- remoteSigRecordType1 = newSigTlv[tlv.TlvType1](remoteWireSig)
+ remoteSig = sigMustParse(remoteSigBytes)
+ remoteWireSig = mustWireSig(&remoteSig)
+
+ localSchnorrSigBytes = bytes.Repeat([]byte{0x01}, 64)
+ localSchnorrSig, _ = lnwire.NewSigFromSchnorrRawSignature(
+ localSchnorrSigBytes,
+ )
+
+ remoteSchnorrSigBytes = bytes.Repeat([]byte{0x02}, 64)
+ remoteSchnorrSig, _ = lnwire.NewSigFromSchnorrRawSignature(
+ remoteSchnorrSigBytes,
+ )
localTx = wire.MsgTx{Version: 2}
closeTx = wire.NewMsgTx(2)
-
- defaultTimeout = 500 * time.Millisecond
- longTimeout = 3 * time.Second
- defaultPoll = 50 * time.Millisecond
)
func sigMustParse(sigBytes []byte) ecdsa.Signature {
@@ -117,7 +124,7 @@ func assertStateTransitions[Event any, Env protofsm.Environment](
for _, expectedState := range expectedStates {
newState, err := fn.RecvOrTimeout(
- stateSub.NewItemCreated.ChanOut(), defaultTimeout,
+ stateSub.NewItemCreated.ChanOut(), 10*time.Millisecond,
)
require.NoError(t, err, "expected state: %T", expectedState)
@@ -128,7 +135,7 @@ func assertStateTransitions[Event any, Env protofsm.Environment](
select {
case newState := <-stateSub.NewItemCreated.ChanOut():
t.Fatalf("unexpected state transition: %v", newState)
- case <-time.After(defaultPoll):
+ default:
}
}
@@ -153,7 +160,7 @@ func assertUnknownEventFail(t *testing.T, startingState ProtocolState) {
defer closeHarness.stopAndAssert()
closeHarness.sendEventAndExpectFailure(
- t.Context(), &unknownEvent{},
+ context.Background(), &unknownEvent{},
ErrInvalidStateTransition,
)
})
@@ -173,7 +180,7 @@ func assertSpendEventCloseFin(t *testing.T, startingState ProtocolState) {
defer closeHarness.stopAndAssert()
closeHarness.chanCloser.SendEvent(
- t.Context(), &SpendEvent{},
+ context.Background(), &SpendEvent{},
)
closeHarness.assertStateTransitions(&CloseFin{})
@@ -187,6 +194,9 @@ type harnessCfg struct {
localUpfrontAddr fn.Option[lnwire.DeliveryAddress]
remoteUpfrontAddr fn.Option[lnwire.DeliveryAddress]
+
+ localMusigSession fn.Option[MusigSession]
+ remoteMusigSession fn.Option[MusigSession]
}
// rbfCloserTestHarness is a test harness for the RBF closer.
@@ -291,12 +301,10 @@ func (r *rbfCloserTestHarness) assertStartupAssertions() {
}
func (r *rbfCloserTestHarness) assertNoStateTransitions() {
- r.T.Helper()
-
select {
case newState := <-r.stateSub.NewItemCreated.ChanOut():
r.T.Fatalf("unexpected state transition: %T", newState)
- case <-time.After(defaultPoll):
+ case <-time.After(10 * time.Millisecond):
}
}
@@ -433,10 +441,33 @@ func (r *rbfCloserTestHarness) expectNewCloseSig(
r.T.Helper()
- r.signer.On(
- "CreateCloseProposal", fee, localScript, remoteScript,
- mock.Anything,
- ).Return(&localSig, &localTx, closeBalance, nil)
+ // For taproot channels, we'll return a musig2 partial siganture instead
+ // of the normal schnorr sig.
+ switch {
+ case r.env.LocalMusigSession != nil:
+ var s btcec.ModNScalar
+ s.SetInt(1)
+
+ privKey, _ := btcec.NewPrivateKey()
+ rPoint := privKey.PubKey()
+
+ partislSig := musig2.NewPartialSignature(&s, rPoint)
+ musigSig := lnwallet.NewMusigPartialSig(
+ &partislSig, lnwire.Musig2Nonce{}, lnwire.Musig2Nonce{},
+ nil, fn.None[chainhash.Hash](),
+ )
+ r.signer.On(
+ "CreateCloseProposal", fee, localScript, remoteScript,
+ mock.Anything,
+ ).Return(musigSig, &localTx, closeBalance, nil)
+
+ // For non-taproot channels, return regular ECDSA signature.
+ default:
+ r.signer.On(
+ "CreateCloseProposal", fee, localScript, remoteScript,
+ mock.Anything,
+ ).Return(&localSig, &localTx, closeBalance, nil)
+ }
}
func (r *rbfCloserTestHarness) waitForMsgSent() {
@@ -444,7 +475,7 @@ func (r *rbfCloserTestHarness) waitForMsgSent() {
err := wait.Predicate(func() bool {
return r.daemonAdapters.msgSent.Load()
- }, longTimeout)
+ }, time.Second*3)
require.NoError(r.T, err)
}
@@ -470,11 +501,22 @@ func (r *rbfCloserTestHarness) expectCloseFinalized(
remoteScript []byte, fee btcutil.Amount,
balanceAfterClose btcutil.Amount, isLocal bool) {
- // The caller should obtain the final signature.
- r.signer.On("CompleteCooperativeClose",
- localCoopSig, remoteCoopSig, localScript,
- remoteScript, fee, mock.Anything,
- ).Return(closeTx, balanceAfterClose, nil)
+ // For taproot, we expect the CompleteCooperativeClose to be called with
+ // musig signatures. We need to match on any signature type since the
+ // exact types will differ.
+ switch {
+ case r.env.LocalMusigSession != nil:
+ r.signer.On("CompleteCooperativeClose",
+ mock.Anything, mock.Anything, localScript,
+ remoteScript, fee, mock.Anything,
+ ).Return(closeTx, balanceAfterClose, nil)
+ default:
+ // The caller should obtain the final signature.
+ r.signer.On("CompleteCooperativeClose",
+ localCoopSig, remoteCoopSig, localScript,
+ remoteScript, fee, mock.Anything,
+ ).Return(closeTx, balanceAfterClose, nil)
+ }
// The caller should also mark the transaction as broadcast on disk.
r.chanObserver.On("MarkCoopBroadcasted", closeTx, isLocal).Return(nil)
@@ -539,7 +581,7 @@ func (r *rbfCloserTestHarness) expectHalfSignerIteration(
initEvent ProtocolEvent, balanceAfterClose, absoluteFee btcutil.Amount,
dustExpect dustExpectation, iteration bool) {
- ctx := r.T.Context()
+ ctx := context.Background()
numFeeCalls := 2
// If we're using the SendOfferEvent as a trigger, we only need to call
@@ -566,13 +608,40 @@ func (r *rbfCloserTestHarness) expectHalfSignerIteration(
msgExpect := singleMsgMatcher(func(m *lnwire.ClosingComplete) bool {
r.T.Helper()
+ // For taproot channels, check TaprootClosingSigs, as we'll be
+ // sending musig signatures over.
+ if r.env.LocalMusigSession != nil {
+ switch {
+ case m.TaprootClosingSigs.CloserNoClosee.IsSome():
+ r.T.Logf("taproot closer no closee field "+
+ "set, expected: %v",
+ dustExpect)
+
+ return dustExpect == remoteDustExpect
+ case m.TaprootClosingSigs.NoCloserClosee.IsSome():
+ r.T.Logf("taproot no close closee "+
+ "field set, expected: %v",
+ dustExpect)
+
+ return dustExpect == localDustExpect
+ default:
+ r.T.Logf("taproot no dust field set, "+
+ "expected: %v", dustExpect)
+
+ //nolint:ll
+ return (m.TaprootClosingSigs.CloserAndClosee.IsSome() &&
+ dustExpect == noDustExpect)
+ }
+ }
+
+ // For non-taproot channels, check regular ClosingSigs
switch {
- case m.CloserNoClosee.IsSome():
+ case m.ClosingSigs.CloserNoClosee.IsSome():
r.T.Logf("closer no closee field set, expected: %v",
dustExpect)
return dustExpect == remoteDustExpect
- case m.NoCloserClosee.IsSome():
+ case m.ClosingSigs.NoCloserClosee.IsSome():
r.T.Logf("no close closee field set, expected: %v",
dustExpect)
@@ -580,7 +649,7 @@ func (r *rbfCloserTestHarness) expectHalfSignerIteration(
default:
r.T.Logf("no dust field set, expected: %v", dustExpect)
- return (m.CloserAndClosee.IsSome() &&
+ return (m.ClosingSigs.CloserAndClosee.IsSome() &&
dustExpect == noDustExpect)
}
})
@@ -638,19 +707,35 @@ func (r *rbfCloserTestHarness) expectHalfSignerIteration(
// The proposed fee, as well as our local signature should be
// properly stashed in the state.
require.Equal(r.T, absoluteFee, offerSentState.ProposedFee)
- require.Equal(r.T, localSigWire, offerSentState.LocalSig)
+
+ switch {
+ case r.env.LocalMusigSession != nil:
+ // For taproot, we verify that we have a schnorr signature
+ // stored.
+ require.NotNil(r.T, offerSentState.LocalSig)
+
+ // The signature should be marked as schnorr type
+ sigBytes := offerSentState.LocalSig.RawBytes()
+ require.Len(r.T, sigBytes, 64)
+
+ // Verify it's not a zero signature
+ require.NotEqual(r.T, make([]byte, 64), sigBytes)
+ default:
+ // For non-taproot channels, we expect the exact ECDSA signature
+ require.Equal(r.T, localSigWire, offerSentState.LocalSig)
+ }
}
func (r *rbfCloserTestHarness) assertSingleRbfIteration(
initEvent ProtocolEvent, balanceAfterClose, absoluteFee btcutil.Amount,
dustExpect dustExpectation, iteration bool) {
- ctx := r.T.Context()
+ ctx := context.Background()
// We'll now send in the send offer event, which should trigger 1/2 of
// the RBF loop, ending us in the LocalOfferSent state.
r.expectHalfSignerIteration(
- initEvent, balanceAfterClose, absoluteFee, dustExpect,
+ initEvent, balanceAfterClose, absoluteFee, noDustExpect,
iteration,
)
@@ -681,18 +766,81 @@ func (r *rbfCloserTestHarness) assertSingleRbfIteration(
r.assertLocalClosePending()
}
-// assertSingleRemoteRbfIteration asserts that a single RBF iteration initiated
-// by the remote party completes successfully. The sendEvent callback controls
-// when the event that kicks off the process is sent, which is useful for tests
-// that need to set up mocks before the event is processed. The callback is
-// provided with the context and the initial offer event so most callers can
-// pass chanCloser.SendEvent directly.
+// newNonceTlv is a helper function that returns a new optional TLV nonce field.
+//
+//nolint:ll
+func newNonceTlv(nonce lnwire.Musig2Nonce) tlv.OptionalRecordT[tlv.TlvType22, lnwire.Musig2Nonce] {
+ return tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType22](nonce))
+}
+
+// newPartialSigTlv is a helper function that returns a new optional TLV partial
+// sig field.
+//
+//nolint:ll
+func newPartialSigTlv[T tlv.TlvType](ps lnwire.PartialSig) tlv.OptionalRecordT[T, lnwire.PartialSig] {
+ return tlv.SomeRecordT(tlv.NewRecordT[T](ps))
+}
+
+// newPartialSigWithNonceTlv is a helper function that returns a new optional
+// TLV partial sig with nonce field.
+func newPartialSigWithNonceTlv[T tlv.TlvType](psn lnwire.PartialSigWithNonce,
+) tlv.OptionalRecordT[T, lnwire.PartialSigWithNonce] {
+
+ return tlv.SomeRecordT(tlv.NewRecordT[T](psn))
+}
+
+// assertSingleRbfIterationWithNonce is a variant of assertSingleRbfIteration
+// that includes nonce handling for taproot channels.
+func (r *rbfCloserTestHarness) assertSingleRbfIterationWithNonce(
+ initEvent ProtocolEvent, balanceAfterClose, absoluteFee btcutil.Amount,
+ dustExpect dustExpectation, iteration bool,
+ nextCloseeNonce lnwire.Musig2Nonce) {
+
+ ctx := context.Background()
+
+ // We'll now send in the send offer event, which should trigger 1/2 of
+ // the RBF loop, ending us in the LocalOfferSent state.
+ r.expectHalfSignerIteration(
+ initEvent, balanceAfterClose, absoluteFee, noDustExpect,
+ iteration,
+ )
+
+ // Now that we're in the local offer sent state, we'll send the response
+ // of the remote party, which completes one iteration
+ localSigEvent := &LocalSigReceived{
+ SigMsg: lnwire.ClosingSig{
+ CloserScript: localAddr,
+ CloseeScript: remoteAddr,
+ TaprootPartialSigs: lnwire.TaprootPartialSigs{
+ CloserAndClosee: newPartialSigTlv[tlv.TlvType7](
+ lnwire.PartialSig{
+ Sig: btcec.ModNScalar{},
+ },
+ ),
+ },
+ NextCloseeNonce: newNonceTlv(nextCloseeNonce),
+ },
+ }
+
+ // Before we send the event, we expect the close the final signature to
+ // be combined/obtained, and for the close to finalized on disk.
+ r.expectCloseFinalized(
+ &localSig, &remoteSig, localAddr, remoteAddr, absoluteFee,
+ balanceAfterClose, true,
+ )
+
+ r.chanCloser.SendEvent(ctx, localSigEvent)
+
+ // We should transition to the pending closing state now.
+ r.assertLocalClosePending()
+}
+
func (r *rbfCloserTestHarness) assertSingleRemoteRbfIteration(
initEvent *OfferReceivedEvent, balanceAfterClose,
absoluteFee btcutil.Amount, sequence uint32, iteration bool,
- sendEvent func(context.Context, ProtocolEvent)) {
+ sendInit bool) {
- ctx := r.T.Context()
+ ctx := context.Background()
// When we receive the signature below, our local state machine should
// move to finalize the close.
@@ -702,24 +850,20 @@ func (r *rbfCloserTestHarness) assertSingleRemoteRbfIteration(
absoluteFee, balanceAfterClose, false,
)
- sendEvent(ctx, initEvent)
+ if sendInit {
+ r.chanCloser.SendEvent(ctx, initEvent)
+ }
// Our outer state should transition to ClosingNegotiation state.
- transitions := []RbfState{
- &ClosingNegotiation{},
- }
+ r.assertStateTransitions(&ClosingNegotiation{})
// If this is an iteration, then we'll go from ClosePending ->
// RemoteCloseStart -> ClosePending. So we'll assert an extra transition
// here.
if iteration {
- transitions = append(transitions, &ClosingNegotiation{})
+ r.assertStateTransitions(&ClosingNegotiation{})
}
- // Now that we know how many state transitions to expect, we'll wait
- // for them.
- r.assertStateTransitions(transitions...)
-
// If we examine the final resting state, we should see that the we're
// now in the ClosePending state for the remote peer.
currentState := assertStateT[*ClosingNegotiation](r)
@@ -748,7 +892,7 @@ func assertStateT[T ProtocolState](h *rbfCloserTestHarness) T {
func newRbfCloserTestHarness(t *testing.T,
cfg *harnessCfg) *rbfCloserTestHarness {
- ctx := t.Context()
+ ctx := context.Background()
startingHeight := 200
@@ -796,6 +940,15 @@ func newRbfCloserTestHarness(t *testing.T,
ChanObserver: mockObserver,
CloseSigner: mockSigner,
}
+
+ // If musig sessions are provided, we set them in the environment.
+ cfg.localMusigSession.WhenSome(func(session MusigSession) {
+ env.LocalMusigSession = session
+ })
+ cfg.remoteMusigSession.WhenSome(func(session MusigSession) {
+ env.RemoteMusigSession = session
+ })
+
harness.env = env
var pkScript []byte
@@ -820,7 +973,7 @@ func newRbfCloserTestHarness(t *testing.T,
MsgMapper: fn.Some[protofsm.MsgMapper[ProtocolEvent]](
msgMapper,
),
- CustomPollInterval: fn.Some(defaultPoll),
+ CustomPollInterval: fn.Some(time.Nanosecond),
}
// Before we start we always expect an initial spend event.
@@ -829,13 +982,10 @@ func newRbfCloserTestHarness(t *testing.T,
).Return(nil)
chanCloser := protofsm.NewStateMachine(protoCfg)
+ chanCloser.Start(ctx)
- // We register our subscriber before starting the state machine, to make
- // sure we don't miss any events.
harness.stateSub = chanCloser.RegisterStateEvents()
- chanCloser.Start(ctx)
-
harness.chanCloser = &chanCloser
return harness
@@ -851,10 +1001,242 @@ func newCloser(t *testing.T, cfg *harnessCfg) *rbfCloserTestHarness {
return chanCloser
}
+// testInitiatorShutdownRecvOk is a helper function that tests the initiator
+// shutdown received scenario for both taproot and non-taproot channels in the
+// ShutdownPending state.
+func testInitiatorShutdownRecvOk(t *testing.T, ctx context.Context,
+ startingState *ShutdownPending, isTaproot bool) {
+
+ testName := "non_taproot"
+ if isTaproot {
+ testName = "taproot"
+ }
+
+ t.Run(testName, func(t *testing.T) {
+ firstState := *startingState
+ firstState.IdealFeeRate = fn.Some(
+ chainfee.FeePerKwFloor.FeePerVByte(),
+ )
+ firstState.ShutdownScripts = ShutdownScripts{
+ LocalDeliveryScript: localAddr,
+ RemoteDeliveryScript: remoteAddr,
+ }
+
+ var mockLocalMusig, mockRemoteMusig *mockMusigSession
+ localCloseeNonce := lnwire.Musig2Nonce{1, 2, 3}
+ remoteCloseeNonce := lnwire.Musig2Nonce{4, 5, 6}
+
+ if isTaproot {
+ firstState.NonceState = NonceState{
+ LocalCloseeNonce: fn.Some(localCloseeNonce),
+ RemoteCloseeNonce: fn.None[lnwire.Musig2Nonce](),
+ }
+ mockLocalMusig = newMockMusigSession()
+ mockRemoteMusig = newMockMusigSession()
+ }
+
+ cfg := &harnessCfg{
+ initialState: fn.Some[ProtocolState](
+ &firstState,
+ ),
+ localUpfrontAddr: fn.Some(localAddr),
+ remoteUpfrontAddr: fn.Some(remoteAddr),
+ }
+ if isTaproot {
+ cfg.localMusigSession = fn.Some[MusigSession](
+ mockLocalMusig,
+ )
+ cfg.remoteMusigSession = fn.Some[MusigSession](
+ mockRemoteMusig,
+ )
+ }
+
+ closeHarness := newCloser(t, cfg)
+ defer closeHarness.stopAndAssert()
+
+ // We should disable the outgoing adds for the channel at this
+ // point as well.
+ closeHarness.expectFinalBalances(fn.None[ShutdownBalances]())
+ closeHarness.expectIncomingAddsDisabled()
+
+ // Create shutdown event, with nonce for taproot channels
+ shutdownEvent := &ShutdownReceived{
+ ShutdownScript: remoteAddr,
+ }
+ if isTaproot {
+ shutdownEvent.RemoteShutdownNonce = fn.Some(
+ remoteCloseeNonce,
+ )
+ }
+
+ // We'll send in a shutdown received event, with the expected
+ // co-op close addr.
+ closeHarness.chanCloser.SendEvent(ctx, shutdownEvent)
+
+ // We should transition to the channel flushing state.
+ closeHarness.assertStateTransitions(&ChannelFlushing{})
+
+ // Now we'll ensure that the flushing state has the proper
+ // co-op close state.
+ currentState := assertStateT[*ChannelFlushing](closeHarness)
+
+ require.Equal(
+ t, localAddr, currentState.LocalDeliveryScript,
+ )
+ require.Equal(
+ t, remoteAddr, currentState.RemoteDeliveryScript,
+ )
+ require.Equal(
+ t, firstState.IdealFeeRate, currentState.IdealFeeRate,
+ )
+
+ if isTaproot {
+ // Verify nonce state was updated with remote's closee nonce.
+ require.True(
+ t, currentState.NonceState.RemoteCloseeNonce.IsSome(),
+ )
+ require.Equal(
+ t, remoteCloseeNonce,
+ currentState.NonceState.RemoteCloseeNonce.UnwrapOr(
+ lnwire.Musig2Nonce{},
+ ),
+ )
+
+ // Verify musig sessions were set up.
+ require.NotNil(
+ t, closeHarness.env.LocalMusigSession,
+ "LocalMusigSession should not be nil",
+ )
+ require.NotNil(
+ t, closeHarness.env.RemoteMusigSession,
+ "RemoteMusigSession should not be nil",
+ )
+
+ // Verify InitRemoteNonce was called on
+ // LocalMusigSession with remote's nonce. This prepares
+ // the LocalMusigSession for when we act as closer.
+ require.True(
+ t, mockLocalMusig.remoteNonceInited,
+ "LocalMusigSession.InitRemoteNonce "+
+ "should have been called",
+ )
+ expectedRemoteNonce := musig2.Nonces{
+ PubNonce: remoteCloseeNonce,
+ }
+ require.Equal(
+ t, expectedRemoteNonce,
+ mockLocalMusig.remoteNonce,
+ )
+ }
+ })
+}
+
+// testRemoteInitiatedCloseOk is a helper function that tests the remote
+// initiated close scenario for both taproot and non-taproot channels.
+func testRemoteInitiatedCloseOk(t *testing.T, ctx context.Context, isTaproot bool) {
+ testName := "non_taproot"
+ if isTaproot {
+ testName = "taproot"
+ }
+
+ t.Run(testName, func(t *testing.T) {
+ var mockLocalMusig, mockRemoteMusig *mockMusigSession
+ remoteCloseeNonce := lnwire.Musig2Nonce{4, 5, 6}
+
+ cfg := &harnessCfg{
+ localUpfrontAddr: fn.Some(localAddr),
+ }
+ if isTaproot {
+ mockLocalMusig = newMockMusigSession()
+ mockRemoteMusig = newMockMusigSession()
+ cfg.localMusigSession = fn.Some[MusigSession](
+ mockLocalMusig,
+ )
+ cfg.remoteMusigSession = fn.Some[MusigSession](
+ mockRemoteMusig,
+ )
+ }
+
+ closeHarness := newCloser(t, cfg)
+ defer closeHarness.stopAndAssert()
+
+ // We assert our shutdown events, and also that we eventually
+ // send a shutdown to the remote party. We'll hold back the
+ // send in this case though, as we should only send once the no
+ // updates are dangling.
+ closeHarness.expectShutdownEvents(shutdownExpect{
+ isInitiator: false,
+ allowSend: false,
+ recvShutdown: true,
+ })
+
+ // Create shutdown event, with nonce for taproot channels
+ shutdownEvent := &ShutdownReceived{
+ ShutdownScript: remoteAddr,
+ }
+ if isTaproot {
+ shutdownEvent.RemoteShutdownNonce = fn.Some(
+ remoteCloseeNonce,
+ )
+ }
+
+ // Next, we'll emit the recv event, with the addr of the remote
+ // party.
+ closeHarness.chanCloser.SendEvent(ctx, shutdownEvent)
+
+ // We should transition to the shutdown pending state.
+ closeHarness.assertStateTransitions(&ShutdownPending{})
+
+ currentState := assertStateT[*ShutdownPending](closeHarness)
+
+ // Both the local and remote shutdown scripts should be set.
+ require.Equal(
+ t, localAddr,
+ currentState.ShutdownScripts.LocalDeliveryScript,
+ )
+ require.Equal(
+ t, remoteAddr,
+ currentState.ShutdownScripts.RemoteDeliveryScript,
+ )
+
+ // For taproot channels, verify nonce handling
+ if isTaproot {
+ // Verify nonce state was set with remote's closee
+ // nonce.
+ require.True(
+ t, currentState.NonceState.RemoteCloseeNonce.IsSome(),
+ )
+ require.Equal(
+ t, remoteCloseeNonce,
+ currentState.NonceState.RemoteCloseeNonce.UnwrapOr(
+ lnwire.Musig2Nonce{},
+ ),
+ )
+
+ // Verify InitRemoteNonce was called on
+ // LocalMusigSession.
+ require.True(t, mockLocalMusig.remoteNonceInited)
+ expectedRemoteNonce := musig2.Nonces{
+ PubNonce: remoteCloseeNonce,
+ }
+ require.Equal(
+ t, expectedRemoteNonce,
+ mockLocalMusig.remoteNonce,
+ )
+
+ // Also verify we generated and stored our local closee
+ // nonce.
+ require.True(
+ t, currentState.NonceState.LocalCloseeNonce.IsSome(),
+ )
+ }
+ })
+}
+
// TestRbfChannelActiveTransitions tests the transitions of from the
// ChannelActive state.
func TestRbfChannelActiveTransitions(t *testing.T) {
- ctx := t.Context()
+ ctx := context.Background()
localAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x01}, 20))
remoteAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x02}, 20))
@@ -955,41 +1337,40 @@ func TestRbfChannelActiveTransitions(t *testing.T) {
// When we receive a shutdown, we should transition to the shutdown
// pending state, with the local+remote shutdown addrs known.
t.Run("remote_initiated_close_ok", func(t *testing.T) {
- closeHarness := newCloser(t, &harnessCfg{
- localUpfrontAddr: fn.Some(localAddr),
- })
- defer closeHarness.stopAndAssert()
-
- // We assert our shutdown events, and also that we eventually
- // send a shutdown to the remote party. We'll hold back the
- // send in this case though, as we should only send once the no
- // updates are dangling.
- closeHarness.expectShutdownEvents(shutdownExpect{
- isInitiator: false,
- allowSend: false,
- recvShutdown: true,
- })
+ // Test both non-taproot and taproot channels
+ testRemoteInitiatedCloseOk(t, ctx, false)
+ testRemoteInitiatedCloseOk(t, ctx, true)
+ })
- // Next, we'll emit the recv event, with the addr of the remote
- // party.
- closeHarness.chanCloser.SendEvent(
- ctx, &ShutdownReceived{ShutdownScript: remoteAddr},
- )
+ // If the remote party sends a shutdown for a taproot channel without a
+ // nonce, we should reject it.
+ t.Run("remote_initiated_taproot_no_nonce_fail", func(t *testing.T) {
+ mockLocalMusig := newMockMusigSession()
+ mockRemoteMusig := newMockMusigSession()
- // We should transition to the shutdown pending state.
- closeHarness.assertStateTransitions(&ShutdownPending{})
+ cfg := &harnessCfg{
+ localUpfrontAddr: fn.Some(localAddr),
+ localMusigSession: fn.Some[MusigSession](
+ mockLocalMusig,
+ ),
+ remoteMusigSession: fn.Some[MusigSession](
+ mockRemoteMusig,
+ ),
+ }
- currentState := assertStateT[*ShutdownPending](closeHarness)
+ closeHarness := newCloser(t, cfg)
+ defer closeHarness.stopAndAssert()
- // Both the local and remote shutdown scripts should be set.
- require.Equal(
- t, localAddr,
- currentState.ShutdownScripts.LocalDeliveryScript,
- )
- require.Equal(
- t, remoteAddr,
- currentState.ShutdownScripts.RemoteDeliveryScript,
+ // We'll now create then send a shutdown that is missing their
+ // shutdown nonce. This should result in an error.
+ shutdownEvent := &ShutdownReceived{
+ ShutdownScript: remoteAddr,
+ RemoteShutdownNonce: fn.None[lnwire.Musig2Nonce](),
+ }
+ closeHarness.sendEventAndExpectFailure(
+ ctx, shutdownEvent, ErrTaprootShutdownNonceMissing,
)
+ closeHarness.assertNoStateTransitions()
})
// Any other event should be ignored.
@@ -1005,7 +1386,7 @@ func TestRbfChannelActiveTransitions(t *testing.T) {
// shutdown ourselves.
func TestRbfShutdownPendingTransitions(t *testing.T) {
t.Parallel()
- ctx := t.Context()
+ ctx := context.Background()
startingState := &ShutdownPending{}
@@ -1052,6 +1433,14 @@ func TestRbfShutdownPendingTransitions(t *testing.T) {
// Otherwise, if the shutdown is well composed, then we should
// transition to the ChannelFlushing state.
t.Run("initiator_shutdown_recv_ok", func(t *testing.T) {
+ // Test both non-taproot and taproot channels
+ testInitiatorShutdownRecvOk(t, ctx, startingState, false)
+ testInitiatorShutdownRecvOk(t, ctx, startingState, true)
+ })
+
+ // If the remote party sends a shutdown for a taproot channel without
+ // a nonce in the ShutdownPending state, we should reject it.
+ t.Run("initiator_shutdown_recv_taproot_no_nonce_fail", func(t *testing.T) {
firstState := *startingState
firstState.IdealFeeRate = fn.Some(
chainfee.FeePerKwFloor.FeePerVByte(),
@@ -1061,38 +1450,43 @@ func TestRbfShutdownPendingTransitions(t *testing.T) {
RemoteDeliveryScript: remoteAddr,
}
- closeHarness := newCloser(t, &harnessCfg{
+ // Set up taproot channel with nonce state
+ mockLocalMusig := newMockMusigSession()
+ mockRemoteMusig := newMockMusigSession()
+ localCloseeNonce := lnwire.Musig2Nonce{1, 2, 3}
+
+ firstState.NonceState = NonceState{
+ LocalCloseeNonce: fn.Some(localCloseeNonce),
+ RemoteCloseeNonce: fn.None[lnwire.Musig2Nonce](),
+ }
+
+ cfg := &harnessCfg{
initialState: fn.Some[ProtocolState](
&firstState,
),
localUpfrontAddr: fn.Some(localAddr),
remoteUpfrontAddr: fn.Some(remoteAddr),
- })
- defer closeHarness.stopAndAssert()
+ localMusigSession: fn.Some[MusigSession](
+ mockLocalMusig,
+ ),
+ remoteMusigSession: fn.Some[MusigSession](
+ mockRemoteMusig,
+ ),
+ }
- // We should disable the outgoing adds for the channel at this
- // point as well.
- closeHarness.expectFinalBalances(fn.None[ShutdownBalances]())
- closeHarness.expectIncomingAddsDisabled()
+ closeHarness := newCloser(t, cfg)
+ defer closeHarness.stopAndAssert()
- // We'll send in a shutdown received event, with the expected
- // co-op close addr.
- closeHarness.chanCloser.SendEvent(
- ctx, &ShutdownReceived{ShutdownScript: remoteAddr},
- )
-
- // We should transition to the channel flushing state.
- closeHarness.assertStateTransitions(&ChannelFlushing{})
-
- // Now we'll ensure that the flushing state has the proper
- // co-op close state.
- currentState := assertStateT[*ChannelFlushing](closeHarness)
-
- require.Equal(t, localAddr, currentState.LocalDeliveryScript)
- require.Equal(t, remoteAddr, currentState.RemoteDeliveryScript)
- require.Equal(
- t, firstState.IdealFeeRate, currentState.IdealFeeRate,
+ // Create shutdown event WITHOUT nonce for taproot channel, this
+ // should fail.
+ shutdownEvent := &ShutdownReceived{
+ ShutdownScript: remoteAddr,
+ RemoteShutdownNonce: fn.None[lnwire.Musig2Nonce](),
+ }
+ closeHarness.sendEventAndExpectFailure(
+ ctx, shutdownEvent, ErrTaprootShutdownNonceMissing,
)
+ closeHarness.assertNoStateTransitions()
})
// If we received the shutdown event, then we'll rely on the external
@@ -1233,7 +1627,7 @@ func TestRbfShutdownPendingTransitions(t *testing.T) {
// transition to the negotiation state.
func TestRbfChannelFlushingTransitions(t *testing.T) {
t.Parallel()
- ctx := t.Context()
+ ctx := context.Background()
localBalance := lnwire.NewMSatFromSatoshis(10_000)
remoteBalance := lnwire.NewMSatFromSatoshis(50_000)
@@ -1305,8 +1699,9 @@ func TestRbfChannelFlushingTransitions(t *testing.T) {
// We'll modify the starting balance to be 3x the required fee
// to ensure that we can pay for the fee.
- localBalanceMSat := lnwire.NewMSatFromSatoshis(absoluteFee * 3)
- flushEvent.ShutdownBalances.LocalBalance = localBalanceMSat
+ flushEvent.ShutdownBalances.LocalBalance = lnwire.NewMSatFromSatoshis( //nolint:ll
+ absoluteFee * 3,
+ )
testName := fmt.Sprintf("local_can_pay_for_fee/"+
"fresh_flush=%v", isFreshFlush)
@@ -1324,8 +1719,7 @@ func TestRbfChannelFlushingTransitions(t *testing.T) {
defer closeHarness.stopAndAssert()
localBalance := flushEvent.ShutdownBalances.LocalBalance
- balanceAfterClose := localBalance.ToSatoshis() -
- absoluteFee
+ balanceAfterClose := localBalance.ToSatoshis() - absoluteFee //nolint:ll
// If this is a fresh flush, then we expect the state
// to be marked on disk.
@@ -1374,7 +1768,9 @@ func TestRbfChannelFlushingTransitions(t *testing.T) {
CloserScript: remoteAddr,
CloseeScript: localAddr,
ClosingSigs: lnwire.ClosingSigs{
- CloserAndClosee: remoteSigRecordType3,
+ CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+ remoteWireSig,
+ ),
},
},
}
@@ -1390,13 +1786,10 @@ func TestRbfChannelFlushingTransitions(t *testing.T) {
// Now we'll send in the channel flushed event, and assert that
// this triggers a remote RBF iteration (we process their early
// offer and send our sig).
+ closeHarness.chanCloser.SendEvent(ctx, &flushEvent)
closeHarness.assertSingleRemoteRbfIteration(
remoteOffer, absoluteFee, absoluteFee, sequence, true,
- func(ctx context.Context, _ ProtocolEvent) {
- closeHarness.chanCloser.SendEvent(
- ctx, &flushEvent,
- )
- },
+ false,
)
})
@@ -1407,13 +1800,297 @@ func TestRbfChannelFlushingTransitions(t *testing.T) {
assertSpendEventCloseFin(t, startingState)
}
+// testSendOfferRbfIterationLoop is a helper function that tests the RBF iteration
+// loop scenario for both taproot and non-taproot channels.
+func testSendOfferRbfIterationLoop(t *testing.T, closeTerms *CloseChannelTerms,
+ sendOfferEvent *SendOfferEvent, balanceAfterClose btcutil.Amount,
+ absoluteFee btcutil.Amount, isTaproot bool) {
+
+ testName := "non_taproot"
+ if isTaproot {
+ testName = "taproot"
+ }
+
+ t.Run(testName, func(t *testing.T) {
+ // Create starting state for the test
+ firstState := &ClosingNegotiation{
+ PeerState: lntypes.Dual[AsymmetricPeerState]{
+ Local: &LocalCloseStart{
+ CloseChannelTerms: closeTerms,
+ },
+ },
+ CloseChannelTerms: closeTerms,
+ }
+
+ // For taproot channels, set up nonce state
+ if isTaproot {
+ // Add nonce state to the close terms
+ firstState.CloseChannelTerms.NonceState = NonceState{
+ LocalCloseeNonce: fn.Some(lnwire.Musig2Nonce{1, 2, 3}),
+ RemoteCloseeNonce: fn.Some(lnwire.Musig2Nonce{4, 5, 6}),
+ }
+ // Update the local state's close terms too
+ localState := firstState.PeerState.Local.(*LocalCloseStart)
+ localState.CloseChannelTerms.NonceState = firstState.CloseChannelTerms.NonceState
+ }
+
+ cfg := &harnessCfg{
+ initialState: fn.Some[ProtocolState](firstState),
+ localUpfrontAddr: fn.Some(localAddr),
+ }
+
+ // Set up musig sessions for taproot
+ if isTaproot {
+ mockLocalMusig := newMockMusigSession()
+ mockRemoteMusig := newMockMusigSession()
+ cfg.localMusigSession = fn.Some[MusigSession](mockLocalMusig)
+ cfg.remoteMusigSession = fn.Some[MusigSession](mockRemoteMusig)
+ }
+
+ closeHarness := newCloser(t, cfg)
+ defer closeHarness.stopAndAssert()
+
+ // We'll start out by first triggering a routine iteration,
+ // assuming we start in this negotiation state.
+ if isTaproot {
+ // For taproot, use the nonce-aware iteration with a dummy next closee nonce
+ nextCloseeNonce := lnwire.Musig2Nonce{7, 8, 9}
+ closeHarness.assertSingleRbfIterationWithNonce(
+ sendOfferEvent, balanceAfterClose, absoluteFee,
+ noDustExpect, false, nextCloseeNonce,
+ )
+ } else {
+ closeHarness.assertSingleRbfIteration(
+ sendOfferEvent, balanceAfterClose, absoluteFee,
+ noDustExpect, false,
+ )
+ }
+
+ // Next, we'll send in a new SendOfferEvent event which
+ // simulates the user requesting a RBF fee bump. We'll use 10x
+ // the fee we used in the last iteration.
+ rbfFeeBump := chainfee.FeePerKwFloor.FeePerVByte() * 10
+ localOffer := &SendOfferEvent{
+ TargetFeeRate: rbfFeeBump,
+ }
+
+ // Now we expect that another full RBF iteration takes place (we
+ // initiate a new local sig).
+ if isTaproot {
+ // For taproot, use the nonce-aware iteration with a dummy next closee nonce
+ nextCloseeNonce := lnwire.Musig2Nonce{10, 11, 12}
+ closeHarness.assertSingleRbfIterationWithNonce(
+ localOffer, balanceAfterClose, absoluteFee,
+ noDustExpect, true, nextCloseeNonce,
+ )
+ } else {
+ closeHarness.assertSingleRbfIteration(
+ localOffer, balanceAfterClose, absoluteFee,
+ noDustExpect, true,
+ )
+ }
+ })
+}
+
+// testRecvOfferRbfLoopIterations is a helper function that tests the receive offer
+// RBF loop iteration scenario for both taproot and non-taproot channels.
+func testRecvOfferRbfLoopIterations(t *testing.T, closeTerms *CloseChannelTerms,
+ absoluteFee btcutil.Amount, isTaproot bool) {
+
+ testName := "non_taproot"
+ if isTaproot {
+ testName = "taproot"
+ }
+
+ t.Run(testName, func(t *testing.T) {
+ // We'll modify our balance s.t we're unable to pay for fees,
+ // but aren't yet dust.
+ closingTerms := *closeTerms
+ closingTerms.ShutdownBalances.LocalBalance = lnwire.NewMSatFromSatoshis(
+ 9000,
+ )
+
+ firstState := &ClosingNegotiation{
+ PeerState: lntypes.Dual[AsymmetricPeerState]{
+ Local: &LocalCloseStart{
+ CloseChannelTerms: &closingTerms,
+ },
+ Remote: &RemoteCloseStart{
+ CloseChannelTerms: &closingTerms,
+ },
+ },
+ CloseChannelTerms: &closingTerms,
+ }
+
+ // For taproot channels, set up musig sessions and nonce state
+ var mockLocalMusig, mockRemoteMusig *mockMusigSession
+ if isTaproot {
+ // Add nonce state to the close terms
+ firstState.CloseChannelTerms.NonceState = NonceState{
+ LocalCloseeNonce: fn.Some(lnwire.Musig2Nonce{1, 2, 3}),
+ RemoteCloseeNonce: fn.Some(lnwire.Musig2Nonce{4, 5, 6}),
+ }
+ // Update the local and remote state's close terms too
+ localState := firstState.PeerState.Local.(*LocalCloseStart)
+ localState.CloseChannelTerms.NonceState = firstState.CloseChannelTerms.NonceState
+ remoteState := firstState.PeerState.Remote.(*RemoteCloseStart)
+ remoteState.CloseChannelTerms.NonceState = firstState.CloseChannelTerms.NonceState
+ }
+
+ cfg := &harnessCfg{
+ initialState: fn.Some[ProtocolState](firstState),
+ localUpfrontAddr: fn.Some(localAddr),
+ }
+ if isTaproot {
+ mockLocalMusig = newMockMusigSession()
+ mockRemoteMusig = newMockMusigSession()
+ cfg.localMusigSession = fn.Some[MusigSession](mockLocalMusig)
+ cfg.remoteMusigSession = fn.Some[MusigSession](mockRemoteMusig)
+ }
+
+ closeHarness := newCloser(t, cfg)
+ defer closeHarness.stopAndAssert()
+
+ balanceAfterClose := closingTerms.ShutdownBalances.RemoteBalance.ToSatoshis() - absoluteFee
+ sequence := uint32(mempool.MaxRBFSequence)
+
+ var feeOffer *OfferReceivedEvent
+ if isTaproot {
+ // For taproot, use TaprootClosingSigs with PartialSigWithNonce
+ feeOffer = &OfferReceivedEvent{
+ SigMsg: lnwire.ClosingComplete{
+ CloserScript: remoteAddr,
+ CloseeScript: localAddr,
+ FeeSatoshis: absoluteFee,
+ LockTime: 1,
+ TaprootClosingSigs: lnwire.TaprootClosingSigs{
+ CloserAndClosee: newPartialSigWithNonceTlv[tlv.TlvType7](
+ lnwire.PartialSigWithNonce{
+ PartialSig: lnwire.PartialSig{
+ Sig: btcec.ModNScalar{},
+ },
+ Nonce: lnwire.Musig2Nonce{10, 11, 12}, // Next closer nonce
+ },
+ ),
+ },
+ },
+ }
+ } else {
+ // For non-taproot, use regular ClosingSigs
+ feeOffer = &OfferReceivedEvent{
+ SigMsg: lnwire.ClosingComplete{
+ CloserScript: remoteAddr,
+ CloseeScript: localAddr,
+ FeeSatoshis: absoluteFee,
+ LockTime: 1,
+ ClosingSigs: lnwire.ClosingSigs{
+ CloserAndClosee: newSigTlv[tlv.TlvType3](
+ remoteWireSig,
+ ),
+ },
+ },
+ }
+ }
+
+ // As we're already in the negotiation phase, we'll now trigger
+ // a new iteration by having the remote party send a new offer
+ // sig.
+ closeHarness.assertSingleRemoteRbfIteration(
+ feeOffer, balanceAfterClose, absoluteFee, sequence,
+ false, true,
+ )
+
+ // Next, we'll receive an offer from the remote party, and drive
+ // another RBF iteration. This time, we'll increase the absolute
+ // fee by 1k sats.
+ feeOffer.SigMsg.FeeSatoshis += 1000
+ absoluteFee = feeOffer.SigMsg.FeeSatoshis
+ closeHarness.assertSingleRemoteRbfIteration(
+ feeOffer, balanceAfterClose, absoluteFee, sequence,
+ true, true,
+ )
+
+ closeHarness.assertNoStateTransitions()
+ })
+}
+
// TestRbfCloseClosingNegotiationLocal tests the local portion of the primary
// RBF close loop. We should be able to transition to a close state, get a sig,
// then restart all over again to re-request a signature of at new higher fee
// rate.
+// testSendOfferIterationNoDust is a helper function that tests the send offer
+// iteration scenario for both taproot and non-taproot channels.
+func testSendOfferIterationNoDust(t *testing.T, startingState *ClosingNegotiation,
+ sendOfferEvent *SendOfferEvent, balanceAfterClose btcutil.Amount,
+ absoluteFee btcutil.Amount, isTaproot bool) {
+
+ testName := "non_taproot"
+ if isTaproot {
+ testName = "taproot"
+ }
+
+ t.Run(testName, func(t *testing.T) {
+ // For taproot channels, set up musig sessions and nonce state
+ var mockLocalMusig, mockRemoteMusig *mockMusigSession
+ nextCloseeNonce := lnwire.Musig2Nonce{7, 8, 9}
+
+ // Create a copy of startingState with nonce state for taproot.
+ testStartingState := *startingState
+ if isTaproot {
+ testStartingState.CloseChannelTerms.NonceState = NonceState{
+ LocalCloseeNonce: fn.Some(lnwire.Musig2Nonce{1, 2, 3}),
+ RemoteCloseeNonce: fn.Some(lnwire.Musig2Nonce{4, 5, 6}),
+ }
+
+ localState := testStartingState.PeerState.Local.(*LocalCloseStart)
+ localState.CloseChannelTerms.NonceState = testStartingState.CloseChannelTerms.NonceState
+ }
+
+ cfg := &harnessCfg{
+ initialState: fn.Some[ProtocolState](&testStartingState),
+ }
+ if isTaproot {
+ mockLocalMusig = newMockMusigSession()
+ mockRemoteMusig = newMockMusigSession()
+ cfg.localMusigSession = fn.Some[MusigSession](mockLocalMusig)
+ cfg.remoteMusigSession = fn.Some[MusigSession](mockRemoteMusig)
+ }
+
+ closeHarness := newCloser(t, cfg)
+ defer closeHarness.stopAndAssert()
+
+ // We'll now send in the initial sender offer event, which
+ // should then trigger a single RBF iteration, ending at the
+ // pending state.
+ if isTaproot {
+ closeHarness.assertSingleRbfIterationWithNonce(
+ sendOfferEvent, balanceAfterClose, absoluteFee,
+ noDustExpect, false, nextCloseeNonce,
+ )
+
+ // Verify nonce state was updated with new closee nonce
+ currentState := assertStateT[*ClosingNegotiation](
+ closeHarness,
+ )
+ require.True(
+ t, currentState.CloseChannelTerms.NonceState.RemoteCloseeNonce.IsSome(),
+ )
+ require.Equal(
+ t, nextCloseeNonce,
+ currentState.CloseChannelTerms.NonceState.RemoteCloseeNonce.UnwrapOr(lnwire.Musig2Nonce{}),
+ )
+ } else {
+ closeHarness.assertSingleRbfIteration(
+ sendOfferEvent, balanceAfterClose, absoluteFee,
+ noDustExpect, false,
+ )
+ }
+ })
+}
+
func TestRbfCloseClosingNegotiationLocal(t *testing.T) {
t.Parallel()
- ctx := t.Context()
+ ctx := context.Background()
localBalance := lnwire.NewMSatFromSatoshis(40_000)
remoteBalance := lnwire.NewMSatFromSatoshis(50_000)
@@ -1450,17 +2127,13 @@ func TestRbfCloseClosingNegotiationLocal(t *testing.T) {
// In this state, we'll simulate deciding that we need to send a new
// offer to the remote party.
t.Run("send_offer_iteration_no_dust", func(t *testing.T) {
- closeHarness := newCloser(t, &harnessCfg{
- initialState: fn.Some[ProtocolState](startingState),
- })
- defer closeHarness.stopAndAssert()
-
- // We'll now send in the initial sender offer event, which
- // should then trigger a single RBF iteration, ending at the
- // pending state.
- closeHarness.assertSingleRbfIteration(
- sendOfferEvent, balanceAfterClose, absoluteFee,
- noDustExpect, false,
+ testSendOfferIterationNoDust(
+ t, startingState, sendOfferEvent, balanceAfterClose,
+ absoluteFee, false,
+ )
+ testSendOfferIterationNoDust(
+ t, startingState, sendOfferEvent, balanceAfterClose,
+ absoluteFee, true,
)
})
@@ -1489,7 +2162,9 @@ func TestRbfCloseClosingNegotiationLocal(t *testing.T) {
CloserNoClosee: newSigTlv[tlv.TlvType1](
remoteWireSig,
),
- CloserAndClosee: remoteSigRecordType3,
+ CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+ remoteWireSig,
+ ),
},
},
}
@@ -1584,7 +2259,9 @@ func TestRbfCloseClosingNegotiationLocal(t *testing.T) {
CloserScript: remoteAddr,
CloseeScript: remoteAddr,
ClosingSigs: lnwire.ClosingSigs{
- CloserAndClosee: remoteSigRecordType3,
+ CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+ remoteWireSig,
+ ),
},
},
}
@@ -1596,41 +2273,14 @@ func TestRbfCloseClosingNegotiationLocal(t *testing.T) {
// In this test, we'll assert that we're able to restart the RBF loop
// to trigger additional signature iterations.
t.Run("send_offer_rbf_iteration_loop", func(t *testing.T) {
- firstState := &ClosingNegotiation{
- PeerState: lntypes.Dual[AsymmetricPeerState]{
- Local: &LocalCloseStart{
- CloseChannelTerms: closeTerms,
- },
- },
- CloseChannelTerms: closeTerms,
- }
-
- closeHarness := newCloser(t, &harnessCfg{
- initialState: fn.Some[ProtocolState](firstState),
- localUpfrontAddr: fn.Some(localAddr),
- })
- defer closeHarness.stopAndAssert()
-
- // We'll start out by first triggering a routine iteration,
- // assuming we start in this negotiation state.
- closeHarness.assertSingleRbfIteration(
- sendOfferEvent, balanceAfterClose, absoluteFee,
- noDustExpect, false,
+ // Test both non-taproot and taproot channels
+ testSendOfferRbfIterationLoop(
+ t, closeTerms, sendOfferEvent, balanceAfterClose,
+ absoluteFee, false,
)
-
- // Next, we'll send in a new SendOfferEvent event which
- // simulates the user requesting a RBF fee bump. We'll use 10x
- // the fee we used in the last iteration.
- rbfFeeBump := chainfee.FeePerKwFloor.FeePerVByte() * 10
- localOffer := &SendOfferEvent{
- TargetFeeRate: rbfFeeBump,
- }
-
- // Now we expect that another full RBF iteration takes place (we
- // initiate a new local sig).
- closeHarness.assertSingleRbfIteration(
- localOffer, balanceAfterClose, absoluteFee,
- noDustExpect, true,
+ testSendOfferRbfIterationLoop(
+ t, closeTerms, sendOfferEvent, balanceAfterClose,
+ absoluteFee, true,
)
})
@@ -1688,13 +2338,96 @@ func TestRbfCloseClosingNegotiationLocal(t *testing.T) {
assertSpendEventCloseFin(t, startingState)
}
+// TestValidateSigTypeMatchesChannelType tests that taproot channels reject
+// regular signatures and non-taproot channels reject taproot signatures.
+func TestValidateSigTypeMatchesChannelType(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ isTaproot bool
+ sendTaproot bool
+ expectedError string
+ }{
+ {
+ name: "taproot channel with regular sig",
+ isTaproot: true,
+ sendTaproot: false,
+ expectedError: "taproot channel requires taproot " +
+ "signature",
+ },
+ {
+ name: "regular channel with taproot sig",
+ isTaproot: false,
+ sendTaproot: true,
+ expectedError: "non-taproot channel requires regular " +
+ "signatures",
+ },
+ {
+ name: "taproot channel with taproot sig",
+ isTaproot: true,
+ sendTaproot: true,
+ },
+ {
+ name: "regular channel with regular sig",
+ isTaproot: false,
+ sendTaproot: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Create a message with mismatched signature type
+ var sigMsg lnwire.ClosingSig
+ if tc.sendTaproot {
+ // Send taproot signature using TaprootPartialSigs
+ // Create a dummy partial sig
+ var scalar btcec.ModNScalar
+ scalar.SetByteSlice(localSchnorrSigBytes[:32])
+ partialSig := lnwire.PartialSig{Sig: scalar}
+
+ sigMsg.TaprootPartialSigs.CloserAndClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType7](partialSig),
+ )
+
+ testNonce := lnwire.Musig2Nonce{7, 8, 9}
+ sigMsg.NextCloseeNonce = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType22](testNonce),
+ )
+ } else {
+ sigMsg.ClosingSigs.CloserAndClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType3](localSigWire),
+ )
+ }
+
+ sigResult, nonce := validateAndExtractSigAndNonce(
+ sigMsg, tc.isTaproot,
+ )
+
+ if tc.expectedError != "" {
+ _, err := sigResult.Unpack()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), tc.expectedError)
+ } else {
+ sig, err := sigResult.Unpack()
+ require.NoError(t, err)
+ require.NotNil(t, sig)
+
+ if tc.isTaproot {
+ require.True(t, nonce.IsSome())
+ }
+ }
+ })
+ }
+}
+
// TestRbfCloseClosingNegotiationRemote tests that state machine is able to
// handle RBF iterations to sign for the closing transaction of the remote
// party.
func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
t.Parallel()
- ctx := t.Context()
+ ctx := context.Background()
localBalance := lnwire.NewMSatFromSatoshis(40_000)
remoteBalance := lnwire.NewMSatFromSatoshis(50_000)
@@ -1750,7 +2483,7 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
closeHarness.assertNoStateTransitions()
})
- // If our balance is dust, then the remote party should send a
+ // If our balance, is dust, then the remote party should send a
// signature that doesn't include our output.
t.Run("recv_offer_err_closer_no_closee", func(t *testing.T) {
// We'll modify our local balance to be dust.
@@ -1782,7 +2515,9 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
CloserScript: remoteAddr,
CloseeScript: localAddr,
ClosingSigs: lnwire.ClosingSigs{
- CloserAndClosee: remoteSigRecordType3,
+ CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+ remoteWireSig,
+ ),
},
},
}
@@ -1808,7 +2543,9 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
CloserScript: remoteAddr,
CloseeScript: localAddr,
ClosingSigs: lnwire.ClosingSigs{
- CloserNoClosee: remoteSigRecordType1,
+ CloserNoClosee: newSigTlv[tlv.TlvType1]( //nolint:ll
+ remoteWireSig,
+ ),
},
},
}
@@ -1822,62 +2559,9 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
// loops to enable the remote party to sign.new versions of the co-op
// close transaction.
t.Run("recv_offer_rbf_loop_iterations", func(t *testing.T) {
- // We'll modify our balance s.t we're unable to pay for fees,
- // but aren't yet dust.
- closingTerms := *closeTerms
- closingTerms.ShutdownBalances.LocalBalance = lnwire.NewMSatFromSatoshis( //nolint:ll
- 9000,
- )
-
- firstState := &ClosingNegotiation{
- PeerState: lntypes.Dual[AsymmetricPeerState]{
- Local: &LocalCloseStart{
- CloseChannelTerms: &closingTerms,
- },
- Remote: &RemoteCloseStart{
- CloseChannelTerms: &closingTerms,
- },
- },
- CloseChannelTerms: &closingTerms,
- }
-
- closeHarness := newCloser(t, &harnessCfg{
- initialState: fn.Some[ProtocolState](firstState),
- localUpfrontAddr: fn.Some(localAddr),
- })
- defer closeHarness.stopAndAssert()
-
- feeOffer := &OfferReceivedEvent{
- SigMsg: lnwire.ClosingComplete{
- CloserScript: remoteAddr,
- CloseeScript: localAddr,
- FeeSatoshis: absoluteFee,
- LockTime: 1,
- ClosingSigs: lnwire.ClosingSigs{
- CloserAndClosee: remoteSigRecordType3,
- },
- },
- }
-
- // As we're already in the negotiation phase, we'll now trigger
- // a new iteration by having the remote party send a new offer
- // sig.
- closeHarness.assertSingleRemoteRbfIteration(
- feeOffer, balanceAfterClose, absoluteFee, sequence,
- false, closeHarness.chanCloser.SendEvent,
- )
-
- // Next, we'll receive an offer from the remote party, and drive
- // another RBF iteration. This time, we'll increase the absolute
- // fee by 1k sats.
- feeOffer.SigMsg.FeeSatoshis += 1000
- absoluteFee = feeOffer.SigMsg.FeeSatoshis
- closeHarness.assertSingleRemoteRbfIteration(
- feeOffer, balanceAfterClose, absoluteFee, sequence,
- true, closeHarness.chanCloser.SendEvent,
- )
-
- closeHarness.assertNoStateTransitions()
+ // Test both non-taproot and taproot channels.
+ testRecvOfferRbfLoopIterations(t, closeTerms, absoluteFee, false)
+ testRecvOfferRbfLoopIterations(t, closeTerms, absoluteFee, true)
})
// This tests that if we get an offer that has the wrong local script,
@@ -1898,7 +2582,9 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
CloserScript: remoteAddr,
CloseeScript: remoteAddr,
ClosingSigs: lnwire.ClosingSigs{
- CloserNoClosee: remoteSigRecordType1,
+ CloserNoClosee: newSigTlv[tlv.TlvType1]( //nolint:ll
+ remoteWireSig,
+ ),
},
},
}
@@ -1947,7 +2633,9 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
FeeSatoshis: absoluteFee,
LockTime: 1,
ClosingSigs: lnwire.ClosingSigs{
- CloserAndClosee: remoteSigRecordType3,
+ CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+ remoteWireSig,
+ ),
},
},
}
@@ -1957,7 +2645,7 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
// sig.
closeHarness.assertSingleRemoteRbfIteration(
feeOffer, balanceAfterClose, absoluteFee, sequence,
- false, closeHarness.chanCloser.SendEvent,
+ false, true,
)
})
@@ -2013,7 +2701,12 @@ func TestRbfCloseErr(t *testing.T) {
// initiate a new local sig).
closeHarness.assertSingleRbfIteration(
localOffer, balanceAfterClose, absoluteFee,
- noDustExpect, true,
+ noDustExpect, false,
+ )
+
+ // We should terminate in the negotiation state.
+ closeHarness.assertStateTransitions(
+ &ClosingNegotiation{},
)
})
@@ -2043,7 +2736,9 @@ func TestRbfCloseErr(t *testing.T) {
FeeSatoshis: absoluteFee,
LockTime: 1,
ClosingSigs: lnwire.ClosingSigs{
- CloserAndClosee: remoteSigRecordType3,
+ CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+ remoteWireSig,
+ ),
},
},
}
@@ -2055,10 +2750,193 @@ func TestRbfCloseErr(t *testing.T) {
// sig.
closeHarness.assertSingleRemoteRbfIteration(
feeOffer, balanceAfterClose, absoluteFee, sequence,
- true, closeHarness.chanCloser.SendEvent,
+ false, true,
)
})
// Sending a Spend event should transition to CloseFin.
assertSpendEventCloseFin(t, startingState)
}
+
+// generateTestNonce creates a test musig2 nonce for testing.
+func generateTestNonce(t *testing.T) *musig2.Nonces {
+ t.Helper()
+
+ // Generate a dummy private key for nonce generation.
+ privKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ nonce, err := musig2.GenNonces(musig2.WithPublicKey(privKey.PubKey()))
+ require.NoError(t, err)
+
+ return nonce
+}
+
+// TestTaprootNonceHandling tests the taproot nonce handling functionality
+// in the RBF cooperative close state machine.
+func TestTaprootNonceHandling(t *testing.T) {
+ t.Parallel()
+
+ closeHarness := newCloser(t, &harnessCfg{
+ localUpfrontAddr: fn.Some(localAddr),
+ })
+ defer closeHarness.stopAndAssert()
+
+ // Set up mock MusigSessions to indicate this is a taproot channel.
+ mockLocalSession := newMockMusigSession()
+ mockRemoteSession := newMockMusigSession()
+ closeHarness.env.LocalMusigSession = mockLocalSession
+ closeHarness.env.RemoteMusigSession = mockRemoteSession
+
+ closeHarness.expectShutdownEvents(shutdownExpect{
+ isInitiator: false,
+ allowSend: false,
+ recvShutdown: true,
+ })
+
+ remoteNonce := generateTestNonce(t)
+ shutdownEvent := &ShutdownReceived{
+ ShutdownScript: remoteAddr,
+ BlockHeight: 100,
+ RemoteShutdownNonce: fn.Some(lnwire.Musig2Nonce(
+ remoteNonce.PubNonce,
+ )),
+ }
+
+ // Send the shutdown event and verify state transition. We should
+ // transition to ShutdownPending.
+ closeHarness.chanCloser.SendEvent(
+ context.Background(), shutdownEvent,
+ )
+
+ closeHarness.assertStateTransitions(&ShutdownPending{})
+
+ // Verify the state transition occurred and the nonce was stored.
+ currentState := assertStateT[*ShutdownPending](closeHarness)
+ require.True(t, currentState.NonceState.RemoteCloseeNonce.IsSome(),
+ "remote closee nonce should be stored")
+
+ storedNonce := currentState.NonceState.RemoteCloseeNonce.UnwrapOrFail(t)
+ require.Equal(
+ t, lnwire.Musig2Nonce(remoteNonce.PubNonce), storedNonce,
+ "stored nonce should match received nonce",
+ )
+}
+
+// TestNextCloseeNonceStorageFromClosingSig tests that NextCloseeNonce from
+// LocalSigReceived (ClosingSig message) is properly stored for the next RBF
+// round in updateAndValidateCloseTerms.
+func TestNextCloseeNonceStorageFromClosingSig(t *testing.T) {
+ t.Parallel()
+
+ // Create a closing negotiation state with taproot
+ closeTerms := &CloseChannelTerms{
+ ShutdownScripts: ShutdownScripts{
+ LocalDeliveryScript: localAddr,
+ RemoteDeliveryScript: remoteAddr,
+ },
+ NonceState: NonceState{
+ LocalCloseeNonce: fn.Some(lnwire.Musig2Nonce{1, 2, 3}),
+ RemoteCloseeNonce: fn.Some(lnwire.Musig2Nonce{4, 5, 6}),
+ },
+ }
+
+ negotiation := &ClosingNegotiation{
+ CloseChannelTerms: closeTerms,
+ }
+
+ // Create a LocalSigReceived event with NextCloseeNonce for the next
+ // round.
+ nextCloseeNonce := lnwire.Musig2Nonce{10, 11, 12}
+ sigEvent := &LocalSigReceived{
+ SigMsg: lnwire.ClosingSig{
+ CloserScript: localAddr,
+ CloseeScript: remoteAddr,
+ FeeSatoshis: btcutil.Amount(1000),
+ LockTime: 1,
+ TaprootPartialSigs: lnwire.TaprootPartialSigs{
+ CloserAndClosee: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType7](
+ lnwire.PartialSig{
+ Sig: btcec.ModNScalar{},
+ },
+ ),
+ ),
+ },
+ NextCloseeNonce: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType22](nextCloseeNonce),
+ ),
+ },
+ }
+
+ // Test that updateAndValidateCloseTerms properly stores the
+ // NextCloseeNonce.
+ err := negotiation.updateAndValidateCloseTerms(sigEvent, true)
+ require.NoError(t, err)
+
+ // Verify the NextCloseeNonce was stored for the next round.
+ require.True(
+ t, negotiation.NonceState.RemoteCloseeNonce.IsSome(),
+ "NextCloseeNonce should be stored for next round",
+ )
+
+ storedNonce := negotiation.NonceState.RemoteCloseeNonce.UnwrapOrFail(t)
+ require.Equal(
+ t, nextCloseeNonce, storedNonce,
+ "stored nonce should match the NextCloseeNonce from ClosingSig",
+ )
+}
+
+// TestProcessRemoteTaprootSigWithSignerNonce tests that processRemoteTaprootSig
+// properly initializes the musig session with the nonce from
+// PartialSigWithNonce.
+func TestProcessRemoteTaprootSigWithSignerNonce(t *testing.T) {
+ t.Parallel()
+
+ // Create a mock musig session that tracks InitRemoteNonce calls
+ mockRemoteMusig := newMockMusigSession()
+
+ // The session should already be initialized from shutdown
+ mockRemoteMusig.remoteNonceInited = true
+ mockRemoteMusig.remoteNonce = musig2.Nonces{
+ PubNonce: lnwire.Musig2Nonce{4, 5, 6},
+ }
+
+ env := &Environment{
+ RemoteMusigSession: mockRemoteMusig,
+ }
+
+ // Create a ClosingComplete message with signer nonce.
+ signerNonce := lnwire.Musig2Nonce{10, 11, 12}
+ jitNonce := lnwire.Musig2Nonce{20, 21, 22}
+ msg := lnwire.ClosingComplete{
+ TaprootClosingSigs: lnwire.TaprootClosingSigs{
+ CloserAndClosee: newPartialSigWithNonceTlv[tlv.TlvType7](
+ lnwire.PartialSigWithNonce{
+ PartialSig: lnwire.PartialSig{
+ Sig: btcec.ModNScalar{},
+ },
+ Nonce: signerNonce,
+ },
+ ),
+ },
+ }
+
+ _, err := processRemoteTaprootSig(env, msg, fn.Some(jitNonce))
+ require.NoError(t, err)
+
+ // Verify the musig session was re-initialized with the JIT nonce
+ // parameter (the nonce they used to sign as the closer)
+ require.True(
+ t, mockRemoteMusig.remoteNonceInited,
+ "InitRemoteNonce should be called",
+ )
+
+ // The session should have the JIT nonce, not the signer nonce from
+ // PartialSigWithNonce.
+ require.Equal(
+ t, musig2.Nonces{PubNonce: jitNonce},
+ mockRemoteMusig.remoteNonce,
+ "musig session should be updated with JIT closer nonce",
+ )
+}
diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go
index ac9432a..c1b68ca 100644
--- a/lnwallet/chancloser/rbf_coop_transitions.go
+++ b/lnwallet/chancloser/rbf_coop_transitions.go
@@ -5,10 +5,13 @@ import (
"fmt"
"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/mempool"
"github.com/btcsuite/btcd/wire"
+ "github.com/davecgh/go-spew/spew"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/labels"
@@ -22,7 +25,7 @@ import (
)
var (
- // ErrThawHeightNotReached is returned if the remote party tries to
+ // ErrInvalidStateTransition is returned if the remote party tries to
// close, but the thaw height hasn't been matched yet.
ErrThawHeightNotReached = fmt.Errorf("thaw height not reached")
)
@@ -30,20 +33,59 @@ var (
// sendShutdownEvents is a helper function that returns a set of daemon events
// we need to emit when we decide that we should send a shutdown message. We'll
// also mark the channel as borked as well, as at this point, we no longer want
-// to continue with normal operation.
+// to continue with normal operation. This function also returns the actual closee
+// nonce used (either provided or auto-generated) for taproot channels.
func sendShutdownEvents(chanID lnwire.ChannelID, chanPoint wire.OutPoint,
deliveryAddr lnwire.DeliveryAddress, peerPub btcec.PublicKey,
- postSendEvent fn.Option[ProtocolEvent],
- chanState ChanStateObserver) (protofsm.DaemonEventSet, error) {
+ postSendEvent fn.Option[ProtocolEvent], chanState ChanStateObserver,
+ env *Environment, localCloseeNonce fn.Option[lnwire.Musig2Nonce],
+) (protofsm.DaemonEventSet, fn.Option[lnwire.Musig2Nonce], error) {
+
+ // Create the shutdown message.
+ shutdownMsg := &lnwire.Shutdown{
+ ChannelID: chanID,
+ Address: deliveryAddr,
+ }
+
+ none := fn.None[lnwire.Musig2Nonce]()
+
+ // For taproot channels using modern RBF flow, auto-generate closee
+ // nonce if not provided. The shutdown message only contains our closee
+ // 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
+ // localNonce, we'll receive their remoteNonce for this session
+ // once we get their ClosingComplete message.
+ if localCloseeNonce.IsNone() {
+ remoteMusig := env.RemoteMusigSession
+ if remoteMusig != nil {
+ closeeNonces, err := remoteMusig.ClosingNonce()
+ if err != nil {
+ return nil, none, fmt.Errorf("unable "+
+ "to generate closee "+
+ "nonce: %w", err)
+ }
+ localCloseeNonce = fn.Some(
+ lnwire.Musig2Nonce(
+ closeeNonces.PubNonce,
+ ),
+ )
+ }
+ }
+ }
- // We'll emit a daemon event that instructs the daemon to send out a
- // new shutdown message to the remote peer.
+ // If we have a closee nonce, then make sure to include it in the
+ // shutdown message.
+ localCloseeNonce.WhenSome(func(nonce lnwire.Musig2Nonce) {
+ shutdownMsg.ShutdownNonce = lnwire.SomeShutdownNonce(nonce)
+ })
+
+ // We'll emit a daemon event that instructs the daemon to send out a new
+ // shutdown message to the remote peer.
msgsToSend := &protofsm.SendMsgEvent[ProtocolEvent]{
TargetPeer: peerPub,
- Msgs: []lnwire.Message{&lnwire.Shutdown{
- ChannelID: chanID,
- Address: deliveryAddr,
- }},
+ Msgs: []lnwire.Message{shutdownMsg},
SendWhen: fn.Some(func() bool {
ok := chanState.NoDanglingUpdates()
if ok {
@@ -60,14 +102,15 @@ func sendShutdownEvents(chanID lnwire.ChannelID, chanPoint wire.OutPoint,
// If a close is already in process (we're in the RBF loop), then we
// can skip everything below, and just send out the shutdown message.
if chanState.FinalBalances().IsSome() {
- return protofsm.DaemonEventSet{msgsToSend}, nil
+ return protofsm.DaemonEventSet{msgsToSend}, localCloseeNonce, nil
}
// Before closing, we'll attempt to send a disable update for the
// channel. We do so before closing the channel as otherwise the
// current edge policy won't be retrievable from the graph.
if err := chanState.DisableChannel(); err != nil {
- return nil, fmt.Errorf("unable to disable channel: %w", err)
+ return nil, none, fmt.Errorf("unable to disable "+
+ "channel: %w", err)
}
// If we have a post-send event, then this means that we're the
@@ -80,21 +123,49 @@ func sendShutdownEvents(chanID lnwire.ChannelID, chanPoint wire.OutPoint,
// As we're about to send a shutdown, we'll disable adds in the
// outgoing direction.
if err := chanState.DisableOutgoingAdds(); err != nil {
- return nil, fmt.Errorf("unable to disable outgoing "+
- "adds: %w", err)
+ return nil, none, fmt.Errorf("unable to disable "+
+ "outgoing adds: %w", err)
}
// To be able to survive a restart, we'll also write to disk
// information about the shutdown we're about to send out.
err := chanState.MarkShutdownSent(deliveryAddr, isInitiator)
if err != nil {
- return nil, fmt.Errorf("unable to mark shutdown sent: %w", err)
+ return nil, none, fmt.Errorf("unable to mark "+
+ "shutdown sent: %w", err)
}
chancloserLog.Debugf("ChannelPoint(%v): marking channel as borked",
chanPoint)
- return protofsm.DaemonEventSet{msgsToSend}, nil
+ return protofsm.DaemonEventSet{msgsToSend}, localCloseeNonce, nil
+}
+
+// initLocalMusigCloseeNonce initializes the LocalMusigSession with the remote's
+// closee nonce. This is used when we act as the closer to create a closing
+// transaction.
+func initLocalMusigCloseeNonce(env *Environment,
+ remoteCloseeNonce fn.Option[lnwire.Musig2Nonce]) {
+
+ if env.LocalMusigSession != nil {
+ remoteCloseeNonce.WhenSome(func(nonce lnwire.Musig2Nonce) {
+ remoteMusigNonce := musig2.Nonces{PubNonce: nonce}
+ env.LocalMusigSession.InitRemoteNonce(&remoteMusigNonce)
+ })
+ }
+}
+
+// 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]) {
+
+ if env.RemoteMusigSession != nil {
+ localCloseeNonce.WhenSome(func(nonce lnwire.Musig2Nonce) {
+ localMusigNonce := musig2.Nonces{PubNonce: nonce}
+ env.RemoteMusigSession.InitRemoteNonce(&localMusigNonce)
+ })
+ }
}
// validateShutdown is a helper function that validates that the shutdown has a
@@ -103,7 +174,7 @@ func sendShutdownEvents(chanID lnwire.ChannelID, chanPoint wire.OutPoint,
func validateShutdown(chanThawHeight fn.Option[uint32],
upfrontAddr fn.Option[lnwire.DeliveryAddress],
msg *ShutdownReceived, chanPoint wire.OutPoint,
- chainParams chaincfg.Params) error {
+ chainParams chaincfg.Params, isTaproot bool) error {
// If we've received a shutdown message, and we have a thaw height,
// then we need to make sure that the channel can now be co-op closed.
@@ -125,6 +196,12 @@ func validateShutdown(chanThawHeight fn.Option[uint32],
return err
}
+ // For taproot channels, validate that the shutdown message includes
+ // the required nonce for the RBF cooperative close flow.
+ if isTaproot && !msg.RemoteShutdownNonce.IsSome() {
+ return ErrTaprootShutdownNonceMissing
+ }
+
// Next, we'll verify that the remote party is sending the expected
// shutdown script.
return fn.MapOption(func(addr lnwire.DeliveryAddress) error {
@@ -138,8 +215,8 @@ func validateShutdown(chanThawHeight fn.Option[uint32],
// the state. From this state, we can receive two possible incoming events:
// SendShutdown and ShutdownReceived. Both of these will transition us to the
// ChannelFlushing state.
-func (c *ChannelActive) ProcessEvent(event ProtocolEvent,
- env *Environment) (*CloseStateTransition, error) {
+func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
switch msg := event.(type) {
// If we get a confirmation, then a prior transaction we broadcasted
@@ -169,10 +246,10 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent,
// and disable the channel on the network level. In this case,
// we don't need a post send event as receive their shutdown is
// what'll move us beyond the ShutdownPending state.
- daemonEvents, err := sendShutdownEvents(
+ daemonEvents, closeeNonce, err := sendShutdownEvents(
env.ChanID, env.ChanPoint, shutdownScript,
env.ChanPeer, fn.None[ProtocolEvent](),
- env.ChanObserver,
+ env.ChanObserver, env, msg.CloseeNonce,
)
if err != nil {
return nil, err
@@ -190,6 +267,9 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent,
ShutdownScripts: ShutdownScripts{
LocalDeliveryScript: shutdownScript,
},
+ NonceState: NonceState{
+ LocalCloseeNonce: closeeNonce,
+ },
},
NewEvents: fn.Some(RbfEvent{
ExternalEvents: daemonEvents,
@@ -209,7 +289,7 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent,
// shutdown addr.
err := validateShutdown(
env.ThawHeight, env.RemoteUpfrontShutdown, msg,
- env.ChanPoint, env.ChainParams,
+ env.ChanPoint, env.ChainParams, env.IsTaproot(),
)
if err != nil {
chancloserLog.Errorf("ChannelPoint(%v): rejecting "+
@@ -234,11 +314,11 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent,
// the set of daemon events we need to emit. We'll also specify
// that once the message has actually been sent, that we
// generate receive an input event of a ShutdownComplete.
- daemonEvents, err := sendShutdownEvents(
+ daemonEvents, closeeNonce, err := sendShutdownEvents(
env.ChanID, env.ChanPoint, shutdownAddr,
env.ChanPeer,
fn.Some[ProtocolEvent](&ShutdownComplete{}),
- env.ChanObserver,
+ env.ChanObserver, env, fn.None[lnwire.Musig2Nonce](),
)
if err != nil {
return nil, err
@@ -256,12 +336,20 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent,
remoteAddr := msg.ShutdownScript
+ // Initialize our LocalMusigSession with their closee nonce.
+ // This prepares the session for when we act as closer.
+ initLocalMusigCloseeNonce(env, msg.RemoteShutdownNonce)
+
return &CloseStateTransition{
NextState: &ShutdownPending{
ShutdownScripts: ShutdownScripts{
LocalDeliveryScript: shutdownAddr,
RemoteDeliveryScript: remoteAddr,
},
+ NonceState: NonceState{
+ RemoteCloseeNonce: msg.RemoteShutdownNonce,
+ LocalCloseeNonce: closeeNonce,
+ },
},
NewEvents: fn.Some(protofsm.EmittedEvent[ProtocolEvent]{
ExternalEvents: daemonEvents,
@@ -283,8 +371,8 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent,
// forward once we receive the ShutdownComplete event. Receiving
// ShutdownComplete means that we've sent our shutdown, as this was specified
// as a post send event.
-func (s *ShutdownPending) ProcessEvent(event ProtocolEvent,
- env *Environment) (*CloseStateTransition, error) {
+func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
switch msg := event.(type) {
// If we get a confirmation, then a prior transaction we broadcasted
@@ -323,7 +411,7 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent,
// shutdown addr.
err := validateShutdown(
env.ThawHeight, env.RemoteUpfrontShutdown, msg,
- env.ChanPoint, env.ChainParams,
+ env.ChanPoint, env.ChainParams, env.IsTaproot(),
)
if err != nil {
chancloserLog.Errorf("ChannelPoint(%v): rejecting "+
@@ -346,6 +434,10 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent,
eventsToEmit = append(eventsToEmit, channelFlushed)
}
+ // Initialize our LocalMusigSession with their closee nonce.
+ // This prepares the session for when we act as closer.
+ initLocalMusigCloseeNonce(env, msg.RemoteShutdownNonce)
+
chancloserLog.Infof("ChannelPoint(%v): disabling incoming adds",
env.ChanPoint)
@@ -372,6 +464,11 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent,
})
}
+ // Make sure that we stash their closee nonce, so we can make a
+ // sig if needed in the next state transition.
+ updatedNonceState := s.NonceState
+ updatedNonceState.RemoteCloseeNonce = msg.RemoteShutdownNonce
+
// We transition to the ChannelFlushing state, where we await
// the ChannelFlushed event.
return &CloseStateTransition{
@@ -381,6 +478,7 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent,
LocalDeliveryScript: s.LocalDeliveryScript, //nolint:ll
RemoteDeliveryScript: msg.ShutdownScript, //nolint:ll
},
+ NonceState: updatedNonceState,
},
NewEvents: newEvents,
}, nil
@@ -425,6 +523,7 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent,
NextState: &ChannelFlushing{
IdealFeeRate: s.IdealFeeRate,
ShutdownScripts: s.ShutdownScripts,
+ NonceState: s.NonceState,
},
NewEvents: newEvents,
}, nil
@@ -442,8 +541,8 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent,
// a ShutdownReceived event, then we'll stay in the ChannelFlushing state, as
// we haven't yet fully cleared the channel. Otherwise, we can move to the
// CloseReady state which'll being the channel closing process.
-func (c *ChannelFlushing) ProcessEvent(event ProtocolEvent,
- env *Environment) (*CloseStateTransition, error) {
+func (c *ChannelFlushing) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
switch msg := event.(type) {
// If we get a confirmation, then a prior transaction we broadcasted
@@ -482,6 +581,7 @@ func (c *ChannelFlushing) ProcessEvent(event ProtocolEvent,
closeTerms := CloseChannelTerms{
ShutdownScripts: c.ShutdownScripts,
ShutdownBalances: msg.ShutdownBalances,
+ NonceState: c.NonceState,
}
chancloserLog.Infof("ChannelPoint(%v): channel flushed! "+
@@ -577,8 +677,8 @@ func (c *ChannelFlushing) ProcessEvent(event ProtocolEvent,
// processNegotiateEvent is a helper function that processes a new event to
// local channel state once we're in the ClosingNegotiation state.
func processNegotiateEvent(c *ClosingNegotiation, event ProtocolEvent,
- env *Environment,
- chanPeer lntypes.ChannelParty) (*CloseStateTransition, error) {
+ env *Environment, chanPeer lntypes.ChannelParty,
+) (*CloseStateTransition, error) {
targetPeerState := c.PeerState.GetForParty(chanPeer)
@@ -607,11 +707,195 @@ func processNegotiateEvent(c *ClosingNegotiation, event ProtocolEvent,
}, nil
}
+// partialSigToWireSig converts a PartialSig to a wire Sig format for taproot.
+func partialSigToWireSig(partialSig lnwire.PartialSig) lnwire.Sig {
+ var wireSig lnwire.Sig
+ sigBytes := partialSig.Sig.Bytes()
+ copy(wireSig.RawBytes()[:32], sigBytes[:])
+ wireSig.ForceSchnorr()
+ return wireSig
+}
+
+// extractTaprootSigAndNonce extracts the partial signature and closee nonce
+// from a taproot ClosingSig message.
+func extractTaprootSigAndNonce(msg lnwire.ClosingSig) (sig fn.Result[lnwire.Sig],
+ nonce fn.Option[lnwire.Musig2Nonce]) {
+
+ // Count how many taproot sig fields are populated.
+ taprootSigInts := []bool{
+ msg.TaprootPartialSigs.CloserNoClosee.IsSome(),
+ msg.TaprootPartialSigs.NoCloserClosee.IsSome(),
+ msg.TaprootPartialSigs.CloserAndClosee.IsSome(),
+ }
+ numTaprootSigs := fn.Foldl(0, taprootSigInts, func(acc int, sigInt bool) int { //nolint:ll
+ if sigInt {
+ return acc + 1
+ }
+ return acc
+ })
+
+ // Validate exactly one sig is set.
+ if numTaprootSigs != 1 {
+ return fn.Errf[lnwire.Sig]("%w: only one sig should be set, got %v",
+ ErrTooManySigs, numTaprootSigs), fn.None[lnwire.Musig2Nonce]()
+ }
+
+ tapSigs := msg.TaprootPartialSigs
+
+ // Extract the partial signature from whichever field has it.
+ var extractedSig lnwire.Sig
+ switch {
+ case msg.TaprootPartialSigs.CloserNoClosee.IsSome():
+ tapSigs.CloserNoClosee.WhenSomeV(func(ps lnwire.PartialSig) {
+ extractedSig = partialSigToWireSig(ps)
+ })
+
+ case msg.TaprootPartialSigs.NoCloserClosee.IsSome():
+ tapSigs.NoCloserClosee.WhenSomeV(func(ps lnwire.PartialSig) {
+ extractedSig = partialSigToWireSig(ps)
+ })
+
+ case msg.TaprootPartialSigs.CloserAndClosee.IsSome():
+ tapSigs.CloserAndClosee.WhenSomeV(func(ps lnwire.PartialSig) {
+ extractedSig = partialSigToWireSig(ps)
+ })
+ }
+
+ // Extract the closee nonce, for taproot channels, we expect this to
+ // always be present.
+ var nextCloseeNonce fn.Option[lnwire.Musig2Nonce]
+ msg.NextCloseeNonce.WhenSomeV(func(nonce lnwire.Musig2Nonce) {
+ nextCloseeNonce = fn.Some(nonce)
+ })
+
+ // Validate that NextCloseeNonce is always set for taproot channels.
+ if nextCloseeNonce.IsNone() {
+ return fn.Errf[lnwire.Sig]("NextCloseeNonce must be set for " +
+ "taproot channels"), fn.None[lnwire.Musig2Nonce]()
+ }
+
+ return fn.Ok(extractedSig), nextCloseeNonce
+}
+
+// extractRegularSig extracts the signature from a non-taproot ClosingSig
+// message.
+func extractRegularSig(msg lnwire.ClosingSig) fn.Result[lnwire.Sig] {
+ // Count how many regular sig fields are populated
+ regularSigInts := []bool{
+ msg.ClosingSigs.CloserNoClosee.IsSome(),
+ msg.ClosingSigs.NoCloserClosee.IsSome(),
+ msg.ClosingSigs.CloserAndClosee.IsSome(),
+ }
+ numRegularSigs := fn.Foldl(0, regularSigInts, func(acc int,
+ sigInt bool) int {
+
+ if sigInt {
+ return acc + 1
+ }
+ return acc
+ })
+
+ // Validate exactly one sig is set
+ if numRegularSigs != 1 {
+ return fn.Errf[lnwire.Sig]("%w: only one sig should be "+
+ "set, got %v", ErrTooManySigs, numRegularSigs)
+ }
+
+ // Extract the signature from the appropriate field
+ switch {
+ case msg.ClosingSigs.CloserNoClosee.IsSome():
+ var sig lnwire.Sig
+ msg.ClosingSigs.CloserNoClosee.WhenSomeV(func(s lnwire.Sig) {
+ sig = s
+ })
+ return fn.Ok(sig)
+
+ case msg.ClosingSigs.NoCloserClosee.IsSome():
+ var sig lnwire.Sig
+ msg.ClosingSigs.NoCloserClosee.WhenSomeV(func(s lnwire.Sig) {
+ sig = s
+ })
+ return fn.Ok(sig)
+
+ case msg.ClosingSigs.CloserAndClosee.IsSome():
+ var sig lnwire.Sig
+ msg.ClosingSigs.CloserAndClosee.WhenSomeV(func(s lnwire.Sig) {
+ sig = s
+ })
+ return fn.Ok(sig)
+
+ default:
+ return fn.Errf[lnwire.Sig]("no signature found")
+ }
+}
+
+// extractSigAndNonceFromClosingSig validates that the signature type in the
+// ClosingSig message matches the channel type (taproot vs non-taproot), then
+// extracts the partial signature and the NextCloseeNonce for the next RBF
+// round. This is used by the closer when receiving the closee's response.
+func extractSigAndNonceFromClosingSig(msg lnwire.ClosingSig,
+) (sig fn.Result[lnwire.Sig], nonce fn.Option[lnwire.Musig2Nonce]) {
+
+ // Check if this is a taproot or regular signature.
+ hasTaprootSigs := msg.TaprootPartialSigs.CloserNoClosee.IsSome() ||
+ msg.TaprootPartialSigs.NoCloserClosee.IsSome() ||
+ msg.TaprootPartialSigs.CloserAndClosee.IsSome()
+
+ hasRegularSigs := msg.ClosingSigs.CloserNoClosee.IsSome() ||
+ msg.ClosingSigs.NoCloserClosee.IsSome() ||
+ msg.ClosingSigs.CloserAndClosee.IsSome()
+
+ // Make sure that only a single set of signatures is present.
+ if hasTaprootSigs && hasRegularSigs {
+ return fn.Errf[lnwire.Sig]("both taproot and regular " +
+ "sigs present"), fn.None[lnwire.Musig2Nonce]()
+ }
+
+ // If it's a taprotot sig, then we may need to also extract the nonce.
+ if hasTaprootSigs {
+ return extractTaprootSigAndNonce(msg)
+ }
+
+ return extractRegularSig(msg), fn.None[lnwire.Musig2Nonce]()
+}
+
+// validateAndExtractSigAndNonce validates that the signature type matches the
+// channel type and then extracts the signature and nonce.
+func validateAndExtractSigAndNonce(msg lnwire.ClosingSig,
+ isTaproot bool) (sig fn.Result[lnwire.Sig], nonce fn.Option[lnwire.Musig2Nonce]) {
+
+ // Check if this is a taproot or regular signature.
+ hasTaprootSigs := msg.TaprootPartialSigs.CloserNoClosee.IsSome() ||
+ msg.TaprootPartialSigs.NoCloserClosee.IsSome() ||
+ msg.TaprootPartialSigs.CloserAndClosee.IsSome()
+
+ hasRegularSigs := msg.ClosingSigs.CloserNoClosee.IsSome() ||
+ msg.ClosingSigs.NoCloserClosee.IsSome() ||
+ msg.ClosingSigs.CloserAndClosee.IsSome()
+
+ // Assert that the signature type matches the channel type.
+ switch {
+ case isTaproot && !hasTaprootSigs && hasRegularSigs:
+ return fn.Errf[lnwire.Sig]("taproot channel requires " +
+ "taproot signatures, got regular signatures"),
+ fn.None[lnwire.Musig2Nonce]()
+
+ case !isTaproot && hasTaprootSigs && !hasRegularSigs:
+ return fn.Errf[lnwire.Sig]("non-taproot channel requires " +
+ "regular signatures, got taproot signatures"),
+ fn.None[lnwire.Musig2Nonce]()
+ }
+
+ // If everything is clear, then we'll go ahead and extract the
+ // signatures.
+ return extractSigAndNonceFromClosingSig(msg)
+}
+
// updateAndValidateCloseTerms is a helper function that validates examines the
// incoming event, and decide if we need to update the remote party's address,
// or reject it if it doesn't include our latest address.
-func (c *ClosingNegotiation) updateAndValidateCloseTerms(
- event ProtocolEvent) error {
+func (c *ClosingNegotiation) updateAndValidateCloseTerms(event ProtocolEvent,
+ isTaproot bool) error {
assertLocalScriptMatches := func(localScriptInMsg []byte) error {
if !bytes.Equal(
@@ -658,6 +942,17 @@ func (c *ClosingNegotiation) updateAndValidateCloseTerms(
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
}
@@ -668,8 +963,8 @@ func (c *ClosingNegotiation) updateAndValidateCloseTerms(
// party in response to new events. From this state, we'll continue to drive
// forward the local and remote states until we arrive at the StateFin stage,
// or we loop back up to the ShutdownPending state.
-func (c *ClosingNegotiation) ProcessEvent(event ProtocolEvent,
- env *Environment) (*CloseStateTransition, error) {
+func (c *ClosingNegotiation) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
// There're two classes of events that can break us out of this state:
// we receive a confirmation event, or we receive a signal to restart
@@ -695,7 +990,8 @@ func (c *ClosingNegotiation) ProcessEvent(event ProtocolEvent,
// At this point, we know its a new signature message. We'll validate,
// and maybe update the set of close terms based on what we receive. We
// might update the remote party's address for example.
- if err := c.updateAndValidateCloseTerms(event); err != nil {
+ err := c.updateAndValidateCloseTerms(event, env.IsTaproot())
+ if err != nil {
return nil, fmt.Errorf("event violates close terms: %w", err)
}
@@ -721,8 +1017,8 @@ func (c *ClosingNegotiation) ProcessEvent(event ProtocolEvent,
case shouldRouteTo(lntypes.Remote):
chancloserLog.Infof("ChannelPoint(%v): routing %T to remote "+
- "chan state", env.ChanPoint, event)
+ "chan state", env.ChanPoint, event)
// Drive forward the remote state based on the next event.
return processNegotiateEvent(c, event, env, lntypes.Remote)
}
@@ -737,10 +1033,66 @@ func newSigTlv[T tlv.TlvType](s lnwire.Sig) tlv.OptionalRecordT[T, lnwire.Sig] {
return tlv.SomeRecordT(tlv.NewRecordT[T](s))
}
+// encodeClosingSignatures is a helper function that creates the appropriate
+// signature structures for the closing_complete message based on the channel
+// type and dust status.
+func encodeClosingSignatures(env *Environment, wireSig lnwire.Sig,
+ musigPartialSig *lnwallet.MusigPartialSig, noCloser, noClosee bool,
+) (lnwire.ClosingSigs, lnwire.TaprootClosingSigs, error) {
+
+ var (
+ closingSigs lnwire.ClosingSigs
+ taprootClosingSigs lnwire.TaprootClosingSigs
+ )
+
+ // If this is a taproot channel, then we'll return the taproot specific
+ // closing sigs variant.
+ if env.IsTaproot() {
+ if musigPartialSig == nil {
+ return closingSigs, taprootClosingSigs,
+ fmt.Errorf("missing partial signature for " +
+ "taproot channel")
+ }
+
+ // Convert the musig partial sig to wire format.
+ // This already includes our JIT closer nonce that we used to sign.
+ partialSigWithNonce := musigPartialSig.ToWireSig()
+
+ switch {
+ case noCloser:
+ taprootClosingSigs.NoCloserClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType6](*partialSigWithNonce),
+ )
+ case noClosee:
+ taprootClosingSigs.CloserNoClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType5](*partialSigWithNonce),
+ )
+ default:
+ taprootClosingSigs.CloserAndClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType7](*partialSigWithNonce),
+ )
+ }
+
+ return closingSigs, taprootClosingSigs, nil
+ }
+
+ // For non-taproot channels, we'll populate the normal ECDSA sigantures.
+ switch {
+ case noClosee:
+ closingSigs.CloserNoClosee = newSigTlv[tlv.TlvType1](wireSig)
+ case noCloser:
+ closingSigs.NoCloserClosee = newSigTlv[tlv.TlvType2](wireSig)
+ default:
+ closingSigs.CloserAndClosee = newSigTlv[tlv.TlvType3](wireSig)
+ }
+
+ return closingSigs, taprootClosingSigs, nil
+}
+
// ProcessEvent implements the event processing to kick off the process of
// obtaining a new (possibly RBF'd) signature for our commitment transaction.
-func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent,
- env *Environment) (*CloseStateTransition, error) {
+func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
switch msg := event.(type) { //nolint:gocritic
// If we receive a SendOfferEvent, then we'll use the specified fee
@@ -780,69 +1132,121 @@ func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent,
// proposals, we'll just always use the known RBF sequence
// value.
localScript := l.LocalDeliveryScript
- rawSig, closeTx, closeBalance, err := env.CloseSigner.CreateCloseProposal( //nolint:ll
- absoluteFee, localScript, l.RemoteDeliveryScript,
+
+ var closeOpts []lnwallet.ChanCloseOpt
+ closeOpts = append(closeOpts,
lnwallet.WithCustomSequence(mempool.MaxRBFSequence),
lnwallet.WithCustomPayer(lntypes.Local),
)
- if err != nil {
- return nil, err
+
+ // For taproot channels, we need to use the LocalMusigSession
+ // for signing when we're the closer (sending closing_complete).
+ if env.IsTaproot() {
+ // Initialize with the remote's closee nonce for
+ // signing. This may be using the very first nonce they
+ // send in shutdown, or the nonce they sent in
+ // ClosingSig after responding to our prior offer.
+ initLocalMusigCloseeNonce(
+ env, l.NonceState.RemoteCloseeNonce,
+ )
+
+ // Generate our JIT closer nonce. This sets the internal
+ // localNonce field in LocalMusigSession.
+ _, err := env.LocalMusigSession.ClosingNonce()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate "+
+ "JIT closer nonce: %w", err)
+ }
+
+ //nolint:ll
+ musigOpts, err := env.LocalMusigSession.ProposalClosingOpts()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get musig "+
+ "closing opts: %w", err)
+ }
+ closeOpts = append(closeOpts, musigOpts...)
}
- wireSig, err := lnwire.NewSigFromSignature(rawSig)
+
+ rawSig, closeTx, closeBalance, err := env.CloseSigner.CreateCloseProposal( //nolint:ll
+ absoluteFee, localScript, l.RemoteDeliveryScript,
+ closeOpts...,
+ )
if err != nil {
return nil, err
}
+ // Depending on the channel type, we'll be encoding a normal
+ // sig, or a musig2 partial sig.
+ var (
+ wireSig lnwire.Sig
+ musigPartialSig *lnwallet.MusigPartialSig
+ )
+
+ // Depending on the channel type, we'll either have a partial
+ // signature, or a regular signature.
+ switch {
+ case env.IsTaproot():
+ var ok bool
+ musigPartialSig, ok = rawSig.(*lnwallet.MusigPartialSig)
+ if !ok {
+ return nil, fmt.Errorf("expected "+
+ "MusigPartialSig for taproot "+
+ "channel, got %T", rawSig)
+ }
+
+ // Convert to schnorr shell format for wire sig.
+ schnorrSig := musigPartialSig.ToSchnorrShell()
+ wireSig, err = lnwire.NewSigFromSignature(schnorrSig)
+ if err != nil {
+ return nil, err
+ }
+ default:
+ // For non-taproot channels, use regular signature
+ // conversion.
+ wireSig, err = lnwire.NewSigFromSignature(rawSig)
+ if err != nil {
+ return nil, err
+ }
+ }
+
chancloserLog.Infof("closing w/ local_addr=%x, "+
"remote_addr=%x, fee=%v", localScript[:],
l.RemoteDeliveryScript[:], absoluteFee)
chancloserLog.Infof("proposing closing_tx=%v",
- lnutils.SpewLogClosure(closeTx))
+ spew.Sdump(closeTx))
- // Now that we have our signature, we'll set the proper
- // closingSigs field based on if the remote party's output is
- // dust or not.
- var closingSigs lnwire.ClosingSigs
+ var noClosee, noCloser bool
switch {
- // If the remote party's output is dust, then we'll set the
- // CloserNoClosee field.
case remoteTxOut == nil:
- closingSigs.CloserNoClosee = newSigTlv[tlv.TlvType1](
- wireSig,
- )
-
- // If after paying for fees, our balance is below dust, then
- // we'll set the NoCloserClosee field.
+ noClosee = true
case closeBalance < lnwallet.DustLimitForSize(len(localScript)):
- closingSigs.NoCloserClosee = newSigTlv[tlv.TlvType2](
- wireSig,
- )
+ noCloser = true
+ }
- // Otherwise, we'll set the CloserAndClosee field.
- //
- // TODO(roasbeef): should actually set both??
- default:
- closingSigs.CloserAndClosee = newSigTlv[tlv.TlvType3](
- wireSig,
- )
+ // Create the appropriate signature structures based on channel
+ // type.
+ closingSigs, taprootClosingSigs, err := encodeClosingSignatures(
+ env, wireSig, musigPartialSig, noCloser, noClosee,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ closingCompleteMsg := &lnwire.ClosingComplete{
+ ChannelID: env.ChanID,
+ CloserScript: l.LocalDeliveryScript,
+ CloseeScript: l.RemoteDeliveryScript,
+ FeeSatoshis: absoluteFee,
+ LockTime: env.BlockHeight,
+ ClosingSigs: closingSigs,
+ TaprootClosingSigs: taprootClosingSigs,
}
- // Now that we have our sig, we'll emit a daemon event to send
- // it to the remote party, then transition to the
- // LocalOfferSent state.
- //
// TODO(roasbeef): type alias for protocol event
sendEvent := protofsm.DaemonEventSet{&protofsm.SendMsgEvent[ProtocolEvent]{ //nolint:ll
TargetPeer: env.ChanPeer,
- Msgs: []lnwire.Message{&lnwire.ClosingComplete{
- ChannelID: env.ChanID,
- CloserScript: l.LocalDeliveryScript,
- CloseeScript: l.RemoteDeliveryScript,
- FeeSatoshis: absoluteFee,
- LockTime: env.BlockHeight,
- ClosingSigs: closingSigs,
- }},
+ Msgs: []lnwire.Message{closingCompleteMsg},
}}
chancloserLog.Infof("ChannelPoint(%v): sending closing sig "+
@@ -866,46 +1270,207 @@ func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent,
ErrInvalidStateTransition, event)
}
-// extractSig extracts the expected signature from the closing sig message.
-// Only one of them should actually be populated as the closing sig message is
-// sent in response to a ClosingComplete message, it should only sign the same
-// version of the co-op close tx as the sender did.
-func extractSig(msg lnwire.ClosingSig) fn.Result[lnwire.Sig] {
- // First, we'll validate that only one signature is included in their
- // response to our initial offer. If not, then we'll exit here, and
- // trigger a recycle of the connection.
- sigInts := []bool{
- msg.CloserNoClosee.IsSome(), msg.NoCloserClosee.IsSome(),
- msg.CloserAndClosee.IsSome(),
+// extractTaprootPartialSigWithNonce extracts the PartialSigWithNonce from
+// TaprootClosingSigs. It returns the partial sig, which field it was found in,
+// and whether it's a NoClosee case.
+func extractTaprootPartialSigWithNonce(sigs lnwire.TaprootClosingSigs) (
+ partialSig fn.Option[lnwire.PartialSigWithNonce], isNoClosee bool) {
+
+ if sigs.CloserNoClosee.IsSome() {
+ var ps lnwire.PartialSigWithNonce
+ sigs.CloserNoClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) {
+ ps = p
+ })
+ return fn.Some(ps), true
+ }
+
+ if sigs.NoCloserClosee.IsSome() {
+ var ps lnwire.PartialSigWithNonce
+ sigs.NoCloserClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) {
+ ps = p
+ })
+ return fn.Some(ps), false
}
- numSigs := fn.Foldl(0, sigInts, func(acc int, sigInt bool) int {
- if sigInt {
- return acc + 1
+
+ if sigs.CloserAndClosee.IsSome() {
+ var ps lnwire.PartialSigWithNonce
+ sigs.CloserAndClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) {
+ ps = p
+ })
+ return fn.Some(ps), false
+ }
+
+ return fn.None[lnwire.PartialSigWithNonce](), false
+}
+
+// createClosingSigMessage creates the ClosingSig message response for the closee role.
+func createClosingSigMessage(env *Environment, wireSig lnwire.Sig, localSig input.Signature,
+ localScript, remoteScript lnwire.DeliveryAddress, fee btcutil.Amount,
+ lockTime uint32, noClosee bool) (*lnwire.ClosingSig, error) {
+
+ var closingSigs lnwire.ClosingSigs
+ var taprootPartialSigs lnwire.TaprootPartialSigs
+ var nextCloseeNonce tlv.OptionalRecordT[tlv.TlvType22, lnwire.Musig2Nonce]
+
+ // For taproot channels, use PartialSig (no nonce) since receiver knows our nonce
+ if env.IsTaproot() {
+ // We already have the MusigPartialSig from earlier
+ musigSig := localSig.(*lnwallet.MusigPartialSig)
+ wireSigWithNonce := musigSig.ToWireSig()
+ partialSig := wireSigWithNonce.PartialSig
+
+ if noClosee {
+ taprootPartialSigs.CloserNoClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType5](partialSig),
+ )
+ } else {
+ taprootPartialSigs.CloserAndClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType7](partialSig),
+ )
}
- return acc
- })
- if numSigs != 1 {
- return fn.Errf[lnwire.Sig]("%w: only one sig should be set, "+
- "got %v", ErrTooManySigs, numSigs)
+ // 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.
+ nextNonces, err := env.RemoteMusigSession.ClosingNonce()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate next closee nonce: %w", err)
+ }
+ nextCloseeNonce = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType22](lnwire.Musig2Nonce(nextNonces.PubNonce)),
+ )
+ } else {
+ // Non-taproot: use regular signatures
+ if noClosee {
+ closingSigs.CloserNoClosee = newSigTlv[tlv.TlvType1](wireSig)
+ } else {
+ closingSigs.CloserAndClosee = newSigTlv[tlv.TlvType3](wireSig)
+ }
}
- // The final sig is the one that's actually set.
- sig := msg.CloserAndClosee.ValOpt().Alt(
- msg.NoCloserClosee.ValOpt(),
- ).Alt(
- msg.CloserNoClosee.ValOpt(),
- )
+ return &lnwire.ClosingSig{
+ ChannelID: env.ChanID,
+ CloserScript: remoteScript,
+ CloseeScript: localScript,
+ FeeSatoshis: fee,
+ LockTime: lockTime,
+ ClosingSigs: closingSigs,
+ TaprootPartialSigs: taprootPartialSigs,
+ NextCloseeNonce: nextCloseeNonce,
+ }, nil
+}
- return fn.NewResult(sig.UnwrapOrErr(ErrNoSig))
+// extractTaprootPartialSig extracts just the PartialSig from TaprootPartialSigs.
+// This is useful when we need the actual partial sig for combining.
+func extractTaprootPartialSig(sigs lnwire.TaprootPartialSigs) (
+ partialSig fn.Option[lnwire.PartialSig]) {
+
+ if sigs.CloserNoClosee.IsSome() {
+ var ps lnwire.PartialSig
+ sigs.CloserNoClosee.WhenSomeV(func(p lnwire.PartialSig) {
+ ps = p
+ })
+ return fn.Some(ps)
+ }
+
+ if sigs.NoCloserClosee.IsSome() {
+ var ps lnwire.PartialSig
+ sigs.NoCloserClosee.WhenSomeV(func(p lnwire.PartialSig) {
+ ps = p
+ })
+ return fn.Some(ps)
+ }
+
+ if sigs.CloserAndClosee.IsSome() {
+ var ps lnwire.PartialSig
+ sigs.CloserAndClosee.WhenSomeV(func(p lnwire.PartialSig) {
+ ps = p
+ })
+ return fn.Some(ps)
+ }
+
+ return fn.None[lnwire.PartialSig]()
+}
+
+// prepareClosingSignatures prepares the local and remote signatures for the
+// closing transaction. For taproot channels, it handles musig signature
+// combination. For non-taproot channels, it converts wire signatures to regular
+// signatures.
+func prepareClosingSignatures(env *Environment, l *LocalOfferSent,
+ msg *LocalSigReceived, sig lnwire.Sig,
+ closeOpts []lnwallet.ChanCloseOpt,
+) (localSig, remoteSig input.Signature, err error) {
+
+ if env.IsTaproot() {
+ // For taproot channels, we need to reconstruct the
+ // MusigPartialSig from the wire signature. We'll need to create
+ // a new CreateCloseProposal to get the proper MusigPartialSig
+ // that CompleteCooperativeClose expects.
+ rawLocalSig, _, _, err := env.CloseSigner.CreateCloseProposal(
+ l.ProposedFee, l.LocalDeliveryScript,
+ l.RemoteDeliveryScript, closeOpts...,
+ )
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to recreate "+
+ "local sig: %w", err)
+ }
+ localSig = rawLocalSig
+
+ // Extract the partial sig from the message using our helper
+ // function.
+ remotePartialSigOpt := extractTaprootPartialSig(
+ msg.SigMsg.TaprootPartialSigs,
+ )
+ if remotePartialSigOpt.IsNone() {
+ return nil, nil, fmt.Errorf("no taproot partial " +
+ "sig found in message")
+ }
+
+ remotePartialSig := remotePartialSigOpt.UnwrapOr(
+ lnwire.PartialSig{},
+ )
+
+ // We also need our local partial sig in wire format.
+ localMusigSig, ok := rawLocalSig.(*lnwallet.MusigPartialSig)
+ if !ok {
+ return nil, nil, fmt.Errorf("expected local sig to "+
+ "be MusigPartialSig, got %T", rawLocalSig)
+ }
+ localPartialSig := localMusigSig.ToWireSig().PartialSig
+
+ // Use CombineClosingOpts to get the proper signatures.
+ // notlint:ll
+ localCombined, remoteCombined, _, err := env.LocalMusigSession.CombineClosingOpts(
+ localPartialSig, remotePartialSig,
+ )
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to combine "+
+ "closing opts: %w", err)
+ }
+
+ return localCombined, remoteCombined, nil
+ }
+
+ // For non-taproot channels, convert wire signatures to regular
+ // signatures.
+ remoteSig, err = sig.ToSignature()
+ if err != nil {
+ return nil, nil, err
+ }
+ localSig, err = l.LocalSig.ToSignature()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return localSig, remoteSig, nil
}
// ProcessEvent implements the state transition function for the
// LocalOfferSent state. In this state, we'll wait for the remote party to
// send a close_signed message which gives us the ability to broadcast a new
// co-op close transaction.
-func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent,
- env *Environment) (*CloseStateTransition, error) {
+func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
switch msg := event.(type) { //nolint:gocritic
// If we receive a LocalSigReceived event, then we'll attempt to
@@ -913,16 +1478,34 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent,
// broadcast the transaction, and transition to the ClosePending state.
case *LocalSigReceived:
// Extract and validate that only one sig field is set.
- sig, err := extractSig(msg.SigMsg).Unpack()
+ sigResult, _ := validateAndExtractSigAndNonce(
+ msg.SigMsg, env.IsTaproot(),
+ )
+ sig, err := sigResult.Unpack()
if err != nil {
return nil, err
}
- remoteSig, err := sig.ToSignature()
- if err != nil {
- return nil, err
+ var closeOpts []lnwallet.ChanCloseOpt
+ closeOpts = append(closeOpts,
+ lnwallet.WithCustomSequence(mempool.MaxRBFSequence),
+ lnwallet.WithCustomPayer(lntypes.Local),
+ )
+
+ // For taproot channels, we'll make sure to add the musig
+ // options before calling prepareClosingSignatures.
+ if env.IsTaproot() {
+ musigOpts, err := env.LocalMusigSession.ProposalClosingOpts()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get musig "+
+ "closing opts: %w", err)
+ }
+ closeOpts = append(closeOpts, musigOpts...)
}
- localSig, err := l.LocalSig.ToSignature()
+
+ localSig, remoteSig, err := prepareClosingSignatures(
+ env, l, msg, sig, closeOpts,
+ )
if err != nil {
return nil, err
}
@@ -931,9 +1514,7 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent,
// it, then extract a valid closing signature from it.
closeTx, _, err := env.CloseSigner.CompleteCooperativeClose(
localSig, remoteSig, l.LocalDeliveryScript,
- l.RemoteDeliveryScript, l.ProposedFee,
- lnwallet.WithCustomSequence(mempool.MaxRBFSequence),
- lnwallet.WithCustomPayer(lntypes.Local),
+ l.RemoteDeliveryScript, l.ProposedFee, closeOpts...,
)
if err != nil {
return nil, err
@@ -976,12 +1557,144 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent,
ErrInvalidStateTransition, event)
}
+// processRemoteTaprootSig handles the extraction and processing of a remote
+// taproot signature for the closee role. It extracts the partial sig with
+// nonce, initializes the musig session, and returns the remote signature.
+func processRemoteTaprootSig(env *Environment, msg lnwire.ClosingComplete,
+ jitNonce fn.Option[lnwire.Musig2Nonce]) (input.Signature, error) {
+
+ // 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)
+
+ partialSigOpt, _ := extractTaprootPartialSigWithNonce(msg.TaprootClosingSigs)
+ if partialSigOpt.IsNone() {
+ return nil, fmt.Errorf("no taproot partial sig found in message")
+ }
+
+ var remotePartialSig lnwire.PartialSigWithNonce
+ partialSigOpt.WhenSome(func(ps lnwire.PartialSigWithNonce) {
+ remotePartialSig = ps
+ })
+
+ // Create a MusigPartialSig from the wire format The Nonce in
+ // PartialSigWithNonce is their next closee nonce for future RBF. We
+ // store it but don't use it for verification of this signature.
+ remoteSig := lnwallet.NewMusigPartialSig(
+ &musig2.PartialSignature{
+ S: &remotePartialSig.PartialSig.Sig,
+ },
+ remotePartialSig.Nonce, lnwire.Musig2Nonce{}, nil,
+ fn.None[chainhash.Hash](),
+ )
+
+ return remoteSig, nil
+}
+
+// createLocalCloseeSignature creates our local signature for the closee role.
+// It returns both the wire format signature and the input.Signature.
+func createLocalCloseeSignature(env *Environment, fee btcutil.Amount,
+ localScript, remoteScript lnwire.DeliveryAddress,
+ chanOpts []lnwallet.ChanCloseOpt) (lnwire.Sig, input.Signature, error) {
+
+ rawSig, _, _, err := env.CloseSigner.CreateCloseProposal(
+ fee, localScript, remoteScript, chanOpts...,
+ )
+ if err != nil {
+ return lnwire.Sig{}, nil, fmt.Errorf("failed to "+
+ "create close proposal: %w", err)
+ }
+
+ var (
+ wireSig lnwire.Sig
+ localSig input.Signature
+ )
+
+ if env.IsTaproot() {
+ musigSig, ok := rawSig.(*lnwallet.MusigPartialSig)
+ if !ok {
+ return lnwire.Sig{}, nil, fmt.Errorf("expected "+
+ "MusigPartialSig for taproot channel, got %T",
+ rawSig)
+ }
+
+ // Convert to schnorr shell format for wire sig encoding.
+ schnorrSig := musigSig.ToSchnorrShell()
+ wireSig, err = lnwire.NewSigFromSignature(schnorrSig)
+ if err != nil {
+ return lnwire.Sig{}, nil, err
+ }
+
+ localSig = musigSig
+ } else {
+ wireSig, err = lnwire.NewSigFromSignature(rawSig)
+ if err != nil {
+ return lnwire.Sig{}, nil, err
+ }
+
+ localSig, err = wireSig.ToSignature()
+ if err != nil {
+ return lnwire.Sig{}, nil, err
+ }
+ }
+
+ return wireSig, localSig, nil
+}
+
+// extractSigAndNonceFromComplete extracts signature and optional nonce from
+// ClosingComplete. For taproot channels, it extracts both the partial signature
+// and the JIT nonce. For non-taproot channels, it extracts just the signature.
+func extractSigAndNonceFromComplete(msg lnwire.ClosingComplete,
+) (sig fn.Option[lnwire.Sig], nonce fn.Option[lnwire.Musig2Nonce],
+ isNoClosee bool) {
+
+ // If this is a taproot channel, then we'll extract the partial sigs.
+ partialSigOpt, isNoClosee := extractTaprootPartialSigWithNonce(
+ msg.TaprootClosingSigs,
+ )
+
+ // If we have a partial sig, then we'll covnert it into our shim wire
+ // format (just the 32 bytes of the partial sig).
+ if partialSigOpt.IsSome() {
+ var partialSig lnwire.PartialSigWithNonce
+ partialSigOpt.WhenSome(func(ps lnwire.PartialSigWithNonce) {
+ partialSig = ps
+ })
+
+ var wireSig lnwire.Sig
+
+ sigBytes := partialSig.PartialSig.Sig.Bytes()
+ copy(wireSig.RawBytes()[:32], sigBytes[:])
+
+ wireSig.ForceSchnorr()
+
+ return fn.Some(wireSig), fn.Some(partialSig.Nonce), isNoClosee
+ }
+
+ none := fn.None[lnwire.Musig2Nonce]()
+
+ if msg.ClosingSigs.CloserNoClosee.IsSome() {
+ return msg.ClosingSigs.CloserNoClosee.ValOpt(), none, true
+ }
+
+ if msg.ClosingSigs.NoCloserClosee.IsSome() {
+ return msg.ClosingSigs.NoCloserClosee.ValOpt(), none, false
+ }
+
+ if msg.ClosingSigs.CloserAndClosee.IsSome() {
+ return msg.ClosingSigs.CloserAndClosee.ValOpt(), none, false
+ }
+
+ return fn.None[lnwire.Sig](), fn.None[lnwire.Musig2Nonce](), false
+}
+
// ProcessEvent implements the state transition function for the
// RemoteCloseStart. In this state, we'll wait for the remote party to send a
// closing_complete message. Assuming they can pay for the fees, we'll sign it
// ourselves, then transition to the next state of ClosePending.
-func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent,
- env *Environment) (*CloseStateTransition, error) {
+func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
switch msg := event.(type) { //nolint:gocritic
// If we receive a OfferReceived event, we'll make sure they can
@@ -998,35 +1711,27 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent,
l.RemoteBalance.ToSatoshis())
}
- // With the basic sanity checks out of the way, we'll now
- // figure out which signature that we'll attempt to sign
- // against.
- var (
- remoteSig input.Signature
- noClosee bool
+ // Extract the signature and JIT nonce from the ClosingComplete
+ // message.
+ sigOpt, jitNonce, noClosee := extractSigAndNonceFromComplete(
+ msg.SigMsg,
)
+
+ // Validate signature presence based on our balance.
switch {
- // If our balance is dust, then we expect the CloserNoClosee
- // sig to be set.
- case l.LocalAmtIsDust():
- if msg.SigMsg.CloserNoClosee.IsNone() {
- return nil, ErrCloserNoClosee
- }
- msg.SigMsg.CloserNoClosee.WhenSomeV(func(s lnwire.Sig) {
- remoteSig, _ = s.ToSignature()
- noClosee = true
- })
+ case l.LocalAmtIsDust() && !noClosee:
+ return nil, ErrCloserNoClosee
+ case !l.LocalAmtIsDust() && noClosee:
+ return nil, ErrCloserAndClosee
+ }
- // Otherwise, we'll assume that CloseAndClosee is set.
- //
- // TODO(roasbeef): NoCloserClosee, but makes no sense?
- default:
- if msg.SigMsg.CloserAndClosee.IsNone() {
- return nil, ErrCloserAndClosee
- }
- msg.SigMsg.CloserAndClosee.WhenSomeV(func(s lnwire.Sig) { //nolint:ll
- remoteSig, _ = s.ToSignature()
- })
+ if sigOpt.IsNone() {
+ return nil, ErrNoSig
+ }
+
+ sig, err := sigOpt.UnwrapOrErr(ErrNoSig)
+ if err != nil {
+ return nil, err
}
chanOpts := []lnwallet.ChanCloseOpt{
@@ -1035,6 +1740,35 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent,
lnwallet.WithCustomPayer(lntypes.Remote),
}
+ var remoteSig input.Signature
+
+ // For taproot channels, add MusigSession options if available
+ // When we're the closee (sending closing_sig), we use
+ // RemoteMusigSession
+ switch {
+ case env.RemoteMusigSession != nil:
+ musigOpts, err := env.RemoteMusigSession.ProposalClosingOpts()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get musig "+
+ "closing opts: %w", err)
+ }
+ chanOpts = append(chanOpts, musigOpts...)
+
+ // Apply their jitNonce, then parse out the partisl
+ // signature from that.
+ remoteSig, err = processRemoteTaprootSig(
+ env, msg.SigMsg, jitNonce,
+ )
+ if err != nil {
+ return nil, err
+ }
+ default:
+ remoteSig, err = sig.ToSignature()
+ if err != nil {
+ return nil, err
+ }
+ }
+
chancloserLog.Infof("responding to close w/ local_addr=%x, "+
"remote_addr=%x, fee=%v",
l.LocalDeliveryScript[:], l.RemoteDeliveryScript[:],
@@ -1046,22 +1780,13 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent,
//
// TODO(roasbeef): need to be able to omit an output when
// signing based on the above, as closing opt
- rawSig, _, _, err := env.CloseSigner.CreateCloseProposal(
- msg.SigMsg.FeeSatoshis, l.LocalDeliveryScript,
- l.RemoteDeliveryScript, chanOpts...,
+ wireSig, localSig, err := createLocalCloseeSignature(
+ env, msg.SigMsg.FeeSatoshis, l.LocalDeliveryScript,
+ l.RemoteDeliveryScript, chanOpts,
)
if err != nil {
return nil, err
}
- wireSig, err := lnwire.NewSigFromSignature(rawSig)
- if err != nil {
- return nil, err
- }
-
- localSig, err := wireSig.ToSignature()
- if err != nil {
- return nil, err
- }
// With our signature created, we'll now attempt to finalize the
// close process.
@@ -1080,15 +1805,14 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent,
lnutils.SpewLogClosure(closeTx),
)
- var closingSigs lnwire.ClosingSigs
- if noClosee {
- closingSigs.CloserNoClosee = newSigTlv[tlv.TlvType1](
- wireSig,
- )
- } else {
- closingSigs.CloserAndClosee = newSigTlv[tlv.TlvType3](
- wireSig,
- )
+ // Create the ClosingSig response message
+ closingSigMsg, err := createClosingSigMessage(
+ env, wireSig, localSig, l.LocalDeliveryScript,
+ l.RemoteDeliveryScript, msg.SigMsg.FeeSatoshis,
+ msg.SigMsg.LockTime, noClosee,
+ )
+ if err != nil {
+ return nil, err
}
// As we're about to broadcast a new version of the co-op close
@@ -1101,19 +1825,9 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent,
return nil, err
}
- // As we transition, we'll omit two events: one to broadcast
- // the transaction, and the other to send our ClosingSig
- // message to the remote party.
sendEvent := &protofsm.SendMsgEvent[ProtocolEvent]{
TargetPeer: env.ChanPeer,
- Msgs: []lnwire.Message{&lnwire.ClosingSig{
- ChannelID: env.ChanID,
- CloserScript: l.RemoteDeliveryScript,
- CloseeScript: l.LocalDeliveryScript,
- FeeSatoshis: msg.SigMsg.FeeSatoshis,
- LockTime: msg.SigMsg.LockTime,
- ClosingSigs: closingSigs,
- }},
+ Msgs: []lnwire.Message{closingSigMsg},
}
broadcastEvent := &protofsm.BroadcastTxn{
Tx: closeTx,
@@ -1155,8 +1869,8 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent,
// ProcessEvent is a semi-terminal state in the rbf-coop close state machine.
// In this state, we're waiting for either a confirmation, or for either side
// to attempt to create a new RBF'd co-op close transaction.
-func (c *ClosePending) ProcessEvent(event ProtocolEvent,
- _ *Environment) (*CloseStateTransition, error) {
+func (c *ClosePending) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
switch msg := event.(type) {
// If we can a spend while waiting for the close, then we'll go to our
@@ -1204,8 +1918,8 @@ func (c *ClosePending) ProcessEvent(event ProtocolEvent,
// ProcessEvent is the event processing for out terminal state. In this state,
// we just keep looping back on ourselves.
-func (c *CloseFin) ProcessEvent(_ ProtocolEvent,
- _ *Environment) (*CloseStateTransition, error) {
+func (c *CloseFin) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
return &CloseStateTransition{
NextState: c,
@@ -1216,8 +1930,8 @@ func (c *CloseFin) ProcessEvent(_ ProtocolEvent,
// In this state, we hit a validation error in an earlier state, so we'll remain
// in this state for the user to examine. We may also process new requests to
// continue the state machine.
-func (c *CloseErr) ProcessEvent(event ProtocolEvent,
- _ *Environment) (*CloseStateTransition, error) {
+func (c *CloseErr) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
switch msg := event.(type) {
// If we get a send offer event in this state, then we're doing a state
@@ -1245,7 +1959,6 @@ func (c *CloseErr) ProcessEvent(event ProtocolEvent,
InternalEvent: []ProtocolEvent{msg},
}),
}, nil
-
default:
return &CloseStateTransition{
NextState: c,
diff --git a/lnwire/closing_complete.go b/lnwire/closing_complete.go
index 7980ef1..a46d2dd 100644
--- a/lnwire/closing_complete.go
+++ b/lnwire/closing_complete.go
@@ -13,7 +13,7 @@ import (
// either include both outputs, or only one of the outputs from either side.
type ClosingSigs struct {
// CloserNoClosee is a signature that excludes the output of the
- // clsoee.
+ // closee.
CloserNoClosee tlv.OptionalRecordT[tlv.TlvType1, Sig]
// NoCloserClosee is a signature that excludes the output of the
@@ -24,6 +24,23 @@ type ClosingSigs struct {
CloserAndClosee tlv.OptionalRecordT[tlv.TlvType3, Sig]
}
+// TaprootClosingSigs houses the 3 possible taproot signatures (with nonces)
+// that can be sent when attempting to complete a cooperative channel closure.
+// These use PartialSigWithNonce to implement the JIT nonce pattern.
+type TaprootClosingSigs struct {
+ // CloserNoClosee is a partial signature with nonce that excludes the
+ // output of the closee. Uses TLV type 5.
+ CloserNoClosee tlv.OptionalRecordT[tlv.TlvType5, PartialSigWithNonce]
+
+ // NoCloserClosee is a partial signature with nonce that excludes the
+ // output of the closer. Uses TLV type 6.
+ NoCloserClosee tlv.OptionalRecordT[tlv.TlvType6, PartialSigWithNonce]
+
+ // CloserAndClosee is a partial signature with nonce that includes
+ // both outputs. Uses TLV type 7.
+ CloserAndClosee tlv.OptionalRecordT[tlv.TlvType7, PartialSigWithNonce]
+}
+
// ClosingComplete is sent by either side to kick off the process of obtaining
// a valid signature on a c o-operative channel closure of their choice.
type ClosingComplete struct {
@@ -47,8 +64,17 @@ type ClosingComplete struct {
LockTime uint32
// ClosingSigs houses the 3 possible signatures that can be sent.
+ // For non-taproot channels, these are regular signatures.
ClosingSigs
+ // TaprootClosingSigs houses the 3 possible taproot signatures that
+ // can be sent. Each signature includes the nonce for the next RBF
+ // round (implementing the JIT nonce pattern).
+ //
+ // NOTE: This field is only populated for taproot channels. When present,
+ // the above ClosingSigs MUST be empty.
+ TaprootClosingSigs
+
// ExtraData is the set of data that was appended to this message to
// fill out the full maximum transport message size. These fields can
// be used to specify optional data such as custom TLV fields.
@@ -57,19 +83,25 @@ type ClosingComplete struct {
// decodeClosingSigs decodes the closing sig TLV records in the passed
// ExtraOpaqueData.
-func decodeClosingSigs(c *ClosingSigs, tlvRecords ExtraOpaqueData) error {
+func decodeClosingSigs(c *ClosingSigs, tc *TaprootClosingSigs, tlvRecords ExtraOpaqueData) error {
+ // Regular signatures
sig1 := c.CloserNoClosee.Zero()
sig2 := c.NoCloserClosee.Zero()
sig3 := c.CloserAndClosee.Zero()
-
- typeMap, err := tlvRecords.ExtractRecords(&sig1, &sig2, &sig3)
+
+ // Taproot signatures (with nonces)
+ tSig1 := tc.CloserNoClosee.Zero()
+ tSig2 := tc.NoCloserClosee.Zero()
+ tSig3 := tc.CloserAndClosee.Zero()
+
+ typeMap, err := tlvRecords.ExtractRecords(
+ &sig1, &sig2, &sig3, &tSig1, &tSig2, &tSig3,
+ )
if err != nil {
return err
}
- // TODO(roasbeef): helper func to made decode of the optional vals
- // easier?
-
+ // Regular signatures
if val, ok := typeMap[c.CloserNoClosee.TlvType()]; ok && val == nil {
c.CloserNoClosee = tlv.SomeRecordT(sig1)
}
@@ -79,6 +111,17 @@ func decodeClosingSigs(c *ClosingSigs, tlvRecords ExtraOpaqueData) error {
if val, ok := typeMap[c.CloserAndClosee.TlvType()]; ok && val == nil {
c.CloserAndClosee = tlv.SomeRecordT(sig3)
}
+
+ // Taproot signatures
+ if val, ok := typeMap[tc.CloserNoClosee.TlvType()]; ok && val == nil {
+ tc.CloserNoClosee = tlv.SomeRecordT(tSig1)
+ }
+ if val, ok := typeMap[tc.NoCloserClosee.TlvType()]; ok && val == nil {
+ tc.NoCloserClosee = tlv.SomeRecordT(tSig2)
+ }
+ if val, ok := typeMap[tc.CloserAndClosee.TlvType()]; ok && val == nil {
+ tc.CloserAndClosee = tlv.SomeRecordT(tSig3)
+ }
return nil
}
@@ -102,7 +145,7 @@ func (c *ClosingComplete) Decode(r io.Reader, _ uint32) error {
return err
}
- if err := decodeClosingSigs(&c.ClosingSigs, tlvRecords); err != nil {
+ if err := decodeClosingSigs(&c.ClosingSigs, &c.TaprootClosingSigs, tlvRecords); err != nil {
return err
}
@@ -114,9 +157,11 @@ func (c *ClosingComplete) Decode(r io.Reader, _ uint32) error {
}
// closingSigRecords returns the set of records that encode the closing sigs,
-// if present.
-func closingSigRecords(c *ClosingSigs) []tlv.RecordProducer {
- recordProducers := make([]tlv.RecordProducer, 0, 3)
+// including both regular and taproot signatures.
+func closingSigRecords(c *ClosingSigs, tc *TaprootClosingSigs) []tlv.RecordProducer {
+ recordProducers := make([]tlv.RecordProducer, 0, 6)
+
+ // Regular signatures
c.CloserNoClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType1, Sig]) {
recordProducers = append(recordProducers, &sig)
})
@@ -126,6 +171,17 @@ func closingSigRecords(c *ClosingSigs) []tlv.RecordProducer {
c.CloserAndClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType3, Sig]) {
recordProducers = append(recordProducers, &sig)
})
+
+ // Taproot signatures (with nonces)
+ tc.CloserNoClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType5, PartialSigWithNonce]) {
+ recordProducers = append(recordProducers, &sig)
+ })
+ tc.NoCloserClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType6, PartialSigWithNonce]) {
+ recordProducers = append(recordProducers, &sig)
+ })
+ tc.CloserAndClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType7, PartialSigWithNonce]) {
+ recordProducers = append(recordProducers, &sig)
+ })
return recordProducers
}
@@ -151,7 +207,7 @@ func (c *ClosingComplete) Encode(w *bytes.Buffer, _ uint32) error {
return err
}
- recordProducers := closingSigRecords(&c.ClosingSigs)
+ recordProducers := closingSigRecords(&c.ClosingSigs, &c.TaprootClosingSigs)
err := EncodeMessageExtraData(&c.ExtraData, recordProducers...)
if err != nil {
diff --git a/lnwire/closing_sig.go b/lnwire/closing_sig.go
index 94a3560..58daff6 100644
--- a/lnwire/closing_sig.go
+++ b/lnwire/closing_sig.go
@@ -5,8 +5,27 @@ import (
"io"
"github.com/btcsuite/btcd/btcutil"
+ "github.com/lightningnetwork/lnd/tlv"
)
+
+// TaprootPartialSigs houses the 3 possible taproot partial signatures (without nonces)
+// that can be sent in a ClosingSig message. These use just PartialSig since the
+// receiver already knows our nonce from the previous ClosingComplete.
+type TaprootPartialSigs struct {
+ // CloserNoClosee is a partial signature that excludes the
+ // output of the closee. Uses TLV type 5.
+ CloserNoClosee tlv.OptionalRecordT[tlv.TlvType5, PartialSig]
+
+ // NoCloserClosee is a partial signature that excludes the
+ // output of the closer. Uses TLV type 6.
+ NoCloserClosee tlv.OptionalRecordT[tlv.TlvType6, PartialSig]
+
+ // CloserAndClosee is a partial signature that includes
+ // both outputs. Uses TLV type 7.
+ CloserAndClosee tlv.OptionalRecordT[tlv.TlvType7, PartialSig]
+}
+
// ClosingSig is sent in response to a ClosingComplete message. It carries the
// signatures of the closee to the closer.
type ClosingSig struct {
@@ -30,14 +49,86 @@ type ClosingSig struct {
LockTime uint32
// ClosingSigs houses the 3 possible signatures that can be sent.
+ // For non-taproot channels, these are regular signatures.
ClosingSigs
+ // TaprootPartialSigs houses the 3 possible taproot partial signatures
+ // that can be sent. For ClosingSig, we only send the partial signature
+ // without the nonce since the remote already knows our nonce from the
+ // previous ClosingComplete message.
+ //
+ // NOTE: This field is only populated for taproot channels. When present,
+ // the above ClosingSigs MUST be empty.
+ TaprootPartialSigs
+
+ // NextCloseeNonce is an optional nonce for RBF iterations. This is the
+ // nonce that the closer should use for this party's closee signature
+ // in the next RBF round.
+ //
+ // NOTE: This field is only populated for taproot channels during RBF.
+ NextCloseeNonce tlv.OptionalRecordT[tlv.TlvType22, Musig2Nonce]
+
// ExtraData is the set of data that was appended to this message to
// fill out the full maximum transport message size. These fields can
// be used to specify optional data such as custom TLV fields.
ExtraData ExtraOpaqueData
}
+// decodeClosingSigSigs decodes the closing sig TLV records from the passed
+// ExtraOpaqueData.
+func decodeClosingSigSigs(c *ClosingSigs, tp *TaprootPartialSigs,
+ nextNonce *tlv.OptionalRecordT[tlv.TlvType22, Musig2Nonce],
+ tlvRecords ExtraOpaqueData) error {
+ // Regular signatures
+ sig1 := c.CloserNoClosee.Zero()
+ sig2 := c.NoCloserClosee.Zero()
+ sig3 := c.CloserAndClosee.Zero()
+
+ // Taproot partial signatures (without nonces)
+ tSig1 := tp.CloserNoClosee.Zero()
+ tSig2 := tp.NoCloserClosee.Zero()
+ tSig3 := tp.CloserAndClosee.Zero()
+
+ // Next closee nonce for RBF
+ nonce := nextNonce.Zero()
+
+ typeMap, err := tlvRecords.ExtractRecords(
+ &sig1, &sig2, &sig3, &tSig1, &tSig2, &tSig3, &nonce,
+ )
+ if err != nil {
+ return err
+ }
+
+ // Regular signatures
+ if val, ok := typeMap[c.CloserNoClosee.TlvType()]; ok && val == nil {
+ c.CloserNoClosee = tlv.SomeRecordT(sig1)
+ }
+ if val, ok := typeMap[c.NoCloserClosee.TlvType()]; ok && val == nil {
+ c.NoCloserClosee = tlv.SomeRecordT(sig2)
+ }
+ if val, ok := typeMap[c.CloserAndClosee.TlvType()]; ok && val == nil {
+ c.CloserAndClosee = tlv.SomeRecordT(sig3)
+ }
+
+ // Taproot partial signatures
+ if val, ok := typeMap[tp.CloserNoClosee.TlvType()]; ok && val == nil {
+ tp.CloserNoClosee = tlv.SomeRecordT(tSig1)
+ }
+ if val, ok := typeMap[tp.NoCloserClosee.TlvType()]; ok && val == nil {
+ tp.NoCloserClosee = tlv.SomeRecordT(tSig2)
+ }
+ if val, ok := typeMap[tp.CloserAndClosee.TlvType()]; ok && val == nil {
+ tp.CloserAndClosee = tlv.SomeRecordT(tSig3)
+ }
+
+ // Next closee nonce
+ if val, ok := typeMap[nextNonce.TlvType()]; ok && val == nil {
+ *nextNonce = tlv.SomeRecordT(nonce)
+ }
+
+ return nil
+}
+
// Decode deserializes a serialized ClosingSig message stored in the passed
// io.Reader.
func (c *ClosingSig) Decode(r io.Reader, _ uint32) error {
@@ -57,7 +148,7 @@ func (c *ClosingSig) Decode(r io.Reader, _ uint32) error {
return err
}
- if err := decodeClosingSigs(&c.ClosingSigs, tlvRecords); err != nil {
+ if err := decodeClosingSigSigs(&c.ClosingSigs, &c.TaprootPartialSigs, &c.NextCloseeNonce, tlvRecords); err != nil {
return err
}
@@ -68,6 +159,42 @@ func (c *ClosingSig) Decode(r io.Reader, _ uint32) error {
return nil
}
+// closingSigSigRecords returns the set of records that encode the closing sigs,
+// including both regular and taproot signatures.
+func closingSigSigRecords(c *ClosingSigs, tp *TaprootPartialSigs,
+ nextNonce tlv.OptionalRecordT[tlv.TlvType22, Musig2Nonce]) []tlv.RecordProducer {
+ recordProducers := make([]tlv.RecordProducer, 0, 7)
+
+ // Regular signatures
+ c.CloserNoClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType1, Sig]) {
+ recordProducers = append(recordProducers, &sig)
+ })
+ c.NoCloserClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType2, Sig]) {
+ recordProducers = append(recordProducers, &sig)
+ })
+ c.CloserAndClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType3, Sig]) {
+ recordProducers = append(recordProducers, &sig)
+ })
+
+ // Taproot partial signatures (without nonces)
+ tp.CloserNoClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType5, PartialSig]) {
+ recordProducers = append(recordProducers, &sig)
+ })
+ tp.NoCloserClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType6, PartialSig]) {
+ recordProducers = append(recordProducers, &sig)
+ })
+ tp.CloserAndClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType7, PartialSig]) {
+ recordProducers = append(recordProducers, &sig)
+ })
+
+ // Next closee nonce for RBF
+ nextNonce.WhenSome(func(nonce tlv.RecordT[tlv.TlvType22, Musig2Nonce]) {
+ recordProducers = append(recordProducers, &nonce)
+ })
+
+ return recordProducers
+}
+
// Encode serializes the target ClosingSig into the passed io.Writer.
func (c *ClosingSig) Encode(w *bytes.Buffer, _ uint32) error {
if err := WriteChannelID(w, c.ChannelID); err != nil {
@@ -89,7 +216,7 @@ func (c *ClosingSig) Encode(w *bytes.Buffer, _ uint32) error {
return err
}
- recordProducers := closingSigRecords(&c.ClosingSigs)
+ recordProducers := closingSigSigRecords(&c.ClosingSigs, &c.TaprootPartialSigs, c.NextCloseeNonce)
err := EncodeMessageExtraData(&c.ExtraData, recordProducers...)
if err != nil {
diff --git a/lnwire/shutdown.go b/lnwire/shutdown.go
index 28df9a4..9715b90 100644
--- a/lnwire/shutdown.go
+++ b/lnwire/shutdown.go
@@ -9,6 +9,8 @@ import (
type (
// ShutdownNonceType is the type of the shutdown nonce TLV record.
+ // This nonce represents the sender's "closee nonce" - the nonce they'll
+ // use when signing the other party's closing transaction.
ShutdownNonceType = tlv.TlvType8
// ShutdownNonceTLV is the TLV record that contains the shutdown nonce.
@@ -22,6 +24,7 @@ func SomeShutdownNonce(nonce Musig2Nonce) ShutdownNonceTLV {
)
}
+
// Shutdown is sent by either side in order to initiate the cooperative closure
// of a channel. This message is sparse as both sides implicitly have the
// information necessary to construct a transaction that will send the settled
@@ -34,8 +37,10 @@ type Shutdown struct {
// Address is the script to which the channel funds will be paid.
Address DeliveryAddress
- // ShutdownNonce is the nonce the sender will use to sign the first
- // co-op sign offer.
+ // ShutdownNonce is the musig2 nonce the sender will use when acting as
+ // the closee (signing the other party's closing transaction). For
+ // taproot channels with RBF support, subsequent nonces are sent using
+ // the JIT (just-in-time) pattern alongside signatures.
ShutdownNonce ShutdownNonceTLV
// CustomRecords maps TLV types to byte slices, storing arbitrary data
diff --git a/lnwire/test_message.go b/lnwire/test_message.go
index 498b591..9b3ebbc 100644
--- a/lnwire/test_message.go
+++ b/lnwire/test_message.go
@@ -661,25 +661,71 @@ func (c *ClosingComplete) RandTestMessage(t *rapid.T) Message {
}
}
- if includeCloserNoClosee {
- sig := RandSignature(t)
- msg.CloserNoClosee = tlv.SomeRecordT(
- tlv.NewRecordT[tlv.TlvType1, Sig](sig),
- )
- }
+ // Randomly decide between regular sigs and taproot sigs
+ useTaprootSigs := rapid.Bool().Draw(t, "useTaprootSigs")
+
+ if useTaprootSigs {
+ // For taproot channels, use PartialSigWithNonce
+ if includeCloserNoClosee {
+ partialSig := *RandPartialSig(t)
+ nonce := RandMusig2Nonce(t)
+ msg.TaprootClosingSigs.CloserNoClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType5, PartialSigWithNonce](
+ PartialSigWithNonce{
+ PartialSig: partialSig,
+ Nonce: nonce,
+ },
+ ),
+ )
+ }
- if includeNoCloserClosee {
- sig := RandSignature(t)
- msg.NoCloserClosee = tlv.SomeRecordT(
- tlv.NewRecordT[tlv.TlvType2, Sig](sig),
- )
- }
+ if includeNoCloserClosee {
+ partialSig := *RandPartialSig(t)
+ nonce := RandMusig2Nonce(t)
+ msg.TaprootClosingSigs.NoCloserClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType6, PartialSigWithNonce](
+ PartialSigWithNonce{
+ PartialSig: partialSig,
+ Nonce: nonce,
+ },
+ ),
+ )
+ }
- if includeCloserAndClosee {
- sig := RandSignature(t)
- msg.CloserAndClosee = tlv.SomeRecordT(
- tlv.NewRecordT[tlv.TlvType3, Sig](sig),
- )
+ if includeCloserAndClosee {
+ partialSig := *RandPartialSig(t)
+ nonce := RandMusig2Nonce(t)
+ msg.TaprootClosingSigs.CloserAndClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType7, PartialSigWithNonce](
+ PartialSigWithNonce{
+ PartialSig: partialSig,
+ Nonce: nonce,
+ },
+ ),
+ )
+ }
+ } else {
+ // For non-taproot channels, use regular signatures
+ if includeCloserNoClosee {
+ sig := RandSignature(t)
+ msg.ClosingSigs.CloserNoClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType1, Sig](sig),
+ )
+ }
+
+ if includeNoCloserClosee {
+ sig := RandSignature(t)
+ msg.ClosingSigs.NoCloserClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType2, Sig](sig),
+ )
+ }
+
+ if includeCloserAndClosee {
+ sig := RandSignature(t)
+ msg.ClosingSigs.CloserAndClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType3, Sig](sig),
+ )
+ }
}
return msg
@@ -698,7 +744,13 @@ func (c *ClosingSig) RandTestMessage(t *rapid.T) Message {
ChannelID: RandChannelID(t),
CloseeScript: RandDeliveryAddress(t),
CloserScript: RandDeliveryAddress(t),
- ExtraData: RandExtraOpaqueData(t, nil),
+ FeeSatoshis: btcutil.Amount(rapid.Int64Range(0, 1000000).Draw(
+ t, "feeSatoshis"),
+ ),
+ LockTime: rapid.Uint32Range(0, 0xffffffff).Draw(
+ t, "lockTime",
+ ),
+ ExtraData: RandExtraOpaqueData(t, nil),
}
includeCloserNoClosee := rapid.Bool().Draw(t, "includeCloserNoClosee")
@@ -721,25 +773,53 @@ func (c *ClosingSig) RandTestMessage(t *rapid.T) Message {
}
}
- if includeCloserNoClosee {
- sig := RandSignature(t)
- msg.CloserNoClosee = tlv.SomeRecordT(
- tlv.NewRecordT[tlv.TlvType1, Sig](sig),
- )
- }
+ // Randomly decide between regular sigs and taproot sigs
+ useTaprootSigs := rapid.Bool().Draw(t, "useTaprootSigs")
- if includeNoCloserClosee {
- sig := RandSignature(t)
- msg.NoCloserClosee = tlv.SomeRecordT(
- tlv.NewRecordT[tlv.TlvType2, Sig](sig),
- )
- }
+ if useTaprootSigs {
+ // For taproot channels in ClosingSig, use just PartialSig (no nonce)
+ if includeCloserNoClosee {
+ partialSig := *RandPartialSig(t)
+ msg.TaprootPartialSigs.CloserNoClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType5, PartialSig](partialSig),
+ )
+ }
- if includeCloserAndClosee {
- sig := RandSignature(t)
- msg.CloserAndClosee = tlv.SomeRecordT(
- tlv.NewRecordT[tlv.TlvType3, Sig](sig),
- )
+ if includeNoCloserClosee {
+ partialSig := *RandPartialSig(t)
+ msg.TaprootPartialSigs.NoCloserClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType6, PartialSig](partialSig),
+ )
+ }
+
+ if includeCloserAndClosee {
+ partialSig := *RandPartialSig(t)
+ msg.TaprootPartialSigs.CloserAndClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType7, PartialSig](partialSig),
+ )
+ }
+ } else {
+ // For non-taproot channels, use regular signatures
+ if includeCloserNoClosee {
+ sig := RandSignature(t)
+ msg.ClosingSigs.CloserNoClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType1, Sig](sig),
+ )
+ }
+
+ if includeNoCloserClosee {
+ sig := RandSignature(t)
+ msg.ClosingSigs.NoCloserClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType2, Sig](sig),
+ )
+ }
+
+ if includeCloserAndClosee {
+ sig := RandSignature(t)
+ msg.ClosingSigs.CloserAndClosee = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType3, Sig](sig),
+ )
+ }
}
return msg
Why this scored 46/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.