Merge pull request #10804 from GeorgeTsagk/close-immediately-itest-lnd-7c38
What changed, and why it matters
This change fixes a data-handling bug in LND's channel-opening code. When opening a Lightning channel using an optional 'auxiliary funding controller' (used for custom channel types such as Taproot Assets), the funding manager was previously passing an incomplete channel state object that lacked the negotiated local and remote channel settings. The patch adds a new method that builds a complete view including both sides' agreed configuration, and updates the funding manager to use it. There is no direct evidence this is exploitable as a security vulnerability; it appears to be a correctness fix that could prevent misbehavior or failures in custom channel flows.
Treat as a routine correctness fix. If running custom/auxiliary channel types (e.g., Taproot Assets channels), ensure this patch is applied so the auxiliary funding controller receives the negotiated channel parameters. No immediate incident response is warranted based on the supplied materials, but operators relying on aux channels should verify behavior after upgrade.
Security signals we found
Data-correctness fix in funding handshake: negotiated channel configs now exposed to auxiliary funding controller
Auxiliary funding controller receives incomplete channel state before patch, complete state after patch
No explicit security claim, CVE, or advisory referenced in commit or supplied materials
Changes are localized to funding manager and channel reservation state construction
Evidence from the diff
The commit introduces lnwallet.ChannelReservation.AuxChanState(), which returns an AuxChanState populated from the partial OpenChannel state but with LocalChanCfg and RemoteChanCfg taken from the already-negotiated ourContribution/theirContribution channel configs. funding/manager.go is updated so both the PSBT initiator path (waitForPsbt) and the fundee path (fundeeProcessFundingCreated) call this new method instead of constructing lnwallet.NewAuxChanState(reservation.ChanState()), whose configs were still empty at that stage. Tests are added to assert the negotiated configs are present and are passed to the aux funding controller in both PSBT and normal funding flows.
Changed components
funding/manager.golnwallet/reservation.goAuxFundingController interface consumersPSBT-based channel funding flowFundee channel funding flowInspect captured patch +291 / −25
### funding/manager.go
@@ -2340,19 +2340,9 @@ func (f *Manager) waitForPsbt(intent *chanfunding.PsbtIntent,
// At this point, we'll see if there's an AuxFundingDesc we
// need to deliver so the funding process can continue
// properly.
- auxFundingDesc, err := fn.MapOptionZ(
- f.cfg.AuxFundingController,
- func(c AuxFundingController) AuxFundingDescResult {
- return c.DescFromPendingChanID(
- cid.tempChanID,
- lnwallet.NewAuxChanState(
- resCtx.reservation.ChanState(),
- ),
- resCtx.reservation.CommitmentKeyRings(),
- true,
- )
- },
- ).Unpack()
+ auxFundingDesc, err := f.auxFundingDesc(
+ cid.tempChanID, resCtx.reservation,
+ )
if err != nil {
failFlow("error continuing PSBT flow", err)
return
@@ -2384,6 +2374,22 @@ func (f *Manager) waitForPsbt(intent *chanfunding.PsbtIntent,
}
}
+// auxFundingDesc returns any aux funding descriptor for the pending channel.
+func (f *Manager) auxFundingDesc(pid PendingChanID,
+ reservation *lnwallet.ChannelReservation) (
+ fn.Option[lnwallet.AuxFundingDesc], error) {
+
+ return fn.MapOptionZ(
+ f.cfg.AuxFundingController,
+ func(c AuxFundingController) AuxFundingDescResult {
+ return c.DescFromPendingChanID(
+ pid, reservation.AuxChanState(),
+ reservation.CommitmentKeyRings(), true,
+ )
+ },
+ ).Unpack()
+}
+
// continueFundingAccept continues the channel funding flow once our
// contribution is finalized, the channel output is known and the funding
// transaction is signed.
@@ -2529,17 +2535,9 @@ func (f *Manager) fundeeProcessFundingCreated(peer lnpeer.Peer,
// At this point, we'll see if there's an AuxFundingDesc we need to
// deliver so the funding process can continue properly.
- auxFundingDesc, err := fn.MapOptionZ(
- f.cfg.AuxFundingController,
- func(c AuxFundingController) AuxFundingDescResult {
- return c.DescFromPendingChanID(
- cid.tempChanID, lnwallet.NewAuxChanState(
- resCtx.reservation.ChanState(),
- ), resCtx.reservation.CommitmentKeyRings(),
- true,
- )
- },
- ).Unpack()
+ auxFundingDesc, err := f.auxFundingDesc(
+ cid.tempChanID, resCtx.reservation,
+ )
if err != nil {
log.Errorf("error continuing PSBT flow: %v", err)
f.failFundingFlow(peer, cid, err)
### funding/manager_test.go
@@ -22,6 +22,7 @@ import (
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/chaincfg/v2"
"github.com/btcsuite/btcd/chainhash/v2"
+ "github.com/btcsuite/btcd/psbt/v2"
"github.com/btcsuite/btcd/wire/v2"
"github.com/btcsuite/btcwallet/wallet"
"github.com/lightningnetwork/lnd/actor"
@@ -43,11 +44,13 @@ import (
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest/mock"
"github.com/lightningnetwork/lnd/lntest/wait"
+ "github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnutils"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwallet/chanfunding"
"github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/msgmux"
"github.com/stretchr/testify/require"
)
@@ -277,6 +280,60 @@ func (m *mockZeroConfAcceptor) Accept(
}
}
+// mockAuxFundingController records the aux channel state passed by the funding
+// manager.
+type mockAuxFundingController struct {
+ auxChanStates chan lnwallet.AuxChanState
+}
+
+// Name returns the name of the mock endpoint.
+func (m *mockAuxFundingController) Name() msgmux.EndpointName {
+ return "mock-aux-funding"
+}
+
+// CanHandle returns false as the mock does not handle any peer messages.
+func (m *mockAuxFundingController) CanHandle(msg msgmux.PeerMsg) bool {
+ return false
+}
+
+// SendMessage is a no-op that reports the message as not handled.
+func (m *mockAuxFundingController) SendMessage(_ context.Context,
+ msg msgmux.PeerMsg) bool {
+
+ return false
+}
+
+// DescFromPendingChanID records the aux channel state it was called with
+// and returns an empty funding descriptor.
+func (m *mockAuxFundingController) DescFromPendingChanID(pid PendingChanID,
+ openChan lnwallet.AuxChanState,
+ keyRing lntypes.Dual[lnwallet.CommitmentKeyRing],
+ initiator bool) AuxFundingDescResult {
+
+ m.auxChanStates <- openChan
+
+ return fn.Ok(fn.None[lnwallet.AuxFundingDesc]())
+}
+
+// DeriveTapscriptRoot returns no tapscript root.
+func (m *mockAuxFundingController) DeriveTapscriptRoot(
+ PendingChanID) AuxTapscriptResult {
+
+ return fn.Ok(fn.None[chainhash.Hash]())
+}
+
+// ChannelReady is a no-op.
+func (m *mockAuxFundingController) ChannelReady(
+ lnwallet.AuxChanState) error {
+
+ return nil
+}
+
+// ChannelFinalized is a no-op.
+func (m *mockAuxFundingController) ChannelFinalized(PendingChanID) error {
+ return nil
+}
+
type newChannelMsg struct {
channel *lnpeer.NewChannel
err chan error
@@ -3148,12 +3205,122 @@ func TestFundingManagerPrivateRestart(t *testing.T) {
assertNoFwdingPolicy(t, alice, bob, channelReadyAlice.ChanID)
}
+// TestFundingManagerAuxChanStatePsbt checks that the PSBT initiator path passes
+// the fully negotiated channel configs to the aux funding controller.
+func TestFundingManagerAuxChanStatePsbt(t *testing.T) {
+ t.Parallel()
+
+ auxController := &mockAuxFundingController{
+ auxChanStates: make(chan lnwallet.AuxChanState, 1),
+ }
+ alice, bob := setupFundingManagers(t, func(cfg *Config) {
+ cfg.AuxFundingController = fn.Some[AuxFundingController](
+ auxController,
+ )
+ })
+ t.Cleanup(func() {
+ tearDownFundingManagers(t, alice, bob)
+ })
+
+ const fundingAmt = btcutil.Amount(5_000_000)
+ updateChan := make(chan *lnrpc.OpenStatusUpdate, 1)
+ errChan := make(chan error, 1)
+ assembler := chanfunding.NewPsbtAssembler(
+ fundingAmt, nil, fundingNetParams.Params, false,
+ )
+ initReq := &InitFundingMsg{
+ Peer: bob,
+ TargetPubkey: bob.privKey.PubKey(),
+ ChainHash: *fundingNetParams.GenesisHash,
+ LocalFundingAmt: fundingAmt,
+ ChanFunder: assembler,
+ Updates: updateChan,
+ Err: errChan,
+ }
+
+ alice.fundingMgr.InitFundingWorkflow(initReq)
+ openChannel, ok := assertFundingMsgSent(
+ t, alice.msgChan, "OpenChannel",
+ ).(*lnwire.OpenChannel)
+ require.True(t, ok)
+ bob.fundingMgr.ProcessFundingMsg(openChannel, alice)
+ acceptChannel, ok := assertFundingMsgSent(
+ t, bob.msgChan, "AcceptChannel",
+ ).(*lnwire.AcceptChannel)
+ require.True(t, ok)
+ alice.fundingMgr.ProcessFundingMsg(acceptChannel, bob)
+
+ var psbtUpdate *lnrpc.ReadyForPsbtFunding
+ select {
+ case update := <-updateChan:
+ psbtFund, ok := update.Update.(*lnrpc.OpenStatusUpdate_PsbtFund)
+ require.True(t, ok)
+ psbtUpdate = psbtFund.PsbtFund
+
+ case err := <-errChan:
+ require.NoError(t, err)
+
+ case <-time.After(5 * time.Second):
+ t.Fatal("PSBT funding update not received")
+ }
+
+ packet, err := psbt.NewFromRawBytes(
+ bytes.NewReader(psbtUpdate.Psbt), false,
+ )
+ require.NoError(t, err)
+ packet.UnsignedTx.TxIn = []*wire.TxIn{{
+ PreviousOutPoint: wire.OutPoint{Index: 0},
+ }}
+ packet.Inputs = []psbt.PInput{{
+ WitnessUtxo: &wire.TxOut{
+ Value: int64(fundingAmt + 1),
+ PkScript: append([]byte{0, 20}, make([]byte, 20)...),
+ },
+ }}
+
+ resCtx, err := alice.fundingMgr.getReservationCtx(
+ bobPubKey, openChannel.PendingChannelID,
+ )
+ require.NoError(t, err)
+ localCfg := *resCtx.reservation.OurContribution().ChannelConfig
+ remoteCfg := *resCtx.reservation.TheirContribution().ChannelConfig
+
+ err = alice.fundingMgr.cfg.Wallet.PsbtFundingVerify(
+ openChannel.PendingChannelID, packet, false,
+ )
+ require.NoError(t, err)
+
+ // Finalizing separately ensures verification, including the reserved
+ // value check, completes before the funding manager resumes.
+ packet.UnsignedTx.TxIn[0].Witness = wire.TxWitness{[]byte{1}}
+ err = alice.fundingMgr.cfg.Wallet.PsbtFundingFinalize(
+ openChannel.PendingChannelID, nil, packet.UnsignedTx,
+ )
+ require.NoError(t, err)
+
+ select {
+ case auxState := <-auxController.auxChanStates:
+ require.Equal(t, localCfg, auxState.LocalChanCfg)
+ require.Equal(t, remoteCfg, auxState.RemoteChanCfg)
+
+ case <-time.After(5 * time.Second):
+ t.Fatal("aux funding controller was not called")
+ }
+}
+
// TestFundingManagerCustomChannelParameters checks that custom requirements we
// specify during the channel funding flow is preserved correctly on both sides.
func TestFundingManagerCustomChannelParameters(t *testing.T) {
t.Parallel()
- alice, bob := setupFundingManagers(t)
+ auxController := &mockAuxFundingController{
+ auxChanStates: make(chan lnwallet.AuxChanState, 2),
+ }
+ alice, bob := setupFundingManagers(t, func(cfg *Config) {
+ cfg.AuxFundingController = fn.Some[AuxFundingController](
+ auxController,
+ )
+ })
t.Cleanup(func() {
tearDownFundingManagers(t, alice, bob)
})
@@ -3288,6 +3455,23 @@ func TestFundingManagerCustomChannelParameters(t *testing.T) {
t, alice.msgChan, "FundingCreated",
).(*lnwire.FundingCreated)
+ // Helper method for checking that the aux funding controller received
+ // the negotiated channel configs of both parties.
+ assertAuxChanState := func(localCfg,
+ remoteCfg channeldb.ChannelConfig) {
+
+ t.Helper()
+
+ select {
+ case auxState := <-auxController.auxChanStates:
+ require.Equal(t, localCfg, auxState.LocalChanCfg)
+ require.Equal(t, remoteCfg, auxState.RemoteChanCfg)
+
+ case <-time.After(time.Second * 5):
+ t.Fatalf("aux funding controller was not called")
+ }
+ }
+
// Helper method for checking the CSV delay stored for a reservation.
assertDelay := func(resCtx *reservationWithCtx,
ourDelay, theirDelay uint16) error {
@@ -3417,9 +3601,19 @@ func TestFundingManagerCustomChannelParameters(t *testing.T) {
t.Fatal(err)
}
+ // Snapshot the negotiated configs before resuming the funding flow.
+ // The aux controller must receive exactly these values even though
+ // they have not yet been copied into the pending channel state.
+ localCfg := *resCtx.reservation.OurContribution().ChannelConfig
+ remoteCfg := *resCtx.reservation.TheirContribution().ChannelConfig
+
// Give the message to Bob.
bob.fundingMgr.ProcessFundingMsg(fundingCreated, alice)
+ // Bob's aux funding controller must have been handed the negotiated
+ // configs as part of the aux channel state.
+ assertAuxChanState(localCfg, remoteCfg)
+
// Finally, Bob should send the FundingSigned message.
fundingSigned := assertFundingMsgSent(
t, bob.msgChan, "FundingSigned",
### lnwallet/reservation.go
@@ -911,6 +911,20 @@ func (r *ChannelReservation) ChanState() *chanstate.OpenChannel {
return r.partialState
}
+// AuxChanState returns a view of the current open channel state for aux
+// funding callers, populated with the negotiated contribution configs. It
+// must only be called after both channel contributions have been processed.
+func (r *ChannelReservation) AuxChanState() AuxChanState {
+ r.RLock()
+ defer r.RUnlock()
+
+ auxState := NewAuxChanState(r.partialState)
+ auxState.LocalChanCfg = r.ourContribution.toChanConfig()
+ auxState.RemoteChanCfg = r.theirContribution.toChanConfig()
+
+ return auxState
+}
+
// CommitmentKeyRings returns the local+remote key ring used for the very first
// commitment transaction both parties.
//
### lnwallet/reservation_test.go
@@ -0,0 +1,60 @@
+package lnwallet
+
+import (
+ "testing"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/btcutil/v2"
+ "github.com/lightningnetwork/lnd/channeldb"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
+)
+
+// TestReservationAuxChanStatePopulatesNegotiatedConfigs asserts that the aux
+// channel state view of a reservation carries the negotiated local and remote
+// channel configs rather than the still empty configs of the partial state.
+func TestReservationAuxChanStatePopulatesNegotiatedConfigs(t *testing.T) {
+ t.Parallel()
+
+ localCfg := &channeldb.ChannelConfig{
+ ChannelStateBounds: channeldb.ChannelStateBounds{
+ ChanReserve: btcutil.Amount(1200),
+ MaxPendingAmount: lnwire.MilliSatoshi(100_000),
+ MinHTLC: lnwire.MilliSatoshi(1000),
+ MaxAcceptedHtlcs: 30,
+ },
+ CommitmentParams: channeldb.CommitmentParams{
+ DustLimit: btcutil.Amount(600),
+ CsvDelay: 144,
+ },
+ }
+ remoteCfg := &channeldb.ChannelConfig{
+ ChannelStateBounds: channeldb.ChannelStateBounds{
+ ChanReserve: btcutil.Amount(2200),
+ MaxPendingAmount: lnwire.MilliSatoshi(200_000),
+ MinHTLC: lnwire.MilliSatoshi(2000),
+ MaxAcceptedHtlcs: 40,
+ },
+ CommitmentParams: channeldb.CommitmentParams{
+ DustLimit: btcutil.Amount(700),
+ CsvDelay: 288,
+ },
+ }
+
+ _, peerPub := btcec.PrivKeyFromBytes([]byte{1})
+ reservation := &ChannelReservation{
+ ourContribution: &ChannelContribution{
+ ChannelConfig: localCfg,
+ },
+ theirContribution: &ChannelContribution{
+ ChannelConfig: remoteCfg,
+ },
+ partialState: &channeldb.OpenChannel{
+ IdentityPub: peerPub,
+ },
+ }
+
+ auxState := reservation.AuxChanState()
+ require.Equal(t, *localCfg, auxState.LocalChanCfg)
+ require.Equal(t, *remoteCfg, auxState.RemoteChanCfg)
+}Why this scored 25/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.