Merge pull request #3836 from wpaulino/interpret-query-channel-range

discovery: use block ranges from ReplyChannelRange to determine end marker
This commit is contained in:
Olaoluwa Osuntokun 2020-01-07 16:52:48 -08:00 committed by GitHub
commit b8f6a550a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 294 additions and 143 deletions

@ -529,12 +529,16 @@ func assertSyncerStatus(t *testing.T, s *GossipSyncer, syncState syncerState,
func assertTransitionToChansSynced(t *testing.T, s *GossipSyncer, peer *mockPeer) {
t.Helper()
assertMsgSent(t, peer, &lnwire.QueryChannelRange{
query := &lnwire.QueryChannelRange{
FirstBlockHeight: 0,
NumBlocks: math.MaxUint32,
})
}
assertMsgSent(t, peer, query)
s.ProcessQueryMsg(&lnwire.ReplyChannelRange{Complete: 1}, nil)
s.ProcessQueryMsg(&lnwire.ReplyChannelRange{
QueryChannelRange: *query,
Complete: 1,
}, nil)
chanSeries := s.cfg.channelSeries.(*mockChannelGraphTimeSeries)

@ -298,6 +298,17 @@ type GossipSyncer struct {
// received over, these will be read by the replyHandler.
queryMsgs chan lnwire.Message
// curQueryRangeMsg keeps track of the latest QueryChannelRange message
// we've sent to a peer to ensure we've consumed all expected replies.
// This field is primarily used within the waitingQueryChanReply state.
curQueryRangeMsg *lnwire.QueryChannelRange
// prevReplyChannelRange keeps track of the previous ReplyChannelRange
// message we've received from a peer to ensure they've fully replied to
// our query by ensuring they covered our requested block range. This
// field is primarily used within the waitingQueryChanReply state.
prevReplyChannelRange *lnwire.ReplyChannelRange
// bufferedChanRangeReplies is used in the waitingQueryChanReply to
// buffer all the chunked response to our query.
bufferedChanRangeReplies []lnwire.ShortChannelID
@ -666,10 +677,64 @@ func (g *GossipSyncer) synchronizeChanIDs() (bool, error) {
return false, err
}
// isLegacyReplyChannelRange determines where a ReplyChannelRange message is
// considered legacy. There was a point where lnd used to include the same query
// over multiple replies, rather than including the portion of the query the
// reply is handling. We'll use this as a way of detecting whether we are
// communicating with a legacy node so we can properly sync with them.
func isLegacyReplyChannelRange(query *lnwire.QueryChannelRange,
reply *lnwire.ReplyChannelRange) bool {
return reply.QueryChannelRange == *query
}
// processChanRangeReply is called each time the GossipSyncer receives a new
// reply to the initial range query to discover new channels that it didn't
// previously know of.
func (g *GossipSyncer) processChanRangeReply(msg *lnwire.ReplyChannelRange) error {
// If we're not communicating with a legacy node, we'll apply some
// further constraints on their reply to ensure it satisfies our query.
if !isLegacyReplyChannelRange(g.curQueryRangeMsg, msg) {
// The first block should be within our original request.
if msg.FirstBlockHeight < g.curQueryRangeMsg.FirstBlockHeight {
return fmt.Errorf("reply includes channels for height "+
"%v prior to query %v", msg.FirstBlockHeight,
g.curQueryRangeMsg.FirstBlockHeight)
}
// The last block should also be. We don't need to check the
// intermediate ones because they should already be in sorted
// order.
replyLastHeight := msg.QueryChannelRange.LastBlockHeight()
queryLastHeight := g.curQueryRangeMsg.LastBlockHeight()
if replyLastHeight > queryLastHeight {
return fmt.Errorf("reply includes channels for height "+
"%v after query %v", replyLastHeight,
queryLastHeight)
}
// If we've previously received a reply for this query, look at
// its last block to ensure the current reply properly follows
// it.
if g.prevReplyChannelRange != nil {
prevReply := g.prevReplyChannelRange
prevReplyLastHeight := prevReply.LastBlockHeight()
// The current reply can either start from the previous
// reply's last block, if there are still more channels
// for the same block, or the block after.
if msg.FirstBlockHeight != prevReplyLastHeight &&
msg.FirstBlockHeight != prevReplyLastHeight+1 {
return fmt.Errorf("first block of reply %v "+
"does not continue from last block of "+
"previous %v", msg.FirstBlockHeight,
prevReplyLastHeight)
}
}
}
g.prevReplyChannelRange = msg
g.bufferedChanRangeReplies = append(
g.bufferedChanRangeReplies, msg.ShortChanIDs...,
)
@ -679,8 +744,25 @@ func (g *GossipSyncer) processChanRangeReply(msg *lnwire.ReplyChannelRange) erro
// If this isn't the last response, then we can exit as we've already
// buffered the latest portion of the streaming reply.
if msg.Complete == 0 {
return nil
switch {
// If we're communicating with a legacy node, we'll need to look at the
// complete field.
case isLegacyReplyChannelRange(g.curQueryRangeMsg, msg):
if msg.Complete == 0 {
return nil
}
// Otherwise, we'll look at the reply's height range.
default:
replyLastHeight := msg.QueryChannelRange.LastBlockHeight()
queryLastHeight := g.curQueryRangeMsg.LastBlockHeight()
// TODO(wilmer): This might require some padding if the remote
// node is not aware of the last height we sent them, i.e., is
// behind a few blocks from us.
if replyLastHeight < queryLastHeight {
return nil
}
}
log.Infof("GossipSyncer(%x): filtering through %v chans",
@ -696,8 +778,10 @@ func (g *GossipSyncer) processChanRangeReply(msg *lnwire.ReplyChannelRange) erro
}
// As we've received the entirety of the reply, we no longer need to
// hold on to the set of buffered replies, so we'll let that be garbage
// collected now.
// hold on to the set of buffered replies or the original query that
// prompted the replies, so we'll let that be garbage collected now.
g.curQueryRangeMsg = nil
g.prevReplyChannelRange = nil
g.bufferedChanRangeReplies = nil
// If there aren't any channels that we don't know of, then we can
@ -757,11 +841,14 @@ func (g *GossipSyncer) genChanRangeQuery(
// Finally, we'll craft the channel range query, using our starting
// height, then asking for all known channels to the foreseeable end of
// the main chain.
return &lnwire.QueryChannelRange{
query := &lnwire.QueryChannelRange{
ChainHash: g.cfg.chainHash,
FirstBlockHeight: startHeight,
NumBlocks: math.MaxUint32 - startHeight,
}, nil
}
g.curQueryRangeMsg = query
return query, nil
}
// replyPeerQueries is called in response to any query by the remote peer.
@ -814,8 +901,9 @@ func (g *GossipSyncer) replyChanRangeQuery(query *lnwire.QueryChannelRange) erro
// Next, we'll consult the time series to obtain the set of known
// channel ID's that match their query.
startBlock := query.FirstBlockHeight
endBlock := startBlock + query.NumBlocks - 1
channelRange, err := g.cfg.channelSeries.FilterChannelRange(
query.ChainHash, startBlock, startBlock+query.NumBlocks,
query.ChainHash, startBlock, endBlock,
)
if err != nil {
return err
@ -824,13 +912,13 @@ func (g *GossipSyncer) replyChanRangeQuery(query *lnwire.QueryChannelRange) erro
// TODO(roasbeef): means can't send max uint above?
// * or make internal 64
// In the base case (no actual response) the first block and the last
// block in the query will be the same. In the loop below, we'll update
// these two variables incrementally with each chunk to properly
// compute the starting block for each response and the number of
// blocks in a response.
firstBlockHeight := query.FirstBlockHeight
lastBlockHeight := query.FirstBlockHeight
// In the base case (no actual response) the first block and last block
// will match those of the query. In the loop below, we'll update these
// two variables incrementally with each chunk to properly compute the
// starting block for each response and the number of blocks in a
// response.
firstBlockHeight := startBlock
lastBlockHeight := endBlock
numChannels := int32(len(channelRange))
numChansSent := int32(0)
@ -866,8 +954,25 @@ func (g *GossipSyncer) replyChanRangeQuery(query *lnwire.QueryChannelRange) erro
// update our pointers to the first and last blocks for each
// response.
if len(channelChunk) > 0 {
firstBlockHeight = channelChunk[0].BlockHeight
lastBlockHeight = channelChunk[len(channelChunk)-1].BlockHeight
// If this is the first response we'll send, we'll point
// the first block to the first block in the query.
// Otherwise, we'll continue from the block we left off
// at.
if numChansSent == 0 {
firstBlockHeight = startBlock
} else {
firstBlockHeight = lastBlockHeight
}
// If this is the last response we'll send, we'll point
// the last block to the last block of the query.
// Otherwise, we'll set it to the height of the last
// channel in the chunk.
if isFinalChunk {
lastBlockHeight = endBlock
} else {
lastBlockHeight = channelChunk[len(channelChunk)-1].BlockHeight
}
}
// The number of blocks contained in this response (the total

@ -709,10 +709,12 @@ func TestGossipSyncerReplyChanRangeQuery(t *testing.T) {
// Next, we'll craft a query to ask for all the new chan ID's after
// block 100.
const startingBlockHeight int = 100
const startingBlockHeight = 100
const numBlocks = 50
const endingBlockHeight = startingBlockHeight + numBlocks - 1
query := &lnwire.QueryChannelRange{
FirstBlockHeight: uint32(startingBlockHeight),
NumBlocks: 50,
NumBlocks: uint32(numBlocks),
}
// We'll then launch a goroutine to reply to the query with a set of 5
@ -744,8 +746,11 @@ func TestGossipSyncerReplyChanRangeQuery(t *testing.T) {
return
case filterReq := <-chanSeries.filterRangeReqs:
// We should be querying for block 100 to 150.
if filterReq.startHeight != 100 && filterReq.endHeight != 150 {
errCh <- fmt.Errorf("wrong height range: %v", spew.Sdump(filterReq))
if filterReq.startHeight != startingBlockHeight &&
filterReq.endHeight != endingBlockHeight {
errCh <- fmt.Errorf("wrong height range: %v",
spew.Sdump(filterReq))
return
}
@ -778,52 +783,55 @@ func TestGossipSyncerReplyChanRangeQuery(t *testing.T) {
t.Fatalf("expected ReplyChannelRange instead got %T", msg)
}
// Only for the first iteration do we set the offset to
// zero as no chunks have been processed yet. For every
// other iteration, we want to move forward by the
// chunkSize (from the staring block height).
offset := 0
if i != 0 {
offset = 1
}
expectedFirstBlockHeight := (i+offset)*2 + startingBlockHeight
// We'll determine the correct values of each field in
// each response based on the order that they were sent.
var (
expectedFirstBlockHeight uint32
expectedNumBlocks uint32
expectedComplete uint8
)
switch {
// If this is not the last chunk, then Complete should
// be set to zero. Otherwise, it should be one.
case i < 2 && rangeResp.Complete != 0:
t.Fatalf("non-final chunk should have "+
"Complete=0: %v", spew.Sdump(rangeResp))
// The first reply should range from our starting block
// height until it reaches its maximum capacity of
// channels.
case i == 0:
expectedFirstBlockHeight = startingBlockHeight
expectedNumBlocks = chunkSize + 1
case i < 2 && rangeResp.NumBlocks != chunkSize+1:
t.Fatalf("NumBlocks fields in resp "+
"incorrect: expected %v got %v",
chunkSize+1, rangeResp.NumBlocks)
// The last reply should range starting from the next
// block of our previous reply up until the ending
// height of the query. It should also have the Complete
// bit set.
case i == numExpectedChunks-1:
expectedFirstBlockHeight = respMsgs[len(respMsgs)-1].BlockHeight
expectedNumBlocks = endingBlockHeight - expectedFirstBlockHeight + 1
expectedComplete = 1
case i < 2 && rangeResp.FirstBlockHeight !=
uint32(expectedFirstBlockHeight):
// Any intermediate replies should range starting from
// the next block of our previous reply up until it
// reaches its maximum capacity of channels.
default:
expectedFirstBlockHeight = respMsgs[len(respMsgs)-1].BlockHeight
expectedNumBlocks = 5
}
t.Fatalf("FirstBlockHeight incorrect: "+
"expected %v got %v",
rangeResp.FirstBlockHeight,
expectedFirstBlockHeight)
case i == 2 && rangeResp.Complete != 1:
t.Fatalf("final chunk should have "+
"Complete=1: %v", spew.Sdump(rangeResp))
switch {
case rangeResp.FirstBlockHeight != expectedFirstBlockHeight:
t.Fatalf("FirstBlockHeight in resp #%d "+
"incorrect: expected %v, got %v", i+1,
expectedFirstBlockHeight,
rangeResp.FirstBlockHeight)
case i == 2 && rangeResp.NumBlocks != 1:
t.Fatalf("NumBlocks fields in resp "+
"incorrect: expected %v got %v", 1,
rangeResp.NumBlocks)
case i == 2 && rangeResp.FirstBlockHeight !=
queryResp[len(queryResp)-1].BlockHeight:
t.Fatalf("FirstBlockHeight incorrect: "+
"expected %v got %v",
rangeResp.FirstBlockHeight,
queryResp[len(queryResp)-1].BlockHeight)
case rangeResp.NumBlocks != expectedNumBlocks:
t.Fatalf("NumBlocks in resp #%d incorrect: "+
"expected %v, got %v", i+1,
expectedNumBlocks, rangeResp.NumBlocks)
case rangeResp.Complete != expectedComplete:
t.Fatalf("Complete in resp #%d incorrect: "+
"expected %v, got %v", i+1,
expectedNumBlocks, rangeResp.Complete)
}
respMsgs = append(respMsgs, rangeResp.ShortChanIDs...)
@ -981,35 +989,91 @@ func TestGossipSyncerGenChanRangeQuery(t *testing.T) {
}
// TestGossipSyncerProcessChanRangeReply tests that we'll properly buffer
// replied channel replies until we have the complete version. If no new
// channels were discovered, then we should go directly to the chanSsSynced
// state. Otherwise, we should go to the queryNewChannels states.
// replied channel replies until we have the complete version.
func TestGossipSyncerProcessChanRangeReply(t *testing.T) {
t.Parallel()
t.Run("legacy", func(t *testing.T) {
testGossipSyncerProcessChanRangeReply(t, true)
})
t.Run("block ranges", func(t *testing.T) {
testGossipSyncerProcessChanRangeReply(t, false)
})
}
// testGossipSyncerProcessChanRangeReply tests that we'll properly buffer
// replied channel replies until we have the complete version. The legacy
// option, if set, uses the Complete field of the reply to determine when we've
// received all expected replies. Otherwise, it looks at the block ranges of
// each reply instead.
func testGossipSyncerProcessChanRangeReply(t *testing.T, legacy bool) {
t.Parallel()
// First, we'll create a GossipSyncer instance with a canned sendToPeer
// message to allow us to intercept their potential sends.
highestID := lnwire.ShortChannelID{
BlockHeight: latestKnownHeight,
}
_, syncer, chanSeries := newTestSyncer(
lnwire.NewShortChanIDFromInt(10), defaultEncoding, defaultChunkSize,
highestID, defaultEncoding, defaultChunkSize,
)
startingState := syncer.state
query, err := syncer.genChanRangeQuery(true)
if err != nil {
t.Fatalf("unable to generate channel range query: %v", err)
}
var replyQueries []*lnwire.QueryChannelRange
if legacy {
// Each reply query is the same as the original query in the
// legacy mode.
replyQueries = []*lnwire.QueryChannelRange{query, query, query}
} else {
// When interpreting block ranges, the first reply should start
// from our requested first block, and the last should end at
// our requested last block.
replyQueries = []*lnwire.QueryChannelRange{
{
FirstBlockHeight: 0,
NumBlocks: 11,
},
{
FirstBlockHeight: 11,
NumBlocks: 1,
},
{
FirstBlockHeight: 12,
NumBlocks: query.NumBlocks - 12,
},
}
}
replies := []*lnwire.ReplyChannelRange{
{
QueryChannelRange: *replyQueries[0],
ShortChanIDs: []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(10),
{
BlockHeight: 10,
},
},
},
{
QueryChannelRange: *replyQueries[1],
ShortChanIDs: []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(11),
{
BlockHeight: 11,
},
},
},
{
Complete: 1,
QueryChannelRange: *replyQueries[2],
Complete: 1,
ShortChanIDs: []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(12),
{
BlockHeight: 12,
},
},
},
}
@ -1030,9 +1094,15 @@ func TestGossipSyncerProcessChanRangeReply(t *testing.T) {
}
expectedReq := []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(10),
lnwire.NewShortChanIDFromInt(11),
lnwire.NewShortChanIDFromInt(12),
{
BlockHeight: 10,
},
{
BlockHeight: 11,
},
{
BlockHeight: 12,
},
}
// As we're about to send the final response, we'll launch a goroutine
@ -1083,48 +1153,6 @@ func TestGossipSyncerProcessChanRangeReply(t *testing.T) {
t.Fatal(err)
}
}
// We'll repeat our final reply again, but this time we won't send any
// new channels. As a result, we should transition over to the
// chansSynced state.
errCh = make(chan error, 1)
go func() {
select {
case <-time.After(time.Second * 15):
errCh <- errors.New("no query received")
return
case req := <-chanSeries.filterReq:
// We should get a request for the entire range of short
// chan ID's.
if !reflect.DeepEqual(expectedReq[2], req[0]) {
errCh <- fmt.Errorf("wrong request: expected %v, got %v",
expectedReq[2], req[0])
return
}
// We'll send back only the last two to simulate filtering.
chanSeries.filterResp <- []lnwire.ShortChannelID{}
errCh <- nil
}
}()
if err := syncer.processChanRangeReply(replies[2]); err != nil {
t.Fatalf("unable to process reply: %v", err)
}
if syncer.syncState() != chansSynced {
t.Fatalf("wrong state: expected %v instead got %v",
chansSynced, syncer.state)
}
// Wait for error from goroutine.
select {
case <-time.After(time.Second * 30):
t.Fatalf("goroutine did not return within 30 seconds")
case err := <-errCh:
if err != nil {
t.Fatal(err)
}
}
}
// TestGossipSyncerSynchronizeChanIDs tests that we properly request chunks of
@ -1260,17 +1288,17 @@ func TestGossipSyncerDelayDOS(t *testing.T) {
// First, we'll create two GossipSyncer instances with a canned
// sendToPeer message to allow us to intercept their potential sends.
startHeight := lnwire.ShortChannelID{
highestID := lnwire.ShortChannelID{
BlockHeight: 1144,
}
msgChan1, syncer1, chanSeries1 := newTestSyncer(
startHeight, defaultEncoding, chunkSize, true, false,
highestID, defaultEncoding, chunkSize, true, false,
)
syncer1.Start()
defer syncer1.Stop()
msgChan2, syncer2, chanSeries2 := newTestSyncer(
startHeight, defaultEncoding, chunkSize, false, true,
highestID, defaultEncoding, chunkSize, false, true,
)
syncer2.Start()
defer syncer2.Stop()
@ -1300,9 +1328,10 @@ func TestGossipSyncerDelayDOS(t *testing.T) {
// inherently disjoint.
var syncer2Chans []lnwire.ShortChannelID
for i := 0; i < numTotalChans; i++ {
syncer2Chans = append(
syncer2Chans, lnwire.NewShortChanIDFromInt(uint64(i)),
)
syncer2Chans = append(syncer2Chans, lnwire.ShortChannelID{
BlockHeight: highestID.BlockHeight - 1,
TxIndex: uint32(i),
})
}
// We'll kick off the test by asserting syncer1 sends over the
@ -1530,17 +1559,17 @@ func TestGossipSyncerRoutineSync(t *testing.T) {
// First, we'll create two GossipSyncer instances with a canned
// sendToPeer message to allow us to intercept their potential sends.
startHeight := lnwire.ShortChannelID{
highestID := lnwire.ShortChannelID{
BlockHeight: 1144,
}
msgChan1, syncer1, chanSeries1 := newTestSyncer(
startHeight, defaultEncoding, chunkSize, true, false,
highestID, defaultEncoding, chunkSize, true, false,
)
syncer1.Start()
defer syncer1.Stop()
msgChan2, syncer2, chanSeries2 := newTestSyncer(
startHeight, defaultEncoding, chunkSize, false, true,
highestID, defaultEncoding, chunkSize, false, true,
)
syncer2.Start()
defer syncer2.Stop()
@ -1548,9 +1577,9 @@ func TestGossipSyncerRoutineSync(t *testing.T) {
// Although both nodes are at the same height, syncer will have 3 chan
// ID's that syncer1 doesn't know of.
syncer2Chans := []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(4),
lnwire.NewShortChanIDFromInt(5),
lnwire.NewShortChanIDFromInt(6),
{BlockHeight: highestID.BlockHeight - 3},
{BlockHeight: highestID.BlockHeight - 2},
{BlockHeight: highestID.BlockHeight - 1},
}
// We'll kick off the test by passing over the QueryChannelRange
@ -1674,35 +1703,34 @@ func TestGossipSyncerAlreadySynced(t *testing.T) {
// our chunk parsing works properly. With this value we should get 3
// queries: two full chunks, and one lingering chunk.
const chunkSize = 2
const numChans = 3
// First, we'll create two GossipSyncer instances with a canned
// sendToPeer message to allow us to intercept their potential sends.
startHeight := lnwire.ShortChannelID{
highestID := lnwire.ShortChannelID{
BlockHeight: 1144,
}
msgChan1, syncer1, chanSeries1 := newTestSyncer(
startHeight, defaultEncoding, chunkSize,
highestID, defaultEncoding, chunkSize,
)
syncer1.Start()
defer syncer1.Stop()
msgChan2, syncer2, chanSeries2 := newTestSyncer(
startHeight, defaultEncoding, chunkSize,
highestID, defaultEncoding, chunkSize,
)
syncer2.Start()
defer syncer2.Stop()
// The channel state of both syncers will be identical. They should
// recognize this, and skip the sync phase below.
syncer1Chans := []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(1),
lnwire.NewShortChanIDFromInt(2),
lnwire.NewShortChanIDFromInt(3),
}
syncer2Chans := []lnwire.ShortChannelID{
lnwire.NewShortChanIDFromInt(1),
lnwire.NewShortChanIDFromInt(2),
lnwire.NewShortChanIDFromInt(3),
var syncer1Chans, syncer2Chans []lnwire.ShortChannelID
for i := numChans; i > 0; i-- {
shortChanID := lnwire.ShortChannelID{
BlockHeight: highestID.BlockHeight - uint32(i),
}
syncer1Chans = append(syncer1Chans, shortChanID)
syncer2Chans = append(syncer2Chans, shortChanID)
}
// We'll now kick off the test by allowing both side to send their

@ -2,6 +2,7 @@ package lnwire
import (
"io"
"math"
"github.com/btcsuite/btcd/chaincfg/chainhash"
)
@ -75,3 +76,14 @@ func (q *QueryChannelRange) MaxPayloadLength(uint32) uint32 {
// 32 + 4 + 4
return 40
}
// LastBlockHeight returns the last block height covered by the range of a
// QueryChannelRange message.
func (q *QueryChannelRange) LastBlockHeight() uint32 {
// Handle overflows by casting to uint64.
lastBlockHeight := uint64(q.FirstBlockHeight) + uint64(q.NumBlocks) - 1
if lastBlockHeight > math.MaxUint32 {
return math.MaxUint32
}
return uint32(lastBlockHeight)
}

10
peer.go

@ -1336,8 +1336,10 @@ func messageSummary(msg lnwire.Message) string {
msg.Complete)
case *lnwire.ReplyChannelRange:
return fmt.Sprintf("complete=%v, encoding=%v, num_chans=%v",
msg.Complete, msg.EncodingType, len(msg.ShortChanIDs))
return fmt.Sprintf("start_height=%v, end_height=%v, "+
"num_chans=%v, encoding=%v", msg.FirstBlockHeight,
msg.LastBlockHeight(), len(msg.ShortChanIDs),
msg.EncodingType)
case *lnwire.QueryShortChanIDs:
return fmt.Sprintf("chain_hash=%v, encoding=%v, num_chans=%v",
@ -1345,8 +1347,8 @@ func messageSummary(msg lnwire.Message) string {
case *lnwire.QueryChannelRange:
return fmt.Sprintf("chain_hash=%v, start_height=%v, "+
"num_blocks=%v", msg.ChainHash, msg.FirstBlockHeight,
msg.NumBlocks)
"end_height=%v", msg.ChainHash, msg.FirstBlockHeight,
msg.LastBlockHeight())
case *lnwire.GossipTimestampRange:
return fmt.Sprintf("chain_hash=%v, first_stamp=%v, "+