diff --git a/lnwallet/chancloser/chancloser.go b/lnwallet/chancloser/chancloser.go index 448e196c8..7947f120b 100644 --- a/lnwallet/chancloser/chancloser.go +++ b/lnwallet/chancloser/chancloser.go @@ -4,9 +4,9 @@ import ( "bytes" "fmt" + "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/davecgh/go-spew/spew" @@ -94,75 +94,16 @@ const ( defaultMaxFeeMultiplier = 3 ) -// Channel abstracts away from the core channel state machine by exposing an -// interface that requires only the methods we need to carry out the channel -// closing process. -type Channel interface { - // ChannelPoint returns the channel point of the target channel. - ChannelPoint() *wire.OutPoint - - // MarkCoopBroadcasted persistently marks that the channel close - // transaction has been broadcast. - MarkCoopBroadcasted(*wire.MsgTx, bool) error - - // IsInitiator returns true we are the initiator of the channel. - IsInitiator() bool - - // ShortChanID returns the scid of the channel. - ShortChanID() lnwire.ShortChannelID - - // AbsoluteThawHeight returns the absolute thaw height of the channel. - // If the channel is pending, or an unconfirmed zero conf channel, then - // an error should be returned. - AbsoluteThawHeight() (uint32, error) - - // LocalBalanceDust returns true if when creating a co-op close - // transaction, the balance of the local party will be dust after - // accounting for any anchor outputs. - LocalBalanceDust() bool - - // RemoteBalanceDust returns true if when creating a co-op close - // transaction, the balance of the remote party will be dust after - // accounting for any anchor outputs. - RemoteBalanceDust() bool - - // RemoteUpfrontShutdownScript returns the upfront shutdown script of - // the remote party. If the remote party didn't specify such a script, - // an empty delivery address should be returned. - RemoteUpfrontShutdownScript() lnwire.DeliveryAddress - - // CreateCloseProposal creates a new co-op close proposal in the form - // of a valid signature, the chainhash of the final txid, and our final - // balance in the created state. - CreateCloseProposal(proposedFee btcutil.Amount, localDeliveryScript []byte, - remoteDeliveryScript []byte) (input.Signature, *chainhash.Hash, - btcutil.Amount, error) - - // CompleteCooperativeClose persistently "completes" the cooperative - // close by producing a fully signed co-op close transaction. - CompleteCooperativeClose(localSig, remoteSig input.Signature, - localDeliveryScript, remoteDeliveryScript []byte, - proposedFee btcutil.Amount) (*wire.MsgTx, btcutil.Amount, error) -} - -// CoopFeeEstimator is used to estimate the fee of a co-op close transaction. -type CoopFeeEstimator interface { - // EstimateFee estimates an _absolute_ fee for a co-op close transaction - // given the local+remote tx outs (for the co-op close transaction), - // channel type, and ideal fee rate. If a passed TxOut is nil, then - // that indicates that an output is dust on the co-op close transaction - // _before_ fees are accounted for. - EstimateFee(chanType channeldb.ChannelType, - localTxOut, remoteTxOut *wire.TxOut, - idealFeeRate chainfee.SatPerKWeight) btcutil.Amount -} - // ChanCloseCfg holds all the items that a ChanCloser requires to carry out its // duties. type ChanCloseCfg struct { // Channel is the channel that should be closed. Channel Channel + // MusigSession is used to handle generating musig2 nonces, and also + // creating the proper set of closing options for taproot channels. + MusigSession MusigSession + // BroadcastTx broadcasts the passed transaction to the network. BroadcastTx func(*wire.MsgTx, string) error @@ -367,19 +308,35 @@ func (c *ChanCloser) initChanShutdown() (*lnwire.Shutdown, error) { // closing script. shutdown := lnwire.NewShutdown(c.cid, c.localDeliveryScript) - // 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 this is a taproot channel, then we'll need to also generate a + // nonce that'll be used sign the co-op close transaction offer. + if c.cfg.Channel.ChanType().IsTaproot() { + firstClosingNonce, err := c.cfg.MusigSession.ClosingNonce() + if err != nil { + return nil, err + } + + shutdown.ShutdownNonce = (*lnwire.ShutdownNonce)( + &firstClosingNonce.PubNonce, + ) + + chancloserLog.Infof("Initiating shutdown w/ nonce: %v", + spew.Sdump(firstClosingNonce.PubNonce)) + } + + // 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 := c.cfg.DisableChannel(c.chanPoint); err != nil { chancloserLog.Warnf("Unable to disable channel %v on close: %v", c.chanPoint, err) } - // Before continuing, mark the channel as cooperatively closed with a nil - // txn. Even though we haven't negotiated the final txn, this guarantees - // that our listchannels rpc will be externally consistent, and reflect - // that the channel is being shutdown by the time the closing request - // returns. + // Before continuing, mark the channel as cooperatively closed with a + // nil txn. Even though we haven't negotiated the final txn, this + // guarantees that our listchannels rpc will be externally consistent, + // and reflect that the channel is being shutdown by the time the + // closing request returns. err := c.cfg.Channel.MarkCoopBroadcasted(nil, c.locallyInitiated) if err != nil { return nil, err @@ -448,6 +405,7 @@ func (c *ChanCloser) CloseRequest() *htlcswitch.ChanClose { // NOTE: This method will PANIC if the underlying channel implementation isn't // the desired type. func (c *ChanCloser) Channel() *lnwallet.LightningChannel { + // TODO(roasbeef): remove this return c.cfg.Channel.(*lnwallet.LightningChannel) } @@ -522,8 +480,9 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, // as otherwise, this is an attempted invalid state transition. shutdownMsg, ok := msg.(*lnwire.Shutdown) if !ok { - return nil, false, fmt.Errorf("expected lnwire.Shutdown, instead "+ - "have %v", spew.Sdump(msg)) + return nil, false, fmt.Errorf("expected "+ + "lnwire.Shutdown, instead have %v", + spew.Sdump(msg)) } // As we're the responder to this shutdown (the other party @@ -571,6 +530,20 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, return nil, false, err } + // If this is a taproot channel, then we'll want to stash the + // remote nonces so we can properly create a new musig + // session for signing. + if c.cfg.Channel.ChanType().IsTaproot() { + if shutdownMsg.ShutdownNonce == nil { + return nil, false, fmt.Errorf("shutdown " + + "nonce not populated") + } + + c.cfg.MusigSession.InitRemoteNonce(&musig2.Nonces{ + PubNonce: *shutdownMsg.ShutdownNonce, + }) + } + chancloserLog.Infof("ChannelPoint(%v): responding to shutdown", c.chanPoint) @@ -588,7 +561,8 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, if chanInitiator { closeSigned, err := c.proposeCloseSigned(c.idealFeeSat) if err != nil { - return nil, false, err + return nil, false, fmt.Errorf("unable to sign "+ + "new co op close offer: %w", err) } msgsToSend = append(msgsToSend, closeSigned) } @@ -628,10 +602,24 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, // closing transaction should look like. c.state = closeFeeNegotiation - // Now that we know their desried delivery script, we can + // Now that we know their desired delivery script, we can // compute what our max/ideal fee will be. c.initFeeBaseline() + // If this is a taproot channel, then we'll want to stash the + // local+remote nonces so we can properly create a new musig + // session for signing. + if c.cfg.Channel.ChanType().IsTaproot() { + if shutdownMsg.ShutdownNonce == nil { + return nil, false, fmt.Errorf("shutdown " + + "nonce not populated") + } + + c.cfg.MusigSession.InitRemoteNonce(&musig2.Nonces{ + PubNonce: *shutdownMsg.ShutdownNonce, + }) + } + chancloserLog.Infof("ChannelPoint(%v): shutdown response received, "+ "entering fee negotiation", c.chanPoint) @@ -641,7 +629,8 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, if c.cfg.Channel.IsInitiator() { closeSigned, err := c.proposeCloseSigned(c.idealFeeSat) if err != nil { - return nil, false, err + return nil, false, fmt.Errorf("unable to sign "+ + "new co op close offer: %w", err) } return []lnwire.Message{closeSigned}, false, nil @@ -661,14 +650,68 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, "instead have %v", spew.Sdump(msg)) } - // We'll compare the proposed total fee, to what we've proposed during - // the negotiations. If it doesn't match any of our prior offers, then - // we'll attempt to ratchet the fee closer to + // If this is a taproot channel, then it MUST have a partial + // signature set at this point. + isTaproot := c.cfg.Channel.ChanType().IsTaproot() + if isTaproot && closeSignedMsg.PartialSig == nil { + return nil, false, fmt.Errorf("partial sig not set " + + "for taproot chan") + } + + isInitiator := c.cfg.Channel.IsInitiator() + + // We'll compare the proposed total fee, to what we've proposed + // during the negotiations. If it doesn't match any of our + // prior offers, then we'll attempt to ratchet the fee closer + // to our ideal fee. remoteProposedFee := closeSignedMsg.FeeSatoshis - if _, ok := c.priorFeeOffers[remoteProposedFee]; !ok { - // We'll now attempt to ratchet towards a fee deemed acceptable by - // both parties, factoring in our ideal fee rate, and the last - // proposed fee by both sides. + + _, feeMatchesOffer := c.priorFeeOffers[remoteProposedFee] + switch { + // For taproot channels, since nonces are involved, we can't do + // the existing co-op close negotiation process without going + // to a fully round based model. Rather than do this, we'll + // just accept the very first offer by the initiator. + case isTaproot && !isInitiator: + chancloserLog.Infof("ChannelPoint(%v) accepting "+ + "initiator fee of %v", c.chanPoint, + remoteProposedFee) + + // To auto-accept the initiators proposal, we'll just + // send back a signature w/ the same offer. We don't + // send the message here, as we can drop down and + // finalize the closure and broadcast, then echo back + // to Alice the final signature. + _, err := c.proposeCloseSigned(remoteProposedFee) + if err != nil { + return nil, false, fmt.Errorf("unable to sign "+ + "new co op close offer: %w", err) + } + + break + + // Otherwise, if we are the initiator, and we just sent a + // signature for a taproot channel, then we'll ensure that the + // fee rate matches up exactly. + case isTaproot && isInitiator && !feeMatchesOffer: + return nil, false, fmt.Errorf("fee rate for "+ + "taproot channels was not accepted: "+ + "sent %v, got %v", + c.idealFeeSat, remoteProposedFee) + + // If we're the initiator of the taproot channel, and we had + // our fee echo'd back, then it's all good, and we can proceed + // with final broadcast. + case isTaproot && isInitiator && feeMatchesOffer: + break + + // Otherwise, if this is a normal segwit v0 channel, and the + // fee doesn't match our offer, then we'll try to "negotiate" a + // new fee. + case !feeMatchesOffer: + // We'll now attempt to ratchet towards a fee deemed + // acceptable by both parties, factoring in our ideal + // fee rate, and the last proposed fee by both sides. feeProposal := calcCompromiseFee(c.chanPoint, c.idealFeeSat, c.lastFeeProposal, remoteProposedFee, ) @@ -678,17 +721,19 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, c.maxFee) } - // With our new fee proposal calculated, we'll craft a new close - // signed signature to send to the other party so we can continue - // the fee negotiation process. + // With our new fee proposal calculated, we'll craft a + // new close signed signature to send to the other + // party so we can continue the fee negotiation + // process. closeSigned, err := c.proposeCloseSigned(feeProposal) if err != nil { - return nil, false, err + return nil, false, fmt.Errorf("unable to sign "+ + "new co op close offer: %w", err) } - // If the compromise fee doesn't match what the peer proposed, then - // we'll return this latest close signed message so we can continue - // negotiation. + // If the compromise fee doesn't match what the peer + // proposed, then we'll return this latest close signed + // message so we can continue negotiation. if feeProposal != remoteProposedFee { chancloserLog.Debugf("ChannelPoint(%v): close tx fee "+ "disagreement, continuing negotiation", c.chanPoint) @@ -699,38 +744,56 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, chancloserLog.Infof("ChannelPoint(%v) fee of %v accepted, ending "+ "negotiation", c.chanPoint, remoteProposedFee) - // Otherwise, we've agreed on a fee for the closing transaction! We'll - // craft the final closing transaction so we can broadcast it to the - // network. - matchingSig := c.priorFeeOffers[remoteProposedFee].Signature - localSig, err := matchingSig.ToSignature() - if err != nil { - return nil, false, err - } - - remoteSig, err := closeSignedMsg.Signature.ToSignature() - if err != nil { - return nil, false, err + // Otherwise, we've agreed on a fee for the closing + // transaction! We'll craft the final closing transaction so we + // can broadcast it to the network. + var ( + localSig, remoteSig input.Signature + closeOpts []lnwallet.ChanCloseOpt + err error + ) + matchingSig := c.priorFeeOffers[remoteProposedFee] + if c.cfg.Channel.ChanType().IsTaproot() { + muSession := c.cfg.MusigSession + localSig, remoteSig, closeOpts, err = muSession.CombineClosingOpts( //nolint:ll + *matchingSig.PartialSig, + *closeSignedMsg.PartialSig, + ) + if err != nil { + return nil, false, err + } + } else { + localSig, err = matchingSig.Signature.ToSignature() + if err != nil { + return nil, false, err + } + remoteSig, err = closeSignedMsg.Signature.ToSignature() + if err != nil { + return nil, false, err + } } closeTx, _, err := c.cfg.Channel.CompleteCooperativeClose( - localSig, remoteSig, c.localDeliveryScript, c.remoteDeliveryScript, - remoteProposedFee, + localSig, remoteSig, c.localDeliveryScript, + c.remoteDeliveryScript, remoteProposedFee, closeOpts..., ) if err != nil { return nil, false, err } c.closingTx = closeTx - // Before publishing the closing tx, we persist it to the database, - // such that it can be republished if something goes wrong. - err = c.cfg.Channel.MarkCoopBroadcasted(closeTx, c.locallyInitiated) + // Before publishing the closing tx, we persist it to the + // database, such that it can be republished if something goes + // wrong. + err = c.cfg.Channel.MarkCoopBroadcasted( + closeTx, c.locallyInitiated, + ) if err != nil { return nil, false, err } - // With the closing transaction crafted, we'll now broadcast it to the - // network. + // With the closing transaction crafted, we'll now broadcast it + // to the network. chancloserLog.Infof("Broadcasting cooperative close tx: %v", newLogClosure(func() string { return spew.Sdump(closeTx) @@ -747,10 +810,11 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, return nil, false, err } - // Finally, we'll transition to the closeFinished state, and also - // return the final close signed message we sent. Additionally, we - // return true for the second argument to indicate we're finished with - // the channel closing negotiation. + // Finally, we'll transition to the closeFinished state, and + // also return the final close signed message we sent. + // Additionally, we return true for the second argument to + // indicate we're finished with the channel closing + // negotiation. c.state = closeFinished matchingOffer := c.priorFeeOffers[remoteProposedFee] return []lnwire.Message{matchingOffer}, true, nil @@ -778,8 +842,23 @@ func (c *ChanCloser) ProcessCloseMsg(msg lnwire.Message) ([]lnwire.Message, // transaction for a channel based on the prior fee negotiations and our current // compromise fee. func (c *ChanCloser) proposeCloseSigned(fee btcutil.Amount) (*lnwire.ClosingSigned, error) { + var ( + closeOpts []lnwallet.ChanCloseOpt + err error + ) + + // If this is a taproot channel, then we'll include the musig session + // generated for the next co-op close negotiation round. + if c.cfg.Channel.ChanType().IsTaproot() { + closeOpts, err = c.cfg.MusigSession.ProposalClosingOpts() + if err != nil { + return nil, err + } + } + rawSig, _, _, err := c.cfg.Channel.CreateCloseProposal( fee, c.localDeliveryScript, c.remoteDeliveryScript, + closeOpts..., ) if err != nil { return nil, err @@ -788,21 +867,42 @@ func (c *ChanCloser) proposeCloseSigned(fee btcutil.Amount) (*lnwire.ClosingSign // We'll note our last signature and proposed fee so when the remote // party responds we'll be able to decide if we've agreed on fees or // not. - c.lastFeeProposal = fee + var ( + parsedSig lnwire.Sig + partialSig *lnwire.PartialSigWithNonce + ) + if c.cfg.Channel.ChanType().IsTaproot() { + musig, ok := rawSig.(*lnwallet.MusigPartialSig) + if !ok { + return nil, fmt.Errorf("expected MusigPartialSig, "+ + "got %T", rawSig) + } - parsedSig, err := lnwire.NewSigFromSignature(rawSig) - if err != nil { - return nil, err + partialSig = musig.ToWireSig() + } else { + parsedSig, err = lnwire.NewSigFromSignature(rawSig) + if err != nil { + return nil, err + } } + c.lastFeeProposal = fee + chancloserLog.Infof("ChannelPoint(%v): proposing fee of %v sat to "+ "close chan", c.chanPoint, int64(fee)) - // We'll assemble a ClosingSigned message using this information and return - // it to the caller so we can kick off the final stage of the channel - // closure process. + // We'll assemble a ClosingSigned message using this information and + // return it to the caller so we can kick off the final stage of the + // channel closure process. closeSignedMsg := lnwire.NewClosingSigned(c.cid, fee, parsedSig) + // For musig2 channels, the main sig is blank, and instead we'll send + // over a partial signature which'll be combine donce our offer is + // accepted. + if partialSig != nil { + closeSignedMsg.PartialSig = &partialSig.PartialSig + } + // We'll also save this close signed, in the case that the remote party // accepts our offer. This way, we don't have to re-sign. c.priorFeeOffers[fee] = closeSignedMsg diff --git a/lnwallet/chancloser/chancloser_test.go b/lnwallet/chancloser/chancloser_test.go index 6a9c8a2f0..61e62a129 100644 --- a/lnwallet/chancloser/chancloser_test.go +++ b/lnwallet/chancloser/chancloser_test.go @@ -4,7 +4,10 @@ import ( "bytes" "fmt" "testing" + "time" + "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" @@ -12,6 +15,9 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnutils" + "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" @@ -135,6 +141,9 @@ type mockChannel struct { chanPoint wire.OutPoint initiator bool scid lnwire.ShortChannelID + chanType channeldb.ChannelType + localKey keychain.KeyDescriptor + remoteKey keychain.KeyDescriptor } func (m *mockChannel) ChannelPoint() *wire.OutPoint { @@ -162,15 +171,26 @@ func (m *mockChannel) RemoteUpfrontShutdownScript() lnwire.DeliveryAddress { } func (m *mockChannel) CreateCloseProposal(fee btcutil.Amount, - localScript, remoteScript []byte, + localScript, remoteScript []byte, _ ...lnwallet.ChanCloseOpt, ) (input.Signature, *chainhash.Hash, btcutil.Amount, error) { + if m.chanType.IsTaproot() { + return lnwallet.NewMusigPartialSig( + &musig2.PartialSignature{ + S: new(btcec.ModNScalar), + R: new(btcec.PublicKey), + }, + lnwire.Musig2Nonce{}, lnwire.Musig2Nonce{}, nil, + ), nil, 0, nil + } + return nil, nil, 0, nil } func (m *mockChannel) CompleteCooperativeClose(localSig, remoteSig input.Signature, localScript, remoteScript []byte, - proposedFee btcutil.Amount) (*wire.MsgTx, btcutil.Amount, error) { + proposedFee btcutil.Amount, + _ ...lnwallet.ChanCloseOpt) (*wire.MsgTx, btcutil.Amount, error) { return nil, 0, nil } @@ -183,6 +203,73 @@ func (m *mockChannel) RemoteBalanceDust() bool { return false } +func (m *mockChannel) ChanType() channeldb.ChannelType { + return m.chanType +} + +func (m *mockChannel) FundingTxOut() *wire.TxOut { + return nil +} + +func (m *mockChannel) MultiSigKeys() (keychain.KeyDescriptor, keychain.KeyDescriptor) { + return m.localKey, m.remoteKey +} + +func newMockTaprootChan(t *testing.T, initiator bool) *mockChannel { + taprootBits := channeldb.SimpleTaprootFeatureBit | + channeldb.AnchorOutputsBit | + channeldb.ZeroHtlcTxFeeBit | + channeldb.SingleFunderTweaklessBit + + localKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + remoteKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + return &mockChannel{ + chanPoint: wire.OutPoint{ + Hash: chainhash.Hash{}, + Index: 0, + }, + initiator: initiator, + chanType: taprootBits, + localKey: keychain.KeyDescriptor{ + PubKey: localKey.PubKey(), + }, + remoteKey: keychain.KeyDescriptor{ + PubKey: remoteKey.PubKey(), + }, + } +} + +type mockMusigSession struct { +} + +func newMockMusigSession() *mockMusigSession { + return &mockMusigSession{} +} + +func (m *mockMusigSession) ProposalClosingOpts() ([]lnwallet.ChanCloseOpt, error) { + return nil, nil +} + +func (m *mockMusigSession) CombineClosingOpts(localSig, + remoteSig lnwire.PartialSig, +) (input.Signature, input.Signature, []lnwallet.ChanCloseOpt, error) { + + return &lnwallet.MusigPartialSig{}, &lnwallet.MusigPartialSig{}, nil, + nil +} + +func (m *mockMusigSession) ClosingNonce() (*musig2.Nonces, error) { + return &musig2.Nonces{}, nil +} + +func (m *mockMusigSession) InitRemoteNonce(nonce *musig2.Nonces) { + return +} + type mockCoopFeeEstimator struct { targetFee btcutil.Amount } @@ -377,3 +464,130 @@ func TestParseUpfrontShutdownAddress(t *testing.T) { }) } } + +func assertType[T any](t *testing.T, typ any) T { + value, ok := typ.(T) + require.True(t, ok) + + return value +} + +// TestTaprootFastClose tests that we are able to properly execute a fast close +// (skip negotiation) for taproot channels. +func TestTaprootFastClose(t *testing.T) { + t.Parallel() + + aliceChan := newMockTaprootChan(t, true) + bobChan := newMockTaprootChan(t, false) + + broadcastSignal := make(chan struct{}, 2) + + idealFee := chainfee.SatPerKWeight(506) + + // Next, we'll make a channel for Alice and Bob, with Alice being the + // initiator. + aliceCloser := NewChanCloser( + ChanCloseCfg{ + Channel: aliceChan, + MusigSession: newMockMusigSession(), + BroadcastTx: func(_ *wire.MsgTx, _ string) error { + broadcastSignal <- struct{}{} + return nil + }, + MaxFee: chainfee.SatPerKWeight(1000), + FeeEstimator: &SimpleCoopFeeEstimator{}, + DisableChannel: func(wire.OutPoint) error { + return nil + }, + }, nil, idealFee, 0, nil, true, + ) + aliceCloser.initFeeBaseline() + + bobCloser := NewChanCloser( + ChanCloseCfg{ + Channel: bobChan, + MusigSession: newMockMusigSession(), + MaxFee: chainfee.SatPerKWeight(1000), + BroadcastTx: func(_ *wire.MsgTx, _ string) error { + broadcastSignal <- struct{}{} + return nil + }, + FeeEstimator: &SimpleCoopFeeEstimator{}, + DisableChannel: func(wire.OutPoint) error { + return nil + }, + }, nil, idealFee, 0, nil, false, + ) + bobCloser.initFeeBaseline() + + // With our set up complete, we'll now initialize the shutdown + // procedure kicked off by Alice. + msg, err := aliceCloser.ShutdownChan() + require.NoError(t, err) + require.NotNil(t, msg) + + // Bob will then process this message. As he's the responder, he should + // only send the shutdown message back to Alice. + bobMsgs, closeFinished, err := bobCloser.ProcessCloseMsg(msg) + require.NoError(t, err) + require.False(t, closeFinished) + require.Len(t, bobMsgs, 1) + require.IsType(t, &lnwire.Shutdown{}, bobMsgs[0]) + + // Alice should process the shutdown message, and create a closing + // signed of her own. + aliceMsgs, closeFinished, err := aliceCloser.ProcessCloseMsg(bobMsgs[0]) + require.NoError(t, err) + require.False(t, closeFinished) + require.Len(t, aliceMsgs, 1) + require.IsType(t, &lnwire.ClosingSigned{}, aliceMsgs[0]) + + // Next, Bob will process the closing signed message, and send back a + // new one that should match exactly the offer Alice sent. + bobMsgs, closeFinished, err = bobCloser.ProcessCloseMsg(aliceMsgs[0]) + require.NoError(t, err) + require.True(t, closeFinished) + require.Len(t, aliceMsgs, 1) + require.IsType(t, &lnwire.ClosingSigned{}, bobMsgs[0]) + + // At this point, Bob has accepted the offer, so he can broadcast the + // closing transaction, and considers the channel closed. + _, err = lnutils.RecvOrTimeout(broadcastSignal, time.Second*1) + require.NoError(t, err) + + // Bob's fee proposal should exactly match Alice's initial fee. + aliceOffer := assertType[*lnwire.ClosingSigned](t, aliceMsgs[0]) + bobOffer := assertType[*lnwire.ClosingSigned](t, bobMsgs[0]) + require.Equal(t, aliceOffer.FeeSatoshis, bobOffer.FeeSatoshis) + + // If we modify Bob's offer, and try to have Alice process it, then she + // should reject it. + ogOffer := bobOffer.FeeSatoshis + bobOffer.FeeSatoshis /= 2 + + aliceMsgs, _, err = aliceCloser.ProcessCloseMsg(bobOffer) + require.Error(t, err) + require.Contains(t, err.Error(), "was not accepted") + + // We'll now restore the original offer before passing it on to Alice. + bobOffer.FeeSatoshis = ogOffer + + // If we use the original offer, then Alice should accept this message, + // and finalize the shutdown process. We expect a message here as Alice + // will echo back the final message. + aliceMsgs, closeFinished, err = aliceCloser.ProcessCloseMsg(bobMsgs[0]) + require.NoError(t, err) + require.True(t, closeFinished) + require.Len(t, aliceMsgs, 1) + require.IsType(t, &lnwire.ClosingSigned{}, aliceMsgs[0]) + + // Alice should now also broadcast her closing transaction. + _, err = lnutils.RecvOrTimeout(broadcastSignal, time.Second*1) + require.NoError(t, err) + + // Finally, Bob will process Alice's echo message, and conclude. + bobMsgs, closeFinished, err = bobCloser.ProcessCloseMsg(aliceMsgs[0]) + require.NoError(t, err) + require.True(t, closeFinished) + require.Len(t, bobMsgs, 0) +} diff --git a/lnwallet/chancloser/interface.go b/lnwallet/chancloser/interface.go new file mode 100644 index 000000000..33ba8b93b --- /dev/null +++ b/lnwallet/chancloser/interface.go @@ -0,0 +1,109 @@ +package chancloser + +import ( + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" +) + +// CoopFeeEstimator is used to estimate the fee of a co-op close transaction. +type CoopFeeEstimator interface { + // EstimateFee estimates an _absolute_ fee for a co-op close transaction + // given the local+remote tx outs (for the co-op close transaction), + // channel type, and ideal fee rate. If a passed TxOut is nil, then + // that indicates that an output is dust on the co-op close transaction + // _before_ fees are accounted for. + EstimateFee(chanType channeldb.ChannelType, + localTxOut, remoteTxOut *wire.TxOut, + idealFeeRate chainfee.SatPerKWeight) btcutil.Amount +} + +// Channel abstracts away from the core channel state machine by exposing an +// interface that requires only the methods we need to carry out the channel +// closing process. +type Channel interface { + // ChannelPoint returns the channel point of the target channel. + ChannelPoint() *wire.OutPoint + + // MarkCoopBroadcasted persistently marks that the channel close + // transaction has been broadcast. + MarkCoopBroadcasted(*wire.MsgTx, bool) error + + // IsInitiator returns true we are the initiator of the channel. + IsInitiator() bool + + // ShortChanID returns the scid of the channel. + ShortChanID() lnwire.ShortChannelID + + // ChanType returns the channel type of the channel. + ChanType() channeldb.ChannelType + + // FundingTxOut returns the funding output of the channel. + FundingTxOut() *wire.TxOut + + // AbsoluteThawHeight returns the absolute thaw height of the channel. + // If the channel is pending, or an unconfirmed zero conf channel, then + // an error should be returned. + AbsoluteThawHeight() (uint32, error) + + // LocalBalanceDust returns true if when creating a co-op close + // transaction, the balance of the local party will be dust after + // accounting for any anchor outputs. + LocalBalanceDust() bool + + // RemoteBalanceDust returns true if when creating a co-op close + // transaction, the balance of the remote party will be dust after + // accounting for any anchor outputs. + RemoteBalanceDust() bool + + // RemoteUpfrontShutdownScript returns the upfront shutdown script of + // the remote party. If the remote party didn't specify such a script, + // an empty delivery address should be returned. + RemoteUpfrontShutdownScript() lnwire.DeliveryAddress + + // CreateCloseProposal creates a new co-op close proposal in the form + // of a valid signature, the chainhash of the final txid, and our final + // balance in the created state. + CreateCloseProposal(proposedFee btcutil.Amount, localDeliveryScript []byte, + remoteDeliveryScript []byte, + closeOpt ...lnwallet.ChanCloseOpt) (input.Signature, *chainhash.Hash, + btcutil.Amount, error) + + // CompleteCooperativeClose persistently "completes" the cooperative + // close by producing a fully signed co-op close transaction. + CompleteCooperativeClose(localSig, remoteSig input.Signature, + localDeliveryScript, remoteDeliveryScript []byte, + proposedFee btcutil.Amount, closeOpt ...lnwallet.ChanCloseOpt, + ) (*wire.MsgTx, btcutil.Amount, error) +} + +// MusigSession is an interface that abstracts away the details of the musig2 +// session details. A session is used to generate the necessary closing options +// needed to close a channel cooperatively. +type MusigSession interface { + // ProposalClosingOpts generates the set of closing options needed to + // generate a new musig2 proposal signature. + ProposalClosingOpts() ([]lnwallet.ChanCloseOpt, error) + + // CombineClosingOpts returns the options that should be used when + // combining the final musig partial signature. The method also maps + // the lnwire partial signatures into an input.Signature that can be + // used more generally. + CombineClosingOpts(localSig, remoteSig lnwire.PartialSig, + ) (input.Signature, input.Signature, []lnwallet.ChanCloseOpt, error) + + // ClosingNonce generates the nonce we'll use to generate the musig2 + // partial signatures for the co-op close transaction. + ClosingNonce() (*musig2.Nonces, error) + + // InitRemoteNonce saves the remote nonce the party sent during their + // shutdown message so it can be used later to generate and verify + // signatures. + InitRemoteNonce(nonce *musig2.Nonces) +} diff --git a/lnwallet/channel.go b/lnwallet/channel.go index bcf78c6a4..52fbbf422 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -26,6 +26,7 @@ import ( "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/channeldb/models" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/shachain" @@ -7091,6 +7092,30 @@ func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel, }, nil } +// chanCloseOpt is a functional option that can be used to modify the co-op +// close process. +type chanCloseOpt struct { + musigSession *MusigSession +} + +// ChanCloseOpt is a closure type that cen be used to modify the set of default +// options. +type ChanCloseOpt func(*chanCloseOpt) + +// defaultCloseOpts is the default set of close options. +func defaultCloseOpts() *chanCloseOpt { + return &chanCloseOpt{} +} + +// WithCoopCloseMusigSession can be used to apply an existing musig2 session to +// the cooperative close process. If specified, then a musig2 co-op close +// (single sig keyspend) will be used. +func WithCoopCloseMusigSession(session *MusigSession) ChanCloseOpt { + return func(opts *chanCloseOpt) { + opts.musigSession = session + } +} + // CreateCloseProposal is used by both parties in a cooperative channel close // workflow to generate proposed close transactions and signatures. This method // should only be executed once all pending HTLCs (if any) on the channel have @@ -7102,8 +7127,8 @@ func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel, // TODO(roasbeef): caller should initiate signal to reject all incoming HTLCs, // settle any in flight. func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount, - localDeliveryScript []byte, - remoteDeliveryScript []byte) (input.Signature, *chainhash.Hash, + localDeliveryScript []byte, remoteDeliveryScript []byte, + closeOpts ...ChanCloseOpt) (input.Signature, *chainhash.Hash, btcutil.Amount, error) { lc.Lock() @@ -7115,6 +7140,11 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount, return nil, nil, 0, ErrChanClosing } + opts := defaultCloseOpts() + for _, optFunc := range closeOpts { + optFunc(opts) + } + // Get the final balances after subtracting the proposed fee, taking // care not to persist the adjusted balance, as the feeRate may change // during the channel closing process. @@ -7140,14 +7170,25 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount, return nil, nil, 0, err } - // Finally, sign the completed cooperative closure transaction. As the - // initiator we'll simply send our signature over to the remote party, - // using the generated txid to be notified once the closure transaction - // has been confirmed. - lc.signDesc.SigHashes = input.NewTxSigHashesV0Only(closeTx) - sig, err := lc.Signer.SignOutputRaw(closeTx, lc.signDesc) - if err != nil { - return nil, nil, 0, err + // If we have a co-op close musig session, then this is a taproot + // channel, so we'll generate a _partial_ signature. + var sig input.Signature + if opts.musigSession != nil { + sig, err = opts.musigSession.SignCommit(closeTx) + if err != nil { + return nil, nil, 0, err + } + } else { + // For regular channels we'll, sign the completed cooperative + // closure transaction. As the initiator we'll simply send our + // signature over to the remote party, using the generated txid + // to be notified once the closure transaction has been + // confirmed. + lc.signDesc.SigHashes = input.NewTxSigHashesV0Only(closeTx) + sig, err = lc.Signer.SignOutputRaw(closeTx, lc.signDesc) + if err != nil { + return nil, nil, 0, err + } } // As everything checks out, indicate in the channel status that a @@ -7168,7 +7209,8 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount, func (lc *LightningChannel) CompleteCooperativeClose( localSig, remoteSig input.Signature, localDeliveryScript, remoteDeliveryScript []byte, - proposedFee btcutil.Amount) (*wire.MsgTx, btcutil.Amount, error) { + proposedFee btcutil.Amount, + closeOpts ...ChanCloseOpt) (*wire.MsgTx, btcutil.Amount, error) { lc.Lock() defer lc.Unlock() @@ -7179,6 +7221,11 @@ func (lc *LightningChannel) CompleteCooperativeClose( return nil, 0, ErrChanClosing } + opts := defaultCloseOpts() + for _, optFunc := range closeOpts { + optFunc(opts) + } + // Get the final balances after subtracting the proposed fee. ourBalance, theirBalance, err := CoopCloseBalance( lc.channelState.ChanType, lc.channelState.IsInitiator, @@ -7201,31 +7248,62 @@ func (lc *LightningChannel) CompleteCooperativeClose( // consensus rules such as being too big, or having any value with a // negative output. tx := btcutil.NewTx(closeTx) + prevOut := lc.signDesc.Output if err := blockchain.CheckTransactionSanity(tx); err != nil { return nil, 0, err } - hashCache := input.NewTxSigHashesV0Only(closeTx) - // Finally, construct the witness stack minding the order of the - // pubkeys+sigs on the stack. - ourKey := lc.channelState.LocalChanCfg.MultiSigKey.PubKey. - SerializeCompressed() - theirKey := lc.channelState.RemoteChanCfg.MultiSigKey.PubKey. - SerializeCompressed() - witness := input.SpendMultiSig( - lc.signDesc.WitnessScript, ourKey, localSig, theirKey, - remoteSig, + prevOutputFetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, ) - closeTx.TxIn[0].Witness = witness + hashCache := txscript.NewTxSigHashes(closeTx, prevOutputFetcher) + + // Next, we'll complete the co-op close transaction. Depending on the + // set of options, we'll either do a regular p2wsh spend, or construct + // the final schnorr signature from a set of partial sigs. + if opts.musigSession != nil { + // For taproot channels, we'll use the attached session to + // combine the two partial signatures into a proper schnorr + // signature. + remotePartialSig, ok := remoteSig.(*MusigPartialSig) + if !ok { + return nil, 0, fmt.Errorf("expected MusigPartialSig, "+ + "got %T", remoteSig) + } + + finalSchnorrSig, err := opts.musigSession.CombineSigs( + remotePartialSig.sig, + ) + if err != nil { + return nil, 0, fmt.Errorf("unable to combine "+ + "final co-op close sig: %w", err) + } + + // The witness for a keyspend is just the signature itself. + closeTx.TxIn[0].Witness = wire.TxWitness{ + finalSchnorrSig.Serialize(), + } + } else { + + // For regular channels, we'll need to , construct the witness + // stack minding the order of the pubkeys+sigs on the stack. + ourKey := lc.channelState.LocalChanCfg.MultiSigKey.PubKey. + SerializeCompressed() + theirKey := lc.channelState.RemoteChanCfg.MultiSigKey.PubKey. + SerializeCompressed() + witness := input.SpendMultiSig( + lc.signDesc.WitnessScript, ourKey, localSig, theirKey, + remoteSig, + ) + closeTx.TxIn[0].Witness = witness + + } // Validate the finalized transaction to ensure the output script is // properly met, and that the remote peer supplied a valid signature. - prevOut := lc.signDesc.Output vm, err := txscript.NewEngine( prevOut.PkScript, closeTx, 0, txscript.StandardVerifyFlags, nil, - hashCache, prevOut.Value, txscript.NewCannedPrevOutputFetcher( - prevOut.PkScript, prevOut.Value, - ), + hashCache, prevOut.Value, prevOutputFetcher, ) if err != nil { return nil, 0, err @@ -7885,11 +7963,23 @@ func (lc *LightningChannel) IdealCommitFeeRate(netFeeRate, minRelayFeeRate, return absoluteMaxFee } -// RemoteNextRevocation returns the channelState's RemoteNextRevocation. +// RemoteNextRevocation returns the channelState's RemoteNextRevocation. For +// musig2 channels, until a nonce pair is processed by the remote party, a nil +// public key is returned. +// +// TODO(roasbeef): revisit, maybe just make a more general method instead? func (lc *LightningChannel) RemoteNextRevocation() *btcec.PublicKey { lc.RLock() defer lc.RUnlock() + if !lc.channelState.ChanType.IsTaproot() { + return lc.channelState.RemoteNextRevocation + } + + if lc.musigSessions == nil { + return nil + } + return lc.channelState.RemoteNextRevocation } @@ -8158,3 +8248,28 @@ func (lc *LightningChannel) InitRemoteMusigNonces(remoteNonce *musig2.Nonces, return nil } + +// ChanType returns the channel type. +func (lc *LightningChannel) ChanType() channeldb.ChannelType { + lc.RLock() + defer lc.RUnlock() + + return lc.channelState.ChanType +} + +// FundingTxOut returns the funding output of the channel. +func (lc *LightningChannel) FundingTxOut() *wire.TxOut { + lc.RLock() + defer lc.RUnlock() + + return &lc.fundingOutput +} + +// MultiSigKeys returns the set of multi-sig keys for an channel. +func (lc *LightningChannel) MultiSigKeys() (keychain.KeyDescriptor, keychain.KeyDescriptor) { + lc.RLock() + defer lc.RUnlock() + + return lc.channelState.LocalChanCfg.MultiSigKey, + lc.channelState.RemoteChanCfg.MultiSigKey +} diff --git a/peer/brontide.go b/peer/brontide.go index 6f57ae234..c589afa2f 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -2903,6 +2903,7 @@ func (p *Brontide) createChanCloser(channel *lnwallet.LightningChannel, chanCloser := chancloser.NewChanCloser( chancloser.ChanCloseCfg{ Channel: channel, + MusigSession: NewMusigChanCloser(channel), FeeEstimator: &chancloser.SimpleCoopFeeEstimator{}, BroadcastTx: p.cfg.Wallet.PublishTransaction, DisableChannel: func(op wire.OutPoint) error { diff --git a/peer/musig_chan_closer.go b/peer/musig_chan_closer.go new file mode 100644 index 000000000..22330cfd7 --- /dev/null +++ b/peer/musig_chan_closer.go @@ -0,0 +1,127 @@ +package peer + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chancloser" + "github.com/lightningnetwork/lnd/lnwire" +) + +// MusigChanCloser is an adapter over the normal channel state machine that +// allows the chan closer to handle the musig2 details of closing taproot +// channels. +type MusigChanCloser struct { + channel *lnwallet.LightningChannel + + musigSession *lnwallet.MusigSession + + localNonce *musig2.Nonces + remoteNonce *musig2.Nonces +} + +// NewMusigChanCloser creates a new musig chan closer from a normal channel. +func NewMusigChanCloser(channel *lnwallet.LightningChannel) *MusigChanCloser { + return &MusigChanCloser{ + channel: channel, + } +} + +// ProposalClosingOpts returns the options that should be used when +// generating a new co-op close signature. +func (m *MusigChanCloser) ProposalClosingOpts() ([]lnwallet.ChanCloseOpt, error) { + switch { + case m.localNonce == nil: + return nil, fmt.Errorf("local nonce not generated") + + case m.remoteNonce == nil: + return nil, fmt.Errorf("remote nonce not generated") + } + + localKey, remoteKey := m.channel.MultiSigKeys() + m.musigSession = lnwallet.NewPartialMusigSession( + *m.localNonce, localKey, remoteKey, + m.channel.Signer, m.channel.FundingTxOut(), + lnwallet.RemoteMusigCommit, + ) + + err := m.musigSession.FinalizeSession(*m.remoteNonce) + if err != nil { + return nil, err + } + + return []lnwallet.ChanCloseOpt{ + lnwallet.WithCoopCloseMusigSession(m.musigSession), + }, nil +} + +// CombineClosingOpts returns the options that should be used when combining +// the final musig partial signature. The method also maps the lnwire partial +// signatures into an input.Signature that can be used more generally. +func (m *MusigChanCloser) CombineClosingOpts(localSig, + remoteSig lnwire.PartialSig) (input.Signature, input.Signature, + []lnwallet.ChanCloseOpt, error) { + + if m.musigSession == nil { + return nil, nil, nil, fmt.Errorf("musig session not created") + } + + // We'll convert the wire partial signatures into an input.Signature + // compliant struct so we can pass it into the final combination + // function. + localPartialSig := &lnwire.PartialSigWithNonce{ + PartialSig: localSig, + Nonce: m.localNonce.PubNonce, + } + remotePartialSig := &lnwire.PartialSigWithNonce{ + PartialSig: remoteSig, + Nonce: m.remoteNonce.PubNonce, + } + + localMuSig := new(lnwallet.MusigPartialSig).FromWireSig( + localPartialSig, + ) + remoteMuSig := new(lnwallet.MusigPartialSig).FromWireSig( + remotePartialSig, + ) + + opts := []lnwallet.ChanCloseOpt{ + lnwallet.WithCoopCloseMusigSession(m.musigSession), + } + + // For taproot channels, we'll need to pass along the session so the + // final combined signature can be created. + return localMuSig, remoteMuSig, opts, nil +} + +// ClosingNonce returns the nonce that should be used when generating the our +// partial signature for the remote party. +func (m *MusigChanCloser) ClosingNonce() (*musig2.Nonces, error) { + if m.localNonce != nil { + return m.localNonce, nil + } + + localKey, _ := m.channel.MultiSigKeys() + nonce, err := musig2.GenNonces( + musig2.WithPublicKey(localKey.PubKey), + ) + if err != nil { + return nil, err + } + + m.localNonce = nonce + + return nonce, nil +} + +// InitRemoteNonce saves the remote nonce the party sent during their shutdown +// message so it can be used later to generate and verify signatures. +func (m *MusigChanCloser) InitRemoteNonce(nonce *musig2.Nonces) { + m.remoteNonce = nonce +} + +// A compile-time assertion to ensure MusigChanCloser implements the +// chancloser.MusigSession interface. +var _ chancloser.MusigSession = (*MusigChanCloser)(nil)