diff --git a/itest/lnd_coop_close_rbf_test.go b/itest/lnd_coop_close_rbf_test.go index 5f8b15d4054..5b2a10c5670 100644 --- a/itest/lnd_coop_close_rbf_test.go +++ b/itest/lnd_coop_close_rbf_test.go @@ -1,38 +1,24 @@ package itest import ( + "testing" + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/node" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) -func testCoopCloseRbf(ht *lntest.HarnessTest) { - rbfCoopFlags := []string{"--protocol.rbf-coop-close"} - - // Set the fee estimate to 1sat/vbyte. This ensures that our manually - // initiated RBF attempts will always be successful. - ht.SetFeeEstimate(250) - ht.SetFeeEstimateWithConf(250, 6) +// rbfTestCase encapsulates the parameters and logic for a single RBF coop close test run. +func runRbfCoopCloseTest(st *lntest.HarnessTest, alice, bob *node.HarnessNode, + chanPoint *lnrpc.ChannelPoint, isTaproot bool) { - // To kick things off, we'll create two new nodes, then fund them with - // enough coins to make a 50/50 channel. - cfgs := [][]string{rbfCoopFlags, rbfCoopFlags} - params := lntest.OpenChannelParams{ - Amt: btcutil.Amount(1000000), - PushAmt: btcutil.Amount(1000000 / 2), - } - chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params) - alice, bob := nodes[0], nodes[1] - chanPoint := chanPoints[0] - - // Now that both sides are active with a funded channel, we can kick - // off the test. - // // To start, we'll have Alice try to close the channel, with a fee rate // of 5 sat/byte. aliceFeeRate := chainfee.SatPerVByte(5) - aliceCloseStream, aliceCloseUpdate := ht.CloseChannelAssertPending( + aliceCloseStream, aliceCloseUpdate := st.CloseChannelAssertPending( alice, chanPoint, false, lntest.WithCoopCloseFeeRate(aliceFeeRate), lntest.WithLocalTxNotify(), @@ -40,62 +26,71 @@ func testCoopCloseRbf(ht *lntest.HarnessTest) { // Confirm that this new update was at 5 sat/vb. alicePendingUpdate := aliceCloseUpdate.GetClosePending() - require.NotNil(ht, aliceCloseUpdate) + require.NotNil(st, aliceCloseUpdate) require.Equal( - ht, int64(aliceFeeRate), alicePendingUpdate.FeePerVbyte, + st, int64(aliceFeeRate), alicePendingUpdate.FeePerVbyte, ) - require.True(ht, alicePendingUpdate.LocalCloseTx) + require.True(st, alicePendingUpdate.LocalCloseTx) // Now, we'll have Bob attempt to RBF the close transaction with a // higher fee rate, double that of Alice's. bobFeeRate := aliceFeeRate * 2 - bobCloseStream, bobCloseUpdate := ht.CloseChannelAssertPending( + bobCloseStream, bobCloseUpdate := st.CloseChannelAssertPending( bob, chanPoint, false, lntest.WithCoopCloseFeeRate(bobFeeRate), lntest.WithLocalTxNotify(), ) // Confirm that this new update was at 10 sat/vb. bobPendingUpdate := bobCloseUpdate.GetClosePending() - require.NotNil(ht, bobCloseUpdate) - require.Equal(ht, bobPendingUpdate.FeePerVbyte, int64(bobFeeRate)) - require.True(ht, bobPendingUpdate.LocalCloseTx) + require.NotNil(st, bobCloseUpdate) + require.Equal(st, bobPendingUpdate.FeePerVbyte, int64(bobFeeRate)) + require.True(st, bobPendingUpdate.LocalCloseTx) var err error // Alice should've also received a similar update that Bob has // increased the closing fee rate to 10 sat/vb with his settled funds. - aliceCloseUpdate, err = ht.ReceiveCloseChannelUpdate(aliceCloseStream) - require.NoError(ht, err) + aliceCloseUpdate, err = st.ReceiveCloseChannelUpdate(aliceCloseStream) + require.NoError(st, err) alicePendingUpdate = aliceCloseUpdate.GetClosePending() - require.NotNil(ht, aliceCloseUpdate) - require.Equal(ht, alicePendingUpdate.FeePerVbyte, int64(bobFeeRate)) - require.False(ht, alicePendingUpdate.LocalCloseTx) + require.NotNil(st, aliceCloseUpdate) + + // For taproot channels, due to different witness sizes, the fee per vbyte + // might be slightly different due to rounding when converting between + // absolute fee and fee per vbyte. + if isTaproot { + // Allow for a small difference in fee calculation for taproot + require.InDelta(st, int64(bobFeeRate), alicePendingUpdate.FeePerVbyte, 1) + } else { + require.Equal(st, alicePendingUpdate.FeePerVbyte, int64(bobFeeRate)) + } + require.False(st, alicePendingUpdate.LocalCloseTx) // We'll now attempt to make a fee update that increases Alice's fee // rate by 6 sat/vb, which should be rejected as it is too small of an // increase for the RBF rules. The RPC API however will return the new // fee. We'll skip the mempool check here as it won't make it in. aliceRejectedFeeRate := aliceFeeRate + 1 - _, aliceCloseUpdate = ht.CloseChannelAssertPending( + _, aliceCloseUpdate = st.CloseChannelAssertPending( alice, chanPoint, false, lntest.WithCoopCloseFeeRate(aliceRejectedFeeRate), lntest.WithLocalTxNotify(), lntest.WithSkipMempoolCheck(), ) alicePendingUpdate = aliceCloseUpdate.GetClosePending() - require.NotNil(ht, aliceCloseUpdate) + require.NotNil(st, aliceCloseUpdate) require.Equal( - ht, alicePendingUpdate.FeePerVbyte, + st, alicePendingUpdate.FeePerVbyte, int64(aliceRejectedFeeRate), ) - require.True(ht, alicePendingUpdate.LocalCloseTx) + require.True(st, alicePendingUpdate.LocalCloseTx) - _, err = ht.ReceiveCloseChannelUpdate(bobCloseStream) - require.NoError(ht, err) + _, err = st.ReceiveCloseChannelUpdate(bobCloseStream) + require.NoError(st, err) // We'll now attempt a fee update that we can't actually pay for. This // will actually show up as an error to the remote party. aliceRejectedFeeRate = 100_000 - _, _ = ht.CloseChannelAssertPending( + _, _ = st.CloseChannelAssertPending( alice, chanPoint, false, lntest.WithCoopCloseFeeRate(aliceRejectedFeeRate), lntest.WithLocalTxNotify(), @@ -104,32 +99,91 @@ func testCoopCloseRbf(ht *lntest.HarnessTest) { // At this point, we'll have Alice+Bob reconnect so we can ensure that // we can continue to do RBF bumps even after a reconnection. - ht.DisconnectNodes(alice, bob) - ht.ConnectNodes(alice, bob) + st.DisconnectNodes(alice, bob) + st.ConnectNodes(alice, bob) // Next, we'll have Alice double that fee rate again to 20 sat/vb. aliceFeeRate = bobFeeRate * 2 - aliceCloseStream, aliceCloseUpdate = ht.CloseChannelAssertPending( + aliceCloseStream, aliceCloseUpdate = st.CloseChannelAssertPending( alice, chanPoint, false, lntest.WithCoopCloseFeeRate(aliceFeeRate), lntest.WithLocalTxNotify(), ) alicePendingUpdate = aliceCloseUpdate.GetClosePending() - require.NotNil(ht, aliceCloseUpdate) + require.NotNil(st, aliceCloseUpdate) require.Equal( - ht, alicePendingUpdate.FeePerVbyte, int64(aliceFeeRate), + st, alicePendingUpdate.FeePerVbyte, int64(aliceFeeRate), ) - require.True(ht, alicePendingUpdate.LocalCloseTx) + require.True(st, alicePendingUpdate.LocalCloseTx) // To conclude, we'll mine a block which should now confirm Alice's // version of the coop close transaction. - block := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + block := st.MineBlocksAndAssertNumTxes(1, 1)[0] // Both Alice and Bob should trigger a final close update to signal the // closing transaction has confirmed. - aliceClosingTxid := ht.WaitForChannelCloseEvent(aliceCloseStream) - ht.AssertTxInBlock(block, aliceClosingTxid) + aliceClosingTxid := st.WaitForChannelCloseEvent(aliceCloseStream) + st.AssertTxInBlock(block, aliceClosingTxid) +} + +func testCoopCloseRbf(ht *lntest.HarnessTest) { + // Test with different channel types including taproot + channelTypes := []struct { + name string + commitType lnrpc.CommitmentType + }{ + { + name: "anchors", + commitType: lnrpc.CommitmentType_ANCHORS, + }, + { + name: "taproot", + commitType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + }, + } + + for _, chanType := range channelTypes { + chanType := chanType + ht.Run(chanType.name, func(t1 *testing.T) { + st := ht.Subtest(t1) + // Set the fee estimate to 1sat/vbyte. This ensures that + // our manually initiated RBF attempts will always be + // successful. + st.SetFeeEstimate(250) + st.SetFeeEstimateWithConf(250, 6) + + // Build node config with commitment type args and RBF + // flag. + baseArgs := lntest.NodeArgsForCommitType(chanType.commitType) + nodeArgs := append(baseArgs, "--protocol.rbf-coop-close") + cfgs := [][]string{nodeArgs, nodeArgs} + + // For taproot channels, we need to make them private. + isTaproot := chanType.commitType == + lnrpc.CommitmentType_SIMPLE_TAPROOT + + params := lntest.OpenChannelParams{ + Amt: btcutil.Amount(1000000), + PushAmt: btcutil.Amount(1000000 / 2), + CommitmentType: chanType.commitType, + Private: isTaproot, + } + + // Create network with Alice -> Bob channel, then use + // that to run the RBF coop close test. + chanPoints, nodes := st.CreateSimpleNetwork( + cfgs, params, + ) + alice, bob := nodes[0], nodes[1] + chanPoint := chanPoints[0] + + runRbfCoopCloseTest(st, alice, bob, chanPoint, isTaproot) + + st.Shutdown(alice) + st.Shutdown(bob) + }) + } } // testRBFCoopCloseDisconnect tests that when a node disconnects that the node diff --git a/lncfg/protocol.go b/lncfg/protocol.go index 3c5220d72a5..71b81fe75e1 100644 --- a/lncfg/protocol.go +++ b/lncfg/protocol.go @@ -37,7 +37,7 @@ type ProtocolOptions struct { // RbfCoopClose should be set if we want to signal that we support for // the new experimental RBF coop close feature. - RbfCoopClose bool `long:"rbf-coop-close" description:"if set, then lnd will signal that it supports the new RBF based coop close protocol, taproot channels are not supported"` + RbfCoopClose bool `long:"rbf-coop-close" description:"if set, then lnd will signal that it supports the new RBF based coop close protocol"` // NoAnchors should be set if we don't want to support opening or accepting // channels having the anchor commitment type. diff --git a/lnwallet/chancloser/chancloser_test.go b/lnwallet/chancloser/chancloser_test.go index f7bdc74b4f3..d002448115d 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_close.md b/lnwallet/chancloser/rbf_close.md index ac532c5d50c..a34a6da7a47 100644 --- a/lnwallet/chancloser/rbf_close.md +++ b/lnwallet/chancloser/rbf_close.md @@ -178,6 +178,43 @@ The `CloseErr` state provides recovery paths when protocol violations occur: Recovery typically involves restarting the negotiation with a new closing offer. +### RBF Nonce Flow Example + +Here's how nonces flow through an RBF cooperative close with taproot: + +1. **Initial Shutdown**: + - Alice sends `shutdown` with her closee nonce `NA` + - Bob sends `shutdown` with his closee nonce `NB` + +2. **First Close Attempt** (Alice as closer): + - Alice sends `closing_complete`: + - Uses Bob's closee nonce NB (from his shutdown) as the closee nonce + - Generates her own closer nonce NC locally + - Signs with aggregate nonce R = NB + NC + - Includes `PartialSigWithNonce` = partial_sig (32 bytes) + closer nonce NC (66 bytes) + - Bob sends `closing_sig`: + - Extracts Alice's closer nonce NC from `PartialSigWithNonce` + - Uses his own closee nonce NB (from his shutdown) + - Signs with aggregate nonce R = NC + NB + - Includes `PartialSig` (32 bytes) + `NextCloseeNonce` NB2 for future RBF + +3. **RBF Iteration** (Bob as closer): + - Bob sends `closing_complete`: + - Uses Alice's next closee nonce NA2 (from her previous `NextCloseeNonce`) as closee nonce + - Generates his own closer nonce NC2 locally + - Signs with aggregate nonce R = NA2 + NC2 + - Includes `PartialSigWithNonce` = partial_sig + closer nonce NC2 + - Alice sends `closing_sig`: + - Extracts Bob's closer nonce NC2 from `PartialSigWithNonce` + - Uses her own closee nonce NA2 (from her previous `NextCloseeNonce`) + - Signs with aggregate nonce R = NC2 + NA2 + - Includes `PartialSig` + `NextCloseeNonce` NA3 for future RBF + +The pattern continues: the closer always uses the peer's closee nonce (from +shutdown or previous NextCloseeNonce) combined with a fresh local closer nonce. +The closee extracts the closer nonce from PartialSigWithNonce and combines it +with their own closee nonce. + ## Example Scenarios ### Standard Cooperative Close @@ -211,9 +248,130 @@ Recovery typically involves restarting the negotiation with a new closing offer. 5. When agreement is reached on new fees: `ClosePending` → `CloseFin` (via `txn_confirmation`) +## Taproot Channel Support + +### MuSig2 Nonce Handling + +For taproot channels, the cooperative close process requires coordination for +MuSig2 signature creation using a JIT (Just-In-Time) nonce pattern: + +#### Nonce Exchange During Shutdown + +For taproot channels using the modern RBF cooperative close flow: +- The `shutdown` message includes a single nonce field: + - `shutdown_nonce` (TLV type 8): The sender's "closee nonce" used when they + send `closing_sig` +- This simplified approach works because nonces are sent JIT with signatures + +#### JIT (Just-In-Time) Nonce Pattern + +The protocol uses an asymmetric signature pattern for taproot channels that +optimizes nonce delivery: + +**Asymmetric Roles**: +- **Closer**: The party proposing a fee (sends `closing_complete`) +- **Closee**: The party accepting the fee (sends `closing_sig`) + +**ClosingComplete (from Closer)**: +- Uses `PartialSigWithNonce` (98 bytes total): + - The partial signature (32 bytes) + - The sender's closer nonce (66 bytes) +- Bundles the closer nonce because the closee hasn't seen it yet +- TLV types 5, 6, 7 (distinct from non-taproot types 1, 2, 3) + +**ClosingSig (from Closee)**: +- Uses `PartialSig` (32 bytes) + separate `NextCloseeNonce`: + - The partial signature in TLV types 5, 6, 7 + - The next closee nonce in TLV type 22 (66 bytes) +- Separates the nonce because the closer already knows the current nonce from + shutdown or previous `PartialSigWithNonce` + +This asymmetric pattern minimizes redundancy while ensuring both parties always +have the nonces they need for signing. + +#### Nonce State Management + +The state machine maintains a simplified `NonceState` structure with only 2 fields: +- `LocalCloseeNonce`: Our closee nonce sent in our shutdown message +- `RemoteCloseeNonce`: The peer's closee nonce from their shutdown message + +The JIT pattern eliminates complex nonce rotation: +- New nonces arrive with signatures, not pre-generated +- Remote nonces are updated automatically from `PartialSigWithNonce` in + `closing_complete` +- Local nonces are generated on-demand when creating signatures + +### Wire Message Extensions + +The following messages have been extended with optional TLV fields for taproot: + +**shutdown**: +- Type 8: `shutdown_nonce` - Sender's closee nonce for cooperative close signing + +**closing_complete**: +- Types 5, 6, 7: `PartialSigWithNonce` - Partial signature with embedded closer nonce + - Type 5: `closer_no_closee` (closer has output, closee is dust) + - Type 6: `no_closer_closee` (closer is dust, closee has output) + - Type 7: `closer_and_closee` (both have outputs) + +**closing_sig**: +- Types 5, 6, 7: `PartialSig` - Just the partial signature (32 bytes) + - Same TLV type meanings as above +- Type 22: `NextCloseeNonce` - Next closee nonce for RBF iterations (66 bytes) + +### Validation Requirements + +For taproot channels: +- Shutdown messages MUST include the sender's closee nonce +- ClosingComplete messages MUST use PartialSigWithNonce (includes next nonce + bundled with signature) +- ClosingSig messages MUST use PartialSig with separate NextCloseeNonce field +- Terminal offers (final RBF attempts) MAY omit next nonces to signal finality + +### Implementation Notes for Nonce Handling + +The MuSig2 session's `InitRemoteNonce` method is called at specific times +depending on our role: + +**When we're the Closer (LocalMusigSession)**: +1. During `ShutdownReceived`: Store their closee nonce in `NonceState.RemoteCloseeNonce` +2. During `SendOfferEvent`: Call `initLocalMusigCloseeNonce` with stored closee nonce +3. During `LocalSigReceived` (when receiving their ClosingSig): + - Use the CURRENT `NonceState.RemoteCloseeNonce` for signature verification + - AFTER verification succeeds, update with `NextCloseeNonce` for future RBF + +**When we're the Closee (RemoteMusigSession)**: +1. Our closee nonce was sent in our `shutdown` message +2. During `OfferReceivedEvent`: Receive their JIT closer nonce in `ClosingComplete` +3. Call `initRemoteMusigCloserNonce` with their closer nonce before signing + +**Critical Ordering Requirement**: The `NextCloseeNonce` from `ClosingSig` must NOT +be applied until AFTER the current signature verification completes. Premature +rotation causes signature combination failure. + +The nonce from `PartialSigWithNonce` in `closing_complete` is stored but not +immediately used with `InitRemoteNonce` - it's used when we need to sign as the +closee in the next round. + +### Helper Function Reference + +The following helper functions manage nonce initialization: + +| Function | Session | Sets | Called When | +|----------|---------|------|-------------| +| `initLocalMusigCloseeNonce` | LocalMusigSession | Remote's closee nonce | We're closer, preparing to sign | +| `initRemoteMusigCloserNonce` | RemoteMusigSession | Remote's closer nonce | We're closee, received ClosingComplete | + +Note: The function names now correctly reflect what nonce is being set: +- `initLocalMusigCloseeNonce`: Sets remote's **closee** nonce (from their shutdown) +- `initRemoteMusigCloserNonce`: Sets remote's **closer** nonce (from their JIT nonce in ClosingComplete) + ## Implementation Notes -- This state machine is implemented in the `peer.go` and `channel.go` files -within the lnd codebase +- This state machine is implemented in `rbf_coop_transitions.go` and + `rbf_coop_states.go` within the `lnwallet/chancloser` package +- The `MusigChanCloser` adapter in `peer/musig_chan_closer.go` implements the + `MusigSession` interface for managing MuSig2 nonces - State transitions are logged at the debug level - The `ChanCloser` interface manages the state machine execution +- Taproot support requires the `MusigSession` interface for nonce coordination diff --git a/lnwallet/chancloser/rbf_coop_msg_mapper.go b/lnwallet/chancloser/rbf_coop_msg_mapper.go index a66cf78cc70..1141e36e643 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 5b549a249f8..c1d4344f0d8 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 58b02f6eafe..4b21e17b3cf 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" @@ -52,6 +55,16 @@ var ( 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) @@ -181,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. @@ -425,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() { @@ -462,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) @@ -558,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) @@ -572,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) } }) @@ -630,7 +707,23 @@ 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( @@ -673,6 +766,75 @@ func (r *rbfCloserTestHarness) assertSingleRbfIteration( r.assertLocalClosePending() } +// 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, @@ -717,6 +879,62 @@ func (r *rbfCloserTestHarness) assertSingleRemoteRbfIteration( require.Equal(r.T, closeTx, pendingState.CloseTx) } +// TestSelectTaprootPartialSigWithNonce tests the selection logic for taproot +// partial signatures with nonces. +func TestSelectTaprootPartialSigWithNonce(t *testing.T) { + var ( + nonceNoClosee lnwire.Musig2Nonce + nonceWithClosee lnwire.Musig2Nonce + nonceNoCloser lnwire.Musig2Nonce + emptyPartialSig lnwire.PartialSig + closerNoCloseePS lnwire.PartialSigWithNonce + withCloseePS lnwire.PartialSigWithNonce + noCloserPS lnwire.PartialSigWithNonce + ) + + nonceNoClosee[0] = 0x01 + nonceWithClosee[0] = 0x02 + nonceNoCloser[0] = 0x03 + + closerNoCloseePS = lnwire.PartialSigWithNonce{ + PartialSig: emptyPartialSig, + Nonce: nonceNoClosee, + } + withCloseePS = lnwire.PartialSigWithNonce{ + PartialSig: emptyPartialSig, + Nonce: nonceWithClosee, + } + noCloserPS = lnwire.PartialSigWithNonce{ + PartialSig: emptyPartialSig, + Nonce: nonceNoCloser, + } + + sigsBoth := lnwire.TaprootClosingSigs{ + CloserNoClosee: newPartialSigWithNonceTlv[tlv.TlvType5]( + closerNoCloseePS, + ), + CloserAndClosee: newPartialSigWithNonceTlv[tlv.TlvType7]( + withCloseePS, + ), + } + + selected, err := selectTaprootPartialSigWithNonce(sigsBoth, false) + require.NoError(t, err) + require.Equal(t, nonceWithClosee, selected.Nonce) + + selected, err = selectTaprootPartialSigWithNonce(sigsBoth, true) + require.NoError(t, err) + require.Equal(t, nonceNoClosee, selected.Nonce) + + sigsNoCloser := lnwire.TaprootClosingSigs{ + NoCloserClosee: newPartialSigWithNonceTlv[tlv.TlvType6](noCloserPS), + } + + selected, err = selectTaprootPartialSigWithNonce(sigsNoCloser, false) + require.NoError(t, err) + require.Equal(t, nonceNoCloser, selected.Nonce) +} + func assertStateT[T ProtocolState](h *rbfCloserTestHarness) T { h.T.Helper() @@ -778,6 +996,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 @@ -830,113 +1057,190 @@ func newCloser(t *testing.T, cfg *harnessCfg) *rbfCloserTestHarness { return chanCloser } -// TestRbfChannelActiveTransitions tests the transitions of from the -// ChannelActive state. -func TestRbfChannelActiveTransitions(t *testing.T) { - ctx := context.Background() - localAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x01}, 20)) - remoteAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x02}, 20)) +// testInitiatorShutdownRecvOkNonTap tests the initiator shutdown received +// scenario for non-taproot channels in the ShutdownPending state. +func testInitiatorShutdownRecvOkNonTap(t *testing.T, ctx context.Context, + startingState *ShutdownPending) { - feeRate := chainfee.SatPerVByte(1000) + t.Run("non_taproot", func(t *testing.T) { + firstState := *startingState + firstState.IdealFeeRate = fn.Some( + chainfee.FeePerKwFloor.FeePerVByte(), + ) + firstState.ShutdownScripts = ShutdownScripts{ + LocalDeliveryScript: localAddr, + RemoteDeliveryScript: remoteAddr, + } - // Test that if a spend event is received, the FSM transitions to the - // CloseFin terminal state. - t.Run("spend_event", func(t *testing.T) { - closeHarness := newCloser(t, &harnessCfg{ - localUpfrontAddr: fn.Some(localAddr), - }) + cfg := &harnessCfg{ + initialState: fn.Some[ProtocolState]( + &firstState, + ), + localUpfrontAddr: fn.Some(localAddr), + remoteUpfrontAddr: fn.Some(remoteAddr), + } + + closeHarness := newCloser(t, cfg) defer closeHarness.stopAndAssert() - closeHarness.chanCloser.SendEvent(ctx, &SpendEvent{}) + // We should disable the outgoing adds for the channel at this + // point as well. + closeHarness.expectFinalBalances(fn.None[ShutdownBalances]()) + closeHarness.expectIncomingAddsDisabled() - closeHarness.assertStateTransitions(&CloseFin{}) - }) + // Create shutdown event. + shutdownEvent := &ShutdownReceived{ + ShutdownScript: remoteAddr, + } - // If we send in a local shutdown event, but fail to get an addr, the - // state machine should terminate. - t.Run("local_initiated_close_addr_fail", func(t *testing.T) { - closeHarness := newCloser(t, &harnessCfg{}) - defer closeHarness.stopAndAssert() + // We'll send in a shutdown received event, with the expected + // co-op close addr. + closeHarness.chanCloser.SendEvent(ctx, shutdownEvent) - closeHarness.failNewAddrFunc() + // We should transition to the channel flushing state. + closeHarness.assertStateTransitions(&ChannelFlushing{}) - // We don't specify an upfront shutdown addr, and don't specify - // on here in the vent, so we should call new addr, but then - // fail. - closeHarness.sendEventAndExpectFailure( - ctx, &SendShutdown{}, errfailAddr, + // 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, ) - closeHarness.assertNoStateTransitions() }) +} - // Initiating the shutdown should have us transition to the shutdown - // pending state. We should also emit events to disable the channel, - // and also send a message to our target peer. - t.Run("local_initiated_close_ok", func(t *testing.T) { - closeHarness := newCloser(t, &harnessCfg{ - localUpfrontAddr: fn.Some(localAddr), - }) +// testInitiatorShutdownRecvOkTaproot tests the initiator shutdown received +// scenario for taproot channels in the ShutdownPending state. +func testInitiatorShutdownRecvOkTaproot(t *testing.T, ctx context.Context, + startingState *ShutdownPending) { + + t.Run("taproot", func(t *testing.T) { + firstState := *startingState + firstState.IdealFeeRate = fn.Some( + chainfee.FeePerKwFloor.FeePerVByte(), + ) + firstState.ShutdownScripts = ShutdownScripts{ + LocalDeliveryScript: localAddr, + RemoteDeliveryScript: remoteAddr, + } + + localCloseeNonce := lnwire.Musig2Nonce{1, 2, 3} + remoteCloseeNonce := lnwire.Musig2Nonce{4, 5, 6} + + 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), + localMusigSession: fn.Some[MusigSession]( + mockLocalMusig, + ), + remoteMusigSession: fn.Some[MusigSession]( + mockRemoteMusig, + ), + } + + closeHarness := newCloser(t, cfg) defer closeHarness.stopAndAssert() - // Once we send the event below, we should get calls to the - // chan observer, the msg sender, and the link control. - closeHarness.expectShutdownEvents(shutdownExpect{ - isInitiator: true, - allowSend: true, - }) - closeHarness.expectMsgSent(nil) + // We should disable the outgoing adds for the channel at this + // point as well. + closeHarness.expectFinalBalances(fn.None[ShutdownBalances]()) + closeHarness.expectIncomingAddsDisabled() - // If we send the shutdown event, we should transition to the - // shutdown pending state. - closeHarness.chanCloser.SendEvent( - ctx, &SendShutdown{IdealFeeRate: feeRate}, - ) - closeHarness.assertStateTransitions(&ShutdownPending{}) + // Create shutdown event with nonce for taproot channel. + shutdownEvent := &ShutdownReceived{ + ShutdownScript: remoteAddr, + RemoteShutdownNonce: fn.Some( + remoteCloseeNonce, + ), + } - // If we examine the internal state, it should be consistent - // with the fee+addr we sent in. - currentState := assertStateT[*ShutdownPending](closeHarness) + // 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, feeRate, currentState.IdealFeeRate.UnsafeFromSome(), + t, localAddr, currentState.LocalDeliveryScript, ) require.Equal( - t, localAddr, - currentState.ShutdownScripts.LocalDeliveryScript, + t, remoteAddr, currentState.RemoteDeliveryScript, + ) + require.Equal( + t, firstState.IdealFeeRate, currentState.IdealFeeRate, ) - // Wait till the msg has been sent to assert our expectations. - // - // TODO(roasbeef): can use call.WaitFor here? - closeHarness.waitForMsgSent() - }) + // 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{}, + ), + ) - // If the remote party attempts to close, and a thaw height is active, - // but not yet met, then we should fail. - t.Run("remote_initiated_thaw_height_close_fail", func(t *testing.T) { - closeHarness := newCloser(t, &harnessCfg{ - localUpfrontAddr: fn.Some(localAddr), - thawHeight: fn.Some(uint32(100000)), - }) - defer closeHarness.stopAndAssert() + // 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", + ) - // Next, we'll emit the recv event, with the addr of the remote - // party. - event := &ShutdownReceived{ - ShutdownScript: remoteAddr, - BlockHeight: 1, + // 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, } - closeHarness.sendEventAndExpectFailure( - ctx, event, ErrThawHeightNotReached, + require.Equal( + t, expectedRemoteNonce, + mockLocalMusig.remoteNonce, ) }) +} - // 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{ +// testRemoteInitiatedCloseOkNonTap tests the remote initiated close scenario +// for non-taproot channels. +func testRemoteInitiatedCloseOkNonTap(t *testing.T, ctx context.Context) { + t.Run("non_taproot", func(t *testing.T) { + cfg := &harnessCfg{ localUpfrontAddr: fn.Some(localAddr), - }) + } + + closeHarness := newCloser(t, cfg) defer closeHarness.stopAndAssert() // We assert our shutdown events, and also that we eventually @@ -949,11 +1253,14 @@ func TestRbfChannelActiveTransitions(t *testing.T) { recvShutdown: true, }) + // Create shutdown event. + shutdownEvent := &ShutdownReceived{ + ShutdownScript: remoteAddr, + } + // Next, we'll emit the recv event, with the addr of the remote // party. - closeHarness.chanCloser.SendEvent( - ctx, &ShutdownReceived{ShutdownScript: remoteAddr}, - ) + closeHarness.chanCloser.SendEvent(ctx, shutdownEvent) // We should transition to the shutdown pending state. closeHarness.assertStateTransitions(&ShutdownPending{}) @@ -970,6 +1277,234 @@ func TestRbfChannelActiveTransitions(t *testing.T) { currentState.ShutdownScripts.RemoteDeliveryScript, ) }) +} + +// testRemoteInitiatedCloseOkTaproot tests the remote initiated close scenario +// for taproot channels. +func testRemoteInitiatedCloseOkTaproot(t *testing.T, ctx context.Context) { + t.Run("taproot", func(t *testing.T) { + remoteCloseeNonce := lnwire.Musig2Nonce{4, 5, 6} + + mockLocalMusig := newMockMusigSession() + mockRemoteMusig := newMockMusigSession() + + cfg := &harnessCfg{ + localUpfrontAddr: fn.Some(localAddr), + localMusigSession: fn.Some[MusigSession]( + mockLocalMusig, + ), + 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 channel. + shutdownEvent := &ShutdownReceived{ + ShutdownScript: remoteAddr, + 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, + ) + + // 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 := context.Background() + localAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x01}, 20)) + remoteAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x02}, 20)) + + feeRate := chainfee.SatPerVByte(1000) + + // Test that if a spend event is received, the FSM transitions to the + // CloseFin terminal state. + t.Run("spend_event", func(t *testing.T) { + closeHarness := newCloser(t, &harnessCfg{ + localUpfrontAddr: fn.Some(localAddr), + }) + defer closeHarness.stopAndAssert() + + closeHarness.chanCloser.SendEvent(ctx, &SpendEvent{}) + + closeHarness.assertStateTransitions(&CloseFin{}) + }) + + // If we send in a local shutdown event, but fail to get an addr, the + // state machine should terminate. + t.Run("local_initiated_close_addr_fail", func(t *testing.T) { + closeHarness := newCloser(t, &harnessCfg{}) + defer closeHarness.stopAndAssert() + + closeHarness.failNewAddrFunc() + + // We don't specify an upfront shutdown addr, and don't specify + // on here in the vent, so we should call new addr, but then + // fail. + closeHarness.sendEventAndExpectFailure( + ctx, &SendShutdown{}, errfailAddr, + ) + closeHarness.assertNoStateTransitions() + }) + + // Initiating the shutdown should have us transition to the shutdown + // pending state. We should also emit events to disable the channel, + // and also send a message to our target peer. + t.Run("local_initiated_close_ok", func(t *testing.T) { + closeHarness := newCloser(t, &harnessCfg{ + localUpfrontAddr: fn.Some(localAddr), + }) + defer closeHarness.stopAndAssert() + + // Once we send the event below, we should get calls to the + // chan observer, the msg sender, and the link control. + closeHarness.expectShutdownEvents(shutdownExpect{ + isInitiator: true, + allowSend: true, + }) + closeHarness.expectMsgSent(nil) + + // If we send the shutdown event, we should transition to the + // shutdown pending state. + closeHarness.chanCloser.SendEvent( + ctx, &SendShutdown{IdealFeeRate: feeRate}, + ) + closeHarness.assertStateTransitions(&ShutdownPending{}) + + // If we examine the internal state, it should be consistent + // with the fee+addr we sent in. + currentState := assertStateT[*ShutdownPending](closeHarness) + + require.Equal( + t, feeRate, currentState.IdealFeeRate.UnsafeFromSome(), + ) + require.Equal( + t, localAddr, + currentState.ShutdownScripts.LocalDeliveryScript, + ) + + // Wait till the msg has been sent to assert our expectations. + // + // TODO(roasbeef): can use call.WaitFor here? + closeHarness.waitForMsgSent() + }) + + // If the remote party attempts to close, and a thaw height is active, + // but not yet met, then we should fail. + t.Run("remote_initiated_thaw_height_close_fail", func(t *testing.T) { + closeHarness := newCloser(t, &harnessCfg{ + localUpfrontAddr: fn.Some(localAddr), + thawHeight: fn.Some(uint32(100000)), + }) + defer closeHarness.stopAndAssert() + + // Next, we'll emit the recv event, with the addr of the remote + // party. + event := &ShutdownReceived{ + ShutdownScript: remoteAddr, + BlockHeight: 1, + } + closeHarness.sendEventAndExpectFailure( + ctx, event, ErrThawHeightNotReached, + ) + }) + + // 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) { + // Test both non-taproot and taproot channels. + testRemoteInitiatedCloseOkNonTap(t, ctx) + testRemoteInitiatedCloseOkTaproot(t, ctx) + }) + + // 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() + + cfg := &harnessCfg{ + localUpfrontAddr: fn.Some(localAddr), + localMusigSession: fn.Some[MusigSession]( + mockLocalMusig, + ), + remoteMusigSession: fn.Some[MusigSession]( + mockRemoteMusig, + ), + } + + closeHarness := newCloser(t, cfg) + defer closeHarness.stopAndAssert() + + // 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. assertUnknownEventFail(t, &ChannelActive{}) @@ -1031,6 +1566,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. + testInitiatorShutdownRecvOkNonTap(t, ctx, startingState) + testInitiatorShutdownRecvOkTaproot(t, ctx, startingState) + }) + + // 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(), @@ -1040,38 +1583,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() - - // We should disable the outgoing adds for the channel at this - // point as well. - closeHarness.expectFinalBalances(fn.None[ShutdownBalances]()) - closeHarness.expectIncomingAddsDisabled() - - // 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{}) + localMusigSession: fn.Some[MusigSession]( + mockLocalMusig, + ), + remoteMusigSession: fn.Some[MusigSession]( + mockRemoteMusig, + ), + } - // Now we'll ensure that the flushing state has the proper - // co-op close state. - currentState := assertStateT[*ChannelFlushing](closeHarness) + closeHarness := newCloser(t, cfg) + defer closeHarness.stopAndAssert() - 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 @@ -1381,14 +1929,298 @@ func TestRbfChannelFlushingTransitions(t *testing.T) { // Any other event should be ignored. assertUnknownEventFail(t, startingState) - // Sending a Spend event should transition to CloseFin. - assertSpendEventCloseFin(t, startingState) + // Sending a Spend event should transition to CloseFin. + 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, + ) + } + }) } -// 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. func TestRbfCloseClosingNegotiationLocal(t *testing.T) { t.Parallel() ctx := context.Background() @@ -1428,17 +2260,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, ) }) @@ -1578,41 +2406,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, ) }) @@ -1670,6 +2471,89 @@ 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. @@ -1804,42 +2688,27 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) { closeHarness.assertNoStateTransitions() }) - // If everything lines up, then we should be able to do multiple RBF - // 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, - } - + // When both CloserNoClosee AND CloserAndClosee are present (which is + // spec-compliant), the closee should select CloserAndClosee when local + // output is not dust. + t.Run("recv_offer_both_sigs_present", func(t *testing.T) { closeHarness := newCloser(t, &harnessCfg{ - initialState: fn.Some[ProtocolState](firstState), - localUpfrontAddr: fn.Some(localAddr), + initialState: fn.Some[ProtocolState](startingState), }) defer closeHarness.stopAndAssert() - feeOffer := &OfferReceivedEvent{ + // Per BOLT spec, when closee's output is not dust, sender MUST + // send both CloserNoClosee and CloserAndClosee sigs. The + // receiver should select CloserAndClosee. + event := &OfferReceivedEvent{ SigMsg: lnwire.ClosingComplete{ + FeeSatoshis: absoluteFee, CloserScript: remoteAddr, CloseeScript: localAddr, - FeeSatoshis: absoluteFee, - LockTime: 1, ClosingSigs: lnwire.ClosingSigs{ + CloserNoClosee: newSigTlv[tlv.TlvType1]( //nolint:ll + remoteWireSig, + ), CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll remoteWireSig, ), @@ -1847,25 +2716,27 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) { }, } - // 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, + balanceAfterClose := localBalance.ToSatoshis() - absoluteFee + closeHarness.expectRemoteCloseFinalized( + &localSig, &remoteSig, localAddr, remoteAddr, + absoluteFee, balanceAfterClose, false, ) - // 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.chanCloser.SendEvent(ctx, event) - closeHarness.assertNoStateTransitions() + // We should remain in ClosingNegotiation (outer state doesn't + // change when receiving an offer). We also shouldn't have + // errored out. + closeHarness.assertStateTransitions(&ClosingNegotiation{}) + }) + + // If everything lines up, then we should be able to do multiple RBF + // 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) { + // 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, @@ -2061,3 +2932,346 @@ func TestRbfCloseErr(t *testing.T) { // 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), false) + 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", + ) +} + +// strictNonceMusigSession is a mock that enforces the correct ordering: +// InitRemoteNonce must be called before ProposalClosingOpts. +type strictNonceMusigSession struct { + remoteNonceInited bool + remoteNonce musig2.Nonces + + proposalOptsCalledBeforeInit bool +} + +func newStrictNonceMusigSession() *strictNonceMusigSession { + return &strictNonceMusigSession{} +} + +func (m *strictNonceMusigSession) ProposalClosingOpts() ([]lnwallet.ChanCloseOpt, + error) { + + // Track if ProposalClosingOpts was called before InitRemoteNonce. + if !m.remoteNonceInited { + m.proposalOptsCalledBeforeInit = true + return nil, fmt.Errorf("ProposalClosingOpts called before " + + "InitRemoteNonce") + } + + return nil, nil +} + +func (m *strictNonceMusigSession) CombineClosingOpts(localSig, + remoteSig lnwire.PartialSig, +) (input.Signature, input.Signature, []lnwallet.ChanCloseOpt, error) { + + return &lnwallet.MusigPartialSig{}, &lnwallet.MusigPartialSig{}, nil, + nil +} + +func (m *strictNonceMusigSession) InitRemoteNonce(nonce *musig2.Nonces) { + m.remoteNonceInited = true + m.remoteNonce = *nonce +} + +func (m *strictNonceMusigSession) ClosingNonce() (*musig2.Nonces, error) { + return &musig2.Nonces{ + PubNonce: [66]byte{1, 2, 3}, + }, nil +} + +// TestLocalOfferSentNonceInitOrder verifies that when processing a +// LocalSigReceived event in the LocalOfferSent state, the NextCloseeNonce from +// ClosingSig is properly initialized via initLocalMusigCloseeNonce BEFORE +// calling ProposalClosingOpts. This is critical for taproot channels because +// ProposalClosingOpts requires the remote nonce to be set to create a valid +// MuSig2 session. +// +// This test catches the bug where ProposalClosingOpts was called before the +// nonce was initialized, causing "final signature is invalid" errors during +// cooperative close. +func TestLocalOfferSentNonceInitOrder(t *testing.T) { + t.Parallel() + + // Create a strict mock that will fail if ProposalClosingOpts is called + // before InitRemoteNonce. + strictLocalMusig := newStrictNonceMusigSession() + + // The remote's closee nonce from shutdown - this is what should be used + // for the current transaction. + remoteCloseeNonceFromShutdown := lnwire.Musig2Nonce{4, 5, 6} + + // Set up the environment with the strict mock. + closeTerms := &CloseChannelTerms{ + ShutdownScripts: ShutdownScripts{ + LocalDeliveryScript: localAddr, + RemoteDeliveryScript: remoteAddr, + }, + NonceState: NonceState{ + LocalCloseeNonce: fn.Some(lnwire.Musig2Nonce{1, 2, 3}), + RemoteCloseeNonce: fn.Some(remoteCloseeNonceFromShutdown), + }, + ShutdownBalances: ShutdownBalances{ + LocalBalance: lnwire.NewMSatFromSatoshis(500_000), + RemoteBalance: lnwire.NewMSatFromSatoshis(500_000), + }, + } + + // Create the LocalOfferSent state that we'll be testing. + localOfferSent := &LocalOfferSent{ + CloseChannelTerms: closeTerms, + ProposedFee: btcutil.Amount(1000), + ProposedFeeRate: chainfee.FeePerKwFloor.FeePerVByte(), + LocalSig: localSchnorrSig, + } + + // The environment needs LocalMusigSession set for taproot path. + // IsTaproot() returns true when LocalMusigSession is non-nil. + env := &Environment{ + ChanPoint: randOutPoint(t), + LocalMusigSession: strictLocalMusig, + } + + // Create a LocalSigReceived event with NextCloseeNonce. + // This simulates receiving a ClosingSig from the remote party. + nextCloseeNonce := lnwire.Musig2Nonce{10, 11, 12} + localSigEvent := &LocalSigReceived{ + SigMsg: lnwire.ClosingSig{ + CloserScript: localAddr, + CloseeScript: remoteAddr, + FeeSatoshis: btcutil.Amount(1000), + LockTime: 1, + TaprootPartialSigs: lnwire.TaprootPartialSigs{ + CloserAndClosee: newPartialSigTlv[tlv.TlvType7]( + lnwire.PartialSig{ + Sig: btcec.ModNScalar{}, + }, + ), + }, + NextCloseeNonce: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType22](nextCloseeNonce), + ), + }, + } + + // Process the event. If the fix is correct, InitRemoteNonce will be + // called before ProposalClosingOpts. + // + // We expect a panic or error from the later code because we don't have + // a full environment (missing CloseSigner, etc). We use recover to + // catch any panics and still check our assertions. + func() { + defer func() { + // Recover from any panic - we just want to check that + // ProposalClosingOpts was called in the right order. + _ = recover() + }() + + _, _ = localOfferSent.ProcessEvent(localSigEvent, env) + }() + + // The critical assertion: ProposalClosingOpts should NOT have been + // called before InitRemoteNonce. + require.False( + t, strictLocalMusig.proposalOptsCalledBeforeInit, + "ProposalClosingOpts was called before InitRemoteNonce - "+ + "this would cause 'final signature is invalid' errors", + ) + + // Also verify that InitRemoteNonce was actually called. + require.True( + t, strictLocalMusig.remoteNonceInited, + "InitRemoteNonce should have been called", + ) + + // And that it was called with the correct nonce from NonceState + // (established during shutdown), NOT the NextCloseeNonce from + // ClosingSig. + require.Equal( + t, musig2.Nonces{PubNonce: remoteCloseeNonceFromShutdown}, + strictLocalMusig.remoteNonce, + "InitRemoteNonce should be called with RemoteCloseeNonce from "+ + "NonceState (not NextCloseeNonce from ClosingSig)", + ) +} diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go index 3ddd591ad42..01dc944039d 100644 --- a/lnwallet/chancloser/rbf_coop_transitions.go +++ b/lnwallet/chancloser/rbf_coop_transitions.go @@ -5,8 +5,10 @@ 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" @@ -31,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, + } - // We'll emit a daemon event that instructs the daemon to send out a - // new shutdown message to the remote peer. + 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 using the RemoteMusigSession, as that'll set our + // localNonce, we'll receive their remoteNonce for this session + // once we get their ClosingComplete message. + if localCloseeNonce.IsNone() { + 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, + ), + ) + } + } + } + + // 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 { @@ -61,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 @@ -81,21 +123,51 @@ 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) + }) + } +} + +// initRemoteMusigCloserNonce initializes the RemoteMusigSession with the +// remote party's closer nonce. This is called when we receive ClosingComplete +// and we're acting as closee. The nonce passed in is the remote's JIT closer +// nonce from their ClosingComplete message. +func initRemoteMusigCloserNonce(env *Environment, + remoteCloserNonce fn.Option[lnwire.Musig2Nonce]) { + + if env.RemoteMusigSession != nil { + remoteCloserNonce.WhenSome(func(nonce lnwire.Musig2Nonce) { + remoteMusigNonce := musig2.Nonces{PubNonce: nonce} + env.RemoteMusigSession.InitRemoteNonce(&remoteMusigNonce) + }) + } } // validateShutdown is a helper function that validates that the shutdown has a @@ -104,7 +176,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. @@ -126,6 +198,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 { @@ -170,10 +248,10 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, // 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 @@ -191,6 +269,9 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, ShutdownScripts: ShutdownScripts{ LocalDeliveryScript: shutdownScript, }, + NonceState: NonceState{ + LocalCloseeNonce: closeeNonce, + }, }, NewEvents: fn.Some(RbfEvent{ ExternalEvents: daemonEvents, @@ -210,7 +291,7 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, // 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 "+ @@ -235,11 +316,11 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, // 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 @@ -257,12 +338,20 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, 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, @@ -324,7 +413,7 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment, // 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 "+ @@ -347,6 +436,10 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment, 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) @@ -373,6 +466,11 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment, }) } + // 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{ @@ -382,6 +480,7 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment, LocalDeliveryScript: s.LocalDeliveryScript, //nolint:ll RemoteDeliveryScript: msg.ShutdownScript, //nolint:ll }, + NonceState: updatedNonceState, }, NewEvents: newEvents, }, nil @@ -426,6 +525,7 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment, NextState: &ChannelFlushing{ IdealFeeRate: s.IdealFeeRate, ShutdownScripts: s.ShutdownScripts, + NonceState: s.NonceState, }, NewEvents: newEvents, }, nil @@ -483,6 +583,7 @@ func (c *ChannelFlushing) ProcessEvent(event ProtocolEvent, env *Environment, closeTerms := CloseChannelTerms{ ShutdownScripts: c.ShutdownScripts, ShutdownBalances: msg.ShutdownBalances, + NonceState: c.NonceState, } chancloserLog.Infof("ChannelPoint(%v): channel flushed! "+ @@ -608,11 +709,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") + } +} + +// extractSigAndNonce extracts the signature and optional nonce from a +// ClosingSig message. For taproot channels, it extracts both the partial +// signature and the JIT nonce. For non-taproot channels, it extracts just the +// signature. +func extractSigAndNonce(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 extractSigAndNonce(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 { + isTaproot bool) error { assertLocalScriptMatches := func(localScriptInMsg []byte) error { if !bytes.Equal( @@ -696,7 +981,8 @@ func (c *ClosingNegotiation) ProcessEvent(event ProtocolEvent, env *Environment, // 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) } @@ -738,6 +1024,62 @@ 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, @@ -781,19 +1123,83 @@ func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, // 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) @@ -801,49 +1207,37 @@ func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, chancloserLog.Infof("proposing closing_tx=%v", 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 "+ @@ -867,38 +1261,220 @@ func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, 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(), - } - numSigs := fn.Foldl(0, sigInts, func(acc int, sigInt bool) int { - if sigInt { - return acc + 1 +// selectTaprootPartialSigWithNonce selects the PartialSigWithNonce to use from +// TaprootClosingSigs based on whether the closee output is omitted. +func selectTaprootPartialSigWithNonce( + sigs lnwire.TaprootClosingSigs, + noClosee bool) (lnwire.PartialSigWithNonce, error) { + + var ps lnwire.PartialSigWithNonce + + if noClosee { + if sigs.CloserNoClosee.IsNone() { + return ps, ErrCloserNoClosee } - return acc - }) - if numSigs != 1 { - return fn.Errf[lnwire.Sig]("%w: only one sig should be set, "+ - "got %v", ErrTooManySigs, numSigs) + sigs.CloserNoClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) { + ps = p + }) + + return ps, nil + } + + if sigs.CloserAndClosee.IsSome() { + sigs.CloserAndClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) { + ps = p + }) + + return ps, nil } - // The final sig is the one that's actually set. - sig := msg.CloserAndClosee.ValOpt().Alt( - msg.NoCloserClosee.ValOpt(), - ).Alt( - msg.CloserNoClosee.ValOpt(), + if sigs.NoCloserClosee.IsSome() { + sigs.NoCloserClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) { + ps = p + }) + + return ps, nil + } + + return ps, ErrNoSig +} + +// 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 + taprootPartialSigs lnwire.TaprootPartialSigs + nextCloseeNonce tlv.OptionalRecordT[ + tlv.TlvType22, lnwire.Musig2Nonce, + ] ) - return fn.NewResult(sig.UnwrapOrErr(ErrNoSig)) + // 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), + ) + } + + // 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, + ) + } + } + + return &lnwire.ClosingSig{ + ChannelID: env.ChanID, + CloserScript: remoteScript, + CloseeScript: localScript, + FeeSatoshis: fee, + LockTime: lockTime, + ClosingSigs: closingSigs, + TaprootPartialSigs: taprootPartialSigs, + NextCloseeNonce: nextCloseeNonce, + }, nil +} + +// 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 @@ -913,17 +1489,49 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent, env *Environment, // validate the signature from the remote party. If valid, then we can // broadcast the transaction, and transition to the ClosePending state. case *LocalSigReceived: - // Extract and validate that only one sig field is set. - sig, err := extractSig(msg.SigMsg).Unpack() + // Extract and validate that only one sig field is set. For + // taproot channels, we also extract the NextCloseeNonce. + sigResult, nextCloseeNonce := validateAndExtractSigAndNonce( + msg.SigMsg, env.IsTaproot(), + ) + sig, err := sigResult.Unpack() 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 need to initialize the remote's + // closee nonce BEFORE calling ProposalClosingOpts. We use the + // nonce from NonceState (set during shutdown or from prior + // ClosingSig's NextCloseeNonce). + if env.IsTaproot() { + // Initialize the remote nonce from our stored state. + // This is the nonce the remote party committed to in + // their shutdown message (or their previous ClosingSig). + initLocalMusigCloseeNonce(env, l.NonceState.RemoteCloseeNonce) + + // Now that the nonce is initialized, we can safely get + // the musig closing options. + musigOpts, err := env.LocalMusigSession.ProposalClosingOpts() + if err != nil { + return nil, fmt.Errorf("failed to get musig "+ + "closing opts: %w", err) + } + closeOpts = append(closeOpts, musigOpts...) + + // Update NonceState with the new nonce from ClosingSig + // for potential future RBF iterations. + l.NonceState.RemoteCloseeNonce = nextCloseeNonce } - localSig, err := l.LocalSig.ToSignature() + + localSig, remoteSig, err := prepareClosingSignatures( + env, l, msg, sig, closeOpts, + ) if err != nil { return nil, err } @@ -932,9 +1540,7 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent, env *Environment, // 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 @@ -977,6 +1583,311 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent, env *Environment, 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], noClosee bool) (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. + initRemoteMusigCloserNonce(env, jitNonce) + + remotePartialSig, err := selectTaprootPartialSigWithNonce( + msg.TaprootClosingSigs, noClosee, + ) + if err != nil { + return nil, err + } + + // 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 +} + +// SigType represents either a regular or taproot signature. +// Left = regular signature, Right = taproot signature with nonce. +type SigType = fn.Either[lnwire.Sig, lnwire.PartialSigWithNonce] + +// NewRegularSigType creates a SigType for a regular (non-taproot) signature. +func NewRegularSigType(sig lnwire.Sig) SigType { + return fn.NewLeft[lnwire.Sig, lnwire.PartialSigWithNonce](sig) +} + +// NewTaprootSigType creates a SigType for a taproot signature with nonce. +func NewTaprootSigType(ps lnwire.PartialSigWithNonce) SigType { + return fn.NewRight[lnwire.Sig, lnwire.PartialSigWithNonce](ps) +} + +// SigFieldSet represents which signature fields are present in a +// ClosingComplete message. +type SigFieldSet struct { + // CloserNoClosee contains the signature for a transaction with only + // the closer's output (closee's output is dust/excluded). + CloserNoClosee fn.Option[SigType] + + // NoCloserClosee contains the signature for a transaction with only + // the closee's output (closer's output is dust/excluded). + NoCloserClosee fn.Option[SigType] + + // CloserAndClosee contains the signature for a transaction with both + // outputs present. + CloserAndClosee fn.Option[SigType] +} + +// IsTaproot returns true if any taproot signatures are present in the field +// set. +func (s SigFieldSet) IsTaproot() bool { + checkTaproot := func(opt fn.Option[SigType]) bool { + return fn.MapOptionZ(opt, func(sig SigType) bool { + return sig.IsRight() + }) + } + + return checkTaproot(s.CloserNoClosee) || + checkTaproot(s.NoCloserClosee) || + checkTaproot(s.CloserAndClosee) +} + +// HasAnySig returns true if at least one signature field is present. +func (s SigFieldSet) HasAnySig() bool { + return s.CloserNoClosee.IsSome() || + s.NoCloserClosee.IsSome() || + s.CloserAndClosee.IsSome() +} + +// parseSigFields extracts signature fields from a ClosingComplete message and +// returns a structured representation of which fields are present. +func parseSigFields(msg lnwire.ClosingComplete) SigFieldSet { + var fields SigFieldSet + + // createSigType is a helper function that creates a SigType based on + // field otpions. + createSigType := func( + taprootOpt fn.Option[lnwire.PartialSigWithNonce], + regularOpt fn.Option[lnwire.Sig], + ) fn.Option[SigType] { + + // The taproot takes precedence if present. + if taprootOpt.IsSome() { + var ps lnwire.PartialSigWithNonce + taprootOpt.WhenSome(func(p lnwire.PartialSigWithNonce) { + ps = p + }) + + return fn.Some(NewTaprootSigType(ps)) + } + + // Otherwise, check for a regular signature. + if regularOpt.IsSome() { + var sig lnwire.Sig + regularOpt.WhenSome(func(s lnwire.Sig) { + sig = s + }) + + return fn.Some(NewRegularSigType(sig)) + } + + return fn.None[SigType]() + } + + fields.CloserNoClosee = createSigType( + msg.TaprootClosingSigs.CloserNoClosee.ValOpt(), + msg.ClosingSigs.CloserNoClosee.ValOpt(), + ) + + fields.NoCloserClosee = createSigType( + msg.TaprootClosingSigs.NoCloserClosee.ValOpt(), + msg.ClosingSigs.NoCloserClosee.ValOpt(), + ) + + fields.CloserAndClosee = createSigType( + msg.TaprootClosingSigs.CloserAndClosee.ValOpt(), + msg.ClosingSigs.CloserAndClosee.ValOpt(), + ) + + return fields +} + +// validateSigFields validates that the signature field set conforms to BOLT +// spec requirements based on the receiver's (closee's) output dust status. +func validateSigFields(sigFields SigFieldSet, localIsDust bool) error { + // Check if any signature is present at all, if not then this is a + // terminal error. + if !sigFields.HasAnySig() { + return ErrNoSig + } + + // Per BOLT spec for the receiver (closee) of closing_complete: + // + // "Select a signature for validation: + // 1. If the local output amount is dust: MUST use closer_output_only + // (CloserNoClosee). + // 3. Otherwise, if closer_and_closee_outputs is present: MUST use + // closer_and_closee_outputs (CloserAndClosee). + // 4. Otherwise: MUST use closee_output_only (NoCloserClosee)." + // + // We validate that the required signature field is present. + if localIsDust { + // Local output is dust, we need CloserNoClosee. + if sigFields.CloserNoClosee.IsNone() { + return ErrCloserNoClosee + } + } else { + // Local output is not dust, we prefer CloserAndClosee, but can + // fall back to NoCloserClosee per spec step 4. + if sigFields.CloserAndClosee.IsNone() && + sigFields.NoCloserClosee.IsNone() { + + return ErrCloserAndClosee + } + } + + return nil +} + +// selectAndExtractSig selects the appropriate signature field based on BOLT +// spec priority and extracts the signature and nonce. +func selectAndExtractSig(fields SigFieldSet, localIsDust bool) ( + sig lnwire.Sig, nonce fn.Option[lnwire.Musig2Nonce], isNoClosee bool, + err error) { + + // Select which field to use based on BOLT spec priority. + var selectedField fn.Option[SigType] + if localIsDust { + // Spec step 1: Local output is dust, use CloserNoClosee. + selectedField = fields.CloserNoClosee + isNoClosee = true + } else { + // Spec step 3: Prefer CloserAndClosee if present. + if fields.CloserAndClosee.IsSome() { + selectedField = fields.CloserAndClosee + isNoClosee = false + } else { + // Spec step 4: Fallback to NoCloserClosee. + selectedField = fields.NoCloserClosee + isNoClosee = false + } + } + + // If the selected field is none, this is an error. + sigType, err := selectedField.UnwrapOrErr(ErrNoSig) + if err != nil { + return lnwire.Sig{}, fn.None[lnwire.Musig2Nonce](), false, err + } + + // Check if this is a taproot signature (Right side of Either) or + // regular (Left side). + nonce = fn.None[lnwire.Musig2Nonce]() + + // If this is a regular signature, extract it directly. + sigType.WhenLeft(func(regularSig lnwire.Sig) { + sig = regularSig + }) + + // Otherwise, for taproot, extract the partial sig and nonce. + sigType.WhenRight(func(partialSig lnwire.PartialSigWithNonce) { + nonce = fn.Some(partialSig.Nonce) + + sigBytes := partialSig.Sig.Bytes() + copy(sig.RawBytes()[:32], sigBytes[:]) + + sig.ForceSchnorr() + }) + + return sig, nonce, isNoClosee, nil +} + +// extractSigAndNonceFromComplete extracts signature and optional nonce from +// ClosingComplete using a three-phase approach: parse, validate, and select. +// +// This function implements the BOLT spec requirements for the receiver (closee) +// of a closing_complete message. +func extractSigAndNonceFromComplete(msg lnwire.ClosingComplete, + localIsDust bool) (sig lnwire.Sig, nonce fn.Option[lnwire.Musig2Nonce], + isNoClosee bool, err error) { + + // First, parse the message to extract which signature fields are + // present. + fields := parseSigFields(msg) + + // Next, validate that the parsed fields conform to BOLT spec + // requirements based on our (closee's) output dust status. + if err := validateSigFields(fields, localIsDust); err != nil { + return lnwire.Sig{}, fn.None[lnwire.Musig2Nonce](), false, err + } + + // Finally, select and extract the appropriate signature based on BOLT + // spec priority. + sig, nonce, isNoClosee, err = selectAndExtractSig(fields, localIsDust) + if err != nil { + return lnwire.Sig{}, fn.None[lnwire.Musig2Nonce](), false, err + } + + return sig, nonce, isNoClosee, nil +} + // 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 @@ -999,35 +1910,14 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, 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. This function parses, validates, and selects the + // appropriate signature per BOLT spec. + sig, jitNonce, noClosee, err := extractSigAndNonceFromComplete( + msg.SigMsg, l.LocalAmtIsDust(), ) - 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 - }) - - // 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 err != nil { + return nil, err } chanOpts := []lnwallet.ChanCloseOpt{ @@ -1036,10 +1926,43 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, lnwallet.WithCustomPayer(lntypes.Remote), } - chancloserLog.Infof("responding to close w/ local_addr=%x, "+ - "remote_addr=%x, fee=%v", + 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: + // First, process the remote taproot signature which + // initializes the remote nonce via InitRemoteNonce(). + // This must happen before ProposalClosingOpts() which + // requires the nonce to be set. + remoteSig, err = processRemoteTaprootSig( + env, msg.SigMsg, jitNonce, noClosee, + ) + if err != nil { + return nil, err + } + + // Now that the nonce is initialized, get the musig + // closing options. + musigOpts, err := env.RemoteMusigSession.ProposalClosingOpts() + if err != nil { + return nil, fmt.Errorf("failed to get musig "+ + "closing opts: %w", err) + } + chanOpts = append(chanOpts, musigOpts...) + default: + remoteSig, err = sig.ToSignature() + if err != nil { + return nil, err + } + } + + chancloserLog.Infof("RemoteCloseStart: responding to close w/ "+ + "local_addr=%x, remote_addr=%x, fee=%v, locktime=%v", l.LocalDeliveryScript[:], l.RemoteDeliveryScript[:], - msg.SigMsg.FeeSatoshis) + msg.SigMsg.FeeSatoshis, msg.SigMsg.LockTime) // Now that we have the remote sig, we'll sign the version they // signed, then attempt to complete the cooperative close @@ -1047,22 +1970,13 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, // // 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. @@ -1081,15 +1995,13 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, lnutils.SpewLogClosure(closeTx), ) - var closingSigs lnwire.ClosingSigs - if noClosee { - closingSigs.CloserNoClosee = newSigTlv[tlv.TlvType1]( - wireSig, - ) - } else { - closingSigs.CloserAndClosee = newSigTlv[tlv.TlvType3]( - wireSig, - ) + 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 @@ -1102,19 +2014,9 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, 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, @@ -1246,7 +2148,6 @@ func (c *CloseErr) ProcessEvent(event ProtocolEvent, env *Environment, InternalEvent: []ProtocolEvent{msg}, }), }, nil - default: return &CloseStateTransition{ NextState: c, diff --git a/lnwire/closing_complete.go b/lnwire/closing_complete.go index 7980ef1ee18..fd989beceda 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,24 @@ 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? - if val, ok := typeMap[c.CloserNoClosee.TlvType()]; ok && val == nil { c.CloserNoClosee = tlv.SomeRecordT(sig1) } @@ -80,6 +111,16 @@ func decodeClosingSigs(c *ClosingSigs, tlvRecords ExtraOpaqueData) error { c.CloserAndClosee = tlv.SomeRecordT(sig3) } + 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 +143,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 +155,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) }) @@ -127,6 +170,17 @@ func closingSigRecords(c *ClosingSigs) []tlv.RecordProducer { 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 +205,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 94a35606638..58daff6a6fc 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 28df9a4caa2..9715b90f2df 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 213918836f1..b69fb9600f1 100644 --- a/lnwire/test_message.go +++ b/lnwire/test_message.go @@ -620,25 +620,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 @@ -657,7 +703,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") @@ -680,25 +732,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 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 includeNoCloserClosee { - sig := RandSignature(t) - msg.NoCloserClosee = tlv.SomeRecordT( - tlv.NewRecordT[tlv.TlvType2, Sig](sig), - ) - } + if includeNoCloserClosee { + partialSig := *RandPartialSig(t) + msg.TaprootPartialSigs.NoCloserClosee = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType6, PartialSig](partialSig), + ) + } - if includeCloserAndClosee { - sig := RandSignature(t) - msg.CloserAndClosee = tlv.SomeRecordT( - tlv.NewRecordT[tlv.TlvType3, Sig](sig), - ) + 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 diff --git a/peer/brontide.go b/peer/brontide.go index 5d00e14ab9e..a0abfcb8f84 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -1243,17 +1243,14 @@ func (p *Brontide) loadActiveChannels(chans []*channeldb.OpenChannel) ( return nil, err } - isTaprootChan := lnChan.ChanType().IsTaproot() - var ( shutdownMsg fn.Option[lnwire.Shutdown] shutdownInfoErr error ) shutdownInfo.WhenSome(func(info channeldb.ShutdownInfo) { // If we can use the new RBF close feature, we don't - // need to create the legacy closer. However for taproot - // channels, we'll continue to use the legacy closer. - if p.rbfCoopCloseAllowed() && !isTaprootChan { + // need to create the legacy closer. + if p.rbfCoopCloseAllowed() { return } @@ -1330,10 +1327,8 @@ func (p *Brontide) loadActiveChannels(chans []*channeldb.OpenChannel) ( p.activeChannels.Store(chanID, lnChan) // We're using the old co-op close, so we don't need to init - // the new RBF chan closer. If we have a taproot chan, then - // we'll also use the legacy type, so we don't need to make the - // new closer. - if !p.rbfCoopCloseAllowed() || isTaprootChan { + // the new RBF chan closer. + if !p.rbfCoopCloseAllowed() { continue } @@ -3312,8 +3307,6 @@ func chooseDeliveryScript(upfront, requested lnwire.DeliveryAddress, func (p *Brontide) restartCoopClose(lnChan *lnwallet.LightningChannel) ( *lnwire.Shutdown, error) { - isTaprootChan := lnChan.ChanType().IsTaproot() - // If this channel has status ChanStatusCoopBroadcasted and does not // have a closing transaction, then the cooperative close process was // started but never finished. We'll re-create the chanCloser state @@ -3366,8 +3359,8 @@ func (p *Brontide) restartCoopClose(lnChan *lnwallet.LightningChannel) ( // If the new RBF co-op close is negotiated, then we'll init and start // that state machine, skipping the steps for the negotiate machine - // below. We don't support this close type for taproot channels though. - if p.rbfCoopCloseAllowed() && !isTaprootChan { + // below. + if p.rbfCoopCloseAllowed() { _, err := p.initRbfChanCloser(lnChan) if err != nil { return nil, fmt.Errorf("unable to init rbf chan "+ @@ -3910,6 +3903,14 @@ func (p *Brontide) initRbfChanCloser( ), } + // For taproot channels, we need to set both LocalMusigSession and + // RemoteMusigSession to handle nonce exchange during RBF cooperative + // close. + if channel.ChanType().IsTaproot() { + env.LocalMusigSession = NewMusigChanCloser(channel) + env.RemoteMusigSession = NewMusigChanCloser(channel) + } + spendEvent := protofsm.RegisterSpend[chancloser.ProtocolEvent]{ OutPoint: channel.ChannelPoint(), PkScript: channel.FundingTxOut().PkScript, @@ -4221,8 +4222,6 @@ func (p *Brontide) handleLocalCloseReq(req *htlcswitch.ChanClose) { return } - isTaprootChan := channel.ChanType().IsTaproot() - switch req.CloseType { // A type of CloseRegular indicates that the user has opted to close // out this channel on-chain, so we execute the cooperative channel @@ -4235,9 +4234,7 @@ func (p *Brontide) handleLocalCloseReq(req *htlcswitch.ChanClose) { // iteration, in which case we'll be obtaining a new // transaction w/ a higher fee rate. // - // We don't support this close type for taproot channels yet - // however. - case !isTaprootChan && p.rbfCoopCloseAllowed(): + case p.rbfCoopCloseAllowed(): err = p.startRbfChanCloser( newRPCShutdownInit(req), channel.ChannelPoint(), ) @@ -5196,12 +5193,9 @@ func (p *Brontide) addActiveChannel(c *lnpeer.NewChannel) error { "peer", chanPoint) } - isTaprootChan := c.ChanType.IsTaproot() - // We're using the old co-op close, so we don't need to init the new RBF - // chan closer. If this is a taproot channel, then we'll also fall - // through, as we don't support this type yet w/ rbf close. - if !p.rbfCoopCloseAllowed() || isTaprootChan { + // chan closer. + if !p.rbfCoopCloseAllowed() { return nil } diff --git a/peer/musig_nonce_order_test.go b/peer/musig_nonce_order_test.go new file mode 100644 index 00000000000..6105f080689 --- /dev/null +++ b/peer/musig_nonce_order_test.go @@ -0,0 +1,247 @@ +package peer + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwallet/chancloser" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestRemoteCloseStartTaprootIntegration tests the full flow of +// RemoteCloseStart handling a ClosingComplete message with taproot signatures. +// +// This is a regression test for a bug where the remote nonce was not properly +// created before sending over our signature. +func TestRemoteCloseStartTaprootIntegration(t *testing.T) { + t.Parallel() + + chanType := channeldb.SingleFunderTweaklessBit | + channeldb.AnchorOutputsBit | channeldb.SimpleTaprootFeatureBit + + aliceChan, bobChan, err := lnwallet.CreateTestChannels(t, chanType) + require.NoError(t, err) + + // Create TWO SEPARATE MusigChanCloser instances. This is the key to + // exposing the bug - in production where the issue existed, they share one. + localSession := NewMusigChanCloser(aliceChan) + remoteSession := NewMusigChanCloser(bobChan) + + // Initialize local session with nonces (simulating LocalCloseStart path + // during shutdown exchange). + _, err = localSession.ClosingNonce() + require.NoError(t, err) + + // Give local session the remote's closee nonce. + remoteCloseeNonce, err := musig2.GenNonces( + musig2.WithPublicKey( + bobChan.State().LocalChanCfg.MultiSigKey.PubKey, + ), + ) + require.NoError(t, err) + localSession.InitRemoteNonce(remoteCloseeNonce) + + // For remoteSession, generate the Local nonce (simulating what would + // happen during the shutdown exchange when we act as closee). + _, err = remoteSession.ClosingNonce() + require.NoError(t, err) + + // NOTE: remoteSession has its local nonce but NO remote nonce yet. + // This is the setup that exposes the bug. In production, both + // sessions point to the same object, so nonces set via localSession + // would also be visible to remoteSession. Here they are separate. + // + // The bug was that ProcessEvent calls ProposalClosingOpts() BEFORE + // processRemoteTaprootSig() which would initialize the remote nonce. + + // Make some fake shutdown scripts for both sides. + localDeliveryScript := bytes.Repeat([]byte{0x01}, 34) + localDeliveryScript[0] = txscript.OP_1 + localDeliveryScript[1] = txscript.OP_DATA_32 + + remoteDeliveryScript := bytes.Repeat([]byte{0x02}, 34) + remoteDeliveryScript[0] = txscript.OP_1 + remoteDeliveryScript[1] = txscript.OP_DATA_32 + + // Create mocks and other set up configs. + closeSigner := &mockCloseSigner{} + feeEstimator := &mockCoopFeeEstimator{ + absoluteFee: btcutil.Amount(1000), + } + chanObserver := &mockChanObserver{} + peerPub := bobChan.State().IdentityPub + env := chancloser.Environment{ + ChainParams: chaincfg.RegressionNetParams, + ChanPeer: *peerPub, + ChanPoint: aliceChan.ChannelPoint(), + ChanID: lnwire.NewChanIDFromOutPoint( + aliceChan.ChannelPoint(), + ), + ChanType: chanType, + DefaultFeeRate: chainfee.SatPerVByte(10), + FeeEstimator: feeEstimator, + ChanObserver: chanObserver, + CloseSigner: closeSigner, + LocalMusigSession: localSession, + RemoteMusigSession: remoteSession, + } + localBalance := lnwire.NewMSatFromSatoshis(btcutil.Amount(500000000)) + remoteBalance := lnwire.NewMSatFromSatoshis(btcutil.Amount(500000000)) + + // Create RemoteCloseStart state, this is where the state machine will + // start from. + state := &chancloser.RemoteCloseStart{ + CloseChannelTerms: &chancloser.CloseChannelTerms{ + ShutdownScripts: chancloser.ShutdownScripts{ + LocalDeliveryScript: localDeliveryScript, + RemoteDeliveryScript: remoteDeliveryScript, + }, + ShutdownBalances: chancloser.ShutdownBalances{ + LocalBalance: localBalance, + RemoteBalance: remoteBalance, + }, + }, + } + + // Generate a valid JIT nonce for the ClosingComplete message. + // This simulates the remote party's closer nonce. + jitNonce, err := musig2.GenNonces( + musig2.WithPublicKey( + aliceChan.State().LocalChanCfg.MultiSigKey.PubKey, + ), + ) + require.NoError(t, err) + + var dummySig btcec.ModNScalar + dummySig.SetInt(12345) + partialSigWithNonce := lnwire.PartialSigWithNonce{ + PartialSig: lnwire.NewPartialSig(dummySig), + Nonce: jitNonce.PubNonce, + } + + // Create OfferReceivedEvent with taproot sig (ClosingComplete). Since + // both local and remote balances are above dust, we need the + // CloserAndClosee variant. + closingComplete := lnwire.ClosingComplete{ + ChannelID: env.ChanID, + CloserScript: remoteDeliveryScript, + CloseeScript: localDeliveryScript, + FeeSatoshis: btcutil.Amount(1000), + LockTime: 0, + TaprootClosingSigs: lnwire.TaprootClosingSigs{ + CloserAndClosee: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType7]( + partialSigWithNonce, + ), + ), + }, + } + + event := &chancloser.OfferReceivedEvent{ + SigMsg: closingComplete, + } + + // Call ProcessEvent. Before the bug fix, this will fail with: "failed + // to get musig closing opts: remote nonce not generated" + // + // This is because ProposalClosingOpts() is called on line 1932 BEFORE + // processRemoteTaprootSig() initializes the nonce on line 1943. + // + // After the fix (swapping the order), this should succeed. + _, err = state.ProcessEvent(event, &env) + + require.NoError( + t, err, "ProcessEvent should not fail - if it fails "+ + "with 'remote nonce not generated', the bug still "+ + "exists", + ) +} + +type mockCloseSigner struct{} + +func (m *mockCloseSigner) CreateCloseProposal( + proposedFee btcutil.Amount, localDeliveryScript, + remoteDeliveryScript []byte, closeOpt ...lnwallet.ChanCloseOpt, +) (input.Signature, *wire.MsgTx, btcutil.Amount, error) { + + // Create a minimal MusigPartialSig for taproot channels. + partialSig := musig2.NewPartialSignature( + new(btcec.ModNScalar), new(btcec.PublicKey), + ) + musigSig := lnwallet.NewMusigPartialSig( + &partialSig, + lnwire.Musig2Nonce{}, + lnwire.Musig2Nonce{}, + nil, + fn.None[chainhash.Hash](), + ) + + return musigSig, wire.NewMsgTx(2), proposedFee, nil +} + +func (m *mockCloseSigner) CompleteCooperativeClose( + localSig, remoteSig input.Signature, + localDeliveryScript, remoteDeliveryScript []byte, + proposedFee btcutil.Amount, closeOpts ...lnwallet.ChanCloseOpt, +) (*wire.MsgTx, btcutil.Amount, error) { + + return wire.NewMsgTx(2), 0, nil +} + +type mockCoopFeeEstimator struct { + absoluteFee btcutil.Amount +} + +func (m *mockCoopFeeEstimator) EstimateFee( + chanType channeldb.ChannelType, localTxOut, remoteTxOut *wire.TxOut, + idealFeeRate chainfee.SatPerKWeight) btcutil.Amount { + + return m.absoluteFee +} + +type mockChanObserver struct{} + +func (m *mockChanObserver) NoDanglingUpdates() bool { + return true +} + +func (m *mockChanObserver) DisableIncomingAdds() error { + return nil +} + +func (m *mockChanObserver) DisableOutgoingAdds() error { + return nil +} + +func (m *mockChanObserver) DisableChannel() error { + return nil +} + +func (m *mockChanObserver) MarkCoopBroadcasted(*wire.MsgTx, bool) error { + return nil +} + +func (m *mockChanObserver) MarkShutdownSent(deliveryAddr []byte, + isInitiator bool) error { + + return nil +} + +func (m *mockChanObserver) FinalBalances() fn.Option[chancloser.ShutdownBalances] { + + return fn.None[chancloser.ShutdownBalances]() +}