graph/db: version FilterKnownChanIDs callback
What changed, and why it matters
This commit refactors how LND decides whether a Lightning channel is a 'zombie' (inactive/stale) during gossip synchronization. Previously the code only passed two timestamps into the decision function, which worked for older v1 gossip channels but was meaningless for newer v2 channels that use block heights instead of timestamps. The change passes the full channel update info—including the gossip version—so future code can make version-aware zombie decisions. It is a preparatory/internal cleanup, not a direct fix for an active exploit.
Treat as a routine refactor that lays groundwork for future v2 gossip correctness. Monitor follow-up commits that address the TODO about v2 block-height freshness in `processChanRangeReply`. No urgent action required.
Security signals we found
Refactor enables version-aware zombie handling for v2 gossip channels
Removes hard-coded GossipVersion1 in MarkEdgeLive call during zombie revival
Adds TODO indicating v2 gossip sync protocol support is incomplete
Internal API change only; no immediate behavioral change for v1 channels
Evidence from the diff
The commit changes the isZombieChan callback signature from func(time.Time, time.Time) bool to func(ChannelUpdateInfo) bool in FilterKnownChanIDs and the ChannelGraphTimeSeries interface. ChannelUpdateInfo gains a Version lnwire.GossipVersion field, and NewChannelUpdateInfo now requires a version argument. The GossipSyncer wraps its existing v1-only isStillZombieChannel check in a closure that extracts Node1UpdateTimestamp/Node2UpdateTimestamp, with an explicit TODO noting that v2 block-height freshness handling is still needed. ChannelGraph.FilterKnownChanIDs now calls MarkEdgeLive with info.Version instead of hard-coding lnwire.GossipVersion1. All call sites and tests are updated accordingly.
Changed components
graph/db/graph.gograph/db/kv_store.gograph/db/sql_store.godiscovery/chan_series.godiscovery/syncer.godiscovery/syncer_test.gograph/db/graph_test.goInspect captured patch +47 / −37
diff --git a/discovery/chan_series.go b/discovery/chan_series.go
index 7ed9bbd..4a9a519 100644
--- a/discovery/chan_series.go
+++ b/discovery/chan_series.go
@@ -40,7 +40,7 @@ type ChannelGraphTimeSeries interface {
// passed superSet.
FilterKnownChanIDs(chain chainhash.Hash,
superSet []graphdb.ChannelUpdateInfo,
- isZombieChan func(time.Time, time.Time) bool) (
+ isZombieChan func(graphdb.ChannelUpdateInfo) bool) (
[]lnwire.ShortChannelID, error)
// FilterChannelRange returns the set of channels that we created
@@ -212,7 +212,7 @@ func (c *ChanSeries) UpdatesInHorizon(chain chainhash.Hash,
// NOTE: This is part of the ChannelGraphTimeSeries interface.
func (c *ChanSeries) FilterKnownChanIDs(_ chainhash.Hash,
superSet []graphdb.ChannelUpdateInfo,
- isZombieChan func(time.Time, time.Time) bool) (
+ isZombieChan func(graphdb.ChannelUpdateInfo) bool) (
[]lnwire.ShortChannelID, error) {
newChanIDs, err := c.graph.FilterKnownChanIDs(
diff --git a/discovery/syncer.go b/discovery/syncer.go
index 37f6705..ff0baf6 100644
--- a/discovery/syncer.go
+++ b/discovery/syncer.go
@@ -975,7 +975,7 @@ func (g *GossipSyncer) processChanRangeReply(_ context.Context,
for i, scid := range msg.ShortChanIDs {
info := graphdb.NewChannelUpdateInfo(
- scid, time.Time{}, time.Time{},
+ scid, lnwire.GossipVersion1, time.Time{}, time.Time{},
)
if len(msg.Timestamps) != 0 {
@@ -1061,9 +1061,17 @@ func (g *GossipSyncer) processChanRangeReply(_ context.Context,
// Otherwise, this is the final response, so we'll now check to see
// which channels they know of that we don't.
+ // TODO(elle): isStillZombieChannel only inspects v1 time-based
+ // timestamps; once the gossip sync protocol supports v2, this
+ // should be updated to handle block-height freshness for v2
+ // channels.
+ isZombieChan := func(info graphdb.ChannelUpdateInfo) bool {
+ return g.cfg.isStillZombieChannel(
+ info.Node1UpdateTimestamp, info.Node2UpdateTimestamp,
+ )
+ }
newChans, err := g.cfg.channelSeries.FilterKnownChanIDs(
- g.cfg.chainHash, g.bufferedChanRangeReplies,
- g.cfg.isStillZombieChannel,
+ g.cfg.chainHash, g.bufferedChanRangeReplies, isZombieChan,
)
if err != nil {
return fmt.Errorf("unable to filter chan ids: %w", err)
diff --git a/discovery/syncer_test.go b/discovery/syncer_test.go
index 2313d1c..cbf4c1d 100644
--- a/discovery/syncer_test.go
+++ b/discovery/syncer_test.go
@@ -108,7 +108,7 @@ func (m *mockChannelGraphTimeSeries) UpdatesInHorizon(chain chainhash.Hash,
func (m *mockChannelGraphTimeSeries) FilterKnownChanIDs(chain chainhash.Hash,
superSet []graphdb.ChannelUpdateInfo,
- isZombieChan func(time.Time, time.Time) bool) (
+ isZombieChan func(graphdb.ChannelUpdateInfo) bool) (
[]lnwire.ShortChannelID, error) {
m.filterReq <- superSet
diff --git a/graph/db/graph.go b/graph/db/graph.go
index b98a806..a082d5b 100644
--- a/graph/db/graph.go
+++ b/graph/db/graph.go
@@ -522,7 +522,7 @@ func (c *ChannelGraph) PruneGraphNodes(ctx context.Context) error {
// channels another peer knows of that we don't.
func (c *ChannelGraph) FilterKnownChanIDs(ctx context.Context,
chansInfo []ChannelUpdateInfo,
- isZombieChan func(time.Time, time.Time) bool) ([]uint64, error) {
+ isZombieChan func(ChannelUpdateInfo) bool) ([]uint64, error) {
unknown, knownZombies, err := c.db.FilterKnownChanIDs(ctx, chansInfo)
if err != nil {
@@ -541,20 +541,16 @@ func (c *ChannelGraph) FilterKnownChanIDs(ctx context.Context,
// recent. During the querying of the gossip msg verification
// happens as usual. However we should start punishing peers
// when they don't provide us honest data ?
- isStillZombie := isZombieChan(
- info.Node1UpdateTimestamp, info.Node2UpdateTimestamp,
- )
-
- if isStillZombie {
+ if isZombieChan(info) {
continue
}
// If we have marked it as a zombie but the latest update
- // timestamps could bring it back from the dead, then we mark it
+ // info could bring it back from the dead, then we mark it
// alive, and we let it be added to the set of IDs to query our
// peer for.
err := c.db.MarkEdgeLive(
- ctx, lnwire.GossipVersion1,
+ ctx, info.Version,
info.ShortChannelID.ToUint64(),
)
// Since there is a chance that the edge could have been marked
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index f49eb3a..06c278d 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -2979,10 +2979,10 @@ func TestFilterKnownChanIDsZombieRevival(t *testing.T) {
// Call FilterKnownChanIDs with an isStillZombie call-back that would
// result in the current zombies still be considered as zombies.
_, err = graph.FilterKnownChanIDs(ctx, []ChannelUpdateInfo{
- {ShortChannelID: scid1},
- {ShortChannelID: scid2},
- {ShortChannelID: scid3},
- }, func(_ time.Time, _ time.Time) bool {
+ {ShortChannelID: scid1, Version: lnwire.GossipVersion1},
+ {ShortChannelID: scid2, Version: lnwire.GossipVersion1},
+ {ShortChannelID: scid3, Version: lnwire.GossipVersion1},
+ }, func(_ ChannelUpdateInfo) bool {
return true
})
require.NoError(t, err)
@@ -2995,14 +2995,15 @@ func TestFilterKnownChanIDsZombieRevival(t *testing.T) {
// would result in channel with SCID 2 no longer being considered a
// zombie.
_, err = graph.FilterKnownChanIDs(ctx, []ChannelUpdateInfo{
- {ShortChannelID: scid1},
+ {ShortChannelID: scid1, Version: lnwire.GossipVersion1},
{
ShortChannelID: scid2,
+ Version: lnwire.GossipVersion1,
Node1UpdateTimestamp: time.Unix(1000, 0),
},
- {ShortChannelID: scid3},
- }, func(t1 time.Time, _ time.Time) bool {
- return !t1.Equal(time.Unix(1000, 0))
+ {ShortChannelID: scid3, Version: lnwire.GossipVersion1},
+ }, func(info ChannelUpdateInfo) bool {
+ return !info.Node1UpdateTimestamp.Equal(time.Unix(1000, 0))
})
require.NoError(t, err)
@@ -3021,9 +3022,7 @@ func TestFilterKnownChanIDs(t *testing.T) {
graph := MakeTestGraph(t)
- isZombieUpdate := func(updateTime1 time.Time,
- updateTime2 time.Time) bool {
-
+ isZombieUpdate := func(_ ChannelUpdateInfo) bool {
return true
}
@@ -3068,7 +3067,7 @@ func TestFilterKnownChanIDs(t *testing.T) {
require.NoError(t, graph.AddChannelEdge(ctx, channel))
chanIDs = append(chanIDs, NewChannelUpdateInfo(
- chanID, time.Time{}, time.Time{},
+ chanID, lnwire.GossipVersion1, time.Time{}, time.Time{},
))
}
@@ -3347,7 +3346,7 @@ func TestStressTestChannelGraphAPI(t *testing.T) {
_, err := graph.FilterKnownChanIDs(
ctx, chanIDs,
- func(t time.Time, t2 time.Time) bool {
+ func(_ ChannelUpdateInfo) bool {
return rand.Intn(2) == 0
},
)
@@ -3565,10 +3564,12 @@ func TestFilterChannelRange(t *testing.T) {
require.NoError(t, graph.AddChannelEdge(ctx, channel2))
chanInfo1 := NewChannelUpdateInfo(
- chanID1, time.Time{}, time.Time{},
+ chanID1, lnwire.GossipVersion1,
+ time.Time{}, time.Time{},
)
chanInfo2 := NewChannelUpdateInfo(
- chanID2, time.Time{}, time.Time{},
+ chanID2, lnwire.GossipVersion1,
+ time.Time{}, time.Time{},
)
channelRanges = append(channelRanges, BlockChannelRange{
Height: chanHeight,
@@ -3585,10 +3586,10 @@ func TestFilterChannelRange(t *testing.T) {
)
chanInfo1 = NewChannelUpdateInfo(
- chanID1, time1, time2,
+ chanID1, lnwire.GossipVersion1, time1, time2,
)
chanInfo2 = NewChannelUpdateInfo(
- chanID2, time3, time4,
+ chanID2, lnwire.GossipVersion1, time3, time4,
)
channelRangesWithTimestamps = append(
channelRangesWithTimestamps, BlockChannelRange{
@@ -4610,9 +4611,8 @@ func TestGraphZombieIndex(t *testing.T) {
// Similarly, if we mark the same edge as live, we should no longer see
// it within the index.
- require.NoError(
- t, graph.MarkEdgeLive(ctx, lnwire.GossipVersion1, edge.ChannelID),
- )
+ err = graph.MarkEdgeLive(ctx, lnwire.GossipVersion1, edge.ChannelID)
+ require.NoError(t, err)
// Attempting to mark the edge as live again now that it is no longer
// in the zombie index should fail.
diff --git a/graph/db/kv_store.go b/graph/db/kv_store.go
index 5e911aa..2ed7fc9 100644
--- a/graph/db/kv_store.go
+++ b/graph/db/kv_store.go
@@ -2764,6 +2764,9 @@ type ChannelUpdateInfo struct {
// ShortChannelID is the SCID identifier of the channel.
ShortChannelID lnwire.ShortChannelID
+ // Version is the gossip version of the channel.
+ Version lnwire.GossipVersion
+
// Node1UpdateTimestamp is the timestamp of the latest received update
// from the node 1 channel peer. This will be set to zero time if no
// update has yet been received from this node.
@@ -2778,11 +2781,13 @@ type ChannelUpdateInfo struct {
// NewChannelUpdateInfo is a constructor which makes sure we initialize the
// timestamps with zero seconds unix timestamp which equals
// `January 1, 1970, 00:00:00 UTC` in case the value is `time.Time{}`.
-func NewChannelUpdateInfo(scid lnwire.ShortChannelID, node1Timestamp,
+func NewChannelUpdateInfo(scid lnwire.ShortChannelID,
+ v lnwire.GossipVersion, node1Timestamp,
node2Timestamp time.Time) ChannelUpdateInfo {
chanInfo := ChannelUpdateInfo{
ShortChannelID: scid,
+ Version: v,
Node1UpdateTimestamp: node1Timestamp,
Node2UpdateTimestamp: node2Timestamp,
}
@@ -2874,7 +2879,7 @@ func (c *KVStore) FilterChannelRange(_ context.Context, startHeight,
cid := lnwire.NewShortChanIDFromInt(rawCid)
chanInfo := NewChannelUpdateInfo(
- cid, time.Time{}, time.Time{},
+ cid, lnwire.GossipVersion1, time.Time{}, time.Time{},
)
if !withTimestamps {
diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go
index 7536721..173e0dc 100644
--- a/graph/db/sql_store.go
+++ b/graph/db/sql_store.go
@@ -1703,7 +1703,8 @@ func (s *SQLStore) FilterChannelRange(ctx context.Context, startHeight,
byteOrder.Uint64(dbChan.Scid),
)
chanInfo := NewChannelUpdateInfo(
- cid, time.Time{}, time.Time{},
+ cid, lnwire.GossipVersion1,
+ time.Time{}, time.Time{},
)
if !withTimestamps {
Why this scored 25/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.