lnwallet/chancloser: revamp sig type parsing to be spec compliant
What changed, and why it matters
This commit fixes how LND parses signature fields during RBF (fee-bumping) cooperative channel closes. Previously, the code rejected valid protocol messages that contained both a 'closer-only' and a 'both-parties' signature, and it didn't always pick the right signature in the order required by the Lightning spec. The change makes signature selection follow the BOLT specification strictly, which prevents a peer from accidentally or deliberately stalling or breaking a cooperative close by sending a spec-compliant message.
Treat as a bug-fix patch with security relevance for RBF cooperative close reliability. Reviewers should verify that validateSigFields and selectAndExtractSig correctly implement the BOLT spec for all dust/non-dust and taproot combinations, and that the new tests exercise the previously failing both-sigs-present case. Node operators running RBF cooperative close (protocol.rbf-coop-close) should upgrade once this is released.
Security signals we found
Protocol compliance fix for RBF cooperative close signature selection
Previously rejected valid messages with both CloserNoClosee and CloserAndClosee signatures
Added strict BOLT-spec priority ordering for signature extraction
Added channel-type mismatch validation between taproot and regular signatures
Fixed initialization order of remote MuSig2 nonce before ProposalClosingOpts
Expanded integration and unit tests for taproot and both-sigs-present scenarios
Evidence from the diff
The patch revamps signature parsing in lnwallet/chancloser for ClosingComplete messages. It introduces a three-phase flow: parseSigFields (collect which of CloserNoClosee/NoCloserClosee/CloserAndClosee are present for both regular and taproot variants), validateSigFields (enforce BOLT-spec presence rules based on whether the closee’s output is dust), and selectAndExtractSig (choose the correct field by spec priority and extract the signature/nonce). The old extractSigAndNonceFromComplete returned the first present field and errored if both CloserNoClosee and CloserAndClosee were set; the new code accepts both and prefers CloserAndClosee when the closee output is not dust. It also adds channel-type validation (taproot vs regular) and reorders processRemoteTaprootSig so the remote nonce is initialized before ProposalClosingOpts is called. Tests are expanded to cover taproot channels and the both-sigs-present case.
Changed components
lnwallet/chancloser/rbf_coop_transitions.golnwire/closing_complete.golnwallet/chancloser/rbf_coop_test.goitest/lnd_coop_close_rbf_test.goInspect captured patch +701 / −274
diff --git a/itest/lnd_coop_close_rbf_test.go b/itest/lnd_coop_close_rbf_test.go
index 7ce746e..4817f33 100644
--- a/itest/lnd_coop_close_rbf_test.go
+++ b/itest/lnd_coop_close_rbf_test.go
@@ -2,42 +2,26 @@ package itest
import (
"fmt"
+ "testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest"
+ "github.com/lightningnetwork/lnd/lntest/node"
"github.com/lightningnetwork/lnd/lntest/wait"
"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)
-
- // 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]
+// 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) {
- // 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(),
@@ -45,62 +29,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(),
@@ -109,32 +102,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/lnwallet/chancloser/rbf_coop_test.go b/lnwallet/chancloser/rbf_coop_test.go
index 644ba30..5cd4d7a 100644
--- a/lnwallet/chancloser/rbf_coop_test.go
+++ b/lnwallet/chancloser/rbf_coop_test.go
@@ -879,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()
@@ -1001,18 +1057,71 @@ func newCloser(t *testing.T, cfg *harnessCfg) *rbfCloserTestHarness {
return chanCloser
}
-// testInitiatorShutdownRecvOk is a helper function that tests the initiator
-// shutdown received scenario for both taproot and non-taproot channels in the
-// ShutdownPending state.
-func testInitiatorShutdownRecvOk(t *testing.T, ctx context.Context,
- startingState *ShutdownPending, isTaproot bool) {
+// 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) {
- testName := "non_taproot"
- if isTaproot {
- testName = "taproot"
- }
+ t.Run("non_taproot", func(t *testing.T) {
+ firstState := *startingState
+ firstState.IdealFeeRate = fn.Some(
+ chainfee.FeePerKwFloor.FeePerVByte(),
+ )
+ firstState.ShutdownScripts = ShutdownScripts{
+ LocalDeliveryScript: localAddr,
+ RemoteDeliveryScript: remoteAddr,
+ }
- t.Run(testName, func(t *testing.T) {
+ cfg := &harnessCfg{
+ initialState: fn.Some[ProtocolState](
+ &firstState,
+ ),
+ localUpfrontAddr: fn.Some(localAddr),
+ remoteUpfrontAddr: fn.Some(remoteAddr),
+ }
+
+ closeHarness := newCloser(t, cfg)
+ defer closeHarness.stopAndAssert()
+
+ // We should disable the outgoing adds for the channel at this
+ // point as well.
+ closeHarness.expectFinalBalances(fn.None[ShutdownBalances]())
+ closeHarness.expectIncomingAddsDisabled()
+
+ // Create shutdown event.
+ shutdownEvent := &ShutdownReceived{
+ ShutdownScript: remoteAddr,
+ }
+
+ // We'll send in a shutdown received event, with the expected
+ // co-op close addr.
+ closeHarness.chanCloser.SendEvent(ctx, shutdownEvent)
+
+ // We should transition to the channel flushing state.
+ closeHarness.assertStateTransitions(&ChannelFlushing{})
+
+ // Now we'll ensure that the flushing state has the proper
+ // co-op close state.
+ currentState := assertStateT[*ChannelFlushing](closeHarness)
+
+ require.Equal(
+ t, localAddr, currentState.LocalDeliveryScript,
+ )
+ require.Equal(
+ t, remoteAddr, currentState.RemoteDeliveryScript,
+ )
+ require.Equal(
+ t, firstState.IdealFeeRate, currentState.IdealFeeRate,
+ )
+ })
+}
+
+// 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(),
@@ -1022,33 +1131,29 @@ func testInitiatorShutdownRecvOk(t *testing.T, ctx context.Context,
RemoteDeliveryScript: remoteAddr,
}
- var mockLocalMusig, mockRemoteMusig *mockMusigSession
localCloseeNonce := lnwire.Musig2Nonce{1, 2, 3}
remoteCloseeNonce := lnwire.Musig2Nonce{4, 5, 6}
- if isTaproot {
- firstState.NonceState = NonceState{
- LocalCloseeNonce: fn.Some(localCloseeNonce),
- RemoteCloseeNonce: fn.None[lnwire.Musig2Nonce](),
- }
- mockLocalMusig = newMockMusigSession()
- mockRemoteMusig = newMockMusigSession()
+ firstState.NonceState = NonceState{
+ LocalCloseeNonce: fn.Some(localCloseeNonce),
+ RemoteCloseeNonce: fn.None[lnwire.Musig2Nonce](),
}
+ mockLocalMusig := newMockMusigSession()
+ mockRemoteMusig := newMockMusigSession()
+
cfg := &harnessCfg{
initialState: fn.Some[ProtocolState](
&firstState,
),
localUpfrontAddr: fn.Some(localAddr),
remoteUpfrontAddr: fn.Some(remoteAddr),
- }
- if isTaproot {
- cfg.localMusigSession = fn.Some[MusigSession](
+ localMusigSession: fn.Some[MusigSession](
mockLocalMusig,
- )
- cfg.remoteMusigSession = fn.Some[MusigSession](
+ ),
+ remoteMusigSession: fn.Some[MusigSession](
mockRemoteMusig,
- )
+ ),
}
closeHarness := newCloser(t, cfg)
@@ -1059,14 +1164,12 @@ func testInitiatorShutdownRecvOk(t *testing.T, ctx context.Context,
closeHarness.expectFinalBalances(fn.None[ShutdownBalances]())
closeHarness.expectIncomingAddsDisabled()
- // Create shutdown event, with nonce for taproot channels
+ // Create shutdown event with nonce for taproot channel.
shutdownEvent := &ShutdownReceived{
ShutdownScript: remoteAddr,
- }
- if isTaproot {
- shutdownEvent.RemoteShutdownNonce = fn.Some(
+ RemoteShutdownNonce: fn.Some(
remoteCloseeNonce,
- )
+ ),
}
// We'll send in a shutdown received event, with the expected
@@ -1090,71 +1193,109 @@ func testInitiatorShutdownRecvOk(t *testing.T, ctx context.Context,
t, firstState.IdealFeeRate, currentState.IdealFeeRate,
)
- if isTaproot {
- // Verify nonce state was updated with remote's closee nonce.
- require.True(
- t, currentState.NonceState.RemoteCloseeNonce.IsSome(),
- )
- require.Equal(
- t, remoteCloseeNonce,
- currentState.NonceState.RemoteCloseeNonce.UnwrapOr(
- lnwire.Musig2Nonce{},
- ),
- )
+ // Verify nonce state was updated with remote's closee nonce.
+ require.True(
+ t, currentState.NonceState.RemoteCloseeNonce.IsSome(),
+ )
+ require.Equal(
+ t, remoteCloseeNonce,
+ currentState.NonceState.RemoteCloseeNonce.UnwrapOr(
+ lnwire.Musig2Nonce{},
+ ),
+ )
- // Verify musig sessions were set up.
- require.NotNil(
- t, closeHarness.env.LocalMusigSession,
- "LocalMusigSession should not be nil",
- )
- require.NotNil(
- t, closeHarness.env.RemoteMusigSession,
- "RemoteMusigSession should not be nil",
- )
+ // Verify musig sessions were set up.
+ require.NotNil(
+ t, closeHarness.env.LocalMusigSession,
+ "LocalMusigSession should not be nil",
+ )
+ require.NotNil(
+ t, closeHarness.env.RemoteMusigSession,
+ "RemoteMusigSession should not be nil",
+ )
- // Verify InitRemoteNonce was called on
- // LocalMusigSession with remote's nonce. This prepares
- // the LocalMusigSession for when we act as closer.
- require.True(
- t, mockLocalMusig.remoteNonceInited,
- "LocalMusigSession.InitRemoteNonce "+
- "should have been called",
- )
- expectedRemoteNonce := musig2.Nonces{
- PubNonce: remoteCloseeNonce,
- }
- require.Equal(
- t, expectedRemoteNonce,
- mockLocalMusig.remoteNonce,
- )
+ // Verify InitRemoteNonce was called on LocalMusigSession with
+ // remote's nonce. This prepares the LocalMusigSession for when
+ // we act as closer.
+ require.True(
+ t, mockLocalMusig.remoteNonceInited,
+ "LocalMusigSession.InitRemoteNonce "+
+ "should have been called",
+ )
+ expectedRemoteNonce := musig2.Nonces{
+ PubNonce: remoteCloseeNonce,
}
+ require.Equal(
+ t, expectedRemoteNonce,
+ mockLocalMusig.remoteNonce,
+ )
})
}
-// testRemoteInitiatedCloseOk is a helper function that tests the remote
-// initiated close scenario for both taproot and non-taproot channels.
-func testRemoteInitiatedCloseOk(t *testing.T, ctx context.Context, isTaproot bool) {
- testName := "non_taproot"
- if isTaproot {
- testName = "taproot"
- }
+// 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),
+ }
- t.Run(testName, func(t *testing.T) {
- var mockLocalMusig, mockRemoteMusig *mockMusigSession
+ 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.
+ shutdownEvent := &ShutdownReceived{
+ ShutdownScript: remoteAddr,
+ }
+
+ // 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,
+ )
+ })
+}
+
+// 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),
- }
- if isTaproot {
- mockLocalMusig = newMockMusigSession()
- mockRemoteMusig = newMockMusigSession()
- cfg.localMusigSession = fn.Some[MusigSession](
+ localMusigSession: fn.Some[MusigSession](
mockLocalMusig,
- )
- cfg.remoteMusigSession = fn.Some[MusigSession](
+ ),
+ remoteMusigSession: fn.Some[MusigSession](
mockRemoteMusig,
- )
+ ),
}
closeHarness := newCloser(t, cfg)
@@ -1170,14 +1311,12 @@ func testRemoteInitiatedCloseOk(t *testing.T, ctx context.Context, isTaproot boo
recvShutdown: true,
})
- // Create shutdown event, with nonce for taproot channels
+ // Create shutdown event with nonce for taproot channel.
shutdownEvent := &ShutdownReceived{
ShutdownScript: remoteAddr,
- }
- if isTaproot {
- shutdownEvent.RemoteShutdownNonce = fn.Some(
+ RemoteShutdownNonce: fn.Some(
remoteCloseeNonce,
- )
+ ),
}
// Next, we'll emit the recv event, with the addr of the remote
@@ -1199,37 +1338,31 @@ func testRemoteInitiatedCloseOk(t *testing.T, ctx context.Context, isTaproot boo
currentState.ShutdownScripts.RemoteDeliveryScript,
)
- // For taproot channels, verify nonce handling
- if isTaproot {
- // Verify nonce state was set with remote's closee
- // nonce.
- require.True(
- t, currentState.NonceState.RemoteCloseeNonce.IsSome(),
- )
- require.Equal(
- t, remoteCloseeNonce,
- currentState.NonceState.RemoteCloseeNonce.UnwrapOr(
- lnwire.Musig2Nonce{},
- ),
- )
-
- // Verify InitRemoteNonce was called on
- // LocalMusigSession.
- require.True(t, mockLocalMusig.remoteNonceInited)
- expectedRemoteNonce := musig2.Nonces{
- PubNonce: remoteCloseeNonce,
- }
- require.Equal(
- t, expectedRemoteNonce,
- mockLocalMusig.remoteNonce,
- )
+ // 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{},
+ ),
+ )
- // Also verify we generated and stored our local closee
- // nonce.
- require.True(
- t, currentState.NonceState.LocalCloseeNonce.IsSome(),
- )
+ // 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(),
+ )
})
}
@@ -1337,9 +1470,9 @@ func TestRbfChannelActiveTransitions(t *testing.T) {
// When we receive a shutdown, we should transition to the shutdown
// pending state, with the local+remote shutdown addrs known.
t.Run("remote_initiated_close_ok", func(t *testing.T) {
- // Test both non-taproot and taproot channels
- testRemoteInitiatedCloseOk(t, ctx, false)
- testRemoteInitiatedCloseOk(t, ctx, true)
+ // 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
@@ -1433,9 +1566,9 @@ func TestRbfShutdownPendingTransitions(t *testing.T) {
// Otherwise, if the shutdown is well composed, then we should
// transition to the ChannelFlushing state.
t.Run("initiator_shutdown_recv_ok", func(t *testing.T) {
- // Test both non-taproot and taproot channels
- testInitiatorShutdownRecvOk(t, ctx, startingState, false)
- testInitiatorShutdownRecvOk(t, ctx, startingState, true)
+ // 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
@@ -2555,6 +2688,48 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
closeHarness.assertNoStateTransitions()
})
+ // 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](startingState),
+ })
+ defer closeHarness.stopAndAssert()
+
+ // 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,
+ ClosingSigs: lnwire.ClosingSigs{
+ CloserNoClosee: newSigTlv[tlv.TlvType1]( //nolint:ll
+ remoteWireSig,
+ ),
+ CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+ remoteWireSig,
+ ),
+ },
+ },
+ }
+
+ balanceAfterClose := localBalance.ToSatoshis() - absoluteFee
+ closeHarness.expectRemoteCloseFinalized(
+ &localSig, &remoteSig, localAddr, remoteAddr,
+ absoluteFee, balanceAfterClose, false,
+ )
+
+ closeHarness.chanCloser.SendEvent(ctx, event)
+
+ // 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.
@@ -2922,7 +3097,9 @@ func TestProcessRemoteTaprootSigWithSignerNonce(t *testing.T) {
},
}
- _, err := processRemoteTaprootSig(env, msg, fn.Some(jitNonce))
+ _, err := processRemoteTaprootSig(
+ env, msg, fn.Some(jitNonce), false,
+ )
require.NoError(t, err)
// Verify the musig session was re-initialized with the JIT nonce
diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go
index c1b68ca..6513ee1 100644
--- a/lnwallet/chancloser/rbf_coop_transitions.go
+++ b/lnwallet/chancloser/rbf_coop_transitions.go
@@ -1270,51 +1270,64 @@ func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
ErrInvalidStateTransition, event)
}
-// extractTaprootPartialSigWithNonce extracts the PartialSigWithNonce from
-// TaprootClosingSigs. It returns the partial sig, which field it was found in,
-// and whether it's a NoClosee case.
-func extractTaprootPartialSigWithNonce(sigs lnwire.TaprootClosingSigs) (
- partialSig fn.Option[lnwire.PartialSigWithNonce], isNoClosee bool) {
+// 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
+ }
- if sigs.CloserNoClosee.IsSome() {
- var ps lnwire.PartialSigWithNonce
sigs.CloserNoClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) {
ps = p
})
- return fn.Some(ps), true
+
+ return ps, nil
}
- if sigs.NoCloserClosee.IsSome() {
- var ps lnwire.PartialSigWithNonce
- sigs.NoCloserClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) {
+ if sigs.CloserAndClosee.IsSome() {
+ sigs.CloserAndClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) {
ps = p
})
- return fn.Some(ps), false
+
+ return ps, nil
}
- if sigs.CloserAndClosee.IsSome() {
- var ps lnwire.PartialSigWithNonce
- sigs.CloserAndClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) {
+ if sigs.NoCloserClosee.IsSome() {
+ sigs.NoCloserClosee.WhenSomeV(func(p lnwire.PartialSigWithNonce) {
ps = p
})
- return fn.Some(ps), false
+
+ return ps, nil
}
- return fn.None[lnwire.PartialSigWithNonce](), false
+ return ps, ErrNoSig
}
-// createClosingSigMessage creates the ClosingSig message response for the closee role.
-func createClosingSigMessage(env *Environment, wireSig lnwire.Sig, localSig input.Signature,
+// createClosingSigMessage creates the ClosingSig message response for the
+// closee role.
+func createClosingSigMessage(env *Environment, wireSig lnwire.Sig,
+ localSig input.Signature,
localScript, remoteScript lnwire.DeliveryAddress, fee btcutil.Amount,
lockTime uint32, noClosee bool) (*lnwire.ClosingSig, error) {
- var closingSigs lnwire.ClosingSigs
- var taprootPartialSigs lnwire.TaprootPartialSigs
- var nextCloseeNonce tlv.OptionalRecordT[tlv.TlvType22, lnwire.Musig2Nonce]
+ var (
+ closingSigs lnwire.ClosingSigs
+ taprootPartialSigs lnwire.TaprootPartialSigs
+ nextCloseeNonce tlv.OptionalRecordT[
+ tlv.TlvType22, lnwire.Musig2Nonce,
+ ]
+ )
- // For taproot channels, use PartialSig (no nonce) since receiver knows our nonce
+ // For taproot channels, use PartialSig (no nonce) since receiver knows
+ // our nonce
if env.IsTaproot() {
- // We already have the MusigPartialSig from earlier
+ // We already have the MusigPartialSig from earlier.
musigSig := localSig.(*lnwallet.MusigPartialSig)
wireSigWithNonce := musigSig.ToWireSig()
partialSig := wireSigWithNonce.PartialSig
@@ -1330,21 +1343,29 @@ func createClosingSigMessage(env *Environment, wireSig lnwire.Sig, localSig inpu
}
// 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.
+ // 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)
+ return nil, fmt.Errorf("failed to generate next "+
+ "closee nonce: %w", err)
}
nextCloseeNonce = tlv.SomeRecordT(
- tlv.NewRecordT[tlv.TlvType22](lnwire.Musig2Nonce(nextNonces.PubNonce)),
+ tlv.NewRecordT[tlv.TlvType22](
+ lnwire.Musig2Nonce(nextNonces.PubNonce),
+ ),
)
} else {
- // Non-taproot: use regular signatures
+ // Non-taproot: use regular signatures.
if noClosee {
- closingSigs.CloserNoClosee = newSigTlv[tlv.TlvType1](wireSig)
+ closingSigs.CloserNoClosee = newSigTlv[tlv.TlvType1](
+ wireSig,
+ )
} else {
- closingSigs.CloserAndClosee = newSigTlv[tlv.TlvType3](wireSig)
+ closingSigs.CloserAndClosee = newSigTlv[tlv.TlvType3](
+ wireSig,
+ )
}
}
@@ -1561,23 +1582,21 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent, env *Environment,
// taproot signature for the closee role. It extracts the partial sig with
// nonce, initializes the musig session, and returns the remote signature.
func processRemoteTaprootSig(env *Environment, msg lnwire.ClosingComplete,
- jitNonce fn.Option[lnwire.Musig2Nonce]) (input.Signature, error) {
+ 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.
initRemoteMusigCloseeNonce(env, jitNonce)
- partialSigOpt, _ := extractTaprootPartialSigWithNonce(msg.TaprootClosingSigs)
- if partialSigOpt.IsNone() {
- return nil, fmt.Errorf("no taproot partial sig found in message")
+ remotePartialSig, err := selectTaprootPartialSigWithNonce(
+ msg.TaprootClosingSigs, noClosee,
+ )
+ if err != nil {
+ return nil, err
}
- var remotePartialSig lnwire.PartialSigWithNonce
- partialSigOpt.WhenSome(func(ps lnwire.PartialSigWithNonce) {
- remotePartialSig = ps
- })
-
// Create a MusigPartialSig from the wire format The Nonce in
// PartialSigWithNonce is their next closee nonce for future RBF. We
// store it but don't use it for verification of this signature.
@@ -1642,51 +1661,241 @@ func createLocalCloseeSignature(env *Environment, fee btcutil.Amount,
return wireSig, localSig, nil
}
-// extractSigAndNonceFromComplete extracts signature and optional nonce from
-// ClosingComplete. For taproot channels, it extracts both the partial signature
-// and the JIT nonce. For non-taproot channels, it extracts just the signature.
-func extractSigAndNonceFromComplete(msg lnwire.ClosingComplete,
-) (sig fn.Option[lnwire.Sig], nonce fn.Option[lnwire.Musig2Nonce],
- isNoClosee bool) {
+// SigType represents either a regular or taproot signature.
+// Left = regular signature, Right = taproot signature with nonce.
+type SigType = fn.Either[lnwire.Sig, lnwire.PartialSigWithNonce]
- // If this is a taproot channel, then we'll extract the partial sigs.
- partialSigOpt, isNoClosee := extractTaprootPartialSigWithNonce(
- msg.TaprootClosingSigs,
- )
+// 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)
+}
- // If we have a partial sig, then we'll covnert it into our shim wire
- // format (just the 32 bytes of the partial sig).
- if partialSigOpt.IsSome() {
- var partialSig lnwire.PartialSigWithNonce
- partialSigOpt.WhenSome(func(ps lnwire.PartialSigWithNonce) {
- partialSig = ps
+// 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()
})
+ }
- var wireSig lnwire.Sig
+ return checkTaproot(s.CloserNoClosee) ||
+ checkTaproot(s.NoCloserClosee) ||
+ checkTaproot(s.CloserAndClosee)
+}
- sigBytes := partialSig.PartialSig.Sig.Bytes()
- copy(wireSig.RawBytes()[:32], sigBytes[:])
+// 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()
+}
- wireSig.ForceSchnorr()
+// 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(wireSig), fn.Some(partialSig.Nonce), isNoClosee
+ 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]()
}
- none := fn.None[lnwire.Musig2Nonce]()
+ fields.CloserNoClosee = createSigType(
+ msg.TaprootClosingSigs.CloserNoClosee.ValOpt(),
+ msg.ClosingSigs.CloserNoClosee.ValOpt(),
+ )
+
+ fields.NoCloserClosee = createSigType(
+ msg.TaprootClosingSigs.NoCloserClosee.ValOpt(),
+ msg.ClosingSigs.NoCloserClosee.ValOpt(),
+ )
- if msg.ClosingSigs.CloserNoClosee.IsSome() {
- return msg.ClosingSigs.CloserNoClosee.ValOpt(), none, true
+ 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
+}
+
+// extractSigAndNonceFromClosingComplete 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 extractSigAndNonceFromClosingComplete(msg lnwire.ClosingComplete,
+ localIsDust, isTaproot 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)
+
+ // Validate that the signature type matches the channel type. Taproot
+ // channels must have taproot signatures, and non-taproot channels must
+ // have regular signatures.
+ switch {
+ case isTaproot && !fields.IsTaproot() && fields.HasAnySig():
+ return lnwire.Sig{}, fn.None[lnwire.Musig2Nonce](), false,
+ fmt.Errorf("taproot channel requires taproot " +
+ "signatures, got regular signatures")
+
+ case !isTaproot && fields.IsTaproot():
+ return lnwire.Sig{}, fn.None[lnwire.Musig2Nonce](), false,
+ fmt.Errorf("non-taproot channel requires regular " +
+ "signatures, got taproot signatures")
}
- if msg.ClosingSigs.NoCloserClosee.IsSome() {
- return msg.ClosingSigs.NoCloserClosee.ValOpt(), none, false
+ // 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
}
- if msg.ClosingSigs.CloserAndClosee.IsSome() {
- return msg.ClosingSigs.CloserAndClosee.ValOpt(), none, false
+ // 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 fn.None[lnwire.Sig](), fn.None[lnwire.Musig2Nonce](), false
+ return sig, nonce, isNoClosee, nil
}
// ProcessEvent implements the state transition function for the
@@ -1712,24 +1921,11 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
}
// Extract the signature and JIT nonce from the ClosingComplete
- // message.
- sigOpt, jitNonce, noClosee := extractSigAndNonceFromComplete(
- msg.SigMsg,
+ // message. This function parses, validates, and selects the
+ // appropriate signature per BOLT spec.
+ sig, jitNonce, noClosee, err := extractSigAndNonceFromClosingComplete(
+ msg.SigMsg, l.LocalAmtIsDust(), env.IsTaproot(),
)
-
- // Validate signature presence based on our balance.
- switch {
- case l.LocalAmtIsDust() && !noClosee:
- return nil, ErrCloserNoClosee
- case !l.LocalAmtIsDust() && noClosee:
- return nil, ErrCloserAndClosee
- }
-
- if sigOpt.IsNone() {
- return nil, ErrNoSig
- }
-
- sig, err := sigOpt.UnwrapOrErr(ErrNoSig)
if err != nil {
return nil, err
}
@@ -1746,22 +1942,26 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
// When we're the closee (sending closing_sig), we use
// RemoteMusigSession
switch {
- case env.RemoteMusigSession != nil:
+ case env.IsTaproot():
+ // 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...)
-
- // Apply their jitNonce, then parse out the partisl
- // signature from that.
- remoteSig, err = processRemoteTaprootSig(
- env, msg.SigMsg, jitNonce,
- )
- if err != nil {
- return nil, err
- }
default:
remoteSig, err = sig.ToSignature()
if err != nil {
diff --git a/lnwire/closing_complete.go b/lnwire/closing_complete.go
index a46d2dd..fd989be 100644
--- a/lnwire/closing_complete.go
+++ b/lnwire/closing_complete.go
@@ -88,7 +88,7 @@ func decodeClosingSigs(c *ClosingSigs, tc *TaprootClosingSigs, tlvRecords ExtraO
sig1 := c.CloserNoClosee.Zero()
sig2 := c.NoCloserClosee.Zero()
sig3 := c.CloserAndClosee.Zero()
-
+
// Taproot signatures (with nonces)
tSig1 := tc.CloserNoClosee.Zero()
tSig2 := tc.NoCloserClosee.Zero()
@@ -101,7 +101,6 @@ func decodeClosingSigs(c *ClosingSigs, tc *TaprootClosingSigs, tlvRecords ExtraO
return err
}
- // Regular signatures
if val, ok := typeMap[c.CloserNoClosee.TlvType()]; ok && val == nil {
c.CloserNoClosee = tlv.SomeRecordT(sig1)
}
@@ -111,8 +110,7 @@ func decodeClosingSigs(c *ClosingSigs, tc *TaprootClosingSigs, tlvRecords ExtraO
if val, ok := typeMap[c.CloserAndClosee.TlvType()]; ok && val == nil {
c.CloserAndClosee = tlv.SomeRecordT(sig3)
}
-
- // Taproot signatures
+
if val, ok := typeMap[tc.CloserNoClosee.TlvType()]; ok && val == nil {
tc.CloserNoClosee = tlv.SomeRecordT(tSig1)
}
@@ -160,7 +158,7 @@ func (c *ClosingComplete) Decode(r io.Reader, _ uint32) error {
// 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)
@@ -171,7 +169,7 @@ func closingSigRecords(c *ClosingSigs, tc *TaprootClosingSigs) []tlv.RecordProdu
c.CloserAndClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType3, Sig]) {
recordProducers = append(recordProducers, &sig)
})
-
+
// Taproot signatures (with nonces)
tc.CloserNoClosee.WhenSome(func(sig tlv.RecordT[tlv.TlvType5, PartialSigWithNonce]) {
recordProducers = append(recordProducers, &sig)
Why this scored 58/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.