From cb10f5d72c7ccb887d684755f5fa77c1819f9423 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 13 Oct 2025 15:10:41 +0200 Subject: [PATCH 1/3] contractcourt: add aux chan closer to chain watcher Due to a circular dependency issue, we cannot directly import these definitions from lnwallet/chancloser. So we'll redefine the types we need and define a slim interface around the aux closer, which contains exactly the functionality that we'll want to use here. --- contractcourt/chain_arbitrator.go | 6 +++ contractcourt/chain_watcher.go | 75 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/contractcourt/chain_arbitrator.go b/contractcourt/chain_arbitrator.go index 05eb46a68be..2e5f54258ae 100644 --- a/contractcourt/chain_arbitrator.go +++ b/contractcourt/chain_arbitrator.go @@ -230,6 +230,10 @@ type ChainArbitratorConfig struct { // AuxResolver is an optional interface that can be used to modify the // way contracts are resolved. AuxResolver fn.Option[lnwallet.AuxContractResolver] + + // AuxCloser is an optional interface that can be used to finalize + // cooperative channel closes. + AuxCloser fn.Option[AuxChanCloser] } // ChainArbitrator is a sub-system that oversees the on-chain resolution of all @@ -1138,6 +1142,7 @@ func (c *ChainArbitrator) WatchNewChannel(newChan *channeldb.OpenChannel) error extractStateNumHint: lnwallet.GetStateNumHint, auxLeafStore: c.cfg.AuxLeafStore, auxResolver: c.cfg.AuxResolver, + auxCloser: c.cfg.AuxCloser, }, ) if err != nil { @@ -1315,6 +1320,7 @@ func (c *ChainArbitrator) loadOpenChannels() error { extractStateNumHint: lnwallet.GetStateNumHint, auxLeafStore: c.cfg.AuxLeafStore, auxResolver: c.cfg.AuxResolver, + auxCloser: c.cfg.AuxCloser, }, ) if err != nil { diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index 9c566fd6b51..0e1822bbcd0 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -25,6 +25,7 @@ import ( "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" ) const ( @@ -37,6 +38,77 @@ const ( maxCommitPointPollTimeout = 10 * time.Minute ) +// CloseOutput represents an output that should be included in the close +// transaction. +type CloseOutput struct { + // Amt is the amount of the output. + Amt btcutil.Amount + + // DustLimit is the dust limit for the local node. + DustLimit btcutil.Amount + + // PkScript is the script that should be used to pay to the output. + PkScript []byte + + // ShutdownRecords is the set of custom records that may result in + // extra close outputs being added. + ShutdownRecords lnwire.CustomRecords +} + +// AuxShutdownReq is used to request a set of extra custom records to include +// in the shutdown message. +type AuxShutdownReq struct { + // ChanPoint is the channel point of the channel that is being shut + // down. + ChanPoint wire.OutPoint + + // ShortChanID is the short channel ID of the channel that is being + // closed. + ShortChanID lnwire.ShortChannelID + + // Initiator is true if the local node is the initiator of the channel. + Initiator bool + + // InternalKey is the internal key for the shutdown addr. This will + // only be set for taproot shutdown addrs. + InternalKey fn.Option[btcec.PublicKey] + + // CommitBlob is the blob that was included in the last commitment. + CommitBlob fn.Option[tlv.Blob] + + // FundingBlob is the blob that was included in the funding state. + FundingBlob fn.Option[tlv.Blob] +} + +// AuxCloseDesc is used to describe the channel close that is being performed. +type AuxCloseDesc struct { + AuxShutdownReq + + // CloseFee is the closing fee to be paid for this state. + CloseFee btcutil.Amount + + // CommitFee is the fee that was paid for the last commitment. + CommitFee btcutil.Amount + + // LocalCloseOutput is the output that the local node should be paid + // to. This is None if the local party will not have an output on the + // co-op close transaction. + LocalCloseOutput fn.Option[CloseOutput] + + // RemoteCloseOutput is the output that the remote node should be paid + // to. This will be None if the remote party will not have an output on + // the co-op close transaction. + RemoteCloseOutput fn.Option[CloseOutput] +} + +// AuxChanCloser is used to allow an external caller to finalize a cooperative +// channel close. +type AuxChanCloser interface { + // FinalizeClose is called after the close transaction has been agreed + // upon and confirmed. + FinalizeClose(desc AuxCloseDesc, closeTx *wire.MsgTx) error +} + // LocalUnilateralCloseInfo encapsulates all the information we need to act on // a local force close that gets confirmed. type LocalUnilateralCloseInfo struct { @@ -229,6 +301,9 @@ type chainWatcherConfig struct { // auxResolver is used to supplement contract resolution. auxResolver fn.Option[lnwallet.AuxContractResolver] + + // auxCloser is used to finalize cooperative closes. + auxCloser fn.Option[AuxChanCloser] } // chainWatcher is a system that's assigned to every active channel. The duty From 0058de9686b08a1a840bf55768e9aa54f3f943e3 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 13 Oct 2025 15:12:59 +0200 Subject: [PATCH 2/3] countractcourt+lnwallet: move aux close finalization to chain watcher We now execute the aux chan closer finalization within the chain watcher. This is better as we don't need to rely on the remote party being online and sending us a message. Instead we do the finalization once the on-chain transaction has been confirmed. --- contractcourt/chain_watcher.go | 81 +++++++++++++++++++++++++++++++ lnwallet/chancloser/chancloser.go | 27 ----------- 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index 0e1822bbcd0..e3b18bc49b9 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -1061,6 +1061,76 @@ func (c *chainWatcher) toSelfAmount(tx *wire.MsgTx) btcutil.Amount { return btcutil.Amount(fn.Sum(vals)) } +// finalizeCoopClose calls the aux closer to finalize a cooperative close +// transaction that has been confirmed on-chain. +func (c *chainWatcher) finalizeCoopClose(aux AuxChanCloser, + closeTx *wire.MsgTx) error { + + chanState := c.cfg.chanState + + // Get the shutdown info to extract the local delivery script. + shutdown, err := chanState.ShutdownInfo() + if err != nil { + return fmt.Errorf("get shutdown info: %w", err) + } + + // TODO(roasbeef): Extract the internal key if this is a taproot + // channel. For now, we leave it as None since we don't persist it + // separately from the delivery script. + var internalKey fn.Option[btcec.PublicKey] + + // Build the AuxShutdownReq. + req := AuxShutdownReq{ + ChanPoint: chanState.FundingOutpoint, + ShortChanID: chanState.ShortChanID(), + InternalKey: internalKey, + Initiator: chanState.IsInitiator, + CommitBlob: chanState.LocalCommitment.CustomBlob, + FundingBlob: chanState.CustomBlob, + } + + // Extract close outputs from the transaction. We need to identify + // which outputs belong to local vs remote parties. + var localCloseOutput, remoteCloseOutput fn.Option[CloseOutput] + + // Get the delivery scripts for both parties. + var localDeliveryScript lnwire.DeliveryAddress + shutdown.WhenSome(func(s channeldb.ShutdownInfo) { + localDeliveryScript = s.DeliveryScript.Val + }) + + // Scan through the close transaction outputs to identify local and + // remote outputs. + for _, out := range closeTx.TxOut { + if len(localDeliveryScript) > 0 && + slices.Equal(out.PkScript, localDeliveryScript) { + + localCloseOutput = fn.Some(CloseOutput{ + Amt: btcutil.Amount(out.Value), + PkScript: out.PkScript, + DustLimit: chanState.LocalChanCfg.DustLimit, + }) + } else { + // This must be the remote output. + remoteCloseOutput = fn.Some(CloseOutput{ + Amt: btcutil.Amount(out.Value), + PkScript: out.PkScript, + DustLimit: chanState.RemoteChanCfg.DustLimit, + }) + } + } + + // Build the AuxCloseDesc. + desc := AuxCloseDesc{ + AuxShutdownReq: req, + LocalCloseOutput: localCloseOutput, + RemoteCloseOutput: remoteCloseOutput, + } + + // Call FinalizeClose on the aux closer. + return aux.FinalizeClose(desc, closeTx) +} + // dispatchCooperativeClose processed a detect cooperative channel closure. // We'll use the spending transaction to locate our output within the // transaction, then clean up the database state. We'll also dispatch a @@ -1111,6 +1181,17 @@ func (c *chainWatcher) dispatchCooperativeClose(commitSpend *chainntnfs.SpendDet ChannelCloseSummary: closeSummary, } + // If we have an aux closer, finalize the cooperative close now that + // it's confirmed. + err = fn.MapOptionZ( + c.cfg.auxCloser, func(aux AuxChanCloser) error { + return c.finalizeCoopClose(aux, broadcastTx) + }, + ) + if err != nil { + return fmt.Errorf("finalize coop close: %w", err) + } + // With the event processed, we'll now notify all subscribers of the // event. c.Lock() diff --git a/lnwallet/chancloser/chancloser.go b/lnwallet/chancloser/chancloser.go index cc6ccffa8cd..21bdde9bf97 100644 --- a/lnwallet/chancloser/chancloser.go +++ b/lnwallet/chancloser/chancloser.go @@ -970,33 +970,6 @@ func (c *ChanCloser) ReceiveClosingSigned( //nolint:funlen } c.closingTx = closeTx - // If there's an aux chan closer, then we'll finalize with it - // before we write to disk. - err = fn.MapOptionZ( - c.cfg.AuxCloser, func(aux AuxChanCloser) error { - channel := c.cfg.Channel - //nolint:ll - req := AuxShutdownReq{ - ChanPoint: c.chanPoint, - ShortChanID: c.cfg.Channel.ShortChanID(), - InternalKey: c.localInternalKey, - Initiator: channel.IsInitiator(), - CommitBlob: channel.LocalCommitmentBlob(), - FundingBlob: channel.FundingBlob(), - } - desc := AuxCloseDesc{ - AuxShutdownReq: req, - LocalCloseOutput: c.localCloseOutput, - RemoteCloseOutput: c.remoteCloseOutput, - } - - return aux.FinalizeClose(desc, closeTx) - }, - ) - if err != nil { - return noClosing, err - } - // Before publishing the closing tx, we persist it to the // database, such that it can be republished if something goes // wrong. From 2b077d502e831a1fdfa1e879bd900fa3b4d39e34 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 13 Oct 2025 15:14:23 +0200 Subject: [PATCH 3/3] lnd: provide aux closer to chain arbitrator The final step is to provide the aux closer to the corresponding configs from the server. Here we use an adapter pattern which returns an aux chan closer as defined in the contractcourt package. --- server.go | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/server.go b/server.go index a2d36eb8653..2525119f28a 100644 --- a/server.go +++ b/server.go @@ -60,6 +60,7 @@ import ( "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + chcl "github.com/lightningnetwork/lnd/lnwallet/chancloser" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" "github.com/lightningnetwork/lnd/lnwallet/rpcwallet" "github.com/lightningnetwork/lnd/lnwire" @@ -220,6 +221,53 @@ type peerSlotStatus struct { state peerAccessStatus } +// auxCloserAdapter adapts a chancloser.AuxChanCloser to the +// contractcourt.AuxChanCloser interface. +type auxCloserAdapter struct { + chcl.AuxChanCloser +} + +// FinalizeClose adapts the chancloser types to contractcourt types. +func (a auxCloserAdapter) FinalizeClose(desc contractcourt.AuxCloseDesc, + closeTx *wire.MsgTx) error { + + // Convert contractcourt types to chancloser types. + chanCloserDesc := chcl.AuxCloseDesc{ + AuxShutdownReq: chcl.AuxShutdownReq{ + ChanPoint: desc.ChanPoint, + ShortChanID: desc.ShortChanID, + Initiator: desc.Initiator, + InternalKey: desc.InternalKey, + CommitBlob: desc.CommitBlob, + FundingBlob: desc.FundingBlob, + }, + CloseFee: desc.CloseFee, + CommitFee: desc.CommitFee, + LocalCloseOutput: fn.MapOption( + func(o contractcourt.CloseOutput) chcl.CloseOutput { + return chcl.CloseOutput{ + Amt: o.Amt, + DustLimit: o.DustLimit, + PkScript: o.PkScript, + ShutdownRecords: o.ShutdownRecords, + } + }, + )(desc.LocalCloseOutput), + RemoteCloseOutput: fn.MapOption( + func(o contractcourt.CloseOutput) chcl.CloseOutput { + return chcl.CloseOutput{ + Amt: o.Amt, + DustLimit: o.DustLimit, + PkScript: o.PkScript, + ShutdownRecords: o.ShutdownRecords, + } + }, + )(desc.RemoteCloseOutput), + } + + return a.AuxChanCloser.FinalizeClose(chanCloserDesc, closeTx) +} + // server is the main server of the Lightning Network Daemon. The server houses // global state pertaining to the wallet, database, and the rpcserver. // Additionally, the server is also used as a central messaging bus to interact @@ -1375,6 +1423,11 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, AuxLeafStore: implCfg.AuxLeafStore, AuxSigner: implCfg.AuxSigner, AuxResolver: implCfg.AuxContractResolver, + AuxCloser: fn.MapOption( + func(c chcl.AuxChanCloser) contractcourt.AuxChanCloser { + return auxCloserAdapter{AuxChanCloser: c} + }, + )(implCfg.AuxChanCloser), }, dbs.ChanStateDB) // Select the configuration and funding parameters for Bitcoin.