graph/db: use exclusive end time for horizon queries per BOLT 07
What changed, and why it matters
This commit fixes a boundary-condition bug in how LND answers peer queries about which Lightning network nodes and channels have recently updated. The code was including updates that happened exactly at the end of the requested time window, but the Lightning protocol (BOLT 07) says the end time should be excluded. The fix makes three database query paths match the spec and adds tests to prevent regression. It is a protocol-compliance bug, not a direct funds-loss vulnerability, but it could cause a node to send or accept one extra gossip update per query window.
Treat as a low-severity protocol-compliance fix. Merge after review. Operators should upgrade to keep gossip behavior spec-compliant and avoid edge-case inconsistencies with peers, but no urgent mitigation is required.
Security signals we found
Protocol compliance fix for BOLT 07 gossip_timestamp_filter range semantics
Off-by-one boundary error in time-range queries
Inconsistent behavior between KV and SQL storage backends
Potential for duplicate or unexpected gossip message inclusion at range boundary
No input validation bypass, memory corruption, or cryptographic flaw present
Evidence from the diff
The patch changes ChanUpdatesInHorizon and NodeUpdatesInHorizon implementations from inclusive end-time semantics (<=) to exclusive end-time semantics (<) as required by BOLT 07’s gossip_timestamp_filter definition [start, start+range). It updates the KV store iteration break and hasMore checks in fetchNextChanUpdateBatch and fetchNextNodeBatch, the SQL query GetNodesByLastUpdateRange, and all related godocs. New unit tests verify boundary behavior. The SQL channel path was already correct and is untouched.
Changed components
graph/db/kv_store.gograph/db/sql_store.gosqldb/sqlc/graph.sql.gosqldb/sqlc/queries/graph.sqlgraph/db/graph.gograph/db/interfaces.goInspect captured patch +221 / −34
diff --git a/graph/db/graph.go b/graph/db/graph.go
index 507312f..6b29bfc 100644
--- a/graph/db/graph.go
+++ b/graph/db/graph.go
@@ -721,8 +721,8 @@ func (c *ChannelGraph) ForEachNodeCacheable(ctx context.Context,
return c.db.ForEachNodeCacheable(ctx, v, cb, reset)
}
-// NodeUpdatesInHorizon returns all known lightning nodes with updates in the
-// range.
+// NodeUpdatesInHorizon returns all known lightning nodes with an update
+// timestamp in [startTime, endTime) per BOLT 07.
func (c *ChannelGraph) NodeUpdatesInHorizon(ctx context.Context,
startTime, endTime time.Time,
opts ...IteratorOption) iter.Seq2[*models.Node, error] {
@@ -783,8 +783,8 @@ func (c *ChannelGraph) HighestChanID(ctx context.Context,
return c.db.HighestChanID(ctx, v)
}
-// ChanUpdatesInHorizon returns all known channel edges with updates in the
-// horizon.
+// ChanUpdatesInHorizon returns all known channel edges with at least one
+// policy update timestamp in [startTime, endTime) per BOLT 07.
func (c *ChannelGraph) ChanUpdatesInHorizon(ctx context.Context,
startTime, endTime time.Time,
opts ...IteratorOption) iter.Seq2[ChannelEdge, error] {
@@ -944,8 +944,8 @@ 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
-// timestamp within the passed range.
+// NodeUpdatesInHorizon returns all known lightning nodes with an update
+// timestamp in [startTime, endTime) per BOLT 07.
func (c *VersionedGraph) NodeUpdatesInHorizon(ctx context.Context,
startTime, endTime time.Time,
opts ...IteratorOption) iter.Seq2[*models.Node, error] {
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index acd548d..7f1d3b4 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -2708,12 +2708,15 @@ func testNodeUpdatesWithBatchSize(t *testing.T, ctx context.Context,
end: startTime.Add(26 * time.Hour),
want: 25,
},
+ // The end time is exclusive per BOLT 07, so we
+ // add one extra hour to include the last node in
+ // the desired range.
{
name: "first batch only",
start: startTime,
end: startTime.Add(
time.Duration(
- min(batchSize, 25)-1,
+ min(batchSize, 25),
) * time.Hour,
),
want: min(batchSize, 25),
@@ -2723,7 +2726,7 @@ func testNodeUpdatesWithBatchSize(t *testing.T, ctx context.Context,
start: startTime,
end: startTime.Add(
time.Duration(
- min(batchSize, 24),
+ min(batchSize+1, 25),
) * time.Hour,
),
want: min(batchSize+1, 25),
@@ -2748,16 +2751,19 @@ func testNodeUpdatesWithBatchSize(t *testing.T, ctx context.Context,
)
}(),
end: func() time.Time {
+ // End is exclusive, so we add
+ // one hour to include the node
+ // at exactly the start time.
if batchSize <= 25 {
return startTime.Add(
time.Duration(
- batchSize-1,
+ batchSize,
) * time.Hour,
)
}
return startTime.Add(
- time.Duration(25) * time.Hour,
+ time.Duration(26) * time.Hour,
)
}(),
want: func() int {
@@ -2964,6 +2970,177 @@ func TestChanUpdatesInHorizonBoundaryConditions(t *testing.T) {
}
}
+// TestNodeUpdatesInHorizonExclusiveEnd verifies that NodeUpdatesInHorizon uses
+// an exclusive end time per BOLT 07: "timestamp is greater or equal to
+// first_timestamp, and less than first_timestamp plus timestamp_range".
+func TestNodeUpdatesInHorizonExclusiveEnd(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+
+ graph := MakeTestGraph(t)
+
+ // Create three nodes at timestamps 100, 200, and 300.
+ timestamps := []int64{100, 200, 300}
+ for _, ts := range timestamps {
+ node := createTestVertex(t, lnwire.GossipVersion1)
+ node.LastUpdate = time.Unix(ts, 0)
+ require.NoError(t, graph.AddNode(ctx, node))
+ }
+
+ tests := []struct {
+ name string
+ start time.Time
+ end time.Time
+ want int
+ }{
+ {
+ // Start is inclusive: node at exactly startTime
+ // should be included.
+ name: "start time is inclusive",
+ start: time.Unix(100, 0),
+ end: time.Unix(101, 0),
+ want: 1,
+ },
+ {
+ // End is exclusive: node at exactly endTime should
+ // NOT be included.
+ name: "end time is exclusive",
+ start: time.Unix(100, 0),
+ end: time.Unix(200, 0),
+ want: 1,
+ },
+ {
+ // One second past the boundary includes the node.
+ name: "one past end includes boundary node",
+ start: time.Unix(100, 0),
+ end: time.Unix(201, 0),
+ want: 2,
+ },
+ {
+ // Range [200, 300) should include node at 200 but
+ // not node at 300.
+ name: "mid range excludes end",
+ start: time.Unix(200, 0),
+ end: time.Unix(300, 0),
+ want: 1,
+ },
+ {
+ // Range [200, 301) should include nodes at 200
+ // and 300.
+ name: "mid range includes end plus one",
+ start: time.Unix(200, 0),
+ end: time.Unix(301, 0),
+ want: 2,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ iter := graph.NodeUpdatesInHorizon(
+ ctx, tc.start, tc.end,
+ )
+
+ nodes, err := fn.CollectErr(iter)
+ require.NoError(t, err)
+ require.Len(t, nodes, tc.want)
+ })
+ }
+}
+
+// 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".
+func TestChanUpdatesInHorizonExclusiveEnd(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+
+ graph := MakeTestGraph(t)
+
+ node1 := createTestVertex(t, lnwire.GossipVersion1)
+ node2 := createTestVertex(t, lnwire.GossipVersion1)
+ require.NoError(t, graph.AddNode(ctx, node1))
+ require.NoError(t, graph.AddNode(ctx, node2))
+
+ // Create three channels with policy updates at timestamps 100, 200,
+ // and 300.
+ timestamps := []int64{100, 200, 300}
+ for i, ts := range timestamps {
+ channel, chanID := createEdge(
+ lnwire.GossipVersion1, uint32(i*10), 0, 0, 0,
+ node1, node2,
+ )
+ require.NoError(t, graph.AddChannelEdge(ctx, channel))
+
+ edge := newEdgePolicy(
+ lnwire.GossipVersion1, chanID.ToUint64(), ts, true,
+ )
+ edge.ChannelFlags = 0
+ edge.ToNode = node2.PubKeyBytes
+ edge.SigBytes = testSig.Serialize()
+ require.NoError(t, graph.UpdateEdgePolicy(ctx, edge))
+ }
+
+ tests := []struct {
+ name string
+ start time.Time
+ end time.Time
+ want int
+ }{
+ {
+ // Start is inclusive: channel at exactly startTime
+ // should be included.
+ name: "start time is inclusive",
+ start: time.Unix(100, 0),
+ end: time.Unix(101, 0),
+ want: 1,
+ },
+ {
+ // End is exclusive: channel at exactly endTime
+ // should NOT be included.
+ name: "end time is exclusive",
+ start: time.Unix(100, 0),
+ end: time.Unix(200, 0),
+ want: 1,
+ },
+ {
+ // One second past the boundary includes the
+ // channel.
+ name: "one past end includes boundary channel",
+ start: time.Unix(100, 0),
+ end: time.Unix(201, 0),
+ want: 2,
+ },
+ {
+ // Range [200, 300) should include channel at 200
+ // but not channel at 300.
+ name: "mid range excludes end",
+ start: time.Unix(200, 0),
+ end: time.Unix(300, 0),
+ want: 1,
+ },
+ {
+ // Range [200, 301) should include channels at 200
+ // and 300.
+ name: "mid range includes end plus one",
+ start: time.Unix(200, 0),
+ end: time.Unix(301, 0),
+ want: 2,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ iter := graph.ChanUpdatesInHorizon(
+ ctx, tc.start, tc.end,
+ )
+
+ channels, err := fn.CollectErr(iter)
+ require.NoError(t, err)
+ require.Len(t, channels, tc.want)
+ })
+ }
+}
+
// 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
diff --git a/graph/db/interfaces.go b/graph/db/interfaces.go
index a725cb8..2c9d5f5 100644
--- a/graph/db/interfaces.go
+++ b/graph/db/interfaces.go
@@ -120,10 +120,11 @@ type Store interface { //nolint:interfacebloat
DeleteNode(ctx context.Context, v lnwire.GossipVersion,
nodePub route.Vertex) error
- // NodeUpdatesInHorizon returns all the known lightning node which have
- // an update timestamp within the passed range. This method can be used
- // by two nodes to quickly determine if they have the same set of up to
- // date node announcements.
+ // NodeUpdatesInHorizon returns all the known lightning nodes which have
+ // an update timestamp greater than or equal to startTime and less than
+ // endTime, i.e. the range [startTime, endTime) per BOLT 07. This
+ // method can be used by two nodes to quickly determine if they have
+ // the same set of up to date node announcements.
NodeUpdatesInHorizon(ctx context.Context, startTime, endTime time.Time,
opts ...IteratorOption) iter.Seq2[*models.Node, error]
@@ -256,8 +257,9 @@ type Store interface { //nolint:interfacebloat
uint64, error)
// ChanUpdatesInHorizon returns all the known channel edges which have
- // at least one edge that has an update timestamp within the specified
- // horizon.
+ // at least one edge that has an update timestamp greater than or equal
+ // to startTime and less than endTime, i.e. the range
+ // [startTime, endTime) per BOLT 07.
ChanUpdatesInHorizon(ctx context.Context,
startTime, endTime time.Time,
opts ...IteratorOption) iter.Seq2[ChannelEdge, error]
diff --git a/graph/db/kv_store.go b/graph/db/kv_store.go
index aa32f75..951c879 100644
--- a/graph/db/kv_store.go
+++ b/graph/db/kv_store.go
@@ -2286,8 +2286,9 @@ func (c *KVStore) fetchNextChanUpdateBatch(
// Now we'll read items up to the batch size, exiting early if
// we exceed the ending time.
for len(batch) < state.batchSize && indexKey != nil {
- // If we're at the end, then we'll break out now.
- if bytes.Compare(indexKey, endTimeBytes[:]) > 0 {
+ // If we've reached or passed the end time, break
+ // out. Per BOLT 07, the end time is exclusive.
+ if bytes.Compare(indexKey, endTimeBytes[:]) >= 0 {
break
}
@@ -2373,10 +2374,11 @@ func (c *KVStore) fetchNextChanUpdateBatch(
indexKey, _ = updateCursor.Next()
}
- // If we haven't yet crossed the endTimeBytes, then we still
- // have more entries to deliver.
+ // If we haven't yet reached the endTimeBytes, then we still
+ // have more entries to deliver. The end time is exclusive
+ // per BOLT 07.
if indexKey != nil &&
- bytes.Compare(indexKey, endTimeBytes[:]) <= 0 {
+ bytes.Compare(indexKey, endTimeBytes[:]) < 0 {
hasMore = true
}
@@ -2394,7 +2396,8 @@ func (c *KVStore) fetchNextChanUpdateBatch(
}
// ChanUpdatesInHorizon returns all the known channel edges which have at least
-// one edge that has an update timestamp within the specified horizon.
+// one edge that has an update timestamp greater than or equal to startTime and
+// less than endTime, i.e. the range [startTime, endTime) per BOLT 07.
func (c *KVStore) ChanUpdatesInHorizon(_ context.Context,
startTime, endTime time.Time,
opts ...IteratorOption) iter.Seq2[ChannelEdge, error] {
@@ -2568,9 +2571,10 @@ func (c *KVStore) fetchNextNodeBatch(
// Extract the timestamp from the index key (first 8
// bytes). Only compare timestamps, not the full key
// with pubkey.
+ // The end time is exclusive per BOLT 07.
keyTimestamp := byteOrder.Uint64(indexKey[:8])
endTimestamp := uint64(state.endTime.Unix())
- if keyTimestamp > endTimestamp {
+ if keyTimestamp >= endTimestamp {
break
}
@@ -2607,12 +2611,13 @@ func (c *KVStore) fetchNextNodeBatch(
indexKey, _ = updateCursor.Next()
}
- // If we haven't yet crossed the endTime, then we still
- // have more entries to deliver.
+ // If we haven't yet reached the endTime, then we still
+ // have more entries to deliver. The end time is exclusive
+ // per BOLT 07.
if indexKey != nil {
keyTimestamp := byteOrder.Uint64(indexKey[:8])
endTimestamp := uint64(state.endTime.Unix())
- if keyTimestamp <= endTimestamp {
+ if keyTimestamp < endTimestamp {
hasMore = true
}
}
@@ -2644,8 +2649,9 @@ func (c *KVStore) fetchNextNodeBatch(
return nodeBatch, hasMore, nil
}
-// NodeUpdatesInHorizon returns all the known lightning node which have an
-// update timestamp within the passed range.
+// NodeUpdatesInHorizon returns all the known lightning nodes which have an
+// update timestamp greater than or equal to startTime and less than endTime,
+// i.e. the range [startTime, endTime) per BOLT 07.
func (c *KVStore) NodeUpdatesInHorizon(_ context.Context, startTime,
endTime time.Time,
opts ...IteratorOption) iter.Seq2[*models.Node, error] {
diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go
index ff1170a..a16fbf9 100644
--- a/graph/db/sql_store.go
+++ b/graph/db/sql_store.go
@@ -606,9 +606,10 @@ func (s *SQLStore) SetSourceNode(ctx context.Context,
}, sqldb.NoOpReset)
}
-// NodeUpdatesInHorizon returns all the known lightning node which have an
-// update timestamp within the passed range. This method can be used by two
-// nodes to quickly determine if they have the same set of up to date node
+// NodeUpdatesInHorizon returns all the known lightning nodes which have an
+// update timestamp greater than or equal to startTime and less than endTime,
+// i.e. the range [startTime, endTime) per BOLT 07. This method can be used by
+// two nodes to quickly determine if they have the same set of up to date node
// announcements.
//
// NOTE: This is part of the Store interface.
@@ -1161,7 +1162,8 @@ func (s *SQLStore) updateChanCacheBatch(v lnwire.GossipVersion,
}
// ChanUpdatesInHorizon returns all the known channel edges which have at least
-// one edge that has an update timestamp within the specified horizon.
+// one edge that has an update timestamp greater than or equal to startTime and
+// less than endTime, i.e. the range [startTime, endTime) per BOLT 07.
//
// Iterator Lifecycle:
// 1. Initialize state (edgesSeen map, cache tracking, pagination cursors)
diff --git a/sqldb/sqlc/graph.sql.go b/sqldb/sqlc/graph.sql.go
index dc0a064..52d47ea 100644
--- a/sqldb/sqlc/graph.sql.go
+++ b/sqldb/sqlc/graph.sql.go
@@ -2122,7 +2122,7 @@ const getNodesByLastUpdateRange = `-- name: GetNodesByLastUpdateRange :many
SELECT id, version, pub_key, alias, last_update, color, signature, block_height
FROM graph_nodes
WHERE last_update >= $1
- AND last_update <= $2
+ AND last_update < $2
-- Pagination: We use (last_update, 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 timestamps are always positive.
diff --git a/sqldb/sqlc/queries/graph.sql b/sqldb/sqlc/queries/graph.sql
index 78c1ebe..e77c90c 100644
--- a/sqldb/sqlc/queries/graph.sql
+++ b/sqldb/sqlc/queries/graph.sql
@@ -228,7 +228,7 @@ ORDER BY node_id, type, position;
SELECT *
FROM graph_nodes
WHERE last_update >= @start_time
- AND last_update <= @end_time
+ AND last_update < @end_time
-- Pagination: We use (last_update, 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 timestamps are always positive.
Why this scored 40/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.