From 533e5ab0ba99e641ca5229a8cf6033831f16a626 Mon Sep 17 00:00:00 2001 From: haanhvu Date: Tue, 19 May 2026 16:47:41 +0700 Subject: [PATCH 1/4] chancloser, peer: plumb aux close outputs and shutdown custom records through RBF coop close - chancloser now carries shutdown custom records, derives auxiliary close outputs, and preserves them across RBF close state transitions. - peer wires the extra close metadata into the RBF closer environment and surfaces final close outputs in close updates. Signed-off-by: haanhvu --- lnwallet/chancloser/rbf_coop_msg_mapper.go | 1 + lnwallet/chancloser/rbf_coop_states.go | 166 ++++++++++++++++++ lnwallet/chancloser/rbf_coop_test.go | 176 +++++++++++++++++++- lnwallet/chancloser/rbf_coop_transitions.go | 139 +++++++++++++++- peer/brontide.go | 33 +++- 5 files changed, 502 insertions(+), 13 deletions(-) diff --git a/lnwallet/chancloser/rbf_coop_msg_mapper.go b/lnwallet/chancloser/rbf_coop_msg_mapper.go index 2e4079a49a9..b2fee843026 100644 --- a/lnwallet/chancloser/rbf_coop_msg_mapper.go +++ b/lnwallet/chancloser/rbf_coop_msg_mapper.go @@ -67,6 +67,7 @@ func (r *RbfMsgMapper) MapMsg(wireMsg msgmux.PeerMsg) fn.Option[ProtocolEvent] { return someEvent(&ShutdownReceived{ BlockHeight: r.bestHeight(), ShutdownScript: msg.Address, + CustomRecords: msg.CustomRecords, RemoteShutdownNonce: remoteShutdownNonce, }) diff --git a/lnwallet/chancloser/rbf_coop_states.go b/lnwallet/chancloser/rbf_coop_states.go index 9319c1d271e..8a4d40dd5b8 100644 --- a/lnwallet/chancloser/rbf_coop_states.go +++ b/lnwallet/chancloser/rbf_coop_states.go @@ -14,8 +14,10 @@ import ( "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwallet/types" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/protofsm" + "github.com/lightningnetwork/lnd/tlv" ) var ( @@ -127,6 +129,10 @@ type ShutdownReceived struct { // shutdown. ShutdownScript lnwire.DeliveryAddress + // CustomRecords are the custom TLV records + // that were included in the remote party's shutdown message. + CustomRecords lnwire.CustomRecords + // 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. @@ -343,6 +349,41 @@ type Environment struct { // satoshis we'll pay given a local and/or remote output. FeeEstimator CoopFeeEstimator + // AuxCloser is an optional auxiliary channel closer used to derive + // additional shutdown records and close outputs + // for custom channel types. + AuxCloser fn.Option[AuxChanCloser] + + // LocalInternalKey is the optional local internal taproot key used when + // constructing auxiliary shutdown data or close outputs. + LocalInternalKey fn.Option[btcec.PublicKey] + + // FundingBlob is optional auxiliary metadata derived from the funding + // output and passed to the auxiliary closer during close handling. + FundingBlob fn.Option[tlv.Blob] + + // CommitBlob is optional auxiliary metadata derived from the + // latest commitment state and passed to the auxiliary closer + // during close handling. + CommitBlob fn.Option[tlv.Blob] + + // DustLimit is the dust threshold used to determine whether + // this party's close output should be omitted from the + // cooperative close transaction. + DustLimit btcutil.Amount + + // Amt is this party's settled close amount that would be paid to its + // delivery script before accounting for output trimming rules. + Amt btcutil.Amount + + // CommitFee is the commitment transaction fee associated with the + // latest channel state, used when deriving auxiliary close outputs. + CommitFee btcutil.Amount + + // Initiator indicates whether the local node initiated the channel + // and is used when deriving auxiliary shutdown and close parameters. + Initiator bool + // 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. @@ -378,6 +419,93 @@ func (e *Environment) IsTaproot() bool { return e.LocalMusigSession != nil && e.RemoteMusigSession != nil } +// AuxCloseOutputs returns any auxiliary close outputs for the cooperative +// close transaction. +// +//nolint:ll +func (e *Environment) AuxCloseOutputs(closeFee btcutil.Amount, + localCloseOutput fn.Option[types.CloseOutput], + remoteCloseOutput fn.Option[types.CloseOutput]) (fn.Option[AuxCloseOutputs], error) { + + var closeOuts fn.Option[AuxCloseOutputs] + err := fn.MapOptionZ(e.AuxCloser, func(aux AuxChanCloser) error { + req := types.AuxShutdownReq{ + ChanPoint: e.ChanPoint, + ShortChanID: e.Scid, + InternalKey: e.LocalInternalKey, + Initiator: e.Initiator, + CommitBlob: e.CommitBlob, + FundingBlob: e.FundingBlob, + } + outs, err := aux.AuxCloseOutputs(types.AuxCloseDesc{ + AuxShutdownReq: req, + CloseFee: closeFee, + CommitFee: e.CommitFee, + LocalCloseOutput: localCloseOutput, + RemoteCloseOutput: remoteCloseOutput, + }) + if err != nil { + return err + } + + closeOuts = outs + + return nil + }) + if err != nil { + return closeOuts, err + } + + return closeOuts, nil +} + +// CloseOutput returns the close output for the given delivery script and +// shutdown custom records. +// +//nolint:ll +func (e *Environment) CloseOutput(deliveryScript lnwire.DeliveryAddress, + shutdownCustomRecords lnwire.CustomRecords) fn.Option[types.CloseOutput] { + + return fn.Some(types.CloseOutput{ + Amt: e.Amt, + DustLimit: e.DustLimit, + PkScript: deliveryScript, + ShutdownRecords: shutdownCustomRecords, + }) +} + +// ShutdownCustomRecords returns shutdown custom records from the auxiliary +// channel closer. +func (e *Environment) ShutdownCustomRecords( + isInitiator bool) (lnwire.CustomRecords, error) { + + var shutdownCustomRecords lnwire.CustomRecords + + err := fn.MapOptionZ(e.AuxCloser, func(a AuxChanCloser) error { + shutdownBlob, err := a.ShutdownBlob( + types.AuxShutdownReq{ + ChanPoint: e.ChanPoint, + ShortChanID: e.Scid, + Initiator: isInitiator, + InternalKey: e.LocalInternalKey, + CommitBlob: e.CommitBlob, + FundingBlob: e.FundingBlob, + }, + ) + if err != nil { + return err + } + + shutdownBlob.WhenSome(func(cr lnwire.CustomRecords) { + shutdownCustomRecords = cr + }) + + return nil + }) + + return shutdownCustomRecords, err +} + // CloseStateTransition is the StateTransition type specific to the coop close // state machine. // @@ -465,6 +593,18 @@ type ShutdownScripts struct { RemoteDeliveryScript lnwire.DeliveryAddress } +// ShutdownCustomRecords is a set of custom TLV records that we'll use to +// co-op close the channel. +type ShutdownCustomRecords struct { + // LocalCustomRecords are the custom TLV records that we'll include in + // our shutdown message. + LocalCustomRecords lnwire.CustomRecords + + // RemoteCustomRecords are the custom TLV records that the remote party + // included in their shutdown message. + RemoteCustomRecords lnwire.CustomRecords +} + // 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 @@ -482,6 +622,10 @@ type ShutdownPending struct { // close. ShutdownScripts + // ShutdownCustomRecords store the set of custom records we'll use to + // initiate a coop close. + ShutdownCustomRecords + // IdealFeeRate is the ideal fee rate we'd like to use for the closing // attempt. IdealFeeRate fn.Option[chainfee.SatPerVByte] @@ -530,6 +674,10 @@ type ChannelFlushing struct { // close. ShutdownScripts + // ShutdownCustomRecords store the set of custom records we'll use to + // initiate a coop close. + ShutdownCustomRecords + // 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. @@ -668,6 +816,10 @@ type NonceState struct { type CloseChannelTerms struct { ShutdownScripts + ShutdownCustomRecords + + AuxOutputs fn.Option[AuxCloseOutputs] + ShutdownBalances // NonceState tracks nonces for taproot channels across RBF iterations. @@ -897,6 +1049,20 @@ func (c *ClosePending) IsTerminal() bool { type CloseFin struct { // ConfirmedTx is the transaction that confirmed the channel close. ConfirmedTx *wire.MsgTx + + // LocalCloseOutput is the final local close output, if the local + // party's settled balance produced a non-dust output in the confirmed + // cooperative close transaction. + LocalCloseOutput fn.Option[types.CloseOutput] + + // RemoteCloseOutput is the final remote close output, if the remote + // party's settled balance produced a non-dust output in the confirmed + // cooperative close transaction. + RemoteCloseOutput fn.Option[types.CloseOutput] + + // AuxOutputs contains any optional auxiliary outputs that were included + // in the confirmed cooperative close transaction. + AuxOutputs fn.Option[AuxCloseOutputs] } // String returns the name of the state for CloseFin. diff --git a/lnwallet/chancloser/rbf_coop_test.go b/lnwallet/chancloser/rbf_coop_test.go index e8bbcc3f40c..a4b7e8ac868 100644 --- a/lnwallet/chancloser/rbf_coop_test.go +++ b/lnwallet/chancloser/rbf_coop_test.go @@ -25,6 +25,7 @@ import ( "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwallet/types" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/protofsm" "github.com/lightningnetwork/lnd/tlv" @@ -43,6 +44,25 @@ var ( bytes.Repeat([]byte{0x02}, 32)..., )) + localCustomRecords = lnwire.CustomRecords{ + 0: []byte("local"), + } + remoteCustomRecords = lnwire.CustomRecords{ + 1: []byte("remote"), + } + + expectedAuxOutputs = lnwallet.CloseOutput{ + TxOut: wire.TxOut{ + Value: 50_000, + PkScript: []byte{ + 0x00, 0x14, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + }, + }, + IsLocal: false, + } + localSigBytes = fromHex("3045022100cd496f2ab4fe124f977ffe3caa09f757" + "6d8a34156b4e55d326b4dffc0399a094022013500a0510b5094bff220c7" + "4656879b8ca0369d3da78004004c970790862fc03") @@ -544,6 +564,10 @@ func (r *rbfCloserTestHarness) assertLocalClosePending() { require.True(r.T, ok) require.Equal(r.T, closeTx, closePendingState.CloseTx) + + require.Contains(r.T, + closePendingState.AuxOutputs.UnsafeFromSome().ExtraCloseOutputs, + expectedAuxOutputs) } type dustExpectation uint @@ -720,6 +744,10 @@ func (r *rbfCloserTestHarness) expectHalfSignerIteration( // For non-taproot channels, we expect the exact ECDSA signature require.Equal(r.T, localSigWire, offerSentState.LocalSig) } + + require.Contains(r.T, + offerSentState.AuxOutputs.UnsafeFromSome().ExtraCloseOutputs, + expectedAuxOutputs) } func (r *rbfCloserTestHarness) assertSingleRbfIteration( @@ -875,6 +903,10 @@ func (r *rbfCloserTestHarness) assertSingleRemoteRbfIteration( // The proposed fee, as well as our local signature should be properly // stashed in the state. require.Equal(r.T, closeTx, pendingState.CloseTx) + + require.Contains(r.T, + pendingState.AuxOutputs.UnsafeFromSome().ExtraCloseOutputs, + expectedAuxOutputs) } // TestSelectTaprootPartialSigWithNonce tests the selection logic for taproot @@ -942,6 +974,46 @@ func assertStateT[T ProtocolState](h *rbfCloserTestHarness) T { return currentState } +type mockAuxChanCloser struct{} + +func (m *mockAuxChanCloser) ShutdownBlob( + req types.AuxShutdownReq, +) (fn.Option[lnwire.CustomRecords], error) { + + return fn.Some[lnwire.CustomRecords](localCustomRecords), nil +} + +func (m *mockAuxChanCloser) AuxCloseOutputs( + desc types.AuxCloseDesc) (fn.Option[AuxCloseOutputs], error) { + + return fn.Some[AuxCloseOutputs]( + AuxCloseOutputs{ + ExtraCloseOutputs: []lnwallet.CloseOutput{ + { + TxOut: wire.TxOut{ + Value: 50_000, + PkScript: []byte{ + 0x00, 0x14, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, + }, + }, + IsLocal: desc.Initiator, + }, + }, + }, + ), nil +} + +func (m *mockAuxChanCloser) FinalizeClose(desc types.AuxCloseDesc, + closeTx *wire.MsgTx) error { + + return nil +} + // newRbfCloserTestHarness creates a new test harness for the RBF closer. func newRbfCloserTestHarness(t *testing.T, cfg *harnessCfg) *rbfCloserTestHarness { @@ -994,8 +1066,10 @@ func newRbfCloserTestHarness(t *testing.T, LocalUpfrontShutdown: cfg.localUpfrontAddr, NewDeliveryScript: harness.newAddrFunc, FeeEstimator: feeEstimator, - ChanObserver: mockObserver, - CloseSigner: mockSigner, + AuxCloser: fn.Some[AuxChanCloser]( + &mockAuxChanCloser{}), + ChanObserver: mockObserver, + CloseSigner: mockSigner, } // If musig sessions are provided, we set them in the environment. @@ -1075,6 +1149,10 @@ func testInitiatorShutdownRecvOkNonTap(t *testing.T, ctx context.Context, LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, } + firstState.ShutdownCustomRecords = ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + } cfg := &harnessCfg{ initialState: fn.Some[ProtocolState]( @@ -1095,6 +1173,7 @@ func testInitiatorShutdownRecvOkNonTap(t *testing.T, ctx context.Context, // Create shutdown event. shutdownEvent := &ShutdownReceived{ ShutdownScript: remoteAddr, + CustomRecords: remoteCustomRecords, } // We'll send in a shutdown received event, with the expected @@ -1114,6 +1193,16 @@ func testInitiatorShutdownRecvOkNonTap(t *testing.T, ctx context.Context, require.Equal( t, remoteAddr, currentState.RemoteDeliveryScript, ) + + require.Equal( + t, localCustomRecords, currentState.LocalCustomRecords, + ) + require.Equal( + t, + remoteCustomRecords, + currentState.RemoteCustomRecords, + ) + require.Equal( t, firstState.IdealFeeRate, currentState.IdealFeeRate, ) @@ -1134,6 +1223,10 @@ func testInitiatorShutdownRecvOkTaproot(t *testing.T, ctx context.Context, LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, } + firstState.ShutdownCustomRecords = ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + } localCloseeNonce := lnwire.Musig2Nonce{1, 2, 3} remoteCloseeNonce := lnwire.Musig2Nonce{4, 5, 6} @@ -1171,6 +1264,7 @@ func testInitiatorShutdownRecvOkTaproot(t *testing.T, ctx context.Context, // Create shutdown event with nonce for taproot channel. shutdownEvent := &ShutdownReceived{ ShutdownScript: remoteAddr, + CustomRecords: remoteCustomRecords, RemoteShutdownNonce: fn.Some( remoteCloseeNonce, ), @@ -1193,6 +1287,16 @@ func testInitiatorShutdownRecvOkTaproot(t *testing.T, ctx context.Context, require.Equal( t, remoteAddr, currentState.RemoteDeliveryScript, ) + + require.Equal( + t, localCustomRecords, currentState.LocalCustomRecords, + ) + require.Equal( + t, + remoteCustomRecords, + currentState.RemoteCustomRecords, + ) + require.Equal( t, firstState.IdealFeeRate, currentState.IdealFeeRate, ) @@ -1260,6 +1364,7 @@ func testRemoteInitiatedCloseOkNonTap(t *testing.T, ctx context.Context) { // Create shutdown event. shutdownEvent := &ShutdownReceived{ ShutdownScript: remoteAddr, + CustomRecords: remoteCustomRecords, } // Next, we'll emit the recv event, with the addr of the remote @@ -1280,6 +1385,15 @@ func testRemoteInitiatedCloseOkNonTap(t *testing.T, ctx context.Context) { t, remoteAddr, currentState.ShutdownScripts.RemoteDeliveryScript, ) + + require.Equal( + t, localCustomRecords, currentState.LocalCustomRecords, + ) + require.Equal( + t, + remoteCustomRecords, + currentState.RemoteCustomRecords, + ) }) } @@ -1318,6 +1432,7 @@ func testRemoteInitiatedCloseOkTaproot(t *testing.T, ctx context.Context) { // Create shutdown event with nonce for taproot channel. shutdownEvent := &ShutdownReceived{ ShutdownScript: remoteAddr, + CustomRecords: remoteCustomRecords, RemoteShutdownNonce: fn.Some( remoteCloseeNonce, ), @@ -1342,6 +1457,15 @@ func testRemoteInitiatedCloseOkTaproot(t *testing.T, ctx context.Context) { currentState.ShutdownScripts.RemoteDeliveryScript, ) + require.Equal( + t, localCustomRecords, currentState.LocalCustomRecords, + ) + require.Equal( + t, + remoteCustomRecords, + currentState.RemoteCustomRecords, + ) + // Verify nonce state was set with remote's closee nonce. require.True( t, currentState.NonceState.RemoteCloseeNonce.IsSome(), @@ -1444,6 +1568,10 @@ func TestRbfChannelActiveTransitions(t *testing.T) { t, localAddr, currentState.ShutdownScripts.LocalDeliveryScript, ) + require.Equal( + t, localCustomRecords, + currentState.ShutdownCustomRecords.LocalCustomRecords, + ) // Wait till the msg has been sent to assert our expectations. // @@ -1502,6 +1630,7 @@ func TestRbfChannelActiveTransitions(t *testing.T) { // shutdown nonce. This should result in an error. shutdownEvent := &ShutdownReceived{ ShutdownScript: remoteAddr, + CustomRecords: remoteCustomRecords, RemoteShutdownNonce: fn.None[lnwire.Musig2Nonce](), } closeHarness.sendEventAndExpectFailure( @@ -1559,6 +1688,7 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { // should result in an error. event := &ShutdownReceived{ ShutdownScript: localAddr, + CustomRecords: localCustomRecords, } closeHarness.sendEventAndExpectFailure( @@ -1586,6 +1716,10 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, } + firstState.ShutdownCustomRecords = ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + } // Set up taproot channel with nonce state mockLocalMusig := newMockMusigSession() @@ -1618,6 +1752,7 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { // should fail. shutdownEvent := &ShutdownReceived{ ShutdownScript: remoteAddr, + CustomRecords: remoteCustomRecords, RemoteShutdownNonce: fn.None[lnwire.Musig2Nonce](), } closeHarness.sendEventAndExpectFailure( @@ -1638,6 +1773,10 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, } + firstState.ShutdownCustomRecords = ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + } closeHarness := newCloser(t, &harnessCfg{ initialState: fn.Some[ProtocolState]( @@ -1669,6 +1808,10 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, } + firstState.ShutdownCustomRecords = ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + } closeHarness := newCloser(t, &harnessCfg{ initialState: fn.Some[ProtocolState]( @@ -1716,6 +1859,10 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, } + firstState.ShutdownCustomRecords = ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + } closeHarness := newCloser(t, &harnessCfg{ initialState: fn.Some[ProtocolState]( @@ -1776,6 +1923,10 @@ func TestRbfChannelFlushingTransitions(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, }, + ShutdownCustomRecords: ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + }, } flushTemplate := &ChannelFlushed{ @@ -2320,6 +2471,10 @@ func TestRbfCloseClosingNegotiationLocal(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, }, + ShutdownCustomRecords: ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + }, } startingState := &ClosingNegotiation{ PeerState: lntypes.Dual[AsymmetricPeerState]{ @@ -2666,6 +2821,10 @@ func TestRbfCloseClosingNegotiationRemote(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, }, + ShutdownCustomRecords: ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + }, } startingState := &ClosingNegotiation{ PeerState: lntypes.Dual[AsymmetricPeerState]{ @@ -2939,6 +3098,10 @@ func TestRbfCloseErr(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, }, + ShutdownCustomRecords: ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + }, } startingState := &ClosingNegotiation{ PeerState: lntypes.Dual[AsymmetricPeerState]{ @@ -3064,6 +3227,7 @@ func TestTaprootNonceHandling(t *testing.T) { remoteNonce := generateTestNonce(t) shutdownEvent := &ShutdownReceived{ ShutdownScript: remoteAddr, + CustomRecords: remoteCustomRecords, BlockHeight: 100, RemoteShutdownNonce: fn.Some(lnwire.Musig2Nonce( remoteNonce.PubNonce, @@ -3104,6 +3268,10 @@ func TestNextCloseeNonceStorageFromClosingSig(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, }, + ShutdownCustomRecords: ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + }, NonceState: NonceState{ LocalCloseeNonce: fn.Some(lnwire.Musig2Nonce{1, 2, 3}), RemoteCloseeNonce: fn.Some(originalNonce), @@ -3276,6 +3444,10 @@ func TestLocalOfferSentUsesStoredSig(t *testing.T) { LocalDeliveryScript: localAddr, RemoteDeliveryScript: remoteAddr, }, + ShutdownCustomRecords: ShutdownCustomRecords{ + LocalCustomRecords: localCustomRecords, + RemoteCustomRecords: remoteCustomRecords, + }, NonceState: NonceState{ LocalCloseeNonce: fn.Some(lnwire.Musig2Nonce{1, 2, 3}), RemoteCloseeNonce: fn.Some( diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go index eb8b1d67968..3f5b9d65160 100644 --- a/lnwallet/chancloser/rbf_coop_transitions.go +++ b/lnwallet/chancloser/rbf_coop_transitions.go @@ -40,12 +40,14 @@ func sendShutdownEvents(chanID lnwire.ChannelID, chanPoint wire.OutPoint, deliveryAddr lnwire.DeliveryAddress, peerPub btcec.PublicKey, postSendEvent fn.Option[ProtocolEvent], chanState ChanStateObserver, env *Environment, localCloseeNonce fn.Option[lnwire.Musig2Nonce], + shutdownCustomRecords lnwire.CustomRecords, ) (protofsm.DaemonEventSet, fn.Option[lnwire.Musig2Nonce], error) { // Create the shutdown message. shutdownMsg := &lnwire.Shutdown{ - ChannelID: chanID, - Address: deliveryAddr, + ChannelID: chanID, + Address: deliveryAddr, + CustomRecords: shutdownCustomRecords, } none := fn.None[lnwire.Musig2Nonce]() @@ -240,6 +242,11 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, return nil, err } + shutdownCustomRecords, err := env.ShutdownCustomRecords(true) + if err != nil { + return nil, err + } + // We'll emit some daemon events to send the shutdown message // and disable the channel on the network level. In this case, // we don't need a post send event as receive their shutdown is @@ -248,6 +255,7 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, env.ChanID, env.ChanPoint, shutdownScript, env.ChanPeer, fn.None[ProtocolEvent](), env.ChanObserver, env, msg.CloseeNonce, + shutdownCustomRecords, ) if err != nil { return nil, err @@ -259,12 +267,16 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, // From here, we'll transition to the shutdown pending state. In // this state we await their shutdown message (self loop), then // also the flushing event. + //nolint:ll return &CloseStateTransition{ NextState: &ShutdownPending{ IdealFeeRate: fn.Some(msg.IdealFeeRate), ShutdownScripts: ShutdownScripts{ LocalDeliveryScript: shutdownScript, }, + ShutdownCustomRecords: ShutdownCustomRecords{ + LocalCustomRecords: shutdownCustomRecords, + }, NonceState: NonceState{ LocalCloseeNonce: closeeNonce, }, @@ -308,6 +320,11 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, chancloserLog.Infof("ChannelPoint(%v): sending shutdown msg "+ "at next clean commit state", env.ChanPoint) + shutdownCustomRecords, err := env.ShutdownCustomRecords(false) + if err != nil { + return nil, err + } + // Now that we know the shutdown message is valid, we'll obtain // the set of daemon events we need to emit. We'll also specify // that once the message has actually been sent, that we @@ -317,6 +334,7 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, env.ChanPeer, fn.Some[ProtocolEvent](&ShutdownComplete{}), env.ChanObserver, env, fn.None[lnwire.Musig2Nonce](), + shutdownCustomRecords, ) if err != nil { return nil, err @@ -338,12 +356,17 @@ func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment, // This prepares the session for when we act as closer. initLocalMusigCloseeNonce(env, msg.RemoteShutdownNonce) + //nolint:ll return &CloseStateTransition{ NextState: &ShutdownPending{ ShutdownScripts: ShutdownScripts{ LocalDeliveryScript: shutdownAddr, RemoteDeliveryScript: remoteAddr, }, + ShutdownCustomRecords: ShutdownCustomRecords{ + LocalCustomRecords: shutdownCustomRecords, + RemoteCustomRecords: msg.CustomRecords, + }, NonceState: NonceState{ RemoteCloseeNonce: msg.RemoteShutdownNonce, //nolint:ll LocalCloseeNonce: closeeNonce, @@ -469,6 +492,7 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment, // We transition to the ChannelFlushing state, where we await // the ChannelFlushed event. + //nolint:ll return &CloseStateTransition{ NextState: &ChannelFlushing{ IdealFeeRate: s.IdealFeeRate, @@ -476,6 +500,10 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment, LocalDeliveryScript: s.LocalDeliveryScript, //nolint:ll RemoteDeliveryScript: msg.ShutdownScript, //nolint:ll }, + ShutdownCustomRecords: ShutdownCustomRecords{ + LocalCustomRecords: s.LocalCustomRecords, + RemoteCustomRecords: msg.CustomRecords, + }, NonceState: updatedNonceState, }, NewEvents: newEvents, @@ -519,9 +547,10 @@ func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment, // We'll stay here until we receive the ChannelFlushed event. return &CloseStateTransition{ NextState: &ChannelFlushing{ - IdealFeeRate: s.IdealFeeRate, - ShutdownScripts: s.ShutdownScripts, - NonceState: s.NonceState, + IdealFeeRate: s.IdealFeeRate, + ShutdownScripts: s.ShutdownScripts, + ShutdownCustomRecords: s.ShutdownCustomRecords, + NonceState: s.NonceState, }, NewEvents: newEvents, }, nil @@ -577,9 +606,10 @@ func (c *ChannelFlushing) ProcessEvent(event ProtocolEvent, env *Environment, // we'll be using to close the channel, so we'll create them // here. closeTerms := CloseChannelTerms{ - ShutdownScripts: c.ShutdownScripts, - ShutdownBalances: msg.ShutdownBalances, - NonceState: c.NonceState, + ShutdownScripts: c.ShutdownScripts, + ShutdownCustomRecords: c.ShutdownCustomRecords, + ShutdownBalances: msg.ShutdownBalances, + NonceState: c.NonceState, } chancloserLog.Infof("ChannelPoint(%v): channel flushed! "+ @@ -1182,6 +1212,34 @@ func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, closeOpts = append(closeOpts, musigOpts...) } + localCloseOutput := env.CloseOutput( + l.LocalDeliveryScript, + l.LocalCustomRecords) + remoteCloseOutput := env.CloseOutput( + l.RemoteDeliveryScript, + l.RemoteCustomRecords) + + var err error + l.AuxOutputs, err = env.AuxCloseOutputs( + absoluteFee, + localCloseOutput, + remoteCloseOutput) + if err != nil { + return nil, err + } + l.AuxOutputs.WhenSome(func(outs AuxCloseOutputs) { + closeOpts = append( + closeOpts, lnwallet.WithExtraCloseOutputs( + outs.ExtraCloseOutputs, + ), + ) + closeOpts = append( + closeOpts, lnwallet.WithCustomCoopSort( + outs.CustomSort, + ), + ) + }) + rawSig, closeTx, closeBalance, err := env.CloseSigner.CreateCloseProposal( //nolint:ll absoluteFee, localScript, l.RemoteDeliveryScript, closeOpts..., @@ -1572,6 +1630,33 @@ func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent, env *Environment, } closeOpts = append(closeOpts, musigOpts...) + localCloseOutput := env.CloseOutput( + l.LocalDeliveryScript, + l.LocalCustomRecords) + remoteCloseOutput := env.CloseOutput( + l.RemoteDeliveryScript, + l.RemoteCustomRecords) + + l.AuxOutputs, err = env.AuxCloseOutputs( + l.ProposedFee, + localCloseOutput, + remoteCloseOutput) + if err != nil { + return nil, err + } + l.AuxOutputs.WhenSome(func(outs AuxCloseOutputs) { + closeOpts = append( + closeOpts, lnwallet.WithExtraCloseOutputs( + outs.ExtraCloseOutputs, + ), + ) + closeOpts = append( + closeOpts, lnwallet.WithCustomCoopSort( + outs.CustomSort, + ), + ) + }) + // Now that we have their signature, we'll attempt to validate // it, then extract a valid closing signature from it. closeTx, _, err := env.CloseSigner.CompleteCooperativeClose( @@ -2039,6 +2124,33 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, l.LocalDeliveryScript[:], l.RemoteDeliveryScript[:], msg.SigMsg.FeeSatoshis, msg.SigMsg.LockTime) + localCloseOutput := env.CloseOutput( + l.LocalDeliveryScript, + l.LocalCustomRecords) + remoteCloseOutput := env.CloseOutput( + l.RemoteDeliveryScript, + l.RemoteCustomRecords) + + l.AuxOutputs, err = env.AuxCloseOutputs( + msg.SigMsg.FeeSatoshis, + localCloseOutput, + remoteCloseOutput) + if err != nil { + return nil, err + } + l.AuxOutputs.WhenSome(func(outs AuxCloseOutputs) { + chanOpts = append( + chanOpts, lnwallet.WithExtraCloseOutputs( + outs.ExtraCloseOutputs, + ), + ) + chanOpts = append( + chanOpts, lnwallet.WithCustomCoopSort( + outs.CustomSort, + ), + ) + }) + // Now that we have the remote sig, we'll sign the version they // signed, then attempt to complete the cooperative close // process. @@ -2149,9 +2261,18 @@ func (c *ClosePending) ProcessEvent(event ProtocolEvent, env *Environment, // If we can a spend while waiting for the close, then we'll go to our // terminal state. case *SpendEvent: + localCloseOutput := env.CloseOutput( + c.LocalDeliveryScript, + c.LocalCustomRecords) + remoteCloseOutput := env.CloseOutput( + c.RemoteDeliveryScript, + c.RemoteCustomRecords) return &CloseStateTransition{ NextState: &CloseFin{ - ConfirmedTx: msg.Tx, + ConfirmedTx: msg.Tx, + LocalCloseOutput: localCloseOutput, + RemoteCloseOutput: remoteCloseOutput, + AuxOutputs: c.AuxOutputs, }, }, nil diff --git a/peer/brontide.go b/peer/brontide.go index f7a01cd11f5..136844ec268 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -4000,9 +4000,13 @@ func (p *Brontide) observeRbfCloseUpdates(chanCloser *chancloser.RbfChanCloser, // update to the client. closingTxid := closeState.ConfirmedTx.TxHash() if closeReq != nil { + //nolint:ll closeReq.Updates <- &ChannelCloseUpdate{ - ClosingTxid: closingTxid[:], - Success: true, + ClosingTxid: closingTxid[:], + Success: true, + LocalCloseOutput: closeState.LocalCloseOutput, + RemoteCloseOutput: closeState.RemoteCloseOutput, + AuxOutputs: closeState.AuxOutputs, } } chanID := lnwire.NewChanIDFromOutPoint( @@ -4232,12 +4236,23 @@ func (p *Brontide) initRbfChanCloser( return p.genDeliveryScript() }, FeeEstimator: &chancloser.SimpleCoopFeeEstimator{}, + AuxCloser: p.cfg.AuxChanCloser, + FundingBlob: channel.FundingBlob(), + CommitBlob: channel.LocalCommitmentBlob(), + CommitFee: channel.CommitFee(), + Initiator: channel.IsInitiator(), CloseSigner: channel, ChanObserver: newChanObserver( channel, link, p.cfg.ChanStatusMgr, ), } + _, dustAmt := channel.LocalBalanceDust() + env.DustLimit = dustAmt + + localBalance, _ := channel.CommitBalances() + env.Amt = localBalance + // For taproot channels, we need to set both LocalMusigSession and // RemoteMusigSession to handle nonce exchange during RBF cooperative // close. @@ -4246,6 +4261,20 @@ func (p *Brontide) initRbfChanCloser( env.RemoteMusigSession = NewMusigChanCloser(channel) } + shutdownAddr, err := env.LocalUpfrontShutdown.UnwrapOrFuncErr( + env.NewDeliveryScript, + ) + if err != nil { + return nil, err + } + + addrWithInternalKey, err := p.addrWithInternalKey(shutdownAddr) + if err != nil { + return nil, err + } + + env.LocalInternalKey = addrWithInternalKey.InternalKey + spendEvent := protofsm.RegisterSpend[chancloser.ProtocolEvent]{ OutPoint: channel.ChannelPoint(), PkScript: channel.FundingTxOut().PkScript, From 32260fb0070d63d644983bbca32438f9e57e2886 Mon Sep 17 00:00:00 2001 From: haanhvu Date: Tue, 19 May 2026 17:00:04 +0700 Subject: [PATCH 2/4] config, lncfg: allow injecting mock AuxChanCloser in dev builds - add --dev.mock-aux-chan-closer config option for integration tests - expose dev config helpers to detect and retrieve a mock AuxChanCloser - wire ImplementationCfg.AuxChanCloser from dev config when enabled Signed-off-by: haanhvu --- config.go | 17 +++++++++-- lncfg/dev.go | 13 +++++++++ lncfg/dev_integration.go | 62 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index e3d8850e581..6f65b70dbc6 100644 --- a/config.go +++ b/config.go @@ -1944,7 +1944,8 @@ func (c *Config) ImplementationConfig( c, ltndLog, interceptor, c.RemoteSigner.MigrateWatchOnly, ) - return &ImplementationCfg{ + + implCfg := &ImplementationCfg{ GrpcRegistrar: rpcImpl, RestRegistrar: rpcImpl, ExternalValidator: rpcImpl, @@ -1954,10 +1955,17 @@ func (c *Config) ImplementationConfig( WalletConfigBuilder: rpcImpl, ChainControlBuilder: rpcImpl, } + + if c.Dev.NeedMockAuxChanCloser() { + //nolint:ll + implCfg.AuxChanCloser = c.Dev.GetMockAuxChanCloserValueForTest() + } + + return implCfg } defaultImpl := NewDefaultWalletImpl(c, ltndLog, interceptor, false) - return &ImplementationCfg{ + implCfg := &ImplementationCfg{ GrpcRegistrar: defaultImpl, RestRegistrar: defaultImpl, ExternalValidator: defaultImpl, @@ -1965,6 +1973,11 @@ func (c *Config) ImplementationConfig( WalletConfigBuilder: defaultImpl, ChainControlBuilder: defaultImpl, } + if c.Dev.NeedMockAuxChanCloser() { + implCfg.AuxChanCloser = c.Dev.GetMockAuxChanCloserValueForTest() + } + + return implCfg } // CleanAndExpandPath expands environment variables and leading ~ in the diff --git a/lncfg/dev.go b/lncfg/dev.go index 15c9367cbc2..3ce1880ed0d 100644 --- a/lncfg/dev.go +++ b/lncfg/dev.go @@ -6,6 +6,7 @@ import ( "time" "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwallet/chancloser" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" ) @@ -21,6 +22,18 @@ func IsDevBuild() bool { // should always remain empty. type DevConfig struct{} +// NeedMockAuxChanCloser returns the config value for MockAuxChanCloser, +// which is always false for production build. +func (d *DevConfig) NeedMockAuxChanCloser() bool { + return false +} + +// GetMockAuxChanCloserValueForTest returns the mock AuxChanCloser value +// which is always None for production build +func (d *DevConfig) GetMockAuxChanCloserValueForTest() fn.Option[chancloser.AuxChanCloser] { + return fn.None[chancloser.AuxChanCloser]() +} + // ChannelReadyWait returns the config value, which is always 0 for production // build. func (d *DevConfig) ChannelReadyWait() time.Duration { diff --git a/lncfg/dev_integration.go b/lncfg/dev_integration.go index 3793e7c46a5..a5f801ecbb4 100644 --- a/lncfg/dev_integration.go +++ b/lncfg/dev_integration.go @@ -5,8 +5,13 @@ package lncfg import ( "time" + "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chancloser" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" + "github.com/lightningnetwork/lnd/lnwallet/types" + "github.com/lightningnetwork/lnd/lnwire" ) // IsDevBuild returns a bool to indicate whether we are in a development @@ -30,6 +35,63 @@ type DevConfig struct { UnsafeConnect bool `long:"unsafeconnect" description:"Allow the rpcserver to connect to a peer even if there's already a connection."` ForceChannelCloseConfs uint32 `long:"force-channel-close-confs" description:"Force a specific number of confirmations for channel closes (dev/test only)"` MinFwdHistoryAge time.Duration `long:"min-fwd-history-age" description:"Minimum age of forwarding events before they can be deleted via DeleteForwardingHistory (dev/test only, default: 1h)"` + MockAuxChanCloser bool `long:"mock-aux-chan-closer" description:"Set the mock AuxChanCloser for tests."` +} + +// NeedMockAuxChanCloser returns the config value for MockAuxChanCloser, +// indicating whether the integration test needs a mock AuxChanCloser. +func (d *DevConfig) NeedMockAuxChanCloser() bool { + return d.MockAuxChanCloser +} + +// GetMockAuxChanCloserValueForTest returns the mock AuxChanCloser value +// that can be used in integration tests. +// +//nolint:ll +func (d *DevConfig) GetMockAuxChanCloserValueForTest() fn.Option[chancloser.AuxChanCloser] { + return fn.Some[chancloser.AuxChanCloser](&mockAuxChanCloser{}) +} + +// Mock implementation for AuxChanCloser. +type mockAuxChanCloser struct{} + +func (m *mockAuxChanCloser) ShutdownBlob( + req types.AuxShutdownReq, +) (fn.Option[lnwire.CustomRecords], error) { + + return fn.None[lnwire.CustomRecords](), nil +} + +func (m *mockAuxChanCloser) AuxCloseOutputs( + desc types.AuxCloseDesc, +) (fn.Option[chancloser.AuxCloseOutputs], error) { + + return fn.Some[chancloser.AuxCloseOutputs]( + chancloser.AuxCloseOutputs{ + ExtraCloseOutputs: []lnwallet.CloseOutput{ + { + TxOut: wire.TxOut{ + Value: 50_000, + PkScript: []byte{ + 0x00, 0x14, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, + }, + }, + IsLocal: desc.Initiator, + }, + }, + }, + ), nil +} + +func (m *mockAuxChanCloser) FinalizeClose(desc types.AuxCloseDesc, + closeTx *wire.MsgTx) error { + + return nil } // ChannelReadyWait returns the config value `ProcessChannelReadyWait`. From 42b82f4e43a130b75cf19fbd94f42ba70c1db9c7 Mon Sep 17 00:00:00 2001 From: haanhvu Date: Tue, 19 May 2026 17:03:41 +0700 Subject: [PATCH 3/4] itest: add RBF coop close test coverage for aux close outputs - add integration test for RBF cooperative close with mock aux chan closer enabled - assert extra close outputs are included in pending and final close transactions - verify auxiliary outputs persist across fee bump update Signed-off-by: haanhvu --- itest/list_on_test.go | 4 ++ itest/lnd_coop_close_rbf_test.go | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/itest/list_on_test.go b/itest/list_on_test.go index c8e4244e7e3..89414274b72 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -755,6 +755,10 @@ var allTestCases = []*lntest.TestCase{ Name: "rbf coop close", TestFunc: testCoopCloseRbf, }, + { + Name: "rbf coop close with aux close outputs", + TestFunc: testCoopCloseRbfWithAuxCloseOutputs, + }, { Name: "rbf coop close disconnect", TestFunc: testRBFCoopCloseDisconnect, diff --git a/itest/lnd_coop_close_rbf_test.go b/itest/lnd_coop_close_rbf_test.go index f1acf0288f1..d49e002d639 100644 --- a/itest/lnd_coop_close_rbf_test.go +++ b/itest/lnd_coop_close_rbf_test.go @@ -1,11 +1,13 @@ package itest import ( + "bytes" "fmt" "testing" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/lntest/node" @@ -206,6 +208,83 @@ func testCoopCloseRbf(ht *lntest.HarnessTest) { } } +// testCoopCloseRbfWithAuxCloseOutputs tests that +// AuxCloseOutputs are included in the TxOuts when AuxChanCloser exists +// and are preserved across fee updates. +func testCoopCloseRbfWithAuxCloseOutputs(ht *lntest.HarnessTest) { + ht.SetFeeEstimate(250) + ht.SetFeeEstimateWithConf(250, 6) + + rbfCoopFlags := []string{ + "--protocol.rbf-coop-close", + "--dev.mock-aux-chan-closer"} + params := lntest.OpenChannelParams{ + Amt: btcutil.Amount(10_000_000), + PushAmt: btcutil.Amount(5_000_000), + } + cfgs := [][]string{rbfCoopFlags, rbfCoopFlags} + chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params) + alice, bob := nodes[0], nodes[1] + chanPoint := chanPoints[0] + + aliceFeeRate := chainfee.SatPerVByte(5) + aliceCloseStream, aliceCloseUpdate := ht.CloseChannelAssertPending( + alice, chanPoint, false, + lntest.WithCoopCloseFeeRate(aliceFeeRate), + lntest.WithLocalTxNotify(), + ) + + alicePendingUpdate := aliceCloseUpdate.GetClosePending() + checkAdditionalOutputs(ht, chainhash.Hash(alicePendingUpdate.Txid)) + + bobFeeRate := aliceFeeRate * 2 + _, bobCloseUpdate := ht.CloseChannelAssertPending( + bob, chanPoint, false, lntest.WithCoopCloseFeeRate(bobFeeRate), + lntest.WithLocalTxNotify(), + ) + + bobPendingUpdate := bobCloseUpdate.GetClosePending() + checkAdditionalOutputs(ht, chainhash.Hash(bobPendingUpdate.Txid)) + + aliceCloseUpdate, err := ht.ReceiveCloseChannelUpdate(aliceCloseStream) + require.NoError(ht, err) + alicePendingUpdate = aliceCloseUpdate.GetClosePending() + checkAdditionalOutputs(ht, chainhash.Hash(alicePendingUpdate.Txid)) + + block := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + + aliceClosingTxid := ht.WaitForChannelCloseEvent(aliceCloseStream) + checkAdditionalOutputs(ht, aliceClosingTxid) + ht.AssertTxInBlock(block, aliceClosingTxid) +} + +func checkAdditionalOutputs(ht *lntest.HarnessTest, txid chainhash.Hash) { + tx := ht.Miner().GetRawTransaction(txid) + txOuts := tx.MsgTx().TxOut + require.Equal(ht, 3, len(txOuts)) + + expectedTxOut := wire.TxOut{ + Value: 50_000, + PkScript: []byte{ + 0x00, 0x14, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, + }, + } + contains := false + for _, txOut := range txOuts { + if txOut.Value == expectedTxOut.Value && + bytes.Equal( + txOut.PkScript, + expectedTxOut.PkScript, + ) { + + contains = true + } + } + require.True(ht, contains) +} + // testRBFCoopCloseDisconnect tests that when a node disconnects that the node // is properly disconnected. func testRBFCoopCloseDisconnect(ht *lntest.HarnessTest) { From d8c79611029111f4f77ef0bce5f8761ee79bafe6 Mon Sep 17 00:00:00 2001 From: haanhvu Date: Tue, 19 May 2026 19:20:10 +0700 Subject: [PATCH 4/4] docs: add release note Signed-off-by: haanhvu --- docs/release-notes/release-notes-0.22.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 947a38b719d..86627b87aff 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -30,6 +30,8 @@ ## Functional Enhancements +* [Extend the RBF cooperative close flow to support auxiliary channel closers](https://github.com/lightningnetwork/lnd/pull/10817) by propagating shutdown custom records, deriving and preserving auxiliary close outputs across fee updates, exposing final close output details in close notifications. + ## RPC Additions * The `routerrpc.EstimateRouteFee` RPC now supports [restricting fee estimates