Merge pull request #11035 from yyforyongyu/fix-p2p-wedges
What changed, and why it matters
This commit fixes two separate boundary-case bugs in the Lightning Network Daemon (LND). First, it prevents a peer from getting stuck when a channel-funding message arrives after its wallet reservation has already been cleaned up: the code now sends a completion signal instead of leaving the caller waiting forever. Second, it corrects how zero-block gossip range queries are handled so that empty queries keep their starting block height and so that a densely populated first block no longer triggers an invalid zero-block reply before the real reply.
Treat as a routine bug-fix/security-hardening patch. Apply in the next maintenance release. Operators running public LND nodes should upgrade to avoid potential P2P gossip synchronization issues and funding-handling stalls. No immediate emergency response is indicated because the commit fixes defensive boundary cases rather than an actively exploited critical vulnerability.
Security signals we found
Denial-of-service vector: missing funding reservation could leave a peer/goroutine blocked on completeChan, causing resource exhaustion or protocol stall.
P2P gossip protocol correctness: zero-block reply prefixes and incorrect LastBlockHeight for empty ranges could violate BOLT 7 expectations and confuse syncing peers.
Boundary-condition fixes for unsigned integer arithmetic (NumBlocks==0 and MaxUint32 overflow).
No explicit CVE or security advisory referenced in commit or release notes.
Evidence from the diff
The patch addresses two issues under PR #11035. (1) lnwallet/wallet.go: handleFundingCounterPartySigs now writes nil to msg.completeChan when the funding reservation is missing, preventing a goroutine/channel wedge where the caller blocks indefinitely on completeChan after receiving an error. (2) lnwire/query_channel_range.go and reply_channel_range.go: LastBlockHeight now returns FirstBlockHeight when NumBlocks==0, avoiding underflow/empty-range miscomputation. discovery/syncer.go now only sends a preceding range chunk when channelRange.Height > firstHeight, eliminating a zero-block prefix reply when the first block already exceeds the chunk size. Tests are added for both fixes.
Changed components
lnwallet/wallet.go - handleFundingCounterPartySigslnwire/query_channel_range.go - QueryChannelRange.LastBlockHeightlnwire/reply_channel_range.go - ReplyChannelRange.LastBlockHeightdiscovery/syncer.go - replyChanRangeQueryP2P gossip synchronizationChannel funding workflowInspect captured patch +189 / −21
### discovery/syncer.go
@@ -1394,20 +1394,20 @@ func (g *GossipSyncer) replyChanRangeQuery(ctx context.Context,
continue
}
- // Otherwise, we need to send our existing channel chunk as is
- // as its own reply and start a new one for the current block.
- // We'll mark the end of our current chunk as the height before
- // the current block to ensure the whole query range is replied
- // to.
- log.Infof("GossipSyncer(%x): sending range chunk of size=%v",
- g.cfg.peerPub[:], len(channelChunk))
-
- lastHeight = channelRange.Height - 1
- err := sendReplyForChunk(
- channelChunk, firstHeight, lastHeight, false,
- )
- if err != nil {
- return err
+ // Otherwise, send the pending range before starting a new
+ // chunk for the current block. If the first block exceeds the
+ // chunk size, then there is no preceding range to send.
+ if channelRange.Height > firstHeight {
+ log.Infof("GossipSyncer(%x): sending range chunk of "+
+ "size=%v", g.cfg.peerPub[:], len(channelChunk))
+
+ lastHeight = channelRange.Height - 1
+ err := sendReplyForChunk(
+ channelChunk, firstHeight, lastHeight, false,
+ )
+ if err != nil {
+ return err
+ }
}
// With the reply constructed, we'll start tallying channels for
### discovery/syncer_test.go
@@ -1099,9 +1099,55 @@ func TestGossipSyncerReplyChanRangeQuery(t *testing.T) {
}
}
-// TestGossipSyncerReplyChanRangeQuery tests a variety of
-// QueryChannelRange messages to ensure the underlying queries are
-// executed with the correct block range.
+// TestGossipSyncerReplyChanRangeQueryDenseFirstBlock tests that a dense first
+// block does not cause a zero-block reply to be sent before the final reply.
+func TestGossipSyncerReplyChanRangeQueryDenseFirstBlock(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+
+ const (
+ chunkSize = 2
+ startingBlockHeight = 100
+ numBlocks = 50
+ )
+
+ msgChan, syncer, chanSeries := newTestSyncer(
+ lnwire.NewShortChanIDFromInt(10), defaultEncoding, chunkSize,
+ )
+ query := &lnwire.QueryChannelRange{
+ FirstBlockHeight: startingBlockHeight,
+ NumBlocks: numBlocks,
+ }
+ chanSeries.filterRangeResp <- []lnwire.ShortChannelID{
+ {BlockHeight: startingBlockHeight, TxIndex: 1},
+ {BlockHeight: startingBlockHeight, TxIndex: 2},
+ {BlockHeight: startingBlockHeight, TxIndex: 3},
+ }
+
+ require.NoError(t, syncer.replyChanRangeQuery(ctx, query))
+
+ msg := <-msgChan
+ require.Len(t, msg, 1)
+ reply, ok := msg[0].(*lnwire.ReplyChannelRange)
+ require.True(t, ok)
+ require.Equal(t, uint32(startingBlockHeight), reply.FirstBlockHeight)
+ require.Equal(t, uint32(numBlocks), reply.NumBlocks)
+ require.Equal(t, uint8(1), reply.Complete)
+ require.Len(t, reply.ShortChanIDs, chunkSize)
+ for _, scid := range reply.ShortChanIDs {
+ require.Equal(t, uint32(startingBlockHeight), scid.BlockHeight)
+ }
+
+ select {
+ case extraMsg := <-msgChan:
+ t.Fatalf("unexpected extra reply: %v", extraMsg)
+ default:
+ }
+}
+
+// TestGossipSyncerReplyChanRangeQueryBlockRange tests a variety of
+// QueryChannelRange messages to ensure the underlying queries are executed
+// with the correct block range.
func TestGossipSyncerReplyChanRangeQueryBlockRange(t *testing.T) {
t.Parallel()
ctx := t.Context()
@@ -1132,6 +1178,18 @@ func TestGossipSyncerReplyChanRangeQueryBlockRange(t *testing.T) {
FirstBlockHeight: uint32(1000),
NumBlocks: uint32(math.MaxUint32),
},
+
+ // zero-block range at the genesis block
+ {
+ FirstBlockHeight: uint32(0),
+ NumBlocks: uint32(0),
+ },
+
+ // zero-block range after the genesis block
+ {
+ FirstBlockHeight: uint32(1000),
+ NumBlocks: uint32(0),
+ },
}
// Next construct the expected filterRangeReq startHeight and endHeight
@@ -1149,6 +1207,14 @@ func TestGossipSyncerReplyChanRangeQueryBlockRange(t *testing.T) {
startHeight: uint32(1000),
endHeight: uint32(math.MaxUint32),
},
+ {
+ startHeight: uint32(0),
+ endHeight: uint32(0),
+ },
+ {
+ startHeight: uint32(1000),
+ endHeight: uint32(1000),
+ },
}
// We'll then launch a goroutine to capture the filterRangeReqs for
### docs/release-notes/release-notes-0.21.3.md
@@ -21,6 +21,15 @@
# Bug Fixes
+* Channel funding attempts [now return
+ cleanly](https://github.com/lightningnetwork/lnd/pull/11035) when their
+ pending wallet reservation is no longer present.
+
+* Zero-block [`query_channel_range` and `reply_channel_range`
+ messages](https://github.com/lightningnetwork/lnd/pull/11035) now retain their
+ first block height when calculating a defensive range boundary, and dense
+ first blocks no longer produce zero-block reply prefixes.
+
# New Features
## Functional Enhancements
@@ -60,3 +69,5 @@
## Tooling and Documentation
# Contributors (Alphabetical Order)
+
+* Yong Yu
### lnwallet/wallet.go
@@ -2257,6 +2257,7 @@ func (l *LightningWallet) handleFundingCounterPartySigs(msg *addCounterPartySigs
l.limboMtx.RUnlock()
if !ok {
msg.err <- fmt.Errorf("attempted to update non-existent funding state")
+ msg.completeChan <- nil
return
}
### lnwallet/wallet_test.go
@@ -3,9 +3,33 @@ package lnwallet
import (
"testing"
+ "github.com/lightningnetwork/lnd/chanstate"
"github.com/stretchr/testify/require"
)
+// TestHandleFundingCounterPartySigsMissingReservation tests the missing
+// reservation response.
+func TestHandleFundingCounterPartySigsMissingReservation(t *testing.T) {
+ t.Parallel()
+
+ wallet := &LightningWallet{
+ fundingLimbo: make(map[uint64]*ChannelReservation),
+ }
+ completeChan := make(chan *chanstate.OpenChannel, 1)
+ errChan := make(chan error, 1)
+
+ wallet.handleFundingCounterPartySigs(&addCounterPartySigsMsg{
+ pendingFundingID: 1,
+ completeChan: completeChan,
+ err: errChan,
+ })
+
+ require.Len(t, completeChan, 1)
+ require.Nil(t, <-completeChan)
+ require.Len(t, errChan, 1)
+ require.ErrorContains(t, <-errChan, "non-existent funding state")
+}
+
// TestRegisterFundingIntent checks RegisterFundingIntent behaves as expected.
func TestRegisterFundingIntent(t *testing.T) {
t.Parallel()
### lnwire/query_channel_range.go
@@ -133,9 +133,13 @@ func (q *QueryChannelRange) SerializedSize() (uint32, error) {
return MessageSerializedSize(&msgCpy)
}
-// LastBlockHeight returns the last block height covered by the range of a
-// QueryChannelRange message.
+// LastBlockHeight returns the last block height covered by a QueryChannelRange
+// message. Messages with zero blocks return their first block height.
func (q *QueryChannelRange) LastBlockHeight() uint32 {
+ if q.NumBlocks == 0 {
+ return q.FirstBlockHeight
+ }
+
// Handle overflows by casting to uint64.
lastBlockHeight := uint64(q.FirstBlockHeight) + uint64(q.NumBlocks) - 1
if lastBlockHeight > math.MaxUint32 {
### lnwire/reply_channel_range.go
@@ -217,9 +217,13 @@ func (c *ReplyChannelRange) MsgType() MessageType {
return MsgReplyChannelRange
}
-// LastBlockHeight returns the last block height covered by the range of a
-// QueryChannelRange message.
+// LastBlockHeight returns the last block height covered by a ReplyChannelRange
+// message. Messages with zero blocks return their first block height.
func (c *ReplyChannelRange) LastBlockHeight() uint32 {
+ if c.NumBlocks == 0 {
+ return c.FirstBlockHeight
+ }
+
// Handle overflows by casting to uint64.
lastBlockHeight := uint64(c.FirstBlockHeight) + uint64(c.NumBlocks) - 1
if lastBlockHeight > math.MaxUint32 {
### lnwire/reply_channel_range_test.go
@@ -3,11 +3,69 @@ package lnwire
import (
"bytes"
"encoding/hex"
+ "math"
"testing"
"github.com/stretchr/testify/require"
)
+// TestChannelRangeLastBlockHeight tests the inclusive boundary calculation
+// for query and reply channel ranges.
+func TestChannelRangeLastBlockHeight(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ firstBlockHeight uint32
+ numBlocks uint32
+ expected uint32
+ }{
+ {
+ name: "empty at genesis",
+ },
+ {
+ name: "empty after genesis",
+ firstBlockHeight: 500_000,
+ expected: 500_000,
+ },
+ {
+ name: "single block",
+ firstBlockHeight: 500_000,
+ numBlocks: 1,
+ expected: 500_000,
+ },
+ {
+ name: "multiple blocks",
+ numBlocks: 5,
+ expected: 4,
+ },
+ {
+ name: "overflow",
+ firstBlockHeight: math.MaxUint32 - 1,
+ numBlocks: 3,
+ expected: math.MaxUint32,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+
+ query := &QueryChannelRange{
+ FirstBlockHeight: test.firstBlockHeight,
+ NumBlocks: test.numBlocks,
+ }
+ reply := &ReplyChannelRange{
+ FirstBlockHeight: test.firstBlockHeight,
+ NumBlocks: test.numBlocks,
+ }
+
+ require.Equal(t, test.expected, query.LastBlockHeight())
+ require.Equal(t, test.expected, reply.LastBlockHeight())
+ })
+ }
+}
+
// TestReplyChannelRangeUnsorted tests that decoding a ReplyChannelRange request
// that contains duplicate or unsorted ids returns an ErrUnsortedSIDs failure.
func TestReplyChannelRangeUnsorted(t *testing.T) {Why this scored 48/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.