lnd/peer/rbf_close_wrapper_actor.go
Jared Tobin ceff3ceb4b
peer: never use RBF coop close for aux channels
The RBF coop close flow was selected purely from the peer-level
feature bits (rbfCoopCloseAllowed), with no per-channel exclusion.
The RBF close state machine does not invoke any of the aux closer
hooks: the Shutdown message it sends carries no aux custom records,
and the close transaction it negotiates contains no aux outputs. For
a taproot asset (overlay) channel this means the funding output --
which anchors the asset commitment -- is spent by a transaction that
does not re-commit the assets, irrevocably destroying them on-chain.
The aux closer then fails to finalize the confirmed close (it was
never asked to produce vPackets), which blocks the chain watcher's
coop close handler and leaves the channel stuck in waiting-close.

See lightninglabs/taproot-assets#2196 for an instance of this
happening in the wild.

Extend rbfCoopCloseAllowed to take the channel type: it now requires
the RBF feature bits AND that the channel type carries no tapscript
root, and is used at every site that chooses between the RBF closer
and the legacy negotiate closer. The RBF close actor's own eligibility
check is dropped entirely: an actor is only ever registered after
initRbfChanCloser has vetted the channel, so the check was redundant.
Aux channels now always fall back to the legacy closer, which is
aux-aware, regardless of the negotiated feature bits. Since no RBF
msg-router endpoint is registered for aux channels, an incoming Shutdown
from the peer likewise falls through to the legacy close handling. As a
backstop, initRbfChanCloser now refuses to construct an RBF closer for
aux channels outright.
2026-07-14 13:24:33 -02:30

186 lines
6.1 KiB
Go

package peer
import (
"context"
"fmt"
"github.com/btcsuite/btcd/wire/v2"
"github.com/lightningnetwork/lnd/actor"
"github.com/lightningnetwork/lnd/contractcourt"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/htlcswitch"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
)
// rbfCloseMessage is a message type that is used to trigger a cooperative fee
// bump, or initiate a close for the first time.
type rbfCloseMessage struct {
actor.BaseMessage
// Ctx is the context of the caller that initiated the RBF close. This
// is propagated to the underlying close request so that cancellation
// of the caller (e.g. RPC stream disconnect) tears down the associated
// observer goroutine. The caller's context is distinct from the
// actor's own lifecycle context that is passed to Receive.
Ctx context.Context //nolint:containedctx
// ChanPoint is the channel point of the channel to be closed.
ChanPoint wire.OutPoint
// FeeRate is the fee rate to use for the transaction.
FeeRate chainfee.SatPerKWeight
// DeliveryScript is the script to use for the transaction.
DeliveryScript lnwire.DeliveryAddress
}
// MessageType returns the type of the message.
//
// NOTE: This is part of the actor.Message interface.
func (r rbfCloseMessage) MessageType() string {
return fmt.Sprintf("RbfCloseMessage(%v)", r.ChanPoint)
}
// NewRbfBumpCloseMsg returns a message that can be sent to the RBF actor to
// initiate a new fee bump.
func NewRbfBumpCloseMsg(ctx context.Context, op wire.OutPoint,
feeRate chainfee.SatPerKWeight,
deliveryScript lnwire.DeliveryAddress) rbfCloseMessage {
return rbfCloseMessage{
Ctx: ctx,
ChanPoint: op,
FeeRate: feeRate,
DeliveryScript: deliveryScript,
}
}
// RbfCloseActorServiceKey is a service key that can be used to reach an RBF
// chan closer.
type RbfCloseActorServiceKey = actor.ServiceKey[
rbfCloseMessage, *CoopCloseUpdates,
]
// NewRbfCloserPeerServiceKey returns a new service key that can be used to
// reach an RBF chan closer, via an active peer.
func NewRbfCloserPeerServiceKey(op wire.OutPoint) RbfCloseActorServiceKey {
opStr := op.String()
// Just using the channel point here is enough, as we have a unique
// type here rbfCloseMessage which will handle the final actor
// selection.
actorKey := fmt.Sprintf("Peer(RbfChanCloser(%v))", opStr)
return actor.NewServiceKey[rbfCloseMessage, *CoopCloseUpdates](actorKey)
}
// rbfCloseActor is a wrapper around the Brontide peer to expose the internal
// RBF close state machine as an actor. This is intended for callers that need
// to obtain streaming close updates related to the RBF close process.
type rbfCloseActor struct {
chanPeer *Brontide
actorSystem *actor.ActorSystem
chanPoint wire.OutPoint
}
// newRbfCloseActor creates a new instance of the RBF close wrapper actor.
func newRbfCloseActor(chanPoint wire.OutPoint,
chanPeer *Brontide, actorSystem *actor.ActorSystem) *rbfCloseActor {
return &rbfCloseActor{
chanPeer: chanPeer,
actorSystem: actorSystem,
chanPoint: chanPoint,
}
}
// registerActor registers a new RBF close actor with the actor system. If an
// instance with the same service key and types are registered, we'll
// unregister before proceeding.
func (r *rbfCloseActor) registerActor() error {
// First, we'll make the service key of this RBF actor. This'll allow
// us to spawn the actor in the actor system.
actorKey := NewRbfCloserPeerServiceKey(r.chanPoint)
// We only want to have a single actor instance for this rbf closer,
// so we'll now attempt to unregister any other instances.
actorKey.UnregisterAll(r.actorSystem)
// Now that we know that no instances of the actor are present, let's
// register a new instance. We don't actually need the ref though, as
// any interested parties can look up the actor via the service key.
actorID := fmt.Sprintf(
"PeerWrapper(RbfChanCloser(%s))", r.chanPoint,
)
if _, err := actorKey.Spawn(r.actorSystem, actorID, r); err != nil {
return fmt.Errorf("unable to spawn RBF close actor for "+
"channel %v: %w", r.chanPoint, err)
}
return nil
}
// Receive implements the actor.ActorBehavior interface for the rbf closer
// wrapper. This allows us to expose our specific processes around the coop
// close flow as an actor.
//
// NOTE: This implements the actor.ActorBehavior interface.
func (r *rbfCloseActor) Receive(_ context.Context,
msg rbfCloseMessage) fn.Result[*CoopCloseUpdates] {
type retType = *CoopCloseUpdates
// Note that no eligibility check is needed here: an actor is only
// ever registered after initRbfChanCloser has vetted the channel for
// RBF coop close.
closeUpdates := &CoopCloseUpdates{
UpdateChan: make(chan interface{}, 1),
ErrChan: make(chan error, 1),
}
// We'll re-use the existing switch struct here, even though we're
// bypassing the switch entirely. We use the caller's context from the
// message so that canceling the caller (e.g., RPC stream close) also
// tears down the observer goroutine.
closeReq := htlcswitch.ChanClose{
CloseType: contractcourt.CloseRegular,
ChanPoint: &msg.ChanPoint,
TargetFeePerKw: msg.FeeRate,
DeliveryScript: msg.DeliveryScript,
Updates: closeUpdates.UpdateChan,
Err: closeUpdates.ErrChan,
Ctx: msg.Ctx,
}
err := r.chanPeer.startRbfChanCloser(
newRPCShutdownInit(&closeReq), msg.ChanPoint,
)
if err != nil {
peerLog.Errorf("unable to start RBF chan closer for "+
"channel %v: %v", msg.ChanPoint, err)
return fn.Errf[retType]("unable to start RBF chan "+
"closer: %w", err)
}
return fn.Ok(closeUpdates)
}
// RbfChanCloseActor is a router that will route messages to the relevant RBF
// chan closer.
type RbfChanCloseActor = actor.Router[rbfCloseMessage, *CoopCloseUpdates]
// RbfChanCloserRouter creates a new router that will route messages to the
// relevant RBF chan closer.
func RbfChanCloserRouter(actorSystem *actor.ActorSystem,
serviceKey RbfCloseActorServiceKey) *RbfChanCloseActor {
strategy := actor.NewRoundRobinStrategy[
rbfCloseMessage, *CoopCloseUpdates,
]()
return actor.NewRouter(
actorSystem.Receptionist(), serviceKey, strategy, nil,
)
}