lnwallet: fix limboMtx/intentMtx lock order inversion in PsbtFundingVerify
What changed, and why it matters
This commit fixes a classic deadlock bug in LND's wallet code. Two different code paths were acquiring the same two locks in opposite orders, which could cause the wallet's single request handler goroutine to freeze permanently. Once frozen, the node could no longer open or accept any Lightning channels, and newly confirmed channels would get stuck without being announced to the network. Only restarting the node would recover. The fix reorders the lock acquisition so both paths use the same order, and documents the rule.
Apply the patch to ensure consistent lock ordering. Operators running nodes with PSBT or batch funding should upgrade promptly, as the deadlock can silently disable all channel funding activity until restart.
Security signals we found
Deadlock between two mutexes acquired in opposite orders
Single requestHandler goroutine becomes permanently wedged
All ChannelReservation methods blocked without timeout or quit escape
funding.Manager resMtx held indefinitely on peer disconnect
zombie sweeper kills reservationCoordinator on resMtx.RLock
Node cannot open or accept channels after deadlock
Confirmed channels stuck in channelReadySent state, never added to graph or announced
No log lines emitted during the failure
Only node restart recovers
Evidence from the diff
The patch resolves a lock-order inversion between limboMtx and intentMtx in lnwallet/wallet.go. PsbtFundingVerify previously acquired intentMtx then limboMtx, while handleFundingCancelRequest (running in the wallet’s single requestHandler goroutine) acquired them in the opposite order. With PSBT or batch funding, both paths can run concurrently and deadlock. The fix moves the limboMtx-protected lookup of the channel reservation to before intentMtx is acquired, so the order is always limboMtx then intentMtx. Release notes describe the resulting node-wide funding paralysis and need for restart.
Changed components
lnwallet/wallet.goLightningWallet.PsbtFundingVerifyLightningWallet.handleFundingCancelRequestfunding.ManagerreservationCoordinatorrequestHandler goroutineInspect captured patch +51 / −19
### docs/release-notes/release-notes-0.20.4.md
@@ -45,6 +45,14 @@
associations, causing verification to fail and the migration transaction to
roll back.
+* [Fixed a lock order inversion](https://github.com/lightningnetwork/lnd/pull/11008)
+ between `PsbtFundingVerify` and `handleFundingCancelRequest` in the wallet.
+ With PSBT or batch funding the two could deadlock the wallet's single
+ `requestHandler` goroutine, which permanently disabled all channel funding for
+ the whole node: no new channel could be opened, and channels whose funding
+ transaction confirmed stayed in the `channelReadySent` opening state forever,
+ never added to the graph and never announced. Only a restart recovered.
+
# New Features
## Functional Enhancements
@@ -81,5 +89,6 @@
# Contributors (Alphabetical Order)
+* LNBiG
* Yong Yu
* Ziggie
### docs/release-notes/release-notes-0.21.3.md
@@ -45,6 +45,14 @@
associations, causing verification to fail and the migration transaction to
roll back.
+* [Fixed a lock order inversion](https://github.com/lightningnetwork/lnd/pull/11008)
+ between `PsbtFundingVerify` and `handleFundingCancelRequest` in the wallet.
+ With PSBT or batch funding the two could deadlock the wallet's single
+ `requestHandler` goroutine, which permanently disabled all channel funding for
+ the whole node: no new channel could be opened, and channels whose funding
+ transaction confirmed stayed in the `channelReadySent` opening state forever,
+ never added to the graph and never announced. Only a restart recovered.
+
# New Features
## Functional Enhancements
@@ -107,5 +115,6 @@
# Contributors (Alphabetical Order)
* Elle Mouton
+* LNBiG
* Yong Yu
* Ziggie
### lnwallet/wallet.go
@@ -443,7 +443,13 @@ type LightningWallet struct {
// as key in the fundingLimbo map. Used to easily look up a channel
// reservation given a pending channel ID.
reservationIDs map[[32]byte]uint64
- limboMtx sync.RWMutex
+
+ // limboMtx guards fundingLimbo and reservationIDs.
+ //
+ // NOTE: Never acquire limboMtx while holding intentMtx. If both
+ // mutexes must be held simultaneously, acquire limboMtx before
+ // intentMtx. See the note on PsbtFundingVerify.
+ limboMtx sync.RWMutex
// lockedOutPoints is a set of the currently locked outpoint. This
// information is kept in order to provide an easy way to unlock all
@@ -746,9 +752,34 @@ func (l *LightningWallet) RegisterFundingIntent(expectedID [32]byte,
// PsbtFundingVerify looks up a previously registered funding intent by its
// pending channel ID and tries to advance the state machine by verifying the
// passed PSBT.
+//
+// NOTE: limboMtx MUST be acquired before intentMtx, never the other way round.
+// handleFundingCancelRequest holds limboMtx for the duration of the call and
+// acquires intentMtx inside it, so acquiring the two in the opposite order here
+// deadlocks the wallet's requestHandler goroutine, which in turn wedges all
+// channel funding for the whole node.
func (l *LightningWallet) PsbtFundingVerify(pendingChanID [32]byte,
packet *psbt.Packet, skipFinalize bool) error {
+ // Get the channel reservation that corresponds to this pending channel
+ // ID. This has to happen before intentMtx is acquired, see the note
+ // above.
+ l.limboMtx.Lock()
+ pid, ok := l.reservationIDs[pendingChanID]
+ if !ok {
+ l.limboMtx.Unlock()
+ return fmt.Errorf("no channel reservation found for "+
+ "pendingChannelID(%x)", pendingChanID[:])
+ }
+
+ pendingReservation, ok := l.fundingLimbo[pid]
+ l.limboMtx.Unlock()
+
+ if !ok {
+ return fmt.Errorf("no channel reservation found for "+
+ "reservation ID %v", pid)
+ }
+
l.intentMtx.Lock()
defer l.intentMtx.Unlock()
@@ -772,28 +803,11 @@ func (l *LightningWallet) PsbtFundingVerify(pendingChanID [32]byte,
return fmt.Errorf("error verifying PSBT: %w", err)
}
- // Get the channel reservation for that corresponds to this pending
- // channel ID.
- l.limboMtx.Lock()
- pid, ok := l.reservationIDs[pendingChanID]
- if !ok {
- l.limboMtx.Unlock()
- return fmt.Errorf("no channel reservation found for "+
- "pendingChannelID(%x)", pendingChanID[:])
- }
-
- pendingReservation, ok := l.fundingLimbo[pid]
- l.limboMtx.Unlock()
-
- if !ok {
- return fmt.Errorf("no channel reservation found for "+
- "reservation ID %v", pid)
- }
-
// Now the PSBT has been populated and verified, we can again check
// whether the value reserved for anchor fee bumping is respected.
isPublic := pendingReservation.partialState.ChannelFlags&lnwire.FFAnnounceChannel != 0
hasAnchors := pendingReservation.partialState.ChanType.HasAnchors()
+
return l.enforceNewReservedValue(intent, isPublic, hasAnchors)
}
Why this scored 85/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.