multi: version ForEachNode, ForEachNodeCached, NumZombies
What changed, and why it matters
This commit is a software plumbing change: it adds a 'gossip version' parameter to several graph database methods so the code can distinguish between different network protocol versions. It does not fix a crash, a privacy leak, or an obvious way for an attacker to steal funds. Instead, it prepares the codebase for future multi-version channel graph support. The KV (file-based) backend now refuses non-V1 versions, while the SQL backend uses the version to filter queries. Several internal callers are updated to explicitly request version 1.
Treat as a normal refactoring/versioning commit. Reviewers should verify that all new call sites pass an explicit, correct gossip version and that the KVStore guard cannot be bypassed. Monitor follow-up commits that address the TODOs about cross-version graph views, as those may carry actual security or correctness implications.
Security signals we found
API versioning added to graph store methods
KV backend explicitly rejects unsupported gossip versions
SQL backend filters queries by gossip version
Internal call sites hard-coded to GossipVersion1 with TODOs for cross-version views
No explicit security claim in commit message or diff
Evidence from the diff
The patch versions the Store interface methods ForEachNode, ForEachNodeCached, and NumZombies by adding a lnwire.GossipVersion parameter. KVStore rejects any version other than GossipVersion1 with ErrVersionNotSupportedForKVDB. SQLStore propagates the version into paginated queries and zombie-channel counts. ChannelGraph and VersionedGraph wrappers pass the version through; VersionedGraph uses its embedded version c.v. Call sites in rpcserver.go, pilot.go, server.go, and tests are switched from svr.graphDB / s.server.graphDB to versioned v1Graph equivalents, with TODO comments noting future cross-version support. No vulnerability is directly remediated; this is architectural groundwork.
Changed components
graph/db/interfaces.gograph/db/kv_store.gograph/db/sql_store.gograph/db/graph.gorpcserver.gopilot.goserver.gograph/db tests and benchmarksitest/lnd_graph_migration_test.goInspect captured patch +104 / −64
diff --git a/graph/db/benchmark_test.go b/graph/db/benchmark_test.go
index 229aad4..4ab0819 100644
--- a/graph/db/benchmark_test.go
+++ b/graph/db/benchmark_test.go
@@ -349,7 +349,8 @@ func TestPopulateDBs(t *testing.T) {
countNodes := func(graph *ChannelGraph) int {
numNodes := 0
err := graph.ForEachNode(
- ctx, func(node *models.Node) error {
+ ctx, lnwire.GossipVersion1,
+ func(node *models.Node) error {
numNodes++
return nil
@@ -455,7 +456,8 @@ func syncGraph(t *testing.T, src, dest *ChannelGraph) {
}
var wgNodes sync.WaitGroup
- err := src.ForEachNode(ctx, func(node *models.Node) error {
+ v1 := lnwire.GossipVersion1
+ err := src.ForEachNode(ctx, v1, func(node *models.Node) error {
wgNodes.Add(1)
go func() {
defer wgNodes.Done()
@@ -621,7 +623,7 @@ func BenchmarkGraphReadMethods(b *testing.B) {
name: "ForEachNode",
fn: func(b testing.TB, store Store) {
err := store.ForEachNode(
- ctx,
+ ctx, lnwire.GossipVersion1,
func(_ *models.Node) error {
// Increment the counter to
// ensure the callback is doing
@@ -689,7 +691,8 @@ func BenchmarkGraphReadMethods(b *testing.B) {
fn: func(b testing.TB, store Store) {
//nolint:ll
err := store.ForEachNodeCached(
- ctx, false, func(context.Context,
+ ctx, lnwire.GossipVersion1, false,
+ func(context.Context,
route.Vertex,
[]net.Addr,
map[uint64]*DirectedChannel) error {
@@ -813,7 +816,7 @@ func BenchmarkFindOptimalSQLQueryConfig(b *testing.B) {
)
err := store.ForEachNode(
- ctx,
+ ctx, lnwire.GossipVersion1,
func(_ *models.Node) error {
numNodes++
diff --git a/graph/db/graph.go b/graph/db/graph.go
index 4c90a40..072cd9b 100644
--- a/graph/db/graph.go
+++ b/graph/db/graph.go
@@ -272,7 +272,8 @@ func (c *ChannelGraph) GraphSession(ctx context.Context,
// graph, executing the passed callback with each node encountered.
//
// NOTE: The callback contents MUST not be modified.
-func (c *ChannelGraph) ForEachNodeCached(ctx context.Context, withAddrs bool,
+func (c *ChannelGraph) ForEachNodeCached(ctx context.Context,
+ v lnwire.GossipVersion, withAddrs bool,
cb func(ctx context.Context, node route.Vertex, addrs []net.Addr,
chans map[uint64]*DirectedChannel) error, reset func()) error {
@@ -286,7 +287,7 @@ func (c *ChannelGraph) ForEachNodeCached(ctx context.Context, withAddrs bool,
)
}
- return c.db.ForEachNodeCached(ctx, withAddrs, cb, reset)
+ return c.db.ForEachNodeCached(ctx, v, withAddrs, cb, reset)
}
// AddNode adds a vertex/node to the graph database. If the node is not
@@ -626,9 +627,10 @@ func (c *ChannelGraph) ForEachNodeChannel(ctx context.Context,
// ForEachNode iterates through all stored vertices/nodes in the graph.
func (c *ChannelGraph) ForEachNode(ctx context.Context,
- cb func(*models.Node) error, reset func()) error {
+ v lnwire.GossipVersion, cb func(*models.Node) error,
+ reset func()) error {
- return c.db.ForEachNode(ctx, cb, reset)
+ return c.db.ForEachNode(ctx, v, cb, reset)
}
// ForEachNodeCacheable iterates through all stored vertices/nodes in the graph.
@@ -785,8 +787,10 @@ func (c *ChannelGraph) IsZombieEdge(ctx context.Context,
}
// NumZombies returns the current number of zombie channels in the graph.
-func (c *ChannelGraph) NumZombies(ctx context.Context) (uint64, error) {
- return c.db.NumZombies(ctx)
+func (c *ChannelGraph) NumZombies(ctx context.Context,
+ v lnwire.GossipVersion) (uint64, error) {
+
+ return c.db.NumZombies(ctx, v)
}
// PutClosedScid stores a SCID for a closed channel in the database.
@@ -874,14 +878,19 @@ func (c *VersionedGraph) ForEachNodeCached(ctx context.Context,
chans map[uint64]*DirectedChannel) error,
reset func()) error {
- return c.ChannelGraph.ForEachNodeCached(ctx, withAddrs, cb, reset)
+ return c.ChannelGraph.ForEachNodeCached(ctx, c.v, withAddrs, cb, reset)
}
// ForEachNode iterates through all stored vertices/nodes in the graph.
func (c *VersionedGraph) ForEachNode(ctx context.Context,
cb func(*models.Node) error, reset func()) error {
- return c.db.ForEachNode(ctx, cb, reset)
+ return c.db.ForEachNode(ctx, c.v, cb, reset)
+}
+
+// NumZombies returns the current number of zombie channels in the graph.
+func (c *VersionedGraph) NumZombies(ctx context.Context) (uint64, error) {
+ return c.db.NumZombies(ctx, c.v)
}
// NodeUpdatesInHorizon returns all known lightning nodes which have an update
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index 58347a1..06eba01 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -1760,23 +1760,26 @@ func TestGraphTraversal(t *testing.T) {
// set of channels (to force the fall back), we should find all the
// channel as well as the nodes included.
graph.graphCache = nil
- err := graph.ForEachNodeCached(ctx, false, func(_ context.Context,
- node route.Vertex, _ []net.Addr,
- chans map[uint64]*DirectedChannel) error {
+ err := graph.ForEachNodeCached(ctx, lnwire.GossipVersion1, false,
+ func(_ context.Context, node route.Vertex, _ []net.Addr,
+ chans map[uint64]*DirectedChannel) error {
- if _, ok := nodeIndex[node]; !ok {
- return fmt.Errorf("node %x not found in graph", node)
- }
+ if _, ok := nodeIndex[node]; !ok {
+ return fmt.Errorf("node %x not found in graph",
+ node)
+ }
- for chanID := range chans {
- if _, ok := chanIndex[chanID]; !ok {
- return fmt.Errorf("chan %v not found in "+
- "graph", chanID)
+ for chanID := range chans {
+ if _, ok := chanIndex[chanID]; !ok {
+ return fmt.Errorf(
+ "chan %v not found in graph",
+ chanID,
+ )
+ }
}
- }
- return nil
- }, func() {})
+ return nil
+ }, func() {})
require.NoError(t, err)
// Iterate through all the known channels within the graph DB, once
@@ -2148,7 +2151,7 @@ func assertNumChans(t *testing.T, graph *ChannelGraph, n int) {
func assertNumNodes(t *testing.T, graph *ChannelGraph, n int) {
numNodes := 0
- err := graph.ForEachNode(t.Context(),
+ err := graph.ForEachNode(t.Context(), lnwire.GossipVersion1,
func(_ *models.Node) error {
numNodes++
@@ -4590,7 +4593,7 @@ func putSerializedPolicy(t *testing.T, db kvdb.Backend, from []byte,
func assertNumZombies(t *testing.T, graph *ChannelGraph, expZombies uint64) {
t.Helper()
- numZombies, err := graph.NumZombies(t.Context())
+ numZombies, err := graph.NumZombies(t.Context(), lnwire.GossipVersion1)
require.NoError(t, err, "unable to query number of zombies")
require.Equal(t, expZombies, numZombies)
}
diff --git a/graph/db/interfaces.go b/graph/db/interfaces.go
index 00ced9c..4d643b4 100644
--- a/graph/db/interfaces.go
+++ b/graph/db/interfaces.go
@@ -88,7 +88,8 @@ type Store interface { //nolint:interfacebloat
// the addresses are actually needed.
//
// NOTE: The callback contents MUST not be modified.
- ForEachNodeCached(ctx context.Context, withAddrs bool,
+ ForEachNodeCached(ctx context.Context, v lnwire.GossipVersion,
+ withAddrs bool,
cb func(ctx context.Context, node route.Vertex,
addrs []net.Addr,
chans map[uint64]*DirectedChannel) error,
@@ -98,8 +99,8 @@ type Store interface { //nolint:interfacebloat
// graph, executing the passed callback with each node encountered. If
// the callback returns an error, then the transaction is aborted and
// the iteration stops early.
- ForEachNode(ctx context.Context, cb func(*models.Node) error,
- reset func()) error
+ ForEachNode(ctx context.Context, v lnwire.GossipVersion,
+ cb func(*models.Node) error, reset func()) error
// ForEachNodeCacheable iterates through all the stored vertices/nodes
// in the graph, executing the passed callback with each node
@@ -346,7 +347,7 @@ type Store interface { //nolint:interfacebloat
// NumZombies returns the current number of zombie channels in the
// graph.
- NumZombies(ctx context.Context) (uint64, error)
+ NumZombies(ctx context.Context, v lnwire.GossipVersion) (uint64, error)
// PutClosedScid stores a SCID for a closed channel in the database.
// This is so that we can ignore channel announcements that we know to
diff --git a/graph/db/kv_store.go b/graph/db/kv_store.go
index 8e6ba3b..1a8fd82 100644
--- a/graph/db/kv_store.go
+++ b/graph/db/kv_store.go
@@ -699,10 +699,15 @@ func (c *KVStore) FetchNodeFeatures(_ context.Context, v lnwire.GossipVersion,
// data to the call-back.
//
// NOTE: The callback contents MUST not be modified.
-func (c *KVStore) ForEachNodeCached(ctx context.Context, withAddrs bool,
+func (c *KVStore) ForEachNodeCached(ctx context.Context,
+ v lnwire.GossipVersion, withAddrs bool,
cb func(ctx context.Context, node route.Vertex, addrs []net.Addr,
chans map[uint64]*DirectedChannel) error, reset func()) error {
+ if v != lnwire.GossipVersion1 {
+ return ErrVersionNotSupportedForKVDB
+ }
+
// Otherwise call back to a version that uses the database directly.
// We'll iterate over each node, then the set of channels for each
// node, and construct a similar callback functiopn signature as the
@@ -834,9 +839,13 @@ func (c *KVStore) DisabledChannelIDs(
// early.
//
// NOTE: this is part of the Store interface.
-func (c *KVStore) ForEachNode(_ context.Context,
+func (c *KVStore) ForEachNode(_ context.Context, v lnwire.GossipVersion,
cb func(*models.Node) error, reset func()) error {
+ if v != lnwire.GossipVersion1 {
+ return ErrVersionNotSupportedForKVDB
+ }
+
return forEachNode(c.db, func(tx kvdb.RTx,
node *models.Node) error {
@@ -4469,7 +4478,13 @@ func isZombieEdge(zombieIndex kvdb.RBucket,
}
// NumZombies returns the current number of zombie channels in the graph.
-func (c *KVStore) NumZombies(_ context.Context) (uint64, error) {
+func (c *KVStore) NumZombies(
+ _ context.Context, v lnwire.GossipVersion,
+) (uint64, error) {
+
+ if v != lnwire.GossipVersion1 {
+ return 0, ErrVersionNotSupportedForKVDB
+ }
var numZombies uint64
err := kvdb.View(c.db, func(tx kvdb.RTx) error {
edges := tx.ReadBucket(edgeBucket)
diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go
index c2d8a70..dc59159 100644
--- a/graph/db/sql_store.go
+++ b/graph/db/sql_store.go
@@ -983,13 +983,13 @@ func (s *SQLStore) ForEachSourceNodeChannel(ctx context.Context,
// early.
//
// NOTE: part of the Store interface.
-func (s *SQLStore) ForEachNode(ctx context.Context,
+func (s *SQLStore) ForEachNode(ctx context.Context, v lnwire.GossipVersion,
cb func(node *models.Node) error, reset func()) error {
return s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error {
return forEachNodePaginated(
ctx, s.cfg.QueryCfg, db,
- lnwire.GossipVersion1, func(_ context.Context, _ int64,
+ v, func(_ context.Context, _ int64,
node *models.Node) error {
return cb(node)
@@ -1342,7 +1342,8 @@ func (s *SQLStore) ChanUpdatesInHorizon(ctx context.Context,
// if the addresses are actually needed.
//
// NOTE: part of the Store interface.
-func (s *SQLStore) ForEachNodeCached(ctx context.Context, withAddrs bool,
+func (s *SQLStore) ForEachNodeCached(ctx context.Context,
+ v lnwire.GossipVersion, withAddrs bool,
cb func(ctx context.Context, node route.Vertex, addrs []net.Addr,
chans map[uint64]*DirectedChannel) error, reset func()) error {
@@ -1360,7 +1361,7 @@ func (s *SQLStore) ForEachNodeCached(ctx context.Context, withAddrs bool,
return db.ListNodeIDsAndPubKeys(
ctx, sqlc.ListNodeIDsAndPubKeysParams{
- Version: int16(lnwire.GossipVersion1),
+ Version: int16(v),
ID: lastID,
Limit: limit,
},
@@ -1927,13 +1928,16 @@ func (s *SQLStore) IsZombieEdge(ctx context.Context, v lnwire.GossipVersion,
// NumZombies returns the current number of zombie channels in the graph.
//
// NOTE: part of the Store interface.
-func (s *SQLStore) NumZombies(ctx context.Context) (uint64, error) {
+func (s *SQLStore) NumZombies(
+ ctx context.Context, v lnwire.GossipVersion,
+) (uint64, error) {
+
var (
numZombies uint64
)
err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error {
count, err := db.CountZombieChannels(
- ctx, int16(lnwire.GossipVersion1),
+ ctx, int16(v),
)
if err != nil {
return fmt.Errorf("unable to count zombie channels: %w",
diff --git a/itest/lnd_graph_migration_test.go b/itest/lnd_graph_migration_test.go
index d4a17a4..c8ae229 100644
--- a/itest/lnd_graph_migration_test.go
+++ b/itest/lnd_graph_migration_test.go
@@ -8,6 +8,7 @@ import (
graphdb "github.com/lightningnetwork/lnd/graph/db"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lntest/node"
+ "github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/sqldb"
"github.com/stretchr/testify/require"
@@ -65,22 +66,24 @@ func testGraphMigration(ht *lntest.HarnessTest) {
numNodes int
edges = make(map[uint64]bool)
)
- err := db.ForEachNodeCached(ctx, false, func(_ context.Context,
- _ route.Vertex, _ []net.Addr,
- chans map[uint64]*graphdb.DirectedChannel) error {
-
- numNodes++
-
- // For each node, also count the number of edges.
- for _, ch := range chans {
- edges[ch.ChannelID] = true
- }
-
- return nil
- }, func() {
- clear(edges)
- numNodes = 0
- })
+ err := db.ForEachNodeCached(ctx, lnwire.GossipVersion1, false,
+ func(_ context.Context,
+ _ route.Vertex, _ []net.Addr,
+ chans map[uint64]*graphdb.DirectedChannel,
+ ) error {
+
+ numNodes++
+
+ // For each node, count the number of edges.
+ for _, ch := range chans {
+ edges[ch.ChannelID] = true
+ }
+
+ return nil
+ }, func() {
+ clear(edges)
+ numNodes = 0
+ })
require.NoError(ht, err)
require.Equal(ht, expNumNodes, numNodes)
require.Equal(ht, expNumChans, len(edges))
diff --git a/pilot.go b/pilot.go
index 8cbf23c..ff9173f 100644
--- a/pilot.go
+++ b/pilot.go
@@ -185,7 +185,7 @@ func initAutoPilot(svr *server, cfg *lncfg.AutoPilot,
cfg.MinConfs, lnwallet.DefaultAccountName,
)
},
- Graph: autopilot.ChannelGraphFromDatabase(svr.graphDB),
+ Graph: autopilot.ChannelGraphFromDatabase(svr.v1Graph),
Constraints: atplConstraints,
ConnectToPeer: func(target *btcec.PublicKey, addrs []net.Addr) (bool, error) {
// First, we'll check if we're already connected to the
diff --git a/rpcserver.go b/rpcserver.go
index 0b994a2..de28c47 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -7089,10 +7089,11 @@ func (r *rpcServer) GetNodeMetrics(ctx context.Context,
BetweennessCentrality: make(map[string]*lnrpc.FloatMetric),
}
- // Obtain the pointer to the global singleton channel graph, this will
- // provide a consistent view of the graph due to bolt db's
- // transactional model.
- graph := r.server.graphDB
+ // Obtain the pointer to the V1 channel graph, this will provide a
+ // consistent view of the graph due to bolt db's transactional model.
+ //
+ // TODO(elle): switch to a cross-version graph view when available.
+ graph := r.server.v1Graph
// Calculate betweenness centrality if requested. Note that depending on the
// graph size, this may take up to a few minutes.
@@ -7306,7 +7307,8 @@ func (r *rpcServer) QueryRoutes(ctx context.Context,
func (r *rpcServer) GetNetworkInfo(ctx context.Context,
_ *lnrpc.NetworkInfoRequest) (*lnrpc.NetworkInfo, error) {
- graph := r.server.graphDB
+ // TODO(elle): switch to a cross-version graph view when available.
+ graph := r.server.v1Graph
var (
numNodes uint32
diff --git a/server.go b/server.go
index d4972a3..0e7fe48 100644
--- a/server.go
+++ b/server.go
@@ -2991,7 +2991,7 @@ func initNetworkBootstrappers(s *server) ([]discovery.NetworkPeerBootstrapper, e
// First, we'll create an instance of the ChannelGraphBootstrapper as
// this can be used by default if we've already partially seeded the
// network.
- chanGraph := autopilot.ChannelGraphFromDatabase(s.graphDB)
+ chanGraph := autopilot.ChannelGraphFromDatabase(s.v1Graph)
graphBootstrapper, err := discovery.NewGraphBootstrapper(
chanGraph, s.cfg.Bitcoin.IsLocalNetwork(),
)
Why this scored 22/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.