Merge pull request #8512 from lightningnetwork/rbf-coop-fsm

[3/4] - lnwallet/chancloser: add new protofsm based RBF chan closer
This commit is contained in:
Olaoluwa Osuntokun authored and GitHub committed 2024-12-11 00:57:46 +01:00
commit d6eeaec246
17 files changed
+4159 -102

No files matched your search

+2 -10
View File
@@ -539,8 +539,8 @@ func (c *ChanCloser) AuxOutputs() fn.Option[AuxCloseOutputs] {
// 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 {
func validateShutdownScript(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
@@ -568,12 +568,6 @@ func validateShutdownScript(disconnect func() error, upfrontScript,
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
}
@@ -630,7 +624,6 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
// 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 {
@@ -681,7 +674,6 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
// 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 {
+3 -3
View File
@@ -129,8 +129,8 @@ func TestMaybeMatchScript(t *testing.T) {
t.Parallel()
err := validateShutdownScript(
func() error { return nil }, test.upfrontScript,
test.shutdownScript, &chaincfg.SimNetParams,
test.upfrontScript, test.shutdownScript,
&chaincfg.SimNetParams,
)
if err != test.expectedErr {
@@ -189,7 +189,7 @@ func (m *mockChannel) RemoteUpfrontShutdownScript() lnwire.DeliveryAddress {
func (m *mockChannel) CreateCloseProposal(fee btcutil.Amount,
localScript, remoteScript []byte,
_ ...lnwallet.ChanCloseOpt) (input.Signature, *chainhash.Hash,
_ ...lnwallet.ChanCloseOpt) (input.Signature, *wire.MsgTx,
btcutil.Amount, error) {
if m.chanType.IsTaproot() {
+1 -2
View File
@@ -3,7 +3,6 @@ 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/fn/v2"
@@ -100,7 +99,7 @@ type Channel interface { //nolint:interfacebloat
localDeliveryScript []byte, remoteDeliveryScript []byte,
closeOpt ...lnwallet.ChanCloseOpt,
) (
input.Signature, *chainhash.Hash, btcutil.Amount, error)
input.Signature, *wire.MsgTx, btcutil.Amount, error)
// CompleteCooperativeClose persistently "completes" the cooperative
// close by producing a fully signed co-op close transaction.
+176
View File
@@ -0,0 +1,176 @@
package chancloser
import (
"sync/atomic"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/mock"
)
type dummyAdapters struct {
mock.Mock
msgSent atomic.Bool
confChan chan *chainntnfs.TxConfirmation
spendChan chan *chainntnfs.SpendDetail
}
func newDaemonAdapters() *dummyAdapters {
return &dummyAdapters{
confChan: make(chan *chainntnfs.TxConfirmation, 1),
spendChan: make(chan *chainntnfs.SpendDetail, 1),
}
}
func (d *dummyAdapters) SendMessages(pub btcec.PublicKey,
msgs []lnwire.Message) error {
defer d.msgSent.Store(true)
args := d.Called(pub, msgs)
return args.Error(0)
}
func (d *dummyAdapters) BroadcastTransaction(tx *wire.MsgTx,
label string) error {
args := d.Called(tx, label)
return args.Error(0)
}
func (d *dummyAdapters) DisableChannel(op wire.OutPoint) error {
args := d.Called(op)
return args.Error(0)
}
func (d *dummyAdapters) RegisterConfirmationsNtfn(txid *chainhash.Hash,
pkScript []byte, numConfs, heightHint uint32,
opts ...chainntnfs.NotifierOption,
) (*chainntnfs.ConfirmationEvent, error) {
args := d.Called(txid, pkScript, numConfs)
err := args.Error(0)
return &chainntnfs.ConfirmationEvent{
Confirmed: d.confChan,
}, err
}
func (d *dummyAdapters) RegisterSpendNtfn(outpoint *wire.OutPoint,
pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) {
args := d.Called(outpoint, pkScript, heightHint)
err := args.Error(0)
return &chainntnfs.SpendEvent{
Spend: d.spendChan,
}, err
}
type mockFeeEstimator struct {
mock.Mock
}
func (m *mockFeeEstimator) EstimateFee(chanType channeldb.ChannelType,
localTxOut, remoteTxOut *wire.TxOut,
idealFeeRate chainfee.SatPerKWeight) btcutil.Amount {
args := m.Called(chanType, localTxOut, remoteTxOut, idealFeeRate)
return args.Get(0).(btcutil.Amount)
}
type mockChanObserver struct {
mock.Mock
}
func (m *mockChanObserver) NoDanglingUpdates() bool {
args := m.Called()
return args.Bool(0)
}
func (m *mockChanObserver) DisableIncomingAdds() error {
args := m.Called()
return args.Error(0)
}
func (m *mockChanObserver) DisableOutgoingAdds() error {
args := m.Called()
return args.Error(0)
}
func (m *mockChanObserver) MarkCoopBroadcasted(txn *wire.MsgTx,
local bool) error {
args := m.Called(txn, local)
return args.Error(0)
}
func (m *mockChanObserver) MarkShutdownSent(deliveryAddr []byte,
isInitiator bool) error {
args := m.Called(deliveryAddr, isInitiator)
return args.Error(0)
}
func (m *mockChanObserver) FinalBalances() fn.Option[ShutdownBalances] {
args := m.Called()
return args.Get(0).(fn.Option[ShutdownBalances])
}
func (m *mockChanObserver) DisableChannel() error {
args := m.Called()
return args.Error(0)
}
type mockErrorReporter struct {
mock.Mock
}
func (m *mockErrorReporter) ReportError(err error) {
m.Called(err)
}
type mockCloseSigner struct {
mock.Mock
}
func (m *mockCloseSigner) CreateCloseProposal(fee btcutil.Amount,
localScript []byte, remoteScript []byte,
closeOpt ...lnwallet.ChanCloseOpt) (
input.Signature, *wire.MsgTx, btcutil.Amount, error) {
args := m.Called(fee, localScript, remoteScript, closeOpt)
return args.Get(0).(input.Signature), args.Get(1).(*wire.MsgTx),
args.Get(2).(btcutil.Amount), args.Error(3)
}
func (m *mockCloseSigner) CompleteCooperativeClose(localSig,
remoteSig input.Signature,
localScript, remoteScript []byte,
fee btcutil.Amount, closeOpt ...lnwallet.ChanCloseOpt,
) (*wire.MsgTx, btcutil.Amount, error) {
args := m.Called(
localSig, remoteSig, localScript, remoteScript, fee, closeOpt,
)
return args.Get(0).(*wire.MsgTx), args.Get(1).(btcutil.Amount),
args.Error(2)
}
@@ -0,0 +1,77 @@
package chancloser
import (
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
)
// RbfMsgMapper is a struct that implements the MsgMapper interface for the
// rbf-coop close state machine. This enables the state machine to be used with
// protofsm.
type RbfMsgMapper struct {
// blockHeight is the height of the block when the co-op close request
// was initiated. This is used to validate conditions related to the
// thaw height.
blockHeight uint32
// chanID is the channel ID of the channel being closed.
chanID lnwire.ChannelID
}
// NewRbfMsgMapper creates a new RbfMsgMapper instance given the current block
// height when the co-op close request was initiated.
func NewRbfMsgMapper(blockHeight uint32,
chanID lnwire.ChannelID) *RbfMsgMapper {
return &RbfMsgMapper{
blockHeight: blockHeight,
chanID: chanID,
}
}
// someEvent returns the target type as a protocol event option.
func someEvent[T ProtocolEvent](m T) fn.Option[ProtocolEvent] {
return fn.Some(ProtocolEvent(m))
}
// isExpectedChanID returns true if the channel ID of the message matches the
// bound instance.
func (r *RbfMsgMapper) isExpectedChanID(chanID lnwire.ChannelID) bool {
return r.chanID == chanID
}
// MapMsg maps a wire message into a FSM event. If the message is not mappable,
// then an error is returned.
func (r *RbfMsgMapper) MapMsg(wireMsg lnwire.Message) fn.Option[ProtocolEvent] {
switch msg := wireMsg.(type) {
case *lnwire.Shutdown:
if !r.isExpectedChanID(msg.ChannelID) {
return fn.None[ProtocolEvent]()
}
return someEvent(&ShutdownReceived{
BlockHeight: r.blockHeight,
ShutdownScript: msg.Address,
})
case *lnwire.ClosingComplete:
if !r.isExpectedChanID(msg.ChannelID) {
return fn.None[ProtocolEvent]()
}
return someEvent(&OfferReceivedEvent{
SigMsg: *msg,
})
case *lnwire.ClosingSig:
if !r.isExpectedChanID(msg.ChannelID) {
return fn.None[ProtocolEvent]()
}
return someEvent(&LocalSigReceived{
SigMsg: *msg,
})
}
return fn.None[ProtocolEvent]()
}
+763
View File
@@ -0,0 +1,763 @@
package chancloser
import (
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/protofsm"
)
var (
// ErrInvalidStateTransition is returned when we receive an unexpected
// event for a given state.
ErrInvalidStateTransition = fmt.Errorf("invalid state transition")
// ErrTooManySigs is returned when we receive too many sigs from the
// remote party in the ClosingSigs message.
ErrTooManySigs = fmt.Errorf("too many sigs received")
// ErrNoSig is returned when we receive no sig from the remote party.
ErrNoSig = fmt.Errorf("no sig received")
// ErrUnknownFinalBalance is returned if we're unable to determine the
// final channel balance after a flush.
ErrUnknownFinalBalance = fmt.Errorf("unknown final balance")
// ErrRemoteCannotPay is returned if the remote party cannot pay the
// pay for the fees when it sends a signature.
ErrRemoteCannotPay = fmt.Errorf("remote cannot pay fees")
// ErrNonFinalSequence is returned if we receive a non-final sequence
// from the remote party for their signature.
ErrNonFinalSequence = fmt.Errorf("received non-final sequence")
// ErrCloserNoClosee is returned if our balance is dust, but the remote
// party includes our output.
ErrCloserNoClosee = fmt.Errorf("expected CloserNoClosee sig")
// ErrCloserAndClosee is returned when we expect a sig covering both
// outputs, it isn't present.
ErrCloserAndClosee = fmt.Errorf("expected CloserAndClosee sig")
)
// ProtocolEvent is a special interface used to create the equivalent of a
// sum-type, but using a "sealed" interface. Protocol events can be used as
// input to trigger a state transition, and also as output to trigger a new set
// of events into the very same state machine.
type ProtocolEvent interface {
protocolSealed()
}
// ProtocolEvents is a special type constraint that enumerates all the possible
// protocol events. This is used mainly as type-level documentation, and may
// also be useful to constraint certain state transition functions.
type ProtocolEvents interface {
SendShutdown | ShutdownReceived | ShutdownComplete | ChannelFlushed |
SendOfferEvent | OfferReceivedEvent | LocalSigReceived |
SpendEvent
}
// SpendEvent indicates that a transaction spending the funding outpoint has
// been confirmed in the main chain.
type SpendEvent struct {
// Tx is the spending transaction that has been confirmed.
Tx *wire.MsgTx
// BlockHeight is the height of the block that confirmed the
// transaction.
BlockHeight uint32
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (s *SpendEvent) protocolSealed() {}
// SendShutdown indicates that the user wishes to co-op close the channel, so we
// should send a new shutdown message to the remote party.
//
// transition:
// - fromState: ChannelActive
// - toState: ChannelFlushing
type SendShutdown struct {
// DeliveryAddr is the address we'd like to receive the funds to. If
// None, then a new addr will be generated.
DeliveryAddr fn.Option[lnwire.DeliveryAddress]
// IdealFeeRate is the ideal fee rate we'd like to use for the closing
// attempt.
IdealFeeRate chainfee.SatPerVByte
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (s *SendShutdown) protocolSealed() {}
// ShutdownReceived indicates that we received a shutdown event so we need to
// enter the flushing state.
//
// transition:
// - fromState: ChannelActive
// - toState: ChannelFlushing
type ShutdownReceived struct {
// ShutdownScript is the script the remote party wants to use to
// shutdown.
ShutdownScript lnwire.DeliveryAddress
// BlockHeight is the height at which the shutdown message was
// received. This is used for channel leases to determine if a co-op
// close can occur.
BlockHeight uint32
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (s *ShutdownReceived) protocolSealed() {}
// ShutdownComplete is an event that indicates the channel has been fully
// shutdown. At this point, we'll go to the ChannelFlushing state so we can
// wait for all pending updates to be gone from the channel.
//
// transition:
// - fromState: ShutdownPending
// - toState: ChannelFlushing
type ShutdownComplete struct {
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (s *ShutdownComplete) protocolSealed() {}
// ShutdownBalances holds the local+remote balance once the channel has been
// fully flushed.
type ShutdownBalances struct {
// LocalBalance is the local balance of the channel.
LocalBalance lnwire.MilliSatoshi
// RemoteBalance is the remote balance of the channel.
RemoteBalance lnwire.MilliSatoshi
}
// unknownBalance is a special variable used to denote an unknown channel
// balance (channel not fully flushed yet).
var unknownBalance = ShutdownBalances{}
// ChannelFlushed is an event that indicates the channel has been fully flushed
// can we can now start closing negotiation.
//
// transition:
// - fromState: ChannelFlushing
// - toState: ClosingNegotiation
type ChannelFlushed struct {
// FreshFlush indicates if this is the first time the channel has been
// flushed, or if this is a flush as part of an RBF iteration.
FreshFlush bool
// ShutdownBalances is the balances of the channel once it has been
// flushed. We tie this to the ChannelFlushed state as this may not be
// the same as the starting value.
ShutdownBalances
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (c *ChannelFlushed) protocolSealed() {}
// SendOfferEvent is a self-triggered event that transitions us from the
// LocalCloseStart state to the LocalOfferSent state. This kicks off the new
// signing process for the co-op close process.
//
// transition:
// - fromState: LocalCloseStart
// - toState: LocalOfferSent
type SendOfferEvent struct {
// TargetFeeRate is the fee rate we'll use for the closing transaction.
TargetFeeRate chainfee.SatPerVByte
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (s *SendOfferEvent) protocolSealed() {}
// LocalSigReceived is an event that indicates we've received a signature from
// the remote party, which signs our the co-op close transaction at our
// specified fee rate.
//
// transition:
// - fromState: LocalOfferSent
// - toState: ClosePending
type LocalSigReceived struct {
// SigMsg is the sig message we received from the remote party.
SigMsg lnwire.ClosingSig
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (s *LocalSigReceived) protocolSealed() {}
// OfferReceivedEvent is an event that indicates we've received an offer from
// the remote party. This applies to the RemoteCloseStart state.
//
// transition:
// - fromState: RemoteCloseStart
// - toState: ClosePending
type OfferReceivedEvent struct {
// SigMsg is the signature message we received from the remote party.
SigMsg lnwire.ClosingComplete
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (s *OfferReceivedEvent) protocolSealed() {}
// CloseSigner is an interface that abstracts away the details of the signing
// new coop close transactions.
type CloseSigner interface {
// 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, *wire.MsgTx, 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)
}
// ChanStateObserver is an interface used to observe state changes that occur
// in a channel. This can be used to figure out if we're able to send a
// shutdown message or not.
type ChanStateObserver interface {
// NoDanglingUpdates returns true if there are no dangling updates in
// the channel. In other words, there are no active update messages
// that haven't already been covered by a commit sig.
NoDanglingUpdates() bool
// DisableIncomingAdds instructs the channel link to disable process new
// incoming add messages.
DisableIncomingAdds() error
// DisableOutgoingAdds instructs the channel link to disable process
// new outgoing add messages.
DisableOutgoingAdds() error
// DisableChannel attempts to disable a channel (marking it ineligible
// to forward), and also sends out a network update to disable the
// channel.
DisableChannel() error
// MarkCoopBroadcasted persistently marks that the channel close
// transaction has been broadcast.
MarkCoopBroadcasted(*wire.MsgTx, bool) error
// MarkShutdownSent persists the given ShutdownInfo. The existence of
// the ShutdownInfo represents the fact that the Shutdown message has
// been sent by us and so should be re-sent on re-establish.
MarkShutdownSent(deliveryAddr []byte, isInitiator bool) error
// FinalBalances is the balances of the channel once it has been
// flushed. If Some, then this indicates that the channel is now in a
// state where it's always flushed, so we can accelerate the state
// transitions.
FinalBalances() fn.Option[ShutdownBalances]
}
// Environment is a set of dependencies that a state machine may need to carry
// out the logic for a given state transition. All fields are to be considered
// immutable, and will be fixed for the lifetime of the state machine.
type Environment struct {
// ChainParams is the chain parameters for the channel.
ChainParams chaincfg.Params
// ChanPeer is the peer we're attempting to close the channel with.
ChanPeer btcec.PublicKey
// ChanPoint is the channel point of the active channel.
ChanPoint wire.OutPoint
// ChanID is the channel ID of the channel we're attempting to close.
ChanID lnwire.ChannelID
// ShortChanID is the short channel ID of the channel we're attempting
// to close.
Scid lnwire.ShortChannelID
// ChanType is the type of channel we're attempting to close.
ChanType channeldb.ChannelType
// BlockHeight is the current block height.
BlockHeight uint32
// DefaultFeeRate is the fee we'll use for the closing transaction if
// the user didn't specify an ideal fee rate. This may happen if the
// remote party is the one that initiates the co-op close.
DefaultFeeRate chainfee.SatPerVByte
// ThawHeight is the height at which the channel will be thawed. If
// this is None, then co-op close can occur at any moment.
ThawHeight fn.Option[uint32]
// RemoteUprontShutdown is the upfront shutdown addr of the remote
// party. We'll use this to validate if the remote peer is authorized to
// close the channel with the sent addr or not.
RemoteUpfrontShutdown fn.Option[lnwire.DeliveryAddress]
// LocalUprontShutdown is our upfront shutdown address. If Some, then
// we'll default to using this.
LocalUpfrontShutdown fn.Option[lnwire.DeliveryAddress]
// NewDeliveryScript is a function that returns a new delivery script.
// This is used if we don't have an upfront shutdown addr, and no addr
// was specified at closing time.
NewDeliveryScript func() (lnwire.DeliveryAddress, error)
// FeeEstimator is the fee estimator we'll use to determine the fee in
// satoshis we'll pay given a local and/or remote output.
FeeEstimator CoopFeeEstimator
// ChanObserver is an interface used to observe state changes to the
// channel. We'll use this to figure out when/if we can send certain
// messages.
ChanObserver ChanStateObserver
// CloseSigner is the signer we'll use to sign the close transaction.
// This is a part of the ChannelFlushed state, as the channel state
// we'll be signing can only be determined once the channel has been
// flushed.
CloseSigner CloseSigner
}
// Name returns the name of the environment. This is used to uniquely identify
// the environment of related state machines. For this state machine, the name
// is based on the channel ID.
func (e *Environment) Name() string {
return fmt.Sprintf("rbf_chan_closer(%v)", e.ChanPoint)
}
// CloseStateTransition is the StateTransition type specific to the coop close
// state machine.
//
//nolint:ll
type CloseStateTransition = protofsm.StateTransition[ProtocolEvent, *Environment]
// ProtocolState is our sum-type ish interface that represents the current
// protocol state.
type ProtocolState interface {
// protocolStateSealed is a special method that is used to seal the
// interface (only types in this package can implement it).
protocolStateSealed()
// IsTerminal returns true if the target state is a terminal state.
IsTerminal() bool
// ProcessEvent takes a protocol event, and implements a state
// transition for the state.
ProcessEvent(ProtocolEvent, *Environment) (*CloseStateTransition, error)
}
// AsymmetricPeerState is an extension of the normal ProtocolState interface
// that gives a caller a hit on if the target state should process an incoming
// event or not.
type AsymmetricPeerState interface {
ProtocolState
// ShouldRouteTo returns true if the target state should process the
// target event.
ShouldRouteTo(ProtocolEvent) bool
}
// ProtocolStates is a special type constraint that enumerates all the possible
// protocol states.
type ProtocolStates interface {
ChannelActive | ShutdownPending | ChannelFlushing | ClosingNegotiation |
LocalCloseStart | LocalOfferSent | RemoteCloseStart |
ClosePending | CloseFin
}
// ChannelActive is the base state for the channel closer state machine. In
// this state, we haven't begun the shutdown process yet, so the channel is
// still active. Receiving the ShutdownSent or ShutdownReceived events will
// transition us to the ChannelFushing state.
//
// When we transition to this state, we emit a DaemonEvent to send the shutdown
// message if we received one ourselves. Alternatively, we may send out a new
// shutdown if we're initiating it for the very first time.
//
// transition:
// - fromState: None
// - toState: ChannelFlushing
//
// input events:
// - SendShutdown
// - ShutdownReceived
type ChannelActive struct {
}
// IsTerminal returns true if the target state is a terminal state.
func (c *ChannelActive) IsTerminal() bool {
return false
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (c *ChannelActive) protocolStateSealed() {}
// ShutdownScripts is a set of scripts that we'll use to co-op close the
// channel.
type ShutdownScripts struct {
// LocalDeliveryScript is the script that we'll send our settled
// channel funds to.
LocalDeliveryScript lnwire.DeliveryAddress
// RemoteDeliveryScript is the script that we'll send the remote
// party's settled channel funds to.
RemoteDeliveryScript lnwire.DeliveryAddress
}
// ShutdownPending is the state we enter into after we've sent or received the
// shutdown message. If we sent the shutdown, then we'll wait for the remote
// party to send a shutdown. Otherwise, if we received it, then we'll send our
// shutdown then go to the next state.
//
// transition:
// - fromState: ChannelActive
// - toState: ChannelFlushing
//
// input events:
// - SendShutdown
// - ShutdownReceived
type ShutdownPending struct {
// ShutdownScripts store the set of scripts we'll use to initiate a coop
// close.
ShutdownScripts
// IdealFeeRate is the ideal fee rate we'd like to use for the closing
// attempt.
IdealFeeRate fn.Option[chainfee.SatPerVByte]
}
// IsTerminal returns true if the target state is a terminal state.
func (s *ShutdownPending) IsTerminal() bool {
return false
}
// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
func (s *ShutdownPending) protocolStateSealed() {}
// ChannelFlushing is the state we enter into after we've received or sent a
// shutdown message. In this state, we wait the ChannelFlushed event, after
// which we'll transition to the CloseReady state.
//
// transition:
// - fromState: ShutdownPending
// - toState: ClosingNegotiation
//
// input events:
// - ShutdownComplete
// - ShutdownReceived
type ChannelFlushing struct {
// EarlyRemoteOffer is the offer we received from the remote party
// before we obtained the local channel flushed event. We'll stash this
// to process later.
EarlyRemoteOffer fn.Option[OfferReceivedEvent]
// ShutdownScripts store the set of scripts we'll use to initiate a coop
// close.
ShutdownScripts
// IdealFeeRate is the ideal fee rate we'd like to use for the closing
// transaction. Once the channel has been flushed, we'll use this as
// our target fee rate.
IdealFeeRate fn.Option[chainfee.SatPerVByte]
}
// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
func (c *ChannelFlushing) protocolStateSealed() {}
// IsTerminal returns true if the target state is a terminal state.
func (c *ChannelFlushing) IsTerminal() bool {
return false
}
// ClosingNegotiation is the state we transition to once the channel has been
// flushed. This is actually a composite state that contains one for each side
// of the channel, as the closing process is asymmetric. Once either of the
// peer states reaches the CloseFin state, then the channel is fully closed,
// and we'll transition to that terminal state.
//
// transition:
// - fromState: ChannelFlushing
// - toState: CloseFin
//
// input events:
// - ChannelFlushed
type ClosingNegotiation struct {
// PeerStates is a composite state that contains the state for both the
// local and remote parties. Our usage of Dual makes this a special
// state that allows us to treat two states as a single state. We'll use
// the ShouldRouteTo method to determine which state route incoming
// events to.
PeerState lntypes.Dual[AsymmetricPeerState]
}
// IsTerminal returns true if the target state is a terminal state.
func (c *ClosingNegotiation) IsTerminal() bool {
return false
}
// protocolSealed indicates that this struct is a ProtocolEvent instance.
func (c *ClosingNegotiation) protocolStateSealed() {}
// CloseChannelTerms is a set of terms that we'll use to close the channel. This
// includes the balances of the channel, and the scripts we'll use to send each
// party's funds to.
type CloseChannelTerms struct {
ShutdownScripts
ShutdownBalances
}
// DeriveCloseTxOuts takes the close terms, and returns the local and remote tx
// out for the close transaction. If an output is dust, then it'll be nil.
//
// TODO(roasbeef): add func for w/e heuristic to not manifest own output?
func (c *CloseChannelTerms) DeriveCloseTxOuts() (*wire.TxOut, *wire.TxOut) {
//nolint:ll
deriveTxOut := func(balance btcutil.Amount, pkScript []byte) *wire.TxOut {
dustLimit := lnwallet.DustLimitForSize(len(pkScript))
if balance >= dustLimit {
return &wire.TxOut{
PkScript: pkScript,
Value: int64(balance),
}
}
return nil
}
localTxOut := deriveTxOut(
c.LocalBalance.ToSatoshis(),
c.LocalDeliveryScript,
)
remoteTxOut := deriveTxOut(
c.RemoteBalance.ToSatoshis(),
c.RemoteDeliveryScript,
)
return localTxOut, remoteTxOut
}
// RemoteAmtIsDust returns true if the remote output is dust.
func (c *CloseChannelTerms) RemoteAmtIsDust() bool {
return c.RemoteBalance.ToSatoshis() < lnwallet.DustLimitForSize(
len(c.RemoteDeliveryScript),
)
}
// LocalAmtIsDust returns true if the local output is dust.
func (c *CloseChannelTerms) LocalAmtIsDust() bool {
return c.LocalBalance.ToSatoshis() < lnwallet.DustLimitForSize(
len(c.LocalDeliveryScript),
)
}
// LocalCanPayFees returns true if the local party can pay the absolute fee
// from their local settled balance.
func (c *CloseChannelTerms) LocalCanPayFees(absoluteFee btcutil.Amount) bool {
return c.LocalBalance.ToSatoshis() >= absoluteFee
}
// RemoteCanPayFees returns true if the remote party can pay the absolute fee
// from their remote settled balance.
func (c *CloseChannelTerms) RemoteCanPayFees(absoluteFee btcutil.Amount) bool {
return c.RemoteBalance.ToSatoshis() >= absoluteFee
}
// LocalCloseStart is the state we enter into after we've received or sent
// shutdown, and the channel has been flushed. In this state, we'll emit a new
// event to send our offer to drive the rest of the process.
//
// transition:
// - fromState: ChannelFlushing
// - toState: LocalOfferSent
//
// input events:
// - SendOfferEvent
type LocalCloseStart struct {
CloseChannelTerms
}
// ShouldRouteTo returns true if the target state should process the target
// event.
func (l *LocalCloseStart) ShouldRouteTo(event ProtocolEvent) bool {
switch event.(type) {
case *SendOfferEvent:
return true
default:
return false
}
}
// IsTerminal returns true if the target state is a terminal state.
func (l *LocalCloseStart) IsTerminal() bool {
return false
}
// protocolStateaSealed indicates that this struct is a ProtocolEvent instance.
func (l *LocalCloseStart) protocolStateSealed() {}
// LocalOfferSent is the state we transition to after we reveiver the
// SendOfferEvent in the LocalCloseStart state. With this state we send our
// offer to the remote party, then await a sig from them which concludes the
// local cooperative close process.
//
// transition:
// - fromState: LocalCloseStart
// - toState: ClosePending
//
// input events:
// - LocalSigReceived
type LocalOfferSent struct {
CloseChannelTerms
// ProposedFee is the fee we proposed to the remote party.
ProposedFee btcutil.Amount
// LocalSig is the signature we sent to the remote party.
LocalSig lnwire.Sig
}
// ShouldRouteTo returns true if the target state should process the target
// event.
func (l *LocalOfferSent) ShouldRouteTo(event ProtocolEvent) bool {
switch event.(type) {
case *LocalSigReceived:
return true
default:
return false
}
}
// protocolStateaSealed indicates that this struct is a ProtocolEvent instance.
func (l *LocalOfferSent) protocolStateSealed() {}
// IsTerminal returns true if the target state is a terminal state.
func (l *LocalOfferSent) IsTerminal() bool {
return false
}
// ClosePending is the state we enter after concluding the negotiation for the
// remote or local state. At this point, given a confirmation notification we
// can terminate the process. Otherwise, we can receive a fresh CoopCloseReq to
// go back to the very start.
//
// transition:
// - fromState: LocalOfferSent || RemoteCloseStart
// - toState: CloseFin
//
// input events:
// - LocalSigReceived
// - OfferReceivedEvent
type ClosePending struct {
// CloseTx is the pending close transaction.
CloseTx *wire.MsgTx
}
// ShouldRouteTo returns true if the target state should process the target
// event.
func (c *ClosePending) ShouldRouteTo(event ProtocolEvent) bool {
switch event.(type) {
case *SpendEvent:
return true
default:
return false
}
}
// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
func (c *ClosePending) protocolStateSealed() {}
// IsTerminal returns true if the target state is a terminal state.
func (c *ClosePending) IsTerminal() bool {
return true
}
// CloseFin is the terminal state for the channel closer state machine. At this
// point, the close tx has been confirmed on chain.
type CloseFin struct {
// ConfirmedTx is the transaction that confirmed the channel close.
ConfirmedTx *wire.MsgTx
}
// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
func (c *CloseFin) protocolStateSealed() {}
// IsTerminal returns true if the target state is a terminal state.
func (c *CloseFin) IsTerminal() bool {
return true
}
// RemoteCloseStart is similar to the LocalCloseStart, but is used to drive the
// process of signing an offer for the remote party
//
// transition:
// - fromState: ChannelFlushing
// - toState: ClosePending
type RemoteCloseStart struct {
CloseChannelTerms
}
// ShouldRouteTo returns true if the target state should process the target
// event.
func (l *RemoteCloseStart) ShouldRouteTo(event ProtocolEvent) bool {
switch event.(type) {
case *OfferReceivedEvent:
return true
default:
return false
}
}
// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
func (l *RemoteCloseStart) protocolStateSealed() {}
// IsTerminal returns true if the target state is a terminal state.
func (l *RemoteCloseStart) IsTerminal() bool {
return false
}
// RbfChanCloser is a state machine that handles the RBF-enabled cooperative
// channel close protocol.
type RbfChanCloser = protofsm.StateMachine[ProtocolEvent, *Environment]
// RbfChanCloserCfg is a configuration struct that is used to initialize a new
// RBF chan closer state machine.
type RbfChanCloserCfg = protofsm.StateMachineCfg[ProtocolEvent, *Environment]
// RbfSpendMapper is a type used to map the generic spend event to one specific
// to this package.
type RbfSpendMapper = protofsm.SpendMapper[ProtocolEvent]
func SpendMapper(spendEvent *chainntnfs.SpendDetail) ProtocolEvent {
return &SpendEvent{
Tx: spendEvent.SpendingTx,
BlockHeight: uint32(spendEvent.SpendingHeight),
}
}
// RbfMsgMapperT is a type used to map incoming wire messages to protocol
// events.
type RbfMsgMapperT = protofsm.MsgMapper[ProtocolEvent]
// RbfState is a type alias for the state of the RBF channel closer.
type RbfState = protofsm.State[ProtocolEvent, *Environment]
// RbfEvent is a type alias for the event type of the RBF channel closer.
type RbfEvent = protofsm.EmittedEvent[ProtocolEvent]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+104 -14
View File
@@ -8199,6 +8199,12 @@ type chanCloseOpt struct {
// transaction outputs. If this isn't set, then the default BIP-69
// sorting is used.
customSort CloseSortFunc
customSequence fn.Option[uint32]
customLockTime fn.Option[uint32]
customPayer fn.Option[lntypes.ChannelParty]
}
// ChanCloseOpt is a closure type that cen be used to modify the set of default
@@ -8235,6 +8241,31 @@ func WithCustomCoopSort(sorter CloseSortFunc) ChanCloseOpt {
}
}
// WithCustomSequence can be used to specify a custom sequence number for the
// co-op close process. Otherwise, a default non-final sequence will be used.
func WithCustomSequence(sequence uint32) ChanCloseOpt {
return func(opts *chanCloseOpt) {
opts.customSequence = fn.Some(sequence)
}
}
// WithCustomLockTime can be used to specify a custom lock time for the coop
// close transaction.
func WithCustomLockTime(lockTime uint32) ChanCloseOpt {
return func(opts *chanCloseOpt) {
opts.customLockTime = fn.Some(lockTime)
}
}
// WithCustomPayer can be used to specify a custom payer for the closing
// transaction. This overrides the default payer, which is the initiator of the
// channel.
func WithCustomPayer(payer lntypes.ChannelParty) ChanCloseOpt {
return func(opts *chanCloseOpt) {
opts.customPayer = fn.Some(payer)
}
}
// 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
@@ -8244,22 +8275,23 @@ func WithCustomCoopSort(sorter CloseSortFunc) ChanCloseOpt {
// returned.
func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
localDeliveryScript []byte, remoteDeliveryScript []byte,
closeOpts ...ChanCloseOpt) (input.Signature, *chainhash.Hash,
closeOpts ...ChanCloseOpt) (input.Signature, *wire.MsgTx,
btcutil.Amount, error) {
lc.Lock()
defer lc.Unlock()
// If we're already closing the channel, then ignore this request.
if lc.isClosed {
return nil, nil, 0, ErrChanClosing
}
opts := defaultCloseOpts()
for _, optFunc := range closeOpts {
optFunc(opts)
}
// Unless there's a custom payer (sign of the RBF flow), if we're
// already closing the channel, then ignore this request.
if lc.isClosed && opts.customPayer.IsNone() {
return nil, nil, 0, ErrChanClosing
}
// 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.
@@ -8269,6 +8301,7 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(),
lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
lc.channelState.LocalCommitment.CommitFee,
opts.customPayer,
)
if err != nil {
return nil, nil, 0, err
@@ -8294,6 +8327,18 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
)
}
opts.customSequence.WhenSome(func(sequence uint32) {
closeTxOpts = append(closeTxOpts, WithCustomTxInSequence(
sequence,
))
})
opts.customLockTime.WhenSome(func(lockTime uint32) {
closeTxOpts = append(closeTxOpts, WithCustomTxLockTime(
lockTime,
))
})
closeTx, err := CreateCooperativeCloseTx(
fundingTxIn(lc.channelState), lc.channelState.LocalChanCfg.DustLimit,
lc.channelState.RemoteChanCfg.DustLimit, ourBalance, theirBalance,
@@ -8332,8 +8377,8 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
}
}
closeTXID := closeTx.TxHash()
return sig, &closeTXID, ourBalance, nil
return sig, closeTx, ourBalance, nil
}
// CompleteCooperativeClose completes the cooperative closure of the target
@@ -8352,17 +8397,17 @@ func (lc *LightningChannel) CompleteCooperativeClose(
lc.Lock()
defer lc.Unlock()
// If the channel is already closing, then ignore this request.
if lc.isClosed {
// TODO(roasbeef): check to ensure no pending payments
return nil, 0, ErrChanClosing
}
opts := defaultCloseOpts()
for _, optFunc := range closeOpts {
optFunc(opts)
}
// Unless there's a custom payer (sign of the RBF flow), if we're
// already closing the channel, then ignore this request.
if lc.isClosed && opts.customPayer.IsNone() {
return nil, 0, ErrChanClosing
}
// Get the final balances after subtracting the proposed fee.
ourBalance, theirBalance, err := CoopCloseBalance(
lc.channelState.ChanType, lc.channelState.IsInitiator,
@@ -8370,6 +8415,7 @@ func (lc *LightningChannel) CompleteCooperativeClose(
lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(),
lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
lc.channelState.LocalCommitment.CommitFee,
opts.customPayer,
)
if err != nil {
return nil, 0, err
@@ -8395,6 +8441,18 @@ func (lc *LightningChannel) CompleteCooperativeClose(
)
}
opts.customSequence.WhenSome(func(sequence uint32) {
closeTxOpts = append(closeTxOpts, WithCustomTxInSequence(
sequence,
))
})
opts.customLockTime.WhenSome(func(lockTime uint32) {
closeTxOpts = append(closeTxOpts, WithCustomTxLockTime(
lockTime,
))
})
// Create the transaction used to return the current settled balance
// on this active channel back to both parties. In this current model,
// the initiator pays full fees for the cooperative close transaction.
@@ -9111,6 +9169,13 @@ type closeTxOpts struct {
// transaction outputs. If this isn't set, then the default BIP-69
// sorting is used.
customSort CloseSortFunc
// customSequence is an optional custom sequence to set on the co-op
// close transaction. This gives slightly more control compared to the
// enableRBF option.
customSequence fn.Option[uint32]
customLockTime fn.Option[uint32]
}
// defaultCloseTxOpts returns a closeTxOpts struct with default values.
@@ -9147,6 +9212,20 @@ func WithCustomTxSort(sorter CloseSortFunc) CloseTxOpt {
}
}
// WithCustomTxInSequence allows a caller to set a custom sequence on the sole
// input of the co-op close tx.
func WithCustomTxInSequence(sequence uint32) CloseTxOpt {
return func(o *closeTxOpts) {
o.customSequence = fn.Some(sequence)
}
}
func WithCustomTxLockTime(lockTime uint32) CloseTxOpt {
return func(o *closeTxOpts) {
o.customLockTime = fn.Some(lockTime)
}
}
// CreateCooperativeCloseTx creates a transaction which if signed by both
// parties, then broadcast cooperatively closes an active channel. The creation
// of the closure transaction is modified by a boolean indicating if the party
@@ -9169,6 +9248,11 @@ func CreateCooperativeCloseTx(fundingTxIn wire.TxIn,
fundingTxIn.Sequence = mempool.MaxRBFSequence
}
// Otherwise, a custom sequence might be specified.
opts.customSequence.WhenSome(func(sequence uint32) {
fundingTxIn.Sequence = sequence
})
// Construct the transaction to perform a cooperative closure of the
// channel. In the event that one side doesn't have any settled funds
// within the channel then a refund output for that particular side can
@@ -9176,6 +9260,12 @@ func CreateCooperativeCloseTx(fundingTxIn wire.TxIn,
closeTx := wire.NewMsgTx(2)
closeTx.AddTxIn(&fundingTxIn)
opts.customLockTime.WhenSome(func(lockTime uint32) {
closeTx.LockTime = lockTime
})
// TODO(roasbeef): needs support for dropping inputs
// Create both cooperative closure outputs, properly respecting the
// dust limits of both parties.
var localOutputIdx fn.Option[int]
+104 -21
View File
@@ -770,29 +770,66 @@ func TestCommitHTLCSigCustomRecordSize(t *testing.T) {
}
// TestCooperativeChannelClosure checks that the coop close process finishes
// with an agreement from both parties, and that the final balances of the
// close tx check out.
// with an agreement from both parties, and that the final balances of the close
// tx check out.
func TestCooperativeChannelClosure(t *testing.T) {
t.Run("tweakless", func(t *testing.T) {
testCoopClose(t, &coopCloseTestCase{
chanType: channeldb.SingleFunderTweaklessBit,
testCases := []struct {
name string
closeCase coopCloseTestCase
}{
{
name: "tweakless",
closeCase: coopCloseTestCase{
chanType: channeldb.SingleFunderTweaklessBit,
},
},
{
name: "anchors",
closeCase: coopCloseTestCase{
chanType: channeldb.SingleFunderTweaklessBit |
channeldb.AnchorOutputsBit,
anchorAmt: AnchorSize * 2,
},
},
{
name: "anchors local pay",
closeCase: coopCloseTestCase{
chanType: channeldb.SingleFunderTweaklessBit |
channeldb.AnchorOutputsBit,
anchorAmt: AnchorSize * 2,
customPayer: fn.Some(lntypes.Local),
},
},
{
name: "anchors remote pay",
closeCase: coopCloseTestCase{
chanType: channeldb.SingleFunderTweaklessBit |
channeldb.AnchorOutputsBit,
anchorAmt: AnchorSize * 2,
customPayer: fn.Some(lntypes.Remote),
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
testCoopClose(t, testCase.closeCase)
})
})
t.Run("anchors", func(t *testing.T) {
testCoopClose(t, &coopCloseTestCase{
chanType: channeldb.SingleFunderTweaklessBit |
channeldb.AnchorOutputsBit,
anchorAmt: AnchorSize * 2,
})
})
}
}
type coopCloseTestCase struct {
chanType channeldb.ChannelType
anchorAmt btcutil.Amount
customPayer fn.Option[lntypes.ChannelParty]
}
func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
type closeOpts struct {
aliceOpts []ChanCloseOpt
bobOpts []ChanCloseOpt
}
func testCoopClose(t *testing.T, testCase coopCloseTestCase) {
t.Parallel()
// Create a test channel which will be used for the duration of this
@@ -813,17 +850,38 @@ func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
bobChannel.channelState.LocalCommitment.FeePerKw,
)
customPayer := testCase.customPayer
closeOpts := fn.MapOptionZ(
customPayer, func(payer lntypes.ChannelParty) closeOpts {
// If the local party is paying then from Alice's PoV,
// then local party is paying. From Bob's PoV, the
// remote party is paying. If the remote party is, then
// the opposite is true.
return closeOpts{
aliceOpts: []ChanCloseOpt{
WithCustomPayer(payer),
},
bobOpts: []ChanCloseOpt{
WithCustomPayer(payer.CounterParty()),
},
}
},
)
// We'll start with both Alice and Bob creating a new close proposal
// with the same fee.
aliceFee := aliceChannel.CalcFee(aliceFeeRate)
aliceSig, _, _, err := aliceChannel.CreateCloseProposal(
aliceFee, aliceDeliveryScript, bobDeliveryScript,
closeOpts.aliceOpts...,
)
require.NoError(t, err, "unable to create alice coop close proposal")
bobFee := bobChannel.CalcFee(bobFeeRate)
bobSig, _, _, err := bobChannel.CreateCloseProposal(
bobFee, bobDeliveryScript, aliceDeliveryScript,
closeOpts.bobOpts...,
)
require.NoError(t, err, "unable to create bob coop close proposal")
@@ -832,14 +890,14 @@ func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
// transaction is well formed, and the signatures verify.
aliceCloseTx, bobTxBalance, err := bobChannel.CompleteCooperativeClose(
bobSig, aliceSig, bobDeliveryScript, aliceDeliveryScript,
bobFee,
bobFee, closeOpts.bobOpts...,
)
require.NoError(t, err, "unable to complete alice cooperative close")
bobCloseSha := aliceCloseTx.TxHash()
bobCloseTx, aliceTxBalance, err := aliceChannel.CompleteCooperativeClose(
aliceSig, bobSig, aliceDeliveryScript, bobDeliveryScript,
aliceFee,
aliceFee, closeOpts.aliceOpts...,
)
require.NoError(t, err, "unable to complete bob cooperative close")
aliceCloseSha := bobCloseTx.TxHash()
@@ -848,18 +906,43 @@ func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
t.Fatalf("alice and bob close transactions don't match: %v", err)
}
// Finally, make sure the final balances are correct from both's
// perspective.
type chanFees struct {
alice btcutil.Amount
bob btcutil.Amount
}
// Compute the closing fees for each party. If not specified, Alice will
// always pay the fees. Otherwise, it depends on who the payer is.
closeFees := fn.MapOption(func(payer lntypes.ChannelParty) chanFees {
var alice, bob btcutil.Amount
switch payer {
case lntypes.Local:
alice = bobFee
bob = 0
case lntypes.Remote:
bob = bobFee
alice = 0
}
return chanFees{
alice: alice,
bob: bob,
}
})(testCase.customPayer).UnwrapOr(chanFees{alice: bobFee})
// Finally, make sure the final balances are correct from both
// perspectives.
aliceBalance := aliceChannel.channelState.LocalCommitment.
LocalBalance.ToSatoshis()
// The commit balance have had the initiator's (Alice) commitfee and
// The commit balance have had the initiator's (Alice) commit fee and
// any anchors subtracted, so add that back to the final expected
// balance. Alice also pays the coop close fee, so that must be
// subtracted.
commitFee := aliceChannel.channelState.LocalCommitment.CommitFee
expBalanceAlice := aliceBalance + commitFee +
testCase.anchorAmt - bobFee
testCase.anchorAmt - closeFees.alice
if aliceTxBalance != expBalanceAlice {
t.Fatalf("expected balance %v got %v", expBalanceAlice,
aliceTxBalance)
@@ -868,7 +951,7 @@ func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
// Bob is not the initiator, so his final balance should simply be
// equal to the latest commitment balance.
expBalanceBob := bobChannel.channelState.LocalCommitment.
LocalBalance.ToSatoshis()
LocalBalance.ToSatoshis() - closeFees.bob
if bobTxBalance != expBalanceBob {
t.Fatalf("expected bob's balance to be %v got %v",
expBalanceBob, bobTxBalance)
+241
View File
@@ -0,0 +1,241 @@
package lnwallet
import (
"strconv"
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
)
// genValidAmount generates valid bitcoin amounts (non-negative).
func genValidAmount(t *rapid.T, label string) btcutil.Amount {
return btcutil.Amount(
rapid.Int64Range(
100_000, 21_000_000*100_000_000,
).Draw(t, label),
)
}
// genCoopCloseFee generates a reasonable non-zero cooperative close fee.
func genCoopCloseFee(t *rapid.T) btcutil.Amount {
// Generate a fee between 250-10000 sats which is a reasonable range for
// closing transactions
return btcutil.Amount(
rapid.Int64Range(250, 10_000).Draw(t, "coop_close_fee"),
)
}
// genChannelType generates various channel types, ensuring good coverage of
// different channel configurations including anchor outputs and other features.
func genChannelType(t *rapid.T) channeldb.ChannelType {
var chanType channeldb.ChannelType
// For each bit, decide randomly if it should be set.
bits := []channeldb.ChannelType{
channeldb.DualFunderBit,
channeldb.SingleFunderTweaklessBit,
channeldb.NoFundingTxBit,
channeldb.AnchorOutputsBit,
channeldb.FrozenBit,
channeldb.ZeroHtlcTxFeeBit,
channeldb.LeaseExpirationBit,
channeldb.ZeroConfBit,
channeldb.ScidAliasChanBit,
channeldb.ScidAliasFeatureBit,
channeldb.SimpleTaprootFeatureBit,
channeldb.TapscriptRootBit,
}
// Helper to bias towards setting specific bits more frequently.
setBit := func(bit channeldb.ChannelType, probability int) {
bitRange := rapid.IntRange(0, 100)
label := "bit_" + strconv.FormatUint(uint64(bit), 2)
if bitRange.Draw(t, label) < probability {
chanType |= bit
}
}
// We want to ensure good coverage of anchor outputs since they affect
// the balance calculation directly. We'll set the anchor bit with a 50%
// chance.
setBit(channeldb.AnchorOutputsBit, 50)
// For other bits, use varying probabilities to ensure good
// distribution.
for _, bit := range bits {
// The anchor bit was already set above so we can skip it here.
if bit == channeldb.AnchorOutputsBit {
continue
}
// Some bits are related, so we'll make sure we capture that
// dep.
switch bit {
case channeldb.TapscriptRootBit:
// If we have TapscriptRootBit, we must have
// SimpleTaprootFeatureBit.
if chanType&channeldb.SimpleTaprootFeatureBit != 0 {
// 70% chance if taproot is enabled.
setBit(bit, 70)
}
case channeldb.DualFunderBit:
// 40% chance of dual funding.
setBit(bit, 40)
default:
// 30% chance for other bits.
setBit(bit, 30)
}
}
return chanType
}
// genFeePayer generates optional fee payer.
func genFeePayer(t *rapid.T) fn.Option[lntypes.ChannelParty] {
if !rapid.Bool().Draw(t, "has_fee_payer") {
return fn.None[lntypes.ChannelParty]()
}
if rapid.Bool().Draw(t, "is_local") {
return fn.Some(lntypes.Local)
}
return fn.Some(lntypes.Remote)
}
// genCommitFee generates a reasonable non-zero commitment fee.
func genCommitFee(t *rapid.T) btcutil.Amount {
// Generate a reasonable commit fee between 100-5000 sats
return btcutil.Amount(
rapid.Int64Range(100, 5_000).Draw(t, "commit_fee"),
)
}
// TestCoopCloseBalance tests fundamental properties of CoopCloseBalance. This
// ensures that the closing fee is always subtracted from the correct balance,
// amongst other properties.
func TestCoopCloseBalance(tt *testing.T) {
tt.Parallel()
rapid.Check(tt, func(t *rapid.T) {
require := require.New(t)
// Generate test inputs
chanType := genChannelType(t)
isInitiator := rapid.Bool().Draw(t, "is_initiator")
// Generate amounts using specific generators
coopCloseFee := genCoopCloseFee(t)
ourBalance := genValidAmount(t, "local balance")
theirBalance := genValidAmount(t, "remote balance")
feePayer := genFeePayer(t)
commitFee := genCommitFee(t)
ourFinal, theirFinal, err := CoopCloseBalance(
chanType, isInitiator, coopCloseFee,
ourBalance, theirBalance, commitFee, feePayer,
)
// Property 1: If inputs are non-negative, we either get valid
// outputs or an error.
if err != nil {
// On error, final balances should be 0
require.Zero(
ourFinal,
"expected zero our_balance on error",
)
require.Zero(
theirFinal,
"expected zero their_balance on error",
)
return
}
// Property 2: Final balances should be non-negative.
require.GreaterOrEqual(
ourFinal, btcutil.Amount(0),
"our final balance should be non-negative",
)
require.GreaterOrEqual(
theirFinal, btcutil.Amount(0),
"their final balance should be non-negative",
)
// Property 3: Total balance should be conserved minus fees.
initialTotal := ourBalance + theirBalance
initialTotal += commitFee
if chanType.HasAnchors() {
initialTotal += 2 * AnchorSize
}
finalTotal := ourFinal + theirFinal + coopCloseFee
require.Equal(
initialTotal, finalTotal,
"total balance should be conserved",
)
// Property 4: When feePayer is specified, that party's balance
// should be reduced by exactly the coopCloseFee.
if feePayer.IsSome() {
payer := feePayer.UnwrapOrFail(tt)
if payer == lntypes.Local {
require.LessOrEqual(
ourBalance-(ourFinal+coopCloseFee),
btcutil.Amount(0),
"local balance reduced by more than fee", //nolint:ll
)
} else {
require.LessOrEqual(
theirBalance-(theirFinal+coopCloseFee),
btcutil.Amount(0),
"remote balance reduced by more than fee", //nolint:ll
)
}
}
// Property 5: For anchor channels, verify the correct final
// balance factors in the anchor amount.
if chanType.HasAnchors() {
// The initiator delta is the commit fee plus anchor
// amount.
initiatorDelta := commitFee + 2*AnchorSize
// Default to initiator paying unless explicitly
// specified.
isLocalPaying := isInitiator
if feePayer.IsSome() {
isLocalPaying = feePayer.UnwrapOrFail(tt) ==
lntypes.Local
}
if isInitiator {
expectedBalance := ourBalance + initiatorDelta
if isLocalPaying {
expectedBalance -= coopCloseFee
}
require.Equal(expectedBalance, ourFinal,
"initiator (local) balance incorrect")
} else {
// They are the initiator
expectedBalance := theirBalance + initiatorDelta
if !isLocalPaying {
expectedBalance -= coopCloseFee
}
require.Equal(expectedBalance, theirFinal,
"initiator (remote) balance incorrect")
}
}
})
}
+25 -6
View File
@@ -1033,8 +1033,9 @@ func CreateCommitTx(chanType channeldb.ChannelType,
// CoopCloseBalance returns the final balances that should be used to create
// the cooperative close tx, given the channel type and transaction fee.
func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
coopCloseFee, ourBalance, theirBalance,
commitFee btcutil.Amount) (btcutil.Amount, btcutil.Amount, error) {
coopCloseFee, ourBalance, theirBalance, commitFee btcutil.Amount,
feePayer fn.Option[lntypes.ChannelParty],
) (btcutil.Amount, btcutil.Amount, error) {
// We'll make sure we account for the complete balance by adding the
// current dangling commitment fee to the balance of the initiator.
@@ -1046,16 +1047,34 @@ func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
initiatorDelta += 2 * AnchorSize
}
// The initiator will pay the full coop close fee, subtract that value
// from their balance.
initiatorDelta -= coopCloseFee
// To start with, we'll add the anchor and/or commitment fee to the
// balance of the initiator.
if isInitiator {
ourBalance += initiatorDelta
} else {
theirBalance += initiatorDelta
}
// With the initiator's balance credited, we'll now subtract the closing
// fee from the closing party. By default, the initiator pays the full
// amount, but this can be overridden by the feePayer option.
defaultPayer := func() lntypes.ChannelParty {
if isInitiator {
return lntypes.Local
}
return lntypes.Remote
}()
payer := feePayer.UnwrapOr(defaultPayer)
// Based on the payer computed above, we'll subtract the closing fee.
switch payer {
case lntypes.Local:
ourBalance -= coopCloseFee
case lntypes.Remote:
theirBalance -= coopCloseFee
}
// During fee negotiation it should always be verified that the
// initiator can pay the proposed fee, but we do a sanity check just to
// be sure here.
+4 -4
View File
@@ -34,9 +34,9 @@ type ClosingComplete struct {
// channel would like to propose for the close transaction.
FeeSatoshis btcutil.Amount
// Sequence is the sequence number to be used in the input spending the
// LockTime is the locktime number to be used in the input spending the
// funding transaction.
Sequence uint32
LockTime uint32
// ClosingSigs houses the 3 possible signatures that can be sent.
ClosingSigs
@@ -79,7 +79,7 @@ func decodeClosingSigs(c *ClosingSigs, tlvRecords ExtraOpaqueData) error {
// passed io.Reader.
func (c *ClosingComplete) Decode(r io.Reader, _ uint32) error {
// First, read out all the fields that are hard coded into the message.
err := ReadElements(r, &c.ChannelID, &c.FeeSatoshis, &c.Sequence)
err := ReadElements(r, &c.ChannelID, &c.FeeSatoshis, &c.LockTime)
if err != nil {
return err
}
@@ -129,7 +129,7 @@ func (c *ClosingComplete) Encode(w *bytes.Buffer, _ uint32) error {
return err
}
if err := WriteUint32(w, c.Sequence); err != nil {
if err := WriteUint32(w, c.LockTime); err != nil {
return err
}
+1 -1
View File
@@ -1352,7 +1352,7 @@ func TestLightningWireProtocol(t *testing.T) {
req := ClosingComplete{
ChannelID: ChannelID(c),
FeeSatoshis: btcutil.Amount(r.Int63()),
Sequence: uint32(r.Int63()),
LockTime: uint32(r.Int63()),
ClosingSigs: ClosingSigs{},
}
+7
View File
@@ -2275,6 +2275,13 @@ func messageSummary(msg lnwire.Message) string {
return fmt.Sprintf("chan_id=%v, script=%x", msg.ChannelID,
msg.Address[:])
case *lnwire.ClosingComplete:
return fmt.Sprintf("chan_id=%v, fee_sat=%v, locktime=%v",
msg.ChannelID, msg.FeeSatoshis, msg.LockTime)
case *lnwire.ClosingSig:
return fmt.Sprintf("chan_id=%v", msg.ChannelID)
case *lnwire.ClosingSigned:
return fmt.Sprintf("chan_id=%v, fee_sat=%v", msg.ChannelID,
msg.FeeSatoshis)
+21 -36
View File
@@ -34,11 +34,11 @@ type EmittedEvent[Event any] struct {
// InternalEvent is an optional internal event that is to be routed
// back to the target state. This enables state to trigger one or many
// state transitions without a new external event.
InternalEvent fn.Option[[]Event]
InternalEvent []Event
// ExternalEvent is an optional external event that is to be sent to
// the daemon for dispatch. Usually, this is some form of I/O.
ExternalEvents fn.Option[DaemonEventSet]
ExternalEvents DaemonEventSet
}
// StateTransition is a state transition type. It denotes the next state to go
@@ -573,46 +573,31 @@ func (s *StateMachine[Event, Env]) applyEvents(currentState State[Event, Env],
// With the event processed, we'll process any
// new daemon events that were emitted as part
// of this new state transition.
//
//nolint:ll
err := fn.MapOptionZ(events.ExternalEvents, func(dEvents DaemonEventSet) error {
log.Debugf("FSM(%v): processing "+
"daemon %v daemon events",
s.cfg.Env.Name(), len(dEvents))
for _, dEvent := range dEvents {
err := s.executeDaemonEvent(
dEvent,
)
if err != nil {
return err
}
for _, dEvent := range events.ExternalEvents {
err := s.executeDaemonEvent(
dEvent,
)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
// Next, we'll add any new emitted events to
// our event queue.
// Next, we'll add any new emitted events to our
// event queue.
//
//nolint:ll
events.InternalEvent.WhenSome(func(es []Event) {
for _, inEvent := range es {
log.Debugf("FSM(%v): adding "+
"new internal event "+
"to queue: %v",
s.cfg.Env.Name(),
lnutils.SpewLogClosure(
inEvent,
),
)
for _, inEvent := range events.InternalEvent {
log.Debugf("FSM(%v): adding "+
"new internal event "+
"to queue: %v",
s.cfg.Env.Name(),
lnutils.SpewLogClosure(
inEvent,
),
)
eventQueue.Enqueue(inEvent)
}
})
eventQueue.Enqueue(inEvent)
}
return nil
})
+3 -5
View File
@@ -80,9 +80,7 @@ func (d *dummyStateStart) ProcessEvent(event dummyEvents, env *dummyEnv,
return &StateTransition[dummyEvents, *dummyEnv]{
NextState: &dummyStateStart{},
NewEvents: fn.Some(EmittedEvent[dummyEvents]{
InternalEvent: fn.Some(
[]dummyEvents{&goToFin{}},
),
InternalEvent: []dummyEvents{&goToFin{}},
}),
}, nil
@@ -114,13 +112,13 @@ func (d *dummyStateStart) ProcessEvent(event dummyEvents, env *dummyEnv,
canSend: d.canSend,
},
NewEvents: fn.Some(EmittedEvent[dummyEvents]{
ExternalEvents: fn.Some(DaemonEventSet{
ExternalEvents: DaemonEventSet{
sendEvent, sendEvent2,
&BroadcastTxn{
Tx: &wire.MsgTx{},
Label: "test",
},
}),
},
}),
}, nil
}