Merge pull request #11008 from LNBIG-COM/fix/psbt-funding-lock-order
What changed, and why it matters
This commit fixes a classic multi-threading bug in LND's wallet code. Two functions were acquiring the same two locks in opposite orders, which could cause a deadlock. When triggered, the wallet's single request-handler goroutine would freeze, permanently stopping all channel opening on the node and preventing newly confirmed channels from being announced. Only a restart would recover. The fix reorders the lock acquisition so both functions now take the locks in the same order.
Apply the patch to affected branches (0.20.4 and 0.21.3). Nodes using PSBT or batch funding should upgrade promptly. No immediate mitigation other than restart exists for the deadlock condition.
Security signals we found
Deadlock in wallet request handler
Lock-order inversion between limboMtx and intentMtx
Denial of service against channel funding and channel graph announcement
Requires PSBT or batch funding to trigger
Permanent impairment until node restart
Evidence from the diff
The patch resolves a lock-order inversion between limboMtx and intentMtx in lnwallet/wallet.go. PsbtFundingVerify previously acquired intentMtx first, then limboMtx, while handleFundingCancelRequest acquires limboMtx first, then intentMtx. Under PSBT or batch funding, this could deadlock the wallet’s requestHandler goroutine. The fix moves the limboMtx-protected fundingLimbo/reservationIDs lookup before intentMtx is acquired in PsbtFundingVerify, and adds explicit documentation of the required lock ordering. Release notes describe the user-visible effect as a complete halt to channel funding and channel announcement until restart.
Changed components
lnwallet/wallet.goLightningWallet.PsbtFundingVerifyhandleFundingCancelRequestPSBT funding flowBatch funding flowInspect 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 66/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.