lnd/lnwallet/chancloser/chancloser.go
Olaoluwa Osuntokun 8dee76a1b8
peer: decorate delivery addr w/ internal key
In this commit, we move to add the internal key to the delivery addr. This way, we give the aux chan closer the extra information it may need to properly augment the normal co-op close process.
2024-09-19 10:18:41 +02:00

1313 lines
44 KiB
Go

package chancloser
import (
"bytes"
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/davecgh/go-spew/spew"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/htlcswitch"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/labels"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnutils"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
)
var (
// ErrChanAlreadyClosing is returned when a channel shutdown is
// attempted more than once.
ErrChanAlreadyClosing = fmt.Errorf("channel shutdown already initiated")
// ErrChanCloseNotFinished is returned when a caller attempts to access
// a field or function that is contingent on the channel closure
// negotiation already being completed.
ErrChanCloseNotFinished = fmt.Errorf("close negotiation not finished")
// ErrInvalidState is returned when the closing state machine receives a
// message while it is in an unknown state.
ErrInvalidState = fmt.Errorf("invalid state")
// ErrUpfrontShutdownScriptMismatch is returned when a peer or end user
// provides a cooperative close script which does not match the upfront
// shutdown script previously set for that party.
ErrUpfrontShutdownScriptMismatch = fmt.Errorf("shutdown script does not " +
"match upfront shutdown script")
// ErrProposalExceedsMaxFee is returned when as the initiator, the
// latest fee proposal sent by the responder exceed our max fee.
// responder.
ErrProposalExceedsMaxFee = fmt.Errorf("latest fee proposal exceeds " +
"max fee")
// ErrInvalidShutdownScript is returned when we receive an address from
// a peer that isn't either a p2wsh or p2tr address.
ErrInvalidShutdownScript = fmt.Errorf("invalid shutdown script")
// errNoShutdownNonce is returned when a shutdown message is received
// w/o a nonce for a taproot channel.
errNoShutdownNonce = fmt.Errorf("shutdown nonce not populated")
)
// closeState represents all the possible states the channel closer state
// machine can be in. Each message will either advance to the next state, or
// remain at the current state. Once the state machine reaches a state of
// closeFinished, then negotiation is over.
type closeState uint8
const (
// closeIdle is the initial starting state. In this state, the state
// machine has been instantiated, but no state transitions have been
// attempted. If a state machine receives a message while in this state,
// then it is the responder to an initiated cooperative channel closure.
closeIdle closeState = iota
// closeShutdownInitiated is the state that's transitioned to once the
// initiator of a closing workflow sends the shutdown message. At this
// point, they're waiting for the remote party to respond with their own
// shutdown message. After which, they'll both enter the fee negotiation
// phase.
closeShutdownInitiated
// closeAwaitingFlush is the state that's transitioned to once both
// Shutdown messages have been exchanged but we are waiting for the
// HTLCs to clear out of the channel.
closeAwaitingFlush
// closeFeeNegotiation is the third, and most persistent state. Both
// parties enter this state after they've sent and received a shutdown
// message. During this phase, both sides will send monotonically
// increasing fee requests until one side accepts the last fee rate
// offered by the other party. In this case, the party will broadcast
// the closing transaction, and send the accepted fee to the remote
// party. This then causes a shift into the closeFinished state.
closeFeeNegotiation
// closeFinished is the final state of the state machine. In this state,
// a side has accepted a fee offer and has broadcast the valid closing
// transaction to the network. During this phase, the closing
// transaction becomes available for examination.
closeFinished
)
const (
// defaultMaxFeeMultiplier is a multiplier we'll apply to the ideal fee
// of the initiator, to decide when the negotiated fee is too high. By
// default, we want to bail out if we attempt to negotiate a fee that's
// 3x higher than our max fee.
defaultMaxFeeMultiplier = 3
)
// DeliveryAddrWithKey wraps a normal delivery addr, but also includes the
// internal key for the delivery addr if known.
type DeliveryAddrWithKey struct {
// DeliveryAddress is the raw, serialized pkScript of the delivery
// address.
lnwire.DeliveryAddress
// InternalKey is the Taproot internal key of the delivery address, if
// the address is a P2TR output.
InternalKey fn.Option[btcec.PublicKey]
}
// 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
// DisableChannel disables a channel, resulting in it not being able to
// forward payments.
DisableChannel func(wire.OutPoint) error
// Disconnect will disconnect from the remote peer in this close.
Disconnect func() error
// MaxFee, is non-zero represents the highest fee that the initiator is
// willing to pay to close the channel.
MaxFee chainfee.SatPerKWeight
// ChainParams holds the parameters of the chain that we're active on.
ChainParams *chaincfg.Params
// Quit is a channel that should be sent upon in the occasion the state
// machine should cease all progress and shutdown.
Quit chan struct{}
// FeeEstimator is used to estimate the absolute starting co-op close
// fee.
FeeEstimator CoopFeeEstimator
// AuxCloser is an optional interface that can be used to modify the
// way the co-op close process proceeds.
AuxCloser fn.Option[AuxChanCloser]
}
// ChanCloser is a state machine that handles the cooperative channel closure
// procedure. This includes shutting down a channel, marking it ineligible for
// routing HTLC's, negotiating fees with the remote party, and finally
// broadcasting the fully signed closure transaction to the network.
type ChanCloser struct {
// state is the current state of the state machine.
state closeState
// cfg holds the configuration for this ChanCloser instance.
cfg ChanCloseCfg
// chanPoint is the full channel point of the target channel.
chanPoint wire.OutPoint
// cid is the full channel ID of the target channel.
cid lnwire.ChannelID
// negotiationHeight is the height that the fee negotiation begun at.
negotiationHeight uint32
// closingTx is the final, fully signed closing transaction. This will
// only be populated once the state machine shifts to the closeFinished
// state.
closingTx *wire.MsgTx
// idealFeeSat is the ideal fee that the state machine should initially
// offer when starting negotiation. This will be used as a baseline.
idealFeeSat btcutil.Amount
// maxFee is the highest fee the initiator is willing to pay to close
// out the channel. This is either a use specified value, or a default
// multiplier based of the initial starting ideal fee.
maxFee btcutil.Amount
// idealFeeRate is our ideal fee rate.
idealFeeRate chainfee.SatPerKWeight
// lastFeeProposal is the last fee that we proposed to the remote party.
// We'll use this as a pivot point to ratchet our next offer up, down,
// or simply accept the remote party's prior offer.
lastFeeProposal btcutil.Amount
// priorFeeOffers is a map that keeps track of all the proposed fees
// that we've offered during the fee negotiation. We use this map to cut
// the negotiation early if the remote party ever sends an offer that
// we've sent in the past. Once negotiation terminates, we can extract
// the prior signature of our accepted offer from this map.
//
// TODO(roasbeef): need to ensure if they broadcast w/ any of our prior
// sigs, we are aware of
priorFeeOffers map[btcutil.Amount]*lnwire.ClosingSigned
// closeReq is the initial closing request. This will only be populated
// if we're the initiator of this closing negotiation.
//
// TODO(roasbeef): abstract away
closeReq *htlcswitch.ChanClose
// localDeliveryScript is the script that we'll send our settled channel
// funds to.
localDeliveryScript []byte
// localInternalKey is the local delivery address Taproot internal key,
// if the local delivery script is a P2TR output.
localInternalKey fn.Option[btcec.PublicKey]
// remoteDeliveryScript is the script that we'll send the remote party's
// settled channel funds to.
remoteDeliveryScript []byte
// closer is ChannelParty who initiated the coop close
closer lntypes.ChannelParty
// cachedClosingSigned is a cached copy of a received ClosingSigned that
// we use to handle a specific race condition caused by the independent
// message processing queues.
cachedClosingSigned fn.Option[lnwire.ClosingSigned]
// localCloseOutput is the local output on the closing transaction that
// the local party should be paid to. This will only be populated if the
// local balance isn't dust.
localCloseOutput fn.Option[CloseOutput]
// remoteCloseOutput is the remote output on the closing transaction
// that the remote party should be paid to. This will only be populated
// if the remote balance isn't dust.
remoteCloseOutput fn.Option[CloseOutput]
// auxOutputs are the optional additional outputs that might be added to
// the closing transaction.
auxOutputs fn.Option[AuxCloseOutputs]
}
// calcCoopCloseFee computes an "ideal" absolute co-op close fee given the
// delivery scripts of both parties and our ideal fee rate.
func calcCoopCloseFee(chanType channeldb.ChannelType,
localOutput, remoteOutput *wire.TxOut,
idealFeeRate chainfee.SatPerKWeight) btcutil.Amount {
var weightEstimator input.TxWeightEstimator
if chanType.IsTaproot() {
weightEstimator.AddWitnessInput(
input.TaprootSignatureWitnessSize,
)
} else {
weightEstimator.AddWitnessInput(input.MultiSigWitnessSize)
}
// One of these outputs might be dust, so we'll skip adding it to our
// mock transaction, so the fees are more accurate.
if localOutput != nil {
weightEstimator.AddTxOutput(localOutput)
}
if remoteOutput != nil {
weightEstimator.AddTxOutput(remoteOutput)
}
totalWeight := weightEstimator.Weight()
return idealFeeRate.FeeForWeight(totalWeight)
}
// SimpleCoopFeeEstimator is the default co-op close fee estimator. It assumes
// a normal segwit v0 channel, and that no outputs on the closing transaction
// are dust.
type SimpleCoopFeeEstimator struct {
}
// 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.
func (d *SimpleCoopFeeEstimator) EstimateFee(chanType channeldb.ChannelType,
localTxOut, remoteTxOut *wire.TxOut,
idealFeeRate chainfee.SatPerKWeight) btcutil.Amount {
return calcCoopCloseFee(chanType, localTxOut, remoteTxOut, idealFeeRate)
}
// NewChanCloser creates a new instance of the channel closure given the passed
// configuration, and delivery+fee preference. The final argument should only
// be populated iff, we're the initiator of this closing request.
func NewChanCloser(cfg ChanCloseCfg, deliveryScript DeliveryAddrWithKey,
idealFeePerKw chainfee.SatPerKWeight, negotiationHeight uint32,
closeReq *htlcswitch.ChanClose,
closer lntypes.ChannelParty) *ChanCloser {
chanPoint := cfg.Channel.ChannelPoint()
cid := lnwire.NewChanIDFromOutPoint(chanPoint)
return &ChanCloser{
closeReq: closeReq,
state: closeIdle,
chanPoint: chanPoint,
cid: cid,
cfg: cfg,
negotiationHeight: negotiationHeight,
idealFeeRate: idealFeePerKw,
localInternalKey: deliveryScript.InternalKey,
localDeliveryScript: deliveryScript.DeliveryAddress,
priorFeeOffers: make(
map[btcutil.Amount]*lnwire.ClosingSigned,
),
closer: closer,
}
}
// initFeeBaseline computes our ideal fee rate, and also the largest fee we'll
// accept given information about the delivery script of the remote party.
func (c *ChanCloser) initFeeBaseline() {
// Depending on if a balance ends up being dust or not, we'll pass a
// nil TxOut into the EstimateFee call which can handle it.
var localTxOut, remoteTxOut *wire.TxOut
if isDust, _ := c.cfg.Channel.LocalBalanceDust(); !isDust {
localTxOut = &wire.TxOut{
PkScript: c.localDeliveryScript,
Value: 0,
}
}
if isDust, _ := c.cfg.Channel.RemoteBalanceDust(); !isDust {
remoteTxOut = &wire.TxOut{
PkScript: c.remoteDeliveryScript,
Value: 0,
}
}
// Given the target fee-per-kw, we'll compute what our ideal _total_
// fee will be starting at for this fee negotiation.
c.idealFeeSat = c.cfg.FeeEstimator.EstimateFee(
0, localTxOut, remoteTxOut, c.idealFeeRate,
)
// When we're the initiator, we'll want to also factor in the highest
// fee we want to pay. This'll either be 3x the ideal fee, or the
// specified explicit max fee.
c.maxFee = c.idealFeeSat * defaultMaxFeeMultiplier
if c.cfg.MaxFee > 0 {
c.maxFee = c.cfg.FeeEstimator.EstimateFee(
0, localTxOut, remoteTxOut, c.cfg.MaxFee,
)
}
chancloserLog.Infof("Ideal fee for closure of ChannelPoint(%v) "+
"is: %v sat (max_fee=%v sat)", c.cfg.Channel.ChannelPoint(),
int64(c.idealFeeSat), int64(c.maxFee))
}
// initChanShutdown begins the shutdown process by un-registering the channel,
// and creating a valid shutdown message to our target delivery address.
func (c *ChanCloser) initChanShutdown() (*lnwire.Shutdown, error) {
// With both items constructed we'll now send the shutdown message for
// this particular channel, advertising a shutdown request to our
// desired closing script.
shutdown := lnwire.NewShutdown(c.cid, c.localDeliveryScript)
// At this point, we'll check to see if we have any custom records to
// add to the shutdown message.
err := fn.MapOptionZ(c.cfg.AuxCloser, func(a AuxChanCloser) error {
shutdownCustomRecords, err := a.ShutdownBlob(AuxShutdownReq{
ChanPoint: c.chanPoint,
ShortChanID: c.cfg.Channel.ShortChanID(),
Initiator: c.cfg.Channel.IsInitiator(),
InternalKey: c.localInternalKey,
CommitBlob: c.cfg.Channel.LocalCommitmentBlob(),
FundingBlob: c.cfg.Channel.FundingBlob(),
})
if err != nil {
return err
}
shutdownCustomRecords.WhenSome(func(cr lnwire.CustomRecords) {
shutdown.CustomRecords = cr
})
return nil
})
if err != nil {
return nil, err
}
// 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.SomeShutdownNonce(
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)
}
chancloserLog.Infof("ChannelPoint(%v): sending shutdown message",
c.chanPoint)
// At this point, we persist any relevant info regarding the Shutdown
// message we are about to send in order to ensure that if a
// re-establish occurs then we will re-send the same Shutdown message.
shutdownInfo := channeldb.NewShutdownInfo(
c.localDeliveryScript, c.closer.IsLocal(),
)
err = c.cfg.Channel.MarkShutdownSent(shutdownInfo)
if err != nil {
return nil, err
}
// We'll track our local close output, even if it's dust in BTC terms,
// it might still carry value in custom channel terms.
_, dustAmt := c.cfg.Channel.LocalBalanceDust()
localBalance, _ := c.cfg.Channel.CommitBalances()
c.localCloseOutput = fn.Some(CloseOutput{
Amt: localBalance,
DustLimit: dustAmt,
PkScript: c.localDeliveryScript,
ShutdownRecords: shutdown.CustomRecords,
})
return shutdown, nil
}
// ShutdownChan is the first method that's to be called by the initiator of the
// cooperative channel closure. This message returns the shutdown message to
// send to the remote party. Upon completion, we enter the
// closeShutdownInitiated phase as we await a response.
func (c *ChanCloser) ShutdownChan() (*lnwire.Shutdown, error) {
// If we attempt to shutdown the channel for the first time, and we're not
// in the closeIdle state, then the caller made an error.
if c.state != closeIdle {
return nil, ErrChanAlreadyClosing
}
chancloserLog.Infof("ChannelPoint(%v): initiating shutdown", c.chanPoint)
shutdownMsg, err := c.initChanShutdown()
if err != nil {
return nil, err
}
// With the opening steps complete, we'll transition into the
// closeShutdownInitiated state. In this state, we'll wait until the
// other party sends their version of the shutdown message.
c.state = closeShutdownInitiated
// Finally, we'll return the shutdown message to the caller so it can
// send it to the remote peer.
return shutdownMsg, nil
}
// ClosingTx returns the fully signed, final closing transaction.
//
// NOTE: This transaction is only available if the state machine is in the
// closeFinished state.
func (c *ChanCloser) ClosingTx() (*wire.MsgTx, error) {
// If the state machine hasn't finished closing the channel, then we'll
// return an error as we haven't yet computed the closing tx.
if c.state != closeFinished {
return nil, ErrChanCloseNotFinished
}
return c.closingTx, nil
}
// CloseRequest returns the original close request that prompted the creation
// of the state machine.
//
// NOTE: This will only return a non-nil pointer if we were the initiator of
// the cooperative closure workflow.
func (c *ChanCloser) CloseRequest() *htlcswitch.ChanClose {
return c.closeReq
}
// Channel returns the channel stored in the config as a
// *lnwallet.LightningChannel.
//
// 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)
}
// NegotiationHeight returns the negotiation height.
func (c *ChanCloser) NegotiationHeight() uint32 {
return c.negotiationHeight
}
// LocalCloseOutput returns the local close output.
func (c *ChanCloser) LocalCloseOutput() fn.Option[CloseOutput] {
return c.localCloseOutput
}
// RemoteCloseOutput returns the remote close output.
func (c *ChanCloser) RemoteCloseOutput() fn.Option[CloseOutput] {
return c.remoteCloseOutput
}
// AuxOutputs returns optional extra outputs.
func (c *ChanCloser) AuxOutputs() fn.Option[AuxCloseOutputs] {
return c.auxOutputs
}
// validateShutdownScript attempts to match and validate the script provided in
// our peer's shutdown message with the upfront shutdown script we have on
// record. For any script specified, we also make sure it matches our
// requirements. If no upfront shutdown script was set, we do not need to
// enforce option upfront shutdown, so the function returns early. If an
// upfront script is set, we check whether it matches the script provided by
// our peer. If they do not match, we use the disconnect function provided to
// disconnect from the peer.
func validateShutdownScript(disconnect func() error, upfrontScript,
peerScript lnwire.DeliveryAddress, netParams *chaincfg.Params) error {
// Either way, we'll make sure that the script passed meets our
// standards. The upfrontScript should have already been checked at an
// earlier stage, but we'll repeat the check here for defense in depth.
if len(upfrontScript) != 0 {
if !lnwallet.ValidateUpfrontShutdown(upfrontScript, netParams) {
return ErrInvalidShutdownScript
}
}
if len(peerScript) != 0 {
if !lnwallet.ValidateUpfrontShutdown(peerScript, netParams) {
return ErrInvalidShutdownScript
}
}
// If no upfront shutdown script was set, return early because we do
// not need to enforce closure to a specific script.
if len(upfrontScript) == 0 {
return nil
}
// If an upfront shutdown script was provided, disconnect from the peer, as
// per BOLT 2, and return an error.
if !bytes.Equal(upfrontScript, peerScript) {
chancloserLog.Warnf("peer's script: %x does not match upfront "+
"shutdown script: %x", peerScript, upfrontScript)
// Disconnect from the peer because they have violated option upfront
// shutdown.
if err := disconnect(); err != nil {
return err
}
return ErrUpfrontShutdownScriptMismatch
}
return nil
}
// ReceiveShutdown takes a raw Shutdown message and uses it to try and advance
// the ChanCloser state machine, failing if it is coming in at an invalid time.
// If appropriate, it will also generate a Shutdown message of its own to send
// out to the peer. It is possible for this method to return None when no error
// occurred.
func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
fn.Option[lnwire.Shutdown], error) {
noShutdown := fn.None[lnwire.Shutdown]()
// We'll track their remote close output, even if it's dust in BTC
// terms, it might still carry value in custom channel terms.
_, dustAmt := c.cfg.Channel.RemoteBalanceDust()
_, remoteBalance := c.cfg.Channel.CommitBalances()
c.remoteCloseOutput = fn.Some(CloseOutput{
Amt: remoteBalance,
DustLimit: dustAmt,
PkScript: msg.Address,
ShutdownRecords: msg.CustomRecords,
})
switch c.state {
// If we're in the close idle state, and we're receiving a channel
// closure related message, then this indicates that we're on the
// receiving side of an initiated channel closure.
case closeIdle:
// As we're the responder to this shutdown (the other party
// wants to close), we'll check if this is a frozen channel or
// not. If the channel is frozen and we were not also the
// initiator of the channel opening, then we'll deny their close
// attempt.
chanInitiator := c.cfg.Channel.IsInitiator()
if !chanInitiator {
absoluteThawHeight, err :=
c.cfg.Channel.AbsoluteThawHeight()
if err != nil {
return noShutdown, err
}
if c.negotiationHeight < absoluteThawHeight {
return noShutdown, fmt.Errorf("initiator "+
"attempting to co-op close frozen "+
"ChannelPoint(%v) (current_height=%v, "+
"thaw_height=%v)", c.chanPoint,
c.negotiationHeight, absoluteThawHeight)
}
}
// If the remote node opened the channel with option upfront
// shutdown script, check that the script they provided matches.
if err := validateShutdownScript(
c.cfg.Disconnect,
c.cfg.Channel.RemoteUpfrontShutdownScript(),
msg.Address, c.cfg.ChainParams,
); err != nil {
return noShutdown, err
}
// Once we have checked that the other party has not violated
// option upfront shutdown we set their preference for delivery
// address. We'll use this when we craft the closure
// transaction.
c.remoteDeliveryScript = msg.Address
// We'll generate a shutdown message of our own to send across
// the wire.
localShutdown, err := c.initChanShutdown()
if err != nil {
return noShutdown, 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() {
shutdownNonce, err := msg.ShutdownNonce.UnwrapOrErrV(
errNoShutdownNonce,
)
if err != nil {
return noShutdown, err
}
c.cfg.MusigSession.InitRemoteNonce(&musig2.Nonces{
PubNonce: shutdownNonce,
})
}
chancloserLog.Infof("ChannelPoint(%v): responding to shutdown",
c.chanPoint)
// After the other party receives this message, we'll actually
// start the final stage of the closure process: fee
// negotiation. So we'll update our internal state to reflect
// this, so we can handle the next message sent.
c.state = closeAwaitingFlush
return fn.Some(*localShutdown), err
case closeShutdownInitiated:
// If the remote node opened the channel with option upfront
// shutdown script, check that the script they provided matches.
if err := validateShutdownScript(
c.cfg.Disconnect,
c.cfg.Channel.RemoteUpfrontShutdownScript(),
msg.Address, c.cfg.ChainParams,
); err != nil {
return noShutdown, err
}
// Now that we know this is a valid shutdown message and
// address, we'll record their preferred delivery closing
// script.
c.remoteDeliveryScript = msg.Address
// At this point, we can now start the fee negotiation state, by
// constructing and sending our initial signature for what we
// think the closing transaction should look like.
c.state = closeAwaitingFlush
// 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() {
shutdownNonce, err := msg.ShutdownNonce.UnwrapOrErrV(
errNoShutdownNonce,
)
if err != nil {
return noShutdown, err
}
c.cfg.MusigSession.InitRemoteNonce(&musig2.Nonces{
PubNonce: shutdownNonce,
})
}
chancloserLog.Infof("ChannelPoint(%v): shutdown response "+
"received, entering fee negotiation", c.chanPoint)
return noShutdown, nil
default:
// Otherwise we are not in a state where we can accept this
// message.
return noShutdown, ErrInvalidState
}
}
// BeginNegotiation should be called when we have definitively reached a clean
// channel state and are ready to cooperatively arrive at a closing transaction.
// If it is our responsibility to kick off the negotiation, this method will
// generate a ClosingSigned message. If it is the remote's responsibility, then
// it will not. In either case it will transition the ChanCloser state machine
// to the negotiation phase wherein ClosingSigned messages are exchanged until
// a mutually agreeable result is achieved.
func (c *ChanCloser) BeginNegotiation() (fn.Option[lnwire.ClosingSigned],
error) {
noClosingSigned := fn.None[lnwire.ClosingSigned]()
switch c.state {
case closeAwaitingFlush:
// Now that we know their desired delivery script, we can
// compute what our max/ideal fee will be.
c.initFeeBaseline()
// 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.closer,
)
if err != nil {
return noClosingSigned, err
}
// At this point, we can now start the fee negotiation state, by
// constructing and sending our initial signature for what we
// think the closing transaction should look like.
c.state = closeFeeNegotiation
if !c.cfg.Channel.IsInitiator() {
// By default this means we do nothing, but we do want
// to check if we have a cached remote offer to process.
// If we do, we'll process it here.
res := noClosingSigned
err = nil
c.cachedClosingSigned.WhenSome(
func(cs lnwire.ClosingSigned) {
res, err = c.ReceiveClosingSigned(cs)
},
)
return res, err
}
// We'll craft our initial close proposal in order to keep the
// negotiation moving, but only if we're the initiator.
closingSigned, err := c.proposeCloseSigned(c.idealFeeSat)
if err != nil {
return noClosingSigned,
fmt.Errorf("unable to sign new co op "+
"close offer: %w", err)
}
return fn.Some(*closingSigned), nil
default:
return noClosingSigned, ErrInvalidState
}
}
// ReceiveClosingSigned is a method that should be called whenever we receive a
// ClosingSigned message from the wire. It may or may not return a
// ClosingSigned of our own to send back to the remote.
func (c *ChanCloser) ReceiveClosingSigned( //nolint:funlen
msg lnwire.ClosingSigned) (fn.Option[lnwire.ClosingSigned], error) {
noClosing := fn.None[lnwire.ClosingSigned]()
switch c.state {
case closeAwaitingFlush:
// If we hit this case it either means there's a protocol
// violation or that our chanCloser received the remote offer
// before the link finished processing the channel flush.
c.cachedClosingSigned = fn.Some(msg)
return fn.None[lnwire.ClosingSigned](), nil
case closeFeeNegotiation:
// 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 && msg.PartialSig.IsNone() {
return noClosing,
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 := msg.FeeSatoshis
_, 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 noClosing, fmt.Errorf("unable to sign "+
"new co op close offer: %w", err)
}
// 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 noClosing,
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.
proposal := calcCompromiseFee(
c.chanPoint, c.idealFeeSat, c.lastFeeProposal,
remoteProposedFee,
)
if c.cfg.Channel.IsInitiator() && proposal > c.maxFee {
return noClosing, fmt.Errorf(
"%w: %v > %v",
ErrProposalExceedsMaxFee,
proposal, 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.
closeSigned, err := c.proposeCloseSigned(proposal)
if err != nil {
return noClosing, 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 proposal != remoteProposedFee {
chancloserLog.Debugf("ChannelPoint(%v): close "+
"tx fee disagreement, continuing "+
"negotiation", c.chanPoint)
return fn.Some(*closeSigned), nil
}
}
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.
var (
localSig, remoteSig input.Signature
closeOpts []lnwallet.ChanCloseOpt
err error
)
matchingSig := c.priorFeeOffers[remoteProposedFee]
if c.cfg.Channel.ChanType().IsTaproot() {
localWireSig, err := matchingSig.PartialSig.UnwrapOrErrV( //nolint:lll
fmt.Errorf("none local sig"),
)
if err != nil {
return noClosing, err
}
remoteWireSig, err := msg.PartialSig.UnwrapOrErrV(
fmt.Errorf("none remote sig"),
)
if err != nil {
return noClosing, err
}
muSession := c.cfg.MusigSession
localSig, remoteSig, closeOpts, err = muSession.CombineClosingOpts( //nolint:lll
localWireSig, remoteWireSig,
)
if err != nil {
return noClosing, err
}
} else {
localSig, err = matchingSig.Signature.ToSignature()
if err != nil {
return noClosing, err
}
remoteSig, err = msg.Signature.ToSignature()
if err != nil {
return noClosing, err
}
}
// Before we complete the cooperative close, we'll see if we
// have any extra aux options.
c.auxOutputs, err = c.auxCloseOutputs(remoteProposedFee)
if err != nil {
return noClosing, err
}
c.auxOutputs.WhenSome(func(outs AuxCloseOutputs) {
closeOpts = append(
closeOpts, lnwallet.WithExtraCloseOutputs(
outs.ExtraCloseOutputs,
),
)
closeOpts = append(
closeOpts, lnwallet.WithCustomCoopSort(
outs.CustomSort,
),
)
})
closeTx, _, err := c.cfg.Channel.CompleteCooperativeClose(
localSig, remoteSig, c.localDeliveryScript,
c.remoteDeliveryScript, remoteProposedFee, closeOpts...,
)
if err != nil {
return noClosing, err
}
c.closingTx = closeTx
// If there's an aux chan closer, then we'll finalize with it
// before we write to disk.
err = fn.MapOptionZ(
c.cfg.AuxCloser, func(aux AuxChanCloser) error {
channel := c.cfg.Channel
//nolint:lll
req := AuxShutdownReq{
ChanPoint: c.chanPoint,
ShortChanID: c.cfg.Channel.ShortChanID(),
InternalKey: c.localInternalKey,
Initiator: channel.IsInitiator(),
CommitBlob: channel.LocalCommitmentBlob(),
FundingBlob: channel.FundingBlob(),
}
desc := AuxCloseDesc{
AuxShutdownReq: req,
LocalCloseOutput: c.localCloseOutput,
RemoteCloseOutput: c.remoteCloseOutput,
}
return aux.FinalizeClose(desc, closeTx)
},
)
if err != nil {
return noClosing, err
}
// 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.closer,
)
if err != nil {
return noClosing, err
}
// With the closing transaction crafted, we'll now broadcast it
// to the network.
chancloserLog.Infof("Broadcasting cooperative close tx: %v",
lnutils.SpewLogClosure(closeTx))
// Create a close channel label.
chanID := c.cfg.Channel.ShortChanID()
closeLabel := labels.MakeLabel(
labels.LabelTypeChannelClose, &chanID,
)
if err := c.cfg.BroadcastTx(closeTx, closeLabel); err != nil {
return noClosing, 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.
c.state = closeFinished
matchingOffer := c.priorFeeOffers[remoteProposedFee]
return fn.Some(*matchingOffer), nil
// If we received a message while in the closeFinished state, then this
// should only be the remote party echoing the last ClosingSigned
// message that we agreed on.
case closeFinished:
// There's no more to do as both sides should have already
// broadcast the closing transaction at this state.
return noClosing, nil
default:
return noClosing, ErrInvalidState
}
}
// auxCloseOutputs returns any additional outputs that should be used when
// closing the channel.
func (c *ChanCloser) auxCloseOutputs(
closeFee btcutil.Amount) (fn.Option[AuxCloseOutputs], error) {
var closeOuts fn.Option[AuxCloseOutputs]
err := fn.MapOptionZ(c.cfg.AuxCloser, func(aux AuxChanCloser) error {
req := AuxShutdownReq{
ChanPoint: c.chanPoint,
ShortChanID: c.cfg.Channel.ShortChanID(),
InternalKey: c.localInternalKey,
Initiator: c.cfg.Channel.IsInitiator(),
CommitBlob: c.cfg.Channel.LocalCommitmentBlob(),
FundingBlob: c.cfg.Channel.FundingBlob(),
}
outs, err := aux.AuxCloseOutputs(AuxCloseDesc{
AuxShutdownReq: req,
CloseFee: closeFee,
CommitFee: c.cfg.Channel.CommitFee(),
LocalCloseOutput: c.localCloseOutput,
RemoteCloseOutput: c.remoteCloseOutput,
})
if err != nil {
return err
}
closeOuts = outs
return nil
})
if err != nil {
return closeOuts, err
}
return closeOuts, nil
}
// proposeCloseSigned attempts to propose a new signature for the closing
// 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
}
}
// We'll also now see if the aux chan closer has any additional options
// for the closing purpose.
c.auxOutputs, err = c.auxCloseOutputs(fee)
if err != nil {
return nil, err
}
c.auxOutputs.WhenSome(func(outs AuxCloseOutputs) {
closeOpts = append(
closeOpts, lnwallet.WithExtraCloseOutputs(
outs.ExtraCloseOutputs,
),
)
closeOpts = append(
closeOpts, lnwallet.WithCustomCoopSort(
outs.CustomSort,
),
)
})
// With all our options added, we'll attempt to co-op close now.
rawSig, _, _, err := c.cfg.Channel.CreateCloseProposal(
fee, c.localDeliveryScript, c.remoteDeliveryScript,
closeOpts...,
)
if err != nil {
return nil, err
}
// 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.
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)
}
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.
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 combined once our offer is
// accepted.
if partialSig != nil {
closeSignedMsg.PartialSig = lnwire.SomePartialSig(
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
return closeSignedMsg, nil
}
// feeInAcceptableRange returns true if the passed remote fee is deemed to be
// in an "acceptable" range to our local fee. This is an attempt at a
// compromise and to ensure that the fee negotiation has a stopping point. We
// consider their fee acceptable if it's within 30% of our fee.
func feeInAcceptableRange(localFee, remoteFee btcutil.Amount) bool {
// If our offer is lower than theirs, then we'll accept their offer if
// it's no more than 30% *greater* than our current offer.
if localFee < remoteFee {
acceptableRange := localFee + ((localFee * 3) / 10)
return remoteFee <= acceptableRange
}
// If our offer is greater than theirs, then we'll accept their offer if
// it's no more than 30% *less* than our current offer.
acceptableRange := localFee - ((localFee * 3) / 10)
return remoteFee >= acceptableRange
}
// ratchetFee is our step function used to inch our fee closer to something
// that both sides can agree on. If up is true, then we'll attempt to increase
// our offered fee. Otherwise, if up is false, then we'll attempt to decrease
// our offered fee.
func ratchetFee(fee btcutil.Amount, up bool) btcutil.Amount {
// If we need to ratchet up, then we'll increase our fee by 10%.
if up {
return fee + ((fee * 1) / 10)
}
// Otherwise, we'll *decrease* our fee by 10%.
return fee - ((fee * 1) / 10)
}
// calcCompromiseFee performs the current fee negotiation algorithm, taking
// into consideration our ideal fee based on current fee environment, the fee
// we last proposed (if any), and the fee proposed by the peer.
func calcCompromiseFee(chanPoint wire.OutPoint, ourIdealFee, lastSentFee,
remoteFee btcutil.Amount) btcutil.Amount {
// TODO(roasbeef): take in number of rounds as well?
chancloserLog.Infof("ChannelPoint(%v): computing fee compromise, "+
"ideal=%v, last_sent=%v, remote_offer=%v", chanPoint,
int64(ourIdealFee), int64(lastSentFee), int64(remoteFee))
// Otherwise, we'll need to attempt to make a fee compromise if this is
// the second round, and neither side has agreed on fees.
switch {
// If their proposed fee is identical to our ideal fee, then we'll go
// with that as we can short circuit the fee negotiation. Similarly, if
// we haven't sent an offer yet, we'll default to our ideal fee.
case ourIdealFee == remoteFee || lastSentFee == 0:
return ourIdealFee
// If the last fee we sent, is equal to the fee the remote party is
// offering, then we can simply return this fee as the negotiation is
// over.
case remoteFee == lastSentFee:
return lastSentFee
// If the fee the remote party is offering is less than the last one we
// sent, then we'll need to ratchet down in order to move our offer
// closer to theirs.
case remoteFee < lastSentFee:
// If the fee is lower, but still acceptable, then we'll just
// return this fee and end the negotiation.
if feeInAcceptableRange(lastSentFee, remoteFee) {
chancloserLog.Infof("ChannelPoint(%v): proposed "+
"remote fee is close enough, capitulating",
chanPoint)
return remoteFee
}
// Otherwise, we'll ratchet the fee *down* using our current
// algorithm.
return ratchetFee(lastSentFee, false)
// If the fee the remote party is offering is greater than the last one
// we sent, then we'll ratchet up in order to ensure we terminate
// eventually.
case remoteFee > lastSentFee:
// If the fee is greater, but still acceptable, then we'll just
// return this fee in order to put an end to the negotiation.
if feeInAcceptableRange(lastSentFee, remoteFee) {
chancloserLog.Infof("ChannelPoint(%v): proposed "+
"remote fee is close enough, capitulating",
chanPoint)
return remoteFee
}
// Otherwise, we'll ratchet the fee up using our current
// algorithm.
return ratchetFee(lastSentFee, true)
default:
// TODO(roasbeef): fail if their fee isn't in expected range
return remoteFee
}
}
// ParseUpfrontShutdownAddress attempts to parse an upfront shutdown address.
// If the address is empty, it returns nil. If it successfully decoded the
// address, it returns a script that pays out to the address.
func ParseUpfrontShutdownAddress(address string,
params *chaincfg.Params) (lnwire.DeliveryAddress, error) {
if len(address) == 0 {
return nil, nil
}
addr, err := btcutil.DecodeAddress(
address, params,
)
if err != nil {
return nil, fmt.Errorf("invalid address: %w", err)
}
if !addr.IsForNet(params) {
return nil, fmt.Errorf("invalid address: %v is not a %s "+
"address", addr, params.Name)
}
return txscript.PayToAddrScript(addr)
}