lnwallet: expose negotiated configs in aux chan state
What changed, and why it matters
This commit fixes an internal bookkeeping bug in LND's channel-opening code. When opening a Lightning channel that uses an auxiliary (aux) funding controller, the code was previously handing the controller an incomplete view of the negotiated channel settings. The patch makes sure the controller receives the final, agreed-upon local and remote channel configuration values. There is no direct evidence in the commit that this is exploitable as a security vulnerability; it reads as a correctness/robustness fix for a feature used by custom channel types such as Taproot Assets channels.
Treat as a normal code-quality/correctness patch. Reviewers using custom/aux channel types should verify that the aux funding controller now behaves correctly with the negotiated configs. No urgent security action is indicated by the supplied materials.
Security signals we found
Data-flow correctness: aux funding controller now receives negotiated channel configs rather than empty/partial state configs
Refactoring: duplicated NewAuxChanState construction extracted into single helper
Test coverage added for both PSBT and non-PSBT funding paths
No mention of vulnerability, exploit, CVE, or security advisory in commit or supplied references
Evidence from the diff
The change adds a new ChannelReservation.AuxChanState() method in lnwallet/reservation.go that builds an AuxChanState from the partial OpenChannel state but then overwrites LocalChanCfg and RemoteChanCfg with the negotiated contribution configs (ourContribution.toChanConfig() and theirContribution.toChanConfig()). The funding manager is updated to call reservation.AuxChanState() instead of constructing lnwallet.NewAuxChanState(reservation.ChanState()) inline in two places (waitForPsbt and fundeeProcessFundingCreated). Tests are added to assert that the aux controller receives the negotiated configs in both PSBT and normal funding flows. The commit message and diff do not describe a security vulnerability, CVE, or external report.
Changed components
lnwallet/reservation.gofunding/manager.gofunding/manager_test.golnwallet/reservation_test.goInspect captured patch +291 / −25
### funding/manager.go
@@ -2363,19 +2363,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
@@ -2407,6 +2397,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.
@@ -2552,17 +2558,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 28/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.