contractcourt+lnwallet: move aux close finalization to chain watcher
What changed, and why it matters
This commit moves a finalization step for special auxiliary channel closes from the interactive negotiation phase to the point where the closing transaction is confirmed on-chain. The goal is to make the process more reliable by not depending on the remote party staying online and sending a message. The change itself is a refactor of when and where finalization happens, not a fix for a known exploit.
Review as a normal reliability/refactor change. Verify that moving finalization to on-chain confirmation does not introduce race conditions or skip necessary pre-confirmation validation for auxiliary channel closes. No urgent security patch appears indicated from the diff alone.
Security signals we found
Refactor of auxiliary close finalization timing
Reduced dependency on remote party online state for close finalization
No direct memory-safety, cryptographic, or authorization changes visible
Potential change in trust/availability assumptions for custom (aux) channel closes
Evidence from the diff
The patch removes the aux channel closer finalization call from ChanCloser.ReceiveClosingSigned in lnwallet/chancloser/chancloser.go and adds it to chainWatcher.dispatchCooperativeClose in contractcourt/chain_watcher.go. The new finalizeCoopClose helper builds an AuxCloseDesc from channel state and the confirmed close transaction, then calls aux.FinalizeClose. This shifts finalization from message-driven to confirmation-driven, reducing reliance on remote peer availability during close negotiation.
Changed components
contractcourt/chain_watcher.golnwallet/chancloser/chancloser.goAuxChanCloser finalization flowInspect captured patch +79 / −27
diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go
index 6f33c0f..0b3a972 100644
--- a/contractcourt/chain_watcher.go
+++ b/contractcourt/chain_watcher.go
@@ -998,6 +998,74 @@ 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)
+ }
+
+ // Build the AuxShutdownReq.
+ req := types.AuxShutdownReq{
+ ChanPoint: chanState.FundingOutpoint,
+ ShortChanID: chanState.ShortChanID(),
+ Initiator: chanState.IsInitiator,
+ CommitBlob: chanState.LocalCommitment.CustomBlob,
+ FundingBlob: chanState.CustomBlob,
+ }
+
+ // Shutdown info must be present in order to continue.
+ if shutdown.IsNone() {
+ return fmt.Errorf("failed to finalize coop close, shutdown " +
+ "info missing")
+ }
+
+ // Extract close outputs from the transaction. We need to identify
+ // which outputs belong to local vs remote parties.
+ var localCloseOutput, remoteCloseOutput fn.Option[types.CloseOutput]
+
+ // Get the delivery scripts for the local party.
+ 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(types.CloseOutput{
+ Amt: btcutil.Amount(out.Value),
+ PkScript: out.PkScript,
+ DustLimit: chanState.LocalChanCfg.DustLimit,
+ })
+ } else {
+ // This must be the remote output.
+ remoteCloseOutput = fn.Some(types.CloseOutput{
+ Amt: btcutil.Amount(out.Value),
+ PkScript: out.PkScript,
+ DustLimit: chanState.RemoteChanCfg.DustLimit,
+ })
+ }
+ }
+
+ desc := types.AuxCloseDesc{
+ AuxShutdownReq: req,
+ LocalCloseOutput: localCloseOutput,
+ RemoteCloseOutput: remoteCloseOutput,
+ }
+
+ 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
@@ -1048,6 +1116,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 90e91f6..6e5eaf3 100644
--- a/lnwallet/chancloser/chancloser.go
+++ b/lnwallet/chancloser/chancloser.go
@@ -974,33 +974,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 := types.AuxShutdownReq{
- ChanPoint: c.chanPoint,
- ShortChanID: c.cfg.Channel.ShortChanID(),
- InternalKey: c.localInternalKey,
- Initiator: channel.IsInitiator(),
- CommitBlob: channel.LocalCommitmentBlob(),
- FundingBlob: channel.FundingBlob(),
- }
- desc := types.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.
Why this scored 32/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.