graph/db: add gossip version parameter to FilterKnownChanIDs
What changed, and why it matters
This commit refactors how a Lightning Network routing database method, FilterKnownChanIDs, receives the gossip protocol version. Previously the version was read from each individual channel record; now it is passed explicitly from the version-scoped wrapper. The change also adds a sanity check that zombie channels returned by the database match the expected gossip version, and updates tests to cover both v1 and v2 gossip. It is a defensive correctness improvement rather than a clear-cut fix for an active vulnerability.
Treat as a routine correctness/refactoring patch. Reviewers should verify that all call sites of FilterKnownChanIDs now use a VersionedGraph and that the explicit version matches the channel range/series context. No emergency action is indicated by the diff alone, but downstream callers should be checked for version mismatches.
Security signals we found
Defensive version-consistency check added for zombie channel records
SQL zombie lookup now uses the caller-supplied gossip version rather than per-item version field
KVStore now explicitly rejects unsupported gossip versions instead of silently using v1 behavior
Existing zombie-revival logic preserved; no new authorization or input-validation boundary introduced
No mention of CVE, security advisory, or independent researcher attribution in commit
Evidence from the diff
FilterKnownChanIDs is moved from ChannelGraph to VersionedGraph, and the underlying Store/KVStore/SQLStore signatures now take an explicit lnwire.GossipVersion parameter. The SQL implementation uses that version for both the channel iteration and the zombie-channel check, instead of reading chanInfo.Version per item. KVStore rejects non-v1 versions. A new runtime check ensures returned known-zombie ChannelUpdateInfos have the same Version as the VersionedGraph. Tests are generalized to run against both GossipVersion1 and GossipVersion2. The commit does not itself change wire parsing or peer authentication; it tightens version consistency inside the graph DB layer.
Changed components
graph/db/graph.gograph/db/interfaces.gograph/db/kv_store.gograph/db/sql_store.gograph/db/graph_test.goInspect captured patch +177 / −153
diff --git a/graph/db/graph.go b/graph/db/graph.go
index 2e2554b..9e72cad 100644
--- a/graph/db/graph.go
+++ b/graph/db/graph.go
@@ -600,56 +600,6 @@ func (c *ChannelGraph) PruneGraphNodes(ctx context.Context) error {
return nil
}
-// FilterKnownChanIDs takes a set of channel IDs and return the subset of chan
-// ID's that we don't know and are not known zombies of the passed set. In other
-// words, we perform a set difference of our set of chan ID's and the ones
-// passed in. This method can be used by callers to determine the set of
-// channels another peer knows of that we don't.
-func (c *ChannelGraph) FilterKnownChanIDs(ctx context.Context,
- chansInfo []ChannelUpdateInfo,
- isZombieChan func(ChannelUpdateInfo) bool) ([]uint64, error) {
-
- unknown, knownZombies, err := c.db.FilterKnownChanIDs(ctx, chansInfo)
- if err != nil {
- return nil, err
- }
-
- for _, info := range knownZombies {
- // TODO(ziggie): Make sure that for the strict pruning case we
- // compare the pubkeys and whether the right timestamp is not
- // older than the `ChannelPruneExpiry`.
- //
- // NOTE: The timestamp data has no verification attached to it
- // in the `ReplyChannelRange` msg so we are trusting this data
- // at this point. However it is not critical because we are just
- // removing the channel from the db when the timestamps are more
- // 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 ?
- if isZombieChan(info) {
- continue
- }
-
- // If we have marked it as a zombie but the latest update
- // 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, info.Version,
- info.ShortChannelID.ToUint64(),
- )
- // Since there is a chance that the edge could have been marked
- // as "live" between the FilterKnownChanIDs call and the
- // MarkEdgeLive call, we ignore the error if the edge is already
- // marked as live.
- if err != nil && !errors.Is(err, ErrZombieEdgeNotFound) {
- return nil, err
- }
- }
-
- return unknown, nil
-}
-
// MarkEdgeZombie attempts to mark a channel identified by its channel ID as a
// zombie for the given gossip version. This method is used on an ad-hoc basis,
// when channels need to be marked as zombies outside the normal pruning cycle.
@@ -796,6 +746,69 @@ func (c *VersionedGraph) FilterChannelRange(ctx context.Context,
)
}
+// FilterKnownChanIDs takes a set of channel IDs and returns the subset of chan
+// ID's that we don't know and are not known zombies of the passed set. In other
+// words, we perform a set difference of our set of chan ID's and the ones
+// passed in. This method can be used by callers to determine the set of
+// channels another peer knows of that we don't.
+func (c *VersionedGraph) FilterKnownChanIDs(ctx context.Context,
+ chansInfo []ChannelUpdateInfo,
+ isZombieChan func(ChannelUpdateInfo) bool) ([]uint64, error) {
+
+ unknown, knownZombies, err := c.db.FilterKnownChanIDs(
+ ctx, c.v, chansInfo,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ for _, info := range knownZombies {
+ // Sanity check that the returned zombie channels are on the
+ // same gossip version as the one we passed in.
+ if info.Version != c.v {
+ return nil, fmt.Errorf("expected zombie channel's "+
+ "gossip version to be %v, got %v", c.v,
+ info.Version)
+ }
+
+ // TODO(ziggie): Make sure that for the strict pruning case
+ // we compare the pubkeys and whether the right timestamp
+ // is not older than the `ChannelPruneExpiry`.
+ //
+ // NOTE: The timestamp data has no verification attached
+ // to it in the `ReplyChannelRange` msg so we are trusting
+ // this data at this point. However it is not critical
+ // because we are just removing the channel from the db
+ // when the timestamps are more 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?
+ if isZombieChan(info) {
+ continue
+ }
+
+ // If we have marked it as a zombie but the latest update
+ // 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, info.Version,
+ info.ShortChannelID.ToUint64(),
+ )
+ // Since there is a chance that the edge could have been
+ // marked as "live" between the FilterKnownChanIDs call
+ // and the MarkEdgeLive call, we ignore the error if the
+ // edge is already marked as live.
+ if err != nil &&
+ !errors.Is(err, ErrZombieEdgeNotFound) {
+
+ return nil, err
+ }
+ }
+
+ return unknown, nil
+}
+
// FetchChanInfos returns the set of channel edges for the passed channel IDs.
func (c *ChannelGraph) FetchChanInfos(ctx context.Context,
v lnwire.GossipVersion, chanIDs []uint64) ([]ChannelEdge, error) {
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index c5e8ce6..4652827 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -231,6 +231,14 @@ var versionedTests = []versionedTest{
name: "disconnect block at height",
test: testDisconnectBlockAtHeight,
},
+ {
+ name: "filter known chan ids zombie revival",
+ test: testFilterKnownChanIDsZombieRevival,
+ },
+ {
+ name: "filter known chan ids",
+ test: testFilterKnownChanIDs,
+ },
}
// TestVersionedDBs runs various tests against both v1 and v2 versioned
@@ -3613,14 +3621,16 @@ func TestChanUpdatesInHorizonV2(t *testing.T) {
})
}
-// TestFilterKnownChanIDsZombieRevival tests that if a ChannelUpdateInfo is
+// testFilterKnownChanIDsZombieRevival tests that if a ChannelUpdateInfo is
// passed to FilterKnownChanIDs that contains a channel that we have marked as
// a zombie, then we will mark it as live again if the new ChannelUpdate has
// timestamps that would make the channel be considered live again.
//
-// NOTE: this tests focuses on zombie revival. The main logic of
-// FilterKnownChanIDs is tested in TestFilterKnownChanIDs.
-func TestFilterKnownChanIDsZombieRevival(t *testing.T) {
+// NOTE: this test focuses on zombie revival. The main logic of
+// FilterKnownChanIDs is tested in testFilterKnownChanIDs.
+func testFilterKnownChanIDsZombieRevival(t *testing.T,
+ v lnwire.GossipVersion) {
+
t.Parallel()
ctx := t.Context()
@@ -3632,9 +3642,11 @@ func TestFilterKnownChanIDsZombieRevival(t *testing.T) {
scid3 = lnwire.ShortChannelID{BlockHeight: 3}
)
- v1Graph := NewVersionedGraph(graph, lnwire.GossipVersion1)
+ vGraph := NewVersionedGraph(graph, v)
isZombie := func(scid lnwire.ShortChannelID) bool {
- zombie, _, _, err := v1Graph.IsZombieEdge(ctx, scid.ToUint64())
+ zombie, _, _, err := vGraph.IsZombieEdge(
+ ctx, scid.ToUint64(),
+ )
require.NoError(t, err)
return zombie
@@ -3642,13 +3654,11 @@ func TestFilterKnownChanIDsZombieRevival(t *testing.T) {
// Mark channel 1 and 2 as zombies.
err := graph.MarkEdgeZombie(
- ctx, lnwire.GossipVersion1, scid1.ToUint64(),
- [33]byte{}, [33]byte{},
+ ctx, v, scid1.ToUint64(), [33]byte{}, [33]byte{},
)
require.NoError(t, err)
err = graph.MarkEdgeZombie(
- ctx, lnwire.GossipVersion1, scid2.ToUint64(),
- [33]byte{}, [33]byte{},
+ ctx, v, scid2.ToUint64(), [33]byte{}, [33]byte{},
)
require.NoError(t, err)
@@ -3656,12 +3666,22 @@ func TestFilterKnownChanIDsZombieRevival(t *testing.T) {
require.True(t, isZombie(scid2))
require.False(t, isZombie(scid3))
+ // Build a freshness marker appropriate for the gossip version. V1
+ // uses unix timestamps, v2 uses block heights.
+ var revivalFreshness lnwire.Timestamp
+ switch v {
+ case lnwire.GossipVersion1:
+ revivalFreshness = lnwire.UnixTimestamp(1000)
+ case lnwire.GossipVersion2:
+ revivalFreshness = lnwire.BlockHeightTimestamp(1000)
+ }
+
// 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, Version: lnwire.GossipVersion1},
- {ShortChannelID: scid2, Version: lnwire.GossipVersion1},
- {ShortChannelID: scid3, Version: lnwire.GossipVersion1},
+ _, err = vGraph.FilterKnownChanIDs(ctx, []ChannelUpdateInfo{
+ {ShortChannelID: scid1, Version: v},
+ {ShortChannelID: scid2, Version: v},
+ {ShortChannelID: scid3, Version: v},
}, func(_ ChannelUpdateInfo) bool {
return true
})
@@ -3671,19 +3691,19 @@ func TestFilterKnownChanIDsZombieRevival(t *testing.T) {
require.True(t, isZombie(scid2))
require.False(t, isZombie(scid3))
- // Now call it again but this time with a isStillZombie call-back that
- // would result in channel with SCID 2 no longer being considered a
- // zombie.
- _, err = graph.FilterKnownChanIDs(ctx, []ChannelUpdateInfo{
- {ShortChannelID: scid1, Version: lnwire.GossipVersion1},
+ // Now call it again but this time with an isStillZombie call-back
+ // that would result in channel with SCID 2 no longer being
+ // considered a zombie.
+ _, err = vGraph.FilterKnownChanIDs(ctx, []ChannelUpdateInfo{
+ {ShortChannelID: scid1, Version: v},
{
ShortChannelID: scid2,
- Version: lnwire.GossipVersion1,
- Node1Freshness: lnwire.UnixTimestamp(1000),
+ Version: v,
+ Node1Freshness: revivalFreshness,
},
- {ShortChannelID: scid3, Version: lnwire.GossipVersion1},
+ {ShortChannelID: scid3, Version: v},
}, func(info ChannelUpdateInfo) bool {
- return info.Node1Freshness != lnwire.UnixTimestamp(1000)
+ return info.Node1Freshness != revivalFreshness
})
require.NoError(t, err)
@@ -3693,19 +3713,32 @@ func TestFilterKnownChanIDsZombieRevival(t *testing.T) {
require.False(t, isZombie(scid3))
}
-// TestFilterKnownChanIDs tests that we're able to properly perform the set
+// testFilterKnownChanIDs tests that we're able to properly perform the set
// differences of an incoming set of channel ID's, and those that we already
// know of on disk.
-func TestFilterKnownChanIDs(t *testing.T) {
+func testFilterKnownChanIDs(t *testing.T, v lnwire.GossipVersion) {
t.Parallel()
ctx := t.Context()
graph := MakeTestGraph(t)
+ vGraph := NewVersionedGraph(graph, v)
isZombieUpdate := func(_ ChannelUpdateInfo) bool {
return true
}
+ // newChanUpdateInfo builds a ChannelUpdateInfo for the given SCID with
+ // the test's gossip version and zero freshness.
+ newChanUpdateInfo := func(
+ scid lnwire.ShortChannelID,
+ ) ChannelUpdateInfo {
+
+ return ChannelUpdateInfo{
+ ShortChannelID: scid,
+ Version: v,
+ }
+ }
+
var (
scid1 = lnwire.ShortChannelID{BlockHeight: 1}
scid2 = lnwire.ShortChannelID{BlockHeight: 2}
@@ -3715,11 +3748,11 @@ func TestFilterKnownChanIDs(t *testing.T) {
// If we try to filter out a set of channel ID's before we even know of
// any channels, then we should get the entire set back.
preChanIDs := []ChannelUpdateInfo{
- {ShortChannelID: scid1},
- {ShortChannelID: scid2},
- {ShortChannelID: scid3},
+ newChanUpdateInfo(scid1),
+ newChanUpdateInfo(scid2),
+ newChanUpdateInfo(scid3),
}
- filteredIDs, err := graph.FilterKnownChanIDs(
+ filteredIDs, err := vGraph.FilterKnownChanIDs(
ctx, preChanIDs, isZombieUpdate,
)
require.NoError(t, err, "unable to filter chan IDs")
@@ -3730,9 +3763,9 @@ func TestFilterKnownChanIDs(t *testing.T) {
}, filteredIDs)
// We'll start by creating two nodes which will seed our test graph.
- node1 := createTestVertex(t, lnwire.GossipVersion1)
+ node1 := createTestVertex(t, v)
require.NoError(t, graph.AddNode(ctx, node1))
- node2 := createTestVertex(t, lnwire.GossipVersion1)
+ node2 := createTestVertex(t, v)
require.NoError(t, graph.AddNode(ctx, node2))
// Next, we'll add 5 channel ID's to the graph, each of them having a
@@ -3741,112 +3774,86 @@ func TestFilterKnownChanIDs(t *testing.T) {
chanIDs := make([]ChannelUpdateInfo, 0, numChans)
for i := 0; i < numChans; i++ {
channel, chanID := createEdge(
- lnwire.GossipVersion1, uint32(i*10), 0, 0, 0,
- node1, node2,
+ v, uint32(i*10), 0, 0, 0, node1, node2,
)
require.NoError(t, graph.AddChannelEdge(ctx, channel))
- chanIDs = append(chanIDs, NewV1ChannelUpdateInfo(
- chanID, time.Time{}, time.Time{},
- ))
+ chanIDs = append(chanIDs, newChanUpdateInfo(chanID))
}
const numZombies = 5
zombieIDs := make([]ChannelUpdateInfo, 0, numZombies)
for i := 0; i < numZombies; i++ {
channel, chanID := createEdge(
- lnwire.GossipVersion1, uint32(i*10+1), 0, 0, 0,
- node1, node2,
+ v, uint32(i*10+1), 0, 0, 0, node1, node2,
)
require.NoError(t, graph.AddChannelEdge(ctx, channel))
err := graph.DeleteChannelEdges(
- ctx, lnwire.GossipVersion1, false, true,
- channel.ChannelID,
+ ctx, v, false, true, channel.ChannelID,
)
require.NoError(t, err)
- zombieIDs = append(
- zombieIDs, ChannelUpdateInfo{ShortChannelID: chanID},
- )
+ zombieIDs = append(zombieIDs, newChanUpdateInfo(chanID))
}
queryCases := []struct {
queryIDs []ChannelUpdateInfo
-
- resp []ChannelUpdateInfo
+ resp []ChannelUpdateInfo
}{
// If we attempt to filter out all chanIDs we know of, the
// response should be the empty set.
{
queryIDs: chanIDs,
},
- // If we attempt to filter out all zombies that we know of, the
- // response should be the empty set.
+ // If we attempt to filter out all zombies that we know of,
+ // the response should be the empty set.
{
queryIDs: zombieIDs,
},
-
// If we query for a set of ID's that we didn't insert, we
// should get the same set back.
{
queryIDs: []ChannelUpdateInfo{
- {
- ShortChannelID: lnwire.ShortChannelID{
- BlockHeight: 99,
- },
- },
- {
- ShortChannelID: lnwire.ShortChannelID{
- BlockHeight: 100,
- },
- },
+ newChanUpdateInfo(lnwire.ShortChannelID{
+ BlockHeight: 99,
+ }),
+ newChanUpdateInfo(lnwire.ShortChannelID{
+ BlockHeight: 100,
+ }),
},
resp: []ChannelUpdateInfo{
- {
- ShortChannelID: lnwire.ShortChannelID{
- BlockHeight: 99,
- },
- },
- {
- ShortChannelID: lnwire.ShortChannelID{
- BlockHeight: 100,
- },
- },
+ newChanUpdateInfo(lnwire.ShortChannelID{
+ BlockHeight: 99,
+ }),
+ newChanUpdateInfo(lnwire.ShortChannelID{
+ BlockHeight: 100,
+ }),
},
},
-
// If we query for a super-set of our the chan ID's inserted,
// we should only get those new chanIDs back.
{
queryIDs: append(chanIDs, []ChannelUpdateInfo{
- {
- ShortChannelID: lnwire.ShortChannelID{
- BlockHeight: 99,
- },
- },
- {
- ShortChannelID: lnwire.ShortChannelID{
- BlockHeight: 101,
- },
- },
+ newChanUpdateInfo(lnwire.ShortChannelID{
+ BlockHeight: 99,
+ }),
+ newChanUpdateInfo(lnwire.ShortChannelID{
+ BlockHeight: 101,
+ }),
}...),
resp: []ChannelUpdateInfo{
- {
- ShortChannelID: lnwire.ShortChannelID{
- BlockHeight: 99,
- },
- },
- {
- ShortChannelID: lnwire.ShortChannelID{
- BlockHeight: 101,
- },
- },
+ newChanUpdateInfo(lnwire.ShortChannelID{
+ BlockHeight: 99,
+ }),
+ newChanUpdateInfo(lnwire.ShortChannelID{
+ BlockHeight: 101,
+ }),
},
},
}
for _, queryCase := range queryCases {
- resp, err := graph.FilterKnownChanIDs(
+ resp, err := vGraph.FilterKnownChanIDs(
ctx, queryCase.queryIDs, isZombieUpdate,
)
require.NoError(t, err)
diff --git a/graph/db/interfaces.go b/graph/db/interfaces.go
index 5da4dcd..0230300 100644
--- a/graph/db/interfaces.go
+++ b/graph/db/interfaces.go
@@ -265,14 +265,14 @@ type Store interface { //nolint:interfacebloat
r ChanUpdateRange,
opts ...IteratorOption) iter.Seq2[ChannelEdge, error]
- // FilterKnownChanIDs takes a set of channel IDs and return the subset
- // of chan ID's that we don't know and are not known zombies of the
- // passed set. In other words, we perform a set difference of our set
- // of chan ID's and the ones passed in. This method can be used by
- // callers to determine the set of channels another peer knows of that
- // we don't. The ChannelUpdateInfos for the known zombies is also
- // returned.
- FilterKnownChanIDs(ctx context.Context,
+ // FilterKnownChanIDs takes a set of channel IDs for a given gossip
+ // version and returns the subset of chan ID's that we don't know and
+ // are not known zombies of the passed set. In other words, we perform
+ // a set difference of our set of chan ID's and the ones passed in.
+ // This method can be used by callers to determine the set of channels
+ // another peer knows of that we don't. The ChannelUpdateInfos for the
+ // known zombies is also returned.
+ FilterKnownChanIDs(ctx context.Context, v lnwire.GossipVersion,
chansInfo []ChannelUpdateInfo) ([]uint64, []ChannelUpdateInfo,
error)
diff --git a/graph/db/kv_store.go b/graph/db/kv_store.go
index 484c864..ed6d0e0 100644
--- a/graph/db/kv_store.go
+++ b/graph/db/kv_store.go
@@ -2723,8 +2723,13 @@ func (c *KVStore) NodeUpdatesInHorizon(_ context.Context,
// channels another peer knows of that we don't. The ChannelUpdateInfos for the
// known zombies is also returned.
func (c *KVStore) FilterKnownChanIDs(_ context.Context,
+ v lnwire.GossipVersion,
chansInfo []ChannelUpdateInfo) ([]uint64, []ChannelUpdateInfo, error) {
+ if v != lnwire.GossipVersion1 {
+ return nil, nil, ErrVersionNotSupportedForKVDB
+ }
+
var (
newChanIDs []uint64
knownZombies []ChannelUpdateInfo
diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go
index 874f512..65c0f9f 100644
--- a/graph/db/sql_store.go
+++ b/graph/db/sql_store.go
@@ -3121,6 +3121,7 @@ func (s *SQLStore) forEachChanWithPoliciesInSCIDList(ctx context.Context,
//
// NOTE: part of the Store interface.
func (s *SQLStore) FilterKnownChanIDs(ctx context.Context,
+ v lnwire.GossipVersion,
chansInfo []ChannelUpdateInfo) ([]uint64, []ChannelUpdateInfo, error) {
var (
@@ -3152,7 +3153,7 @@ func (s *SQLStore) FilterKnownChanIDs(ctx context.Context,
}
err := s.forEachChanInSCIDList(
- ctx, db, lnwire.GossipVersion1, cb, chansInfo,
+ ctx, db, v, cb, chansInfo,
)
if err != nil {
return fmt.Errorf("unable to iterate "+
@@ -3171,10 +3172,8 @@ func (s *SQLStore) FilterKnownChanIDs(ctx context.Context,
isZombie, err := db.IsZombieChannel(
ctx, sqlc.IsZombieChannelParams{
- Scid: channelIDToBytes(channelID),
- Version: int16(
- chanInfo.Version,
- ),
+ Scid: channelIDToBytes(channelID),
+ Version: int16(v),
},
)
if err != nil {
Why this scored 27/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.