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 // 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 // our peer. If they do not match, we use the disconnect function provided to
// disconnect from the peer. // disconnect from the peer.
func validateShutdownScript(disconnect func() error, upfrontScript, func validateShutdownScript(upfrontScript, peerScript lnwire.DeliveryAddress,
peerScript lnwire.DeliveryAddress, netParams *chaincfg.Params) error { netParams *chaincfg.Params) error {
// Either way, we'll make sure that the script passed meets our // Either way, we'll make sure that the script passed meets our
// standards. The upfrontScript should have already been checked at an // 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 "+ chancloserLog.Warnf("peer's script: %x does not match upfront "+
"shutdown script: %x", peerScript, upfrontScript) "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 ErrUpfrontShutdownScriptMismatch
} }
@@ -630,7 +624,6 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
// If the remote node opened the channel with option upfront // If the remote node opened the channel with option upfront
// shutdown script, check that the script they provided matches. // shutdown script, check that the script they provided matches.
if err := validateShutdownScript( if err := validateShutdownScript(
c.cfg.Disconnect,
c.cfg.Channel.RemoteUpfrontShutdownScript(), c.cfg.Channel.RemoteUpfrontShutdownScript(),
msg.Address, c.cfg.ChainParams, msg.Address, c.cfg.ChainParams,
); err != nil { ); err != nil {
@@ -681,7 +674,6 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
// If the remote node opened the channel with option upfront // If the remote node opened the channel with option upfront
// shutdown script, check that the script they provided matches. // shutdown script, check that the script they provided matches.
if err := validateShutdownScript( if err := validateShutdownScript(
c.cfg.Disconnect,
c.cfg.Channel.RemoteUpfrontShutdownScript(), c.cfg.Channel.RemoteUpfrontShutdownScript(),
msg.Address, c.cfg.ChainParams, msg.Address, c.cfg.ChainParams,
); err != nil { ); err != nil {
+3 -3
View File
@@ -129,8 +129,8 @@ func TestMaybeMatchScript(t *testing.T) {
t.Parallel() t.Parallel()
err := validateShutdownScript( err := validateShutdownScript(
func() error { return nil }, test.upfrontScript, test.upfrontScript, test.shutdownScript,
test.shutdownScript, &chaincfg.SimNetParams, &chaincfg.SimNetParams,
) )
if err != test.expectedErr { if err != test.expectedErr {
@@ -189,7 +189,7 @@ func (m *mockChannel) RemoteUpfrontShutdownScript() lnwire.DeliveryAddress {
func (m *mockChannel) CreateCloseProposal(fee btcutil.Amount, func (m *mockChannel) CreateCloseProposal(fee btcutil.Amount,
localScript, remoteScript []byte, localScript, remoteScript []byte,
_ ...lnwallet.ChanCloseOpt) (input.Signature, *chainhash.Hash, _ ...lnwallet.ChanCloseOpt) (input.Signature, *wire.MsgTx,
btcutil.Amount, error) { btcutil.Amount, error) {
if m.chanType.IsTaproot() { if m.chanType.IsTaproot() {
+1 -2
View File
@@ -3,7 +3,6 @@ package chancloser
import ( import (
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/fn/v2"
@@ -100,7 +99,7 @@ type Channel interface { //nolint:interfacebloat
localDeliveryScript []byte, remoteDeliveryScript []byte, localDeliveryScript []byte, remoteDeliveryScript []byte,
closeOpt ...lnwallet.ChanCloseOpt, closeOpt ...lnwallet.ChanCloseOpt,
) ( ) (
input.Signature, *chainhash.Hash, btcutil.Amount, error) input.Signature, *wire.MsgTx, btcutil.Amount, error)
// CompleteCooperativeClose persistently "completes" the cooperative // CompleteCooperativeClose persistently "completes" the cooperative
// close by producing a fully signed co-op close transaction. // 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 // transaction outputs. If this isn't set, then the default BIP-69
// sorting is used. // sorting is used.
customSort CloseSortFunc 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 // 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 // CreateCloseProposal is used by both parties in a cooperative channel close
// workflow to generate proposed close transactions and signatures. This method // workflow to generate proposed close transactions and signatures. This method
// should only be executed once all pending HTLCs (if any) on the channel have // should only be executed once all pending HTLCs (if any) on the channel have
@@ -8244,22 +8275,23 @@ func WithCustomCoopSort(sorter CloseSortFunc) ChanCloseOpt {
// returned. // returned.
func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount, func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
localDeliveryScript []byte, remoteDeliveryScript []byte, localDeliveryScript []byte, remoteDeliveryScript []byte,
closeOpts ...ChanCloseOpt) (input.Signature, *chainhash.Hash, closeOpts ...ChanCloseOpt) (input.Signature, *wire.MsgTx,
btcutil.Amount, error) { btcutil.Amount, error) {
lc.Lock() lc.Lock()
defer lc.Unlock() defer lc.Unlock()
// If we're already closing the channel, then ignore this request.
if lc.isClosed {
return nil, nil, 0, ErrChanClosing
}
opts := defaultCloseOpts() opts := defaultCloseOpts()
for _, optFunc := range closeOpts { for _, optFunc := range closeOpts {
optFunc(opts) 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 // Get the final balances after subtracting the proposed fee, taking
// care not to persist the adjusted balance, as the feeRate may change // care not to persist the adjusted balance, as the feeRate may change
// during the channel closing process. // during the channel closing process.
@@ -8269,6 +8301,7 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(), lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(),
lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(), lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
lc.channelState.LocalCommitment.CommitFee, lc.channelState.LocalCommitment.CommitFee,
opts.customPayer,
) )
if err != nil { if err != nil {
return nil, nil, 0, err 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( closeTx, err := CreateCooperativeCloseTx(
fundingTxIn(lc.channelState), lc.channelState.LocalChanCfg.DustLimit, fundingTxIn(lc.channelState), lc.channelState.LocalChanCfg.DustLimit,
lc.channelState.RemoteChanCfg.DustLimit, ourBalance, theirBalance, lc.channelState.RemoteChanCfg.DustLimit, ourBalance, theirBalance,
@@ -8332,8 +8377,8 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
} }
} }
closeTXID := closeTx.TxHash() return sig, closeTx, ourBalance, nil
return sig, &closeTXID, ourBalance, nil
} }
// CompleteCooperativeClose completes the cooperative closure of the target // CompleteCooperativeClose completes the cooperative closure of the target
@@ -8352,17 +8397,17 @@ func (lc *LightningChannel) CompleteCooperativeClose(
lc.Lock() lc.Lock()
defer lc.Unlock() 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() opts := defaultCloseOpts()
for _, optFunc := range closeOpts { for _, optFunc := range closeOpts {
optFunc(opts) 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. // Get the final balances after subtracting the proposed fee.
ourBalance, theirBalance, err := CoopCloseBalance( ourBalance, theirBalance, err := CoopCloseBalance(
lc.channelState.ChanType, lc.channelState.IsInitiator, lc.channelState.ChanType, lc.channelState.IsInitiator,
@@ -8370,6 +8415,7 @@ func (lc *LightningChannel) CompleteCooperativeClose(
lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(), lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(),
lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(), lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
lc.channelState.LocalCommitment.CommitFee, lc.channelState.LocalCommitment.CommitFee,
opts.customPayer,
) )
if err != nil { if err != nil {
return nil, 0, err 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 // Create the transaction used to return the current settled balance
// on this active channel back to both parties. In this current model, // on this active channel back to both parties. In this current model,
// the initiator pays full fees for the cooperative close transaction. // 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 // transaction outputs. If this isn't set, then the default BIP-69
// sorting is used. // sorting is used.
customSort CloseSortFunc 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. // 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 // CreateCooperativeCloseTx creates a transaction which if signed by both
// parties, then broadcast cooperatively closes an active channel. The creation // parties, then broadcast cooperatively closes an active channel. The creation
// of the closure transaction is modified by a boolean indicating if the party // 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 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 // Construct the transaction to perform a cooperative closure of the
// channel. In the event that one side doesn't have any settled funds // 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 // 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 := wire.NewMsgTx(2)
closeTx.AddTxIn(&fundingTxIn) 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 // Create both cooperative closure outputs, properly respecting the
// dust limits of both parties. // dust limits of both parties.
var localOutputIdx fn.Option[int] 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 // TestCooperativeChannelClosure checks that the coop close process finishes
// with an agreement from both parties, and that the final balances of the // with an agreement from both parties, and that the final balances of the close
// close tx check out. // tx check out.
func TestCooperativeChannelClosure(t *testing.T) { func TestCooperativeChannelClosure(t *testing.T) {
t.Run("tweakless", func(t *testing.T) { testCases := []struct {
testCoopClose(t, &coopCloseTestCase{ name string
chanType: channeldb.SingleFunderTweaklessBit, 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 { type coopCloseTestCase struct {
chanType channeldb.ChannelType chanType channeldb.ChannelType
anchorAmt btcutil.Amount 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() t.Parallel()
// Create a test channel which will be used for the duration of this // 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, 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 // We'll start with both Alice and Bob creating a new close proposal
// with the same fee. // with the same fee.
aliceFee := aliceChannel.CalcFee(aliceFeeRate) aliceFee := aliceChannel.CalcFee(aliceFeeRate)
aliceSig, _, _, err := aliceChannel.CreateCloseProposal( aliceSig, _, _, err := aliceChannel.CreateCloseProposal(
aliceFee, aliceDeliveryScript, bobDeliveryScript, aliceFee, aliceDeliveryScript, bobDeliveryScript,
closeOpts.aliceOpts...,
) )
require.NoError(t, err, "unable to create alice coop close proposal") require.NoError(t, err, "unable to create alice coop close proposal")
bobFee := bobChannel.CalcFee(bobFeeRate) bobFee := bobChannel.CalcFee(bobFeeRate)
bobSig, _, _, err := bobChannel.CreateCloseProposal( bobSig, _, _, err := bobChannel.CreateCloseProposal(
bobFee, bobDeliveryScript, aliceDeliveryScript, bobFee, bobDeliveryScript, aliceDeliveryScript,
closeOpts.bobOpts...,
) )
require.NoError(t, err, "unable to create bob coop close proposal") 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. // transaction is well formed, and the signatures verify.
aliceCloseTx, bobTxBalance, err := bobChannel.CompleteCooperativeClose( aliceCloseTx, bobTxBalance, err := bobChannel.CompleteCooperativeClose(
bobSig, aliceSig, bobDeliveryScript, aliceDeliveryScript, bobSig, aliceSig, bobDeliveryScript, aliceDeliveryScript,
bobFee, bobFee, closeOpts.bobOpts...,
) )
require.NoError(t, err, "unable to complete alice cooperative close") require.NoError(t, err, "unable to complete alice cooperative close")
bobCloseSha := aliceCloseTx.TxHash() bobCloseSha := aliceCloseTx.TxHash()
bobCloseTx, aliceTxBalance, err := aliceChannel.CompleteCooperativeClose( bobCloseTx, aliceTxBalance, err := aliceChannel.CompleteCooperativeClose(
aliceSig, bobSig, aliceDeliveryScript, bobDeliveryScript, aliceSig, bobSig, aliceDeliveryScript, bobDeliveryScript,
aliceFee, aliceFee, closeOpts.aliceOpts...,
) )
require.NoError(t, err, "unable to complete bob cooperative close") require.NoError(t, err, "unable to complete bob cooperative close")
aliceCloseSha := bobCloseTx.TxHash() 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) t.Fatalf("alice and bob close transactions don't match: %v", err)
} }
// Finally, make sure the final balances are correct from both's type chanFees struct {
// perspective. 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. aliceBalance := aliceChannel.channelState.LocalCommitment.
LocalBalance.ToSatoshis() 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 // any anchors subtracted, so add that back to the final expected
// balance. Alice also pays the coop close fee, so that must be // balance. Alice also pays the coop close fee, so that must be
// subtracted. // subtracted.
commitFee := aliceChannel.channelState.LocalCommitment.CommitFee commitFee := aliceChannel.channelState.LocalCommitment.CommitFee
expBalanceAlice := aliceBalance + commitFee + expBalanceAlice := aliceBalance + commitFee +
testCase.anchorAmt - bobFee testCase.anchorAmt - closeFees.alice
if aliceTxBalance != expBalanceAlice { if aliceTxBalance != expBalanceAlice {
t.Fatalf("expected balance %v got %v", expBalanceAlice, t.Fatalf("expected balance %v got %v", expBalanceAlice,
aliceTxBalance) aliceTxBalance)
@@ -868,7 +951,7 @@ func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
// Bob is not the initiator, so his final balance should simply be // Bob is not the initiator, so his final balance should simply be
// equal to the latest commitment balance. // equal to the latest commitment balance.
expBalanceBob := bobChannel.channelState.LocalCommitment. expBalanceBob := bobChannel.channelState.LocalCommitment.
LocalBalance.ToSatoshis() LocalBalance.ToSatoshis() - closeFees.bob
if bobTxBalance != expBalanceBob { if bobTxBalance != expBalanceBob {
t.Fatalf("expected bob's balance to be %v got %v", t.Fatalf("expected bob's balance to be %v got %v",
expBalanceBob, bobTxBalance) 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 // CoopCloseBalance returns the final balances that should be used to create
// the cooperative close tx, given the channel type and transaction fee. // the cooperative close tx, given the channel type and transaction fee.
func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool, func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
coopCloseFee, ourBalance, theirBalance, coopCloseFee, ourBalance, theirBalance, commitFee btcutil.Amount,
commitFee btcutil.Amount) (btcutil.Amount, btcutil.Amount, error) { feePayer fn.Option[lntypes.ChannelParty],
) (btcutil.Amount, btcutil.Amount, error) {
// We'll make sure we account for the complete balance by adding the // We'll make sure we account for the complete balance by adding the
// current dangling commitment fee to the balance of the initiator. // current dangling commitment fee to the balance of the initiator.
@@ -1046,16 +1047,34 @@ func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
initiatorDelta += 2 * AnchorSize initiatorDelta += 2 * AnchorSize
} }
// The initiator will pay the full coop close fee, subtract that value // To start with, we'll add the anchor and/or commitment fee to the
// from their balance. // balance of the initiator.
initiatorDelta -= coopCloseFee
if isInitiator { if isInitiator {
ourBalance += initiatorDelta ourBalance += initiatorDelta
} else { } else {
theirBalance += initiatorDelta 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 // During fee negotiation it should always be verified that the
// initiator can pay the proposed fee, but we do a sanity check just to // initiator can pay the proposed fee, but we do a sanity check just to
// be sure here. // be sure here.
+4 -4
View File
@@ -34,9 +34,9 @@ type ClosingComplete struct {
// channel would like to propose for the close transaction. // channel would like to propose for the close transaction.
FeeSatoshis btcutil.Amount 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. // funding transaction.
Sequence uint32 LockTime uint32
// ClosingSigs houses the 3 possible signatures that can be sent. // ClosingSigs houses the 3 possible signatures that can be sent.
ClosingSigs ClosingSigs
@@ -79,7 +79,7 @@ func decodeClosingSigs(c *ClosingSigs, tlvRecords ExtraOpaqueData) error {
// passed io.Reader. // passed io.Reader.
func (c *ClosingComplete) Decode(r io.Reader, _ uint32) error { func (c *ClosingComplete) Decode(r io.Reader, _ uint32) error {
// First, read out all the fields that are hard coded into the message. // 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 { if err != nil {
return err return err
} }
@@ -129,7 +129,7 @@ func (c *ClosingComplete) Encode(w *bytes.Buffer, _ uint32) error {
return err return err
} }
if err := WriteUint32(w, c.Sequence); err != nil { if err := WriteUint32(w, c.LockTime); err != nil {
return err return err
} }
+1 -1
View File
@@ -1352,7 +1352,7 @@ func TestLightningWireProtocol(t *testing.T) {
req := ClosingComplete{ req := ClosingComplete{
ChannelID: ChannelID(c), ChannelID: ChannelID(c),
FeeSatoshis: btcutil.Amount(r.Int63()), FeeSatoshis: btcutil.Amount(r.Int63()),
Sequence: uint32(r.Int63()), LockTime: uint32(r.Int63()),
ClosingSigs: ClosingSigs{}, 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, return fmt.Sprintf("chan_id=%v, script=%x", msg.ChannelID,
msg.Address[:]) 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: case *lnwire.ClosingSigned:
return fmt.Sprintf("chan_id=%v, fee_sat=%v", msg.ChannelID, return fmt.Sprintf("chan_id=%v, fee_sat=%v", msg.ChannelID,
msg.FeeSatoshis) 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 // InternalEvent is an optional internal event that is to be routed
// back to the target state. This enables state to trigger one or many // back to the target state. This enables state to trigger one or many
// state transitions without a new external event. // 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 // ExternalEvent is an optional external event that is to be sent to
// the daemon for dispatch. Usually, this is some form of I/O. // 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 // 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 // With the event processed, we'll process any
// new daemon events that were emitted as part // new daemon events that were emitted as part
// of this new state transition. // of this new state transition.
// for _, dEvent := range events.ExternalEvents {
//nolint:ll err := s.executeDaemonEvent(
err := fn.MapOptionZ(events.ExternalEvents, func(dEvents DaemonEventSet) error { dEvent,
log.Debugf("FSM(%v): processing "+ )
"daemon %v daemon events", if err != nil {
s.cfg.Env.Name(), len(dEvents)) return err
for _, dEvent := range dEvents {
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 // Next, we'll add any new emitted events to our
// our event queue. // event queue.
// //
//nolint:ll //nolint:ll
events.InternalEvent.WhenSome(func(es []Event) { for _, inEvent := range events.InternalEvent {
for _, inEvent := range es { log.Debugf("FSM(%v): adding "+
log.Debugf("FSM(%v): adding "+ "new internal event "+
"new internal event "+ "to queue: %v",
"to queue: %v", s.cfg.Env.Name(),
s.cfg.Env.Name(), lnutils.SpewLogClosure(
lnutils.SpewLogClosure( inEvent,
inEvent, ),
), )
)
eventQueue.Enqueue(inEvent) eventQueue.Enqueue(inEvent)
} }
})
return nil return nil
}) })
+3 -5
View File
@@ -80,9 +80,7 @@ func (d *dummyStateStart) ProcessEvent(event dummyEvents, env *dummyEnv,
return &StateTransition[dummyEvents, *dummyEnv]{ return &StateTransition[dummyEvents, *dummyEnv]{
NextState: &dummyStateStart{}, NextState: &dummyStateStart{},
NewEvents: fn.Some(EmittedEvent[dummyEvents]{ NewEvents: fn.Some(EmittedEvent[dummyEvents]{
InternalEvent: fn.Some( InternalEvent: []dummyEvents{&goToFin{}},
[]dummyEvents{&goToFin{}},
),
}), }),
}, nil }, nil
@@ -114,13 +112,13 @@ func (d *dummyStateStart) ProcessEvent(event dummyEvents, env *dummyEnv,
canSend: d.canSend, canSend: d.canSend,
}, },
NewEvents: fn.Some(EmittedEvent[dummyEvents]{ NewEvents: fn.Some(EmittedEvent[dummyEvents]{
ExternalEvents: fn.Some(DaemonEventSet{ ExternalEvents: DaemonEventSet{
sendEvent, sendEvent2, sendEvent, sendEvent2,
&BroadcastTxn{ &BroadcastTxn{
Tx: &wire.MsgTx{}, Tx: &wire.MsgTx{},
Label: "test", Label: "test",
}, },
}), },
}), }),
}, nil }, nil
} }