graph/db: add v2 block-height path for NodeUpdatesInHorizon
What changed, and why it matters
This commit adds a new database query path for a planned v2 gossip protocol feature in LND. It is purely functional code that lets the node list other nodes by block-height range, similar to an existing time-based query. There is no indication this fixes a security bug or introduces a vulnerability; it appears to be a normal feature implementation.
No security action required; review as ordinary feature code.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change implements GetNodesByBlockHeightRange and wires it into SQLStore.nodeUpdatesInHorizonV2, replacing a previous ‘not yet implemented’ stub. The query filters graph_nodes by gossip version, block-height range, compound cursor pagination, and an optional public-node filter that checks for a non-empty channel announcement signature in graph_channels. A corresponding unit test verifies inclusive start/exclusive end semantics and ordering. A trivial log-message string concatenation fix is also included.
Changed components
graph/db/sql_store.gosqldb/sqlc/graph.sql.gosqldb/sqlc/querier.gosqldb/sqlc/queries/graph.sqlgraph/db/graph_test.goInspect captured patch +361 / −6
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index 2d7a0c9..e197c1a 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -3077,6 +3077,143 @@ func TestNodeUpdatesInHorizonExclusiveEnd(t *testing.T) {
}
}
+// TestNodeUpdatesInHorizonV2 tests that NodeUpdatesInHorizon works correctly
+// for v2 gossip using block-height-based ranges with [start, end) semantics.
+func TestNodeUpdatesInHorizonV2(t *testing.T) {
+ t.Parallel()
+
+ if !isSQLDB {
+ t.Skip("v2 gossip only supported with SQL backend")
+ }
+
+ ctx := t.Context()
+
+ graph := NewVersionedGraph(
+ MakeTestGraph(t), lnwire.GossipVersion2,
+ )
+
+ // Query before any nodes exist — should return empty.
+ iter := graph.NodeUpdatesInHorizon(
+ ctx, NodeUpdateRange{
+ StartHeight: fn.Some(uint32(0)),
+ EndHeight: fn.Some(uint32(9999)),
+ },
+ )
+ nodes, err := fn.CollectErr(iter)
+ require.NoError(t, err)
+ require.Empty(t, nodes)
+
+ // Create 10 v2 nodes at block heights 100, 110, 120, ..., 190.
+ const numNodes = 10
+ const startHeight uint32 = 100
+ const heightStep uint32 = 10
+
+ nodeAnns := make([]models.Node, 0, numNodes)
+ for i := 0; i < numNodes; i++ {
+ node := createTestVertex(t, lnwire.GossipVersion2)
+ node.LastBlockHeight = startHeight + uint32(i)*heightStep
+ nodeAnns = append(nodeAnns, *node)
+ require.NoError(t, graph.AddNode(ctx, node))
+ }
+
+ // endHeight is one past the last node's height (exclusive).
+ endHeight := startHeight + uint32(numNodes)*heightStep
+
+ tests := []struct {
+ name string
+ start uint32
+ end uint32
+ want int
+ }{
+ {
+ // Range strictly below all nodes.
+ name: "below range",
+ start: 0,
+ end: 50,
+ want: 0,
+ },
+ {
+ // Range strictly above all nodes.
+ name: "above range",
+ start: 500,
+ end: 600,
+ want: 0,
+ },
+ {
+ // Start is inclusive: node at exactly startHeight
+ // should be included.
+ name: "start height is inclusive",
+ start: startHeight,
+ end: startHeight + 1,
+ want: 1,
+ },
+ {
+ // End is exclusive: node at exactly endHeight-10
+ // (=190) should NOT be included when end=190.
+ name: "end height is exclusive",
+ start: startHeight,
+ end: endHeight - heightStep,
+ want: numNodes - 1,
+ },
+ {
+ // One past the last node includes it.
+ name: "one past end includes last",
+ start: startHeight,
+ end: endHeight - heightStep + 1,
+ want: numNodes,
+ },
+ {
+ // Full range returns all nodes.
+ name: "full range",
+ start: startHeight,
+ end: endHeight,
+ want: numNodes,
+ },
+ {
+ // Skip the first node.
+ name: "skip first",
+ start: startHeight + heightStep,
+ end: endHeight,
+ want: numNodes - 1,
+ },
+ {
+ // Middle slice: heights [120, 170) = nodes at
+ // 120, 130, 140, 150, 160 = 5 nodes.
+ name: "middle slice",
+ start: 120,
+ end: 170,
+ want: 5,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ iter := graph.NodeUpdatesInHorizon(
+ ctx, NodeUpdateRange{
+ StartHeight: fn.Some(tc.start),
+ EndHeight: fn.Some(tc.end),
+ },
+ )
+
+ results, err := fn.CollectErr(iter)
+ require.NoError(t, err)
+ require.Len(t, results, tc.want)
+
+ // Verify nodes are in ascending block height
+ // order.
+ for i := 1; i < len(results); i++ {
+ require.LessOrEqual(
+ t,
+ results[i-1].LastBlockHeight,
+ results[i].LastBlockHeight,
+ "nodes should be in ascending "+
+ "block height order",
+ )
+ }
+ })
+ }
+}
+
// TestChanUpdatesInHorizonExclusiveEnd verifies that ChanUpdatesInHorizon uses
// an exclusive end time per BOLT 07: "timestamp is greater or equal to
// first_timestamp, and less than first_timestamp plus timestamp_range".
diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go
index 4d3f635..008d32a 100644
--- a/graph/db/sql_store.go
+++ b/graph/db/sql_store.go
@@ -52,6 +52,7 @@ type SQLQueries interface {
GetNodesByIDs(ctx context.Context, ids []int64) ([]sqlc.GraphNode, error)
GetNodeIDByPubKey(ctx context.Context, arg sqlc.GetNodeIDByPubKeyParams) (int64, error)
GetNodesByLastUpdateRange(ctx context.Context, arg sqlc.GetNodesByLastUpdateRangeParams) ([]sqlc.GraphNode, error)
+ GetNodesByBlockHeightRange(ctx context.Context, arg sqlc.GetNodesByBlockHeightRangeParams) ([]sqlc.GraphNode, error)
ListNodesPaginated(ctx context.Context, arg sqlc.ListNodesPaginatedParams) ([]sqlc.GraphNode, error)
ListNodeIDsAndPubKeys(ctx context.Context, arg sqlc.ListNodeIDsAndPubKeysParams) ([]sqlc.ListNodeIDsAndPubKeysRow, error)
IsPublicV1Node(ctx context.Context, pubKey []byte) (bool, error)
@@ -632,11 +633,7 @@ func (s *SQLStore) NodeUpdatesInHorizon(ctx context.Context,
return s.nodeUpdatesInHorizonV1(ctx, r, cfg)
case gossipV2:
- err := fmt.Errorf("v2 node updates in horizon not yet " +
- "implemented")
- return func(yield func(*models.Node, error) bool) {
- _ = yield(nil, err)
- }
+ return s.nodeUpdatesInHorizonV2(ctx, r, cfg)
default:
err := fmt.Errorf("unknown gossip version: %v", v)
@@ -746,6 +743,111 @@ func (s *SQLStore) nodeUpdatesInHorizonV1(ctx context.Context,
}
}
+// nodeUpdatesInHorizonV2 implements the v2 block-height-based node horizon
+// query.
+func (s *SQLStore) nodeUpdatesInHorizonV2(ctx context.Context,
+ r NodeUpdateRange,
+ cfg *iterConfig) iter.Seq2[*models.Node, error] {
+
+ startHeight := int64(r.StartHeight.UnwrapOr(0))
+ endHeight := int64(r.EndHeight.UnwrapOr(0))
+ batchSize := cfg.nodeUpdateIterBatchSize
+
+ return func(yield func(*models.Node, error) bool) {
+ var (
+ lastBlock sql.NullInt64
+ lastPubKey = make([]byte, 33)
+ hasMore = true
+ )
+
+ // queryNodes fetches the next page of v2 nodes in the
+ // block-height range.
+ queryNodes := func(db SQLQueries) ([]sqlc.GraphNode, error) {
+ return db.GetNodesByBlockHeightRange(
+ ctx, sqlc.GetNodesByBlockHeightRangeParams{
+ Version: int16(gossipV2),
+ StartHeight: sqldb.SQLInt64(
+ startHeight,
+ ),
+ EndHeight: sqldb.SQLInt64(
+ endHeight,
+ ),
+ LastBlockHeight: lastBlock,
+ LastPubKey: lastPubKey,
+ OnlyPublic: sql.NullBool{
+ Bool: cfg.iterPublicNodes,
+ Valid: true,
+ },
+ MaxResults: sqldb.SQLInt32(batchSize),
+ },
+ )
+ }
+
+ // processNode accumulates a node into the batch and
+ // advances the pagination cursors.
+ processNode := func(node *models.Node,
+ batch *[]*models.Node) error {
+
+ *batch = append(*batch, node)
+
+ lastBlock = sql.NullInt64{
+ Int64: int64(node.LastBlockHeight),
+ Valid: true,
+ }
+ lastPubKey = node.PubKeyBytes[:]
+
+ return nil
+ }
+
+ for hasMore {
+ var batch []*models.Node
+
+ err := s.db.ExecTx(
+ ctx, sqldb.ReadTxOpt(),
+ func(db SQLQueries) error {
+ rows, err := queryNodes(db)
+ if err != nil {
+ return err
+ }
+
+ hasMore = len(rows) == batchSize
+
+ return forEachNodeInBatch(
+ ctx, s.cfg.QueryCfg, db,
+ rows, func(_ int64,
+ n *models.Node) error {
+
+ return processNode(
+ n, &batch,
+ )
+ },
+ )
+ }, func() {
+ batch = nil
+ },
+ )
+ if err != nil {
+ log.Errorf("NodeUpdatesInHorizon(v2) "+
+ "batch error: %v", err)
+
+ yield(nil, err)
+
+ return
+ }
+
+ for _, node := range batch {
+ if !yield(node, nil) {
+ return
+ }
+ }
+
+ if len(batch) == 0 {
+ break
+ }
+ }
+ }
+}
+
// AddChannelEdge adds a new (undirected, blank) edge to the graph database. An
// undirected edge from the two target nodes are created. The information stored
// denotes the static attributes of the channel, such as the channelID, the keys
@@ -1387,7 +1489,7 @@ func (s *SQLStore) chanUpdatesInHorizonV1(ctx context.Context,
"percentage: %.2f (%d/%d)",
float64(hits)*100/float64(total), hits, total)
} else {
- log.Debugf("ChanUpdatesInHorizon(v1) returned no "+
+ log.Debugf("ChanUpdatesInHorizon(v1) returned no " +
"edges in horizon")
}
}
diff --git a/sqldb/sqlc/graph.sql.go b/sqldb/sqlc/graph.sql.go
index 52d47ea..6ba4113 100644
--- a/sqldb/sqlc/graph.sql.go
+++ b/sqldb/sqlc/graph.sql.go
@@ -2070,6 +2070,89 @@ func (q *Queries) GetNodeIDByPubKey(ctx context.Context, arg GetNodeIDByPubKeyPa
return id, err
}
+const getNodesByBlockHeightRange = `-- name: GetNodesByBlockHeightRange :many
+SELECT id, version, pub_key, alias, last_update, color, signature, block_height
+FROM graph_nodes
+WHERE graph_nodes.version = $1
+ AND block_height >= $2
+ AND block_height < $3
+ -- Pagination: We use (block_height, pub_key) as a compound cursor.
+ -- This ensures stable ordering and allows us to resume from where we left off.
+ -- We use COALESCE with -1 as sentinel since block heights are always positive.
+ AND (
+ block_height > COALESCE($4, -1)
+ OR
+ (block_height = COALESCE($4, -1)
+ AND pub_key > $5)
+ )
+ -- Optional filter for public nodes only.
+ AND (
+ COALESCE($6, FALSE) IS FALSE
+ OR
+ -- For V2 protocol, a node is public if it has at least one announced
+ -- v2 channel (indicated by a non-empty channel announcement signature).
+ EXISTS (
+ SELECT 1
+ FROM graph_channels c
+ WHERE c.version = graph_nodes.version
+ AND COALESCE(length(c.signature), 0) > 0
+ AND (c.node_id_1 = graph_nodes.id OR c.node_id_2 = graph_nodes.id)
+ )
+ )
+ORDER BY block_height ASC, pub_key ASC
+LIMIT COALESCE($7, 999999999)
+`
+
+type GetNodesByBlockHeightRangeParams struct {
+ Version int16
+ StartHeight sql.NullInt64
+ EndHeight sql.NullInt64
+ LastBlockHeight sql.NullInt64
+ LastPubKey []byte
+ OnlyPublic interface{}
+ MaxResults interface{}
+}
+
+func (q *Queries) GetNodesByBlockHeightRange(ctx context.Context, arg GetNodesByBlockHeightRangeParams) ([]GraphNode, error) {
+ rows, err := q.db.QueryContext(ctx, getNodesByBlockHeightRange,
+ arg.Version,
+ arg.StartHeight,
+ arg.EndHeight,
+ arg.LastBlockHeight,
+ arg.LastPubKey,
+ arg.OnlyPublic,
+ arg.MaxResults,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GraphNode
+ for rows.Next() {
+ var i GraphNode
+ if err := rows.Scan(
+ &i.ID,
+ &i.Version,
+ &i.PubKey,
+ &i.Alias,
+ &i.LastUpdate,
+ &i.Color,
+ &i.Signature,
+ &i.BlockHeight,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getNodesByIDs = `-- name: GetNodesByIDs :many
SELECT id, version, pub_key, alias, last_update, color, signature, block_height
FROM graph_nodes
diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go
index d2481be..a2359ee 100644
--- a/sqldb/sqlc/querier.go
+++ b/sqldb/sqlc/querier.go
@@ -128,6 +128,7 @@ type Querier interface {
GetNodeFeaturesBatch(ctx context.Context, ids []int64) ([]GraphNodeFeature, error)
GetNodeFeaturesByPubKey(ctx context.Context, arg GetNodeFeaturesByPubKeyParams) ([]int32, error)
GetNodeIDByPubKey(ctx context.Context, arg GetNodeIDByPubKeyParams) (int64, error)
+ GetNodesByBlockHeightRange(ctx context.Context, arg GetNodesByBlockHeightRangeParams) ([]GraphNode, error)
GetNodesByIDs(ctx context.Context, ids []int64) ([]GraphNode, error)
GetNodesByLastUpdateRange(ctx context.Context, arg GetNodesByLastUpdateRangeParams) ([]GraphNode, error)
GetPruneEntriesForHeights(ctx context.Context, heights []int64) ([]GraphPruneLog, error)
diff --git a/sqldb/sqlc/queries/graph.sql b/sqldb/sqlc/queries/graph.sql
index e77c90c..b045b42 100644
--- a/sqldb/sqlc/queries/graph.sql
+++ b/sqldb/sqlc/queries/graph.sql
@@ -258,6 +258,38 @@ WHERE last_update >= @start_time
ORDER BY last_update ASC, pub_key ASC
LIMIT COALESCE(sqlc.narg('max_results'), 999999999);
+-- name: GetNodesByBlockHeightRange :many
+SELECT *
+FROM graph_nodes
+WHERE graph_nodes.version = @version
+ AND block_height >= @start_height
+ AND block_height < @end_height
+ -- Pagination: We use (block_height, pub_key) as a compound cursor.
+ -- This ensures stable ordering and allows us to resume from where we left off.
+ -- We use COALESCE with -1 as sentinel since block heights are always positive.
+ AND (
+ block_height > COALESCE(sqlc.narg('last_block_height'), -1)
+ OR
+ (block_height = COALESCE(sqlc.narg('last_block_height'), -1)
+ AND pub_key > sqlc.narg('last_pub_key'))
+ )
+ -- Optional filter for public nodes only.
+ AND (
+ COALESCE(sqlc.narg('only_public'), FALSE) IS FALSE
+ OR
+ -- For V2 protocol, a node is public if it has at least one announced
+ -- v2 channel (indicated by a non-empty channel announcement signature).
+ EXISTS (
+ SELECT 1
+ FROM graph_channels c
+ WHERE c.version = graph_nodes.version
+ AND COALESCE(length(c.signature), 0) > 0
+ AND (c.node_id_1 = graph_nodes.id OR c.node_id_2 = graph_nodes.id)
+ )
+ )
+ORDER BY block_height ASC, pub_key ASC
+LIMIT COALESCE(sqlc.narg('max_results'), 999999999);
+
-- name: DeleteNodeAddresses :exec
DELETE FROM graph_node_addresses
WHERE node_id = $1;
Why this scored 15/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.