graph/db: add ChanUpdateRange and NodeUpdateRange types
What changed, and why it matters
This commit adds new internal data types (ChanUpdateRange and NodeUpdateRange) and validation logic for upcoming gossip protocol features. It does not change any existing behavior or fix a security issue; it is purely preparatory code with tests.
No security action needed. Treat as normal feature/refactoring commit. Review the follow-up commits that integrate these range types into NodeUpdatesInHorizon and ChanUpdatesInHorizon for potential security implications.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch introduces version-aware range types for channel and node update horizon queries in LND’s graph database. V1 gossip uses Unix timestamps, while v2 gossip uses block heights. The new types validate that callers supply the correct bound type for the requested gossip version and reject mixed or inverted ranges. The commit includes unit tests but does not wire the types into any store methods yet; that is noted as follow-up work.
Changed components
graph/db/options.gograph/db/graph_test.goInspect captured patch +395 / −1
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index 7f1d3b4..36d494e 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -6032,3 +6032,207 @@ func TestLightningNodePersistence(t *testing.T) {
require.Equal(t, nodeAnnBytes, b.Bytes())
}
+
+// TestUpdateRangeValidateForVersion verifies that ChanUpdateRange and
+// NodeUpdateRange reject invalid field combinations for each gossip version.
+func TestUpdateRangeValidateForVersion(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now()
+
+ tests := []struct {
+ name string
+ fn func() error
+ wantErr string
+ }{
+ {
+ name: "v1 chan range with time - ok",
+ fn: func() error {
+ r := ChanUpdateRange{
+ StartTime: fn.Some(now),
+ EndTime: fn.Some(now),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion1,
+ )
+ },
+ },
+ {
+ name: "v1 chan range with height - rejected",
+ fn: func() error {
+ r := ChanUpdateRange{
+ StartHeight: fn.Some(uint32(1)),
+ EndHeight: fn.Some(uint32(100)),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion1,
+ )
+ },
+ wantErr: "v1 chan update range must use time",
+ },
+ {
+ name: "v2 chan range with height - ok",
+ fn: func() error {
+ r := ChanUpdateRange{
+ StartHeight: fn.Some(uint32(1)),
+ EndHeight: fn.Some(uint32(100)),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion2,
+ )
+ },
+ },
+ {
+ name: "v2 chan range with time - rejected",
+ fn: func() error {
+ r := ChanUpdateRange{
+ StartTime: fn.Some(now),
+ EndTime: fn.Some(now),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion2,
+ )
+ },
+ wantErr: "v2 chan update range must use blocks",
+ },
+ {
+ name: "mixed chan range - rejected",
+ fn: func() error {
+ r := ChanUpdateRange{
+ StartTime: fn.Some(now),
+ StartHeight: fn.Some(uint32(1)),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion1,
+ )
+ },
+ wantErr: "both time and block",
+ },
+ {
+ name: "v1 node range with time - ok",
+ fn: func() error {
+ r := NodeUpdateRange{
+ StartTime: fn.Some(now),
+ EndTime: fn.Some(now),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion1,
+ )
+ },
+ },
+ {
+ name: "v2 node range with height - ok",
+ fn: func() error {
+ r := NodeUpdateRange{
+ StartHeight: fn.Some(uint32(1)),
+ EndHeight: fn.Some(uint32(100)),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion2,
+ )
+ },
+ },
+ {
+ name: "v2 node range with time - rejected",
+ fn: func() error {
+ r := NodeUpdateRange{
+ StartTime: fn.Some(now),
+ EndTime: fn.Some(now),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion2,
+ )
+ },
+ wantErr: "v2 node update range must use height",
+ },
+ {
+ name: "v1 chan range missing bounds - rejected",
+ fn: func() error {
+ r := ChanUpdateRange{
+ StartTime: fn.Some(now),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion1,
+ )
+ },
+ wantErr: "missing time bounds",
+ },
+ {
+ name: "v1 chan range inverted - rejected",
+ fn: func() error {
+ r := ChanUpdateRange{
+ StartTime: fn.Some(now.Add(time.Hour)),
+ EndTime: fn.Some(now),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion1,
+ )
+ },
+ wantErr: "start time after end time",
+ },
+ {
+ name: "v2 chan range inverted - rejected",
+ fn: func() error {
+ r := ChanUpdateRange{
+ StartHeight: fn.Some(uint32(100)),
+ EndHeight: fn.Some(uint32(50)),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion2,
+ )
+ },
+ wantErr: "start height after end height",
+ },
+ {
+ name: "v1 node range inverted - rejected",
+ fn: func() error {
+ r := NodeUpdateRange{
+ StartTime: fn.Some(now.Add(time.Hour)),
+ EndTime: fn.Some(now),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion1,
+ )
+ },
+ wantErr: "start time after end time",
+ },
+ {
+ name: "v2 node range inverted - rejected",
+ fn: func() error {
+ r := NodeUpdateRange{
+ StartHeight: fn.Some(uint32(100)),
+ EndHeight: fn.Some(uint32(50)),
+ }
+
+ return r.validateForVersion(
+ lnwire.GossipVersion2,
+ )
+ },
+ wantErr: "start height after end height",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.fn()
+ if tc.wantErr == "" {
+ require.NoError(t, err)
+ } else {
+ require.ErrorContains(t, err,
+ tc.wantErr)
+ }
+ })
+ }
+}
diff --git a/graph/db/options.go b/graph/db/options.go
index df49fd7..e33396d 100644
--- a/graph/db/options.go
+++ b/graph/db/options.go
@@ -1,6 +1,13 @@
package graphdb
-import "time"
+import (
+ "fmt"
+ "iter"
+ "time"
+
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/lightningnetwork/lnd/lnwire"
+)
const (
// DefaultRejectCacheSize is the default number of rejectCacheEntries to
@@ -39,6 +46,189 @@ type iterConfig struct {
iterPublicNodes bool
}
+// ChanUpdateRange describes a range for channel updates. Only one of the time
+// or height ranges should be set depending on the gossip version.
+type ChanUpdateRange struct {
+ // StartTime is the inclusive lower time bound (v1 gossip only).
+ StartTime fn.Option[time.Time]
+
+ // EndTime is the exclusive upper time bound (v1 gossip only).
+ EndTime fn.Option[time.Time]
+
+ // StartHeight is the inclusive lower block-height bound (v2 gossip
+ // only).
+ StartHeight fn.Option[uint32]
+
+ // EndHeight is the exclusive upper block-height bound (v2 gossip
+ // only).
+ EndHeight fn.Option[uint32]
+}
+
+// validateForVersion checks that the range fields are consistent with the
+// given gossip version: v1 requires time bounds, v2 requires block-height
+// bounds, and mixing the two is rejected.
+func (r ChanUpdateRange) validateForVersion(v lnwire.GossipVersion) error {
+ var (
+ hasStartTime = r.StartTime.IsSome()
+ hasEndTime = r.EndTime.IsSome()
+ hasTimeRange = hasStartTime || hasEndTime
+
+ hasStartHeight = r.StartHeight.IsSome()
+ hasEndHeight = r.EndHeight.IsSome()
+ hasBlockRange = hasStartHeight || hasEndHeight
+ )
+
+ if hasTimeRange && hasBlockRange {
+ return fmt.Errorf("chan update range has both time and block " +
+ "ranges")
+ }
+
+ switch v {
+ case gossipV1:
+ if hasBlockRange {
+ return fmt.Errorf("v1 chan update range must use time")
+ }
+
+ if !hasTimeRange {
+ return fmt.Errorf("v1 chan update range missing time")
+ }
+
+ if !hasStartTime || !hasEndTime {
+ return fmt.Errorf("v1 chan update range " +
+ "missing time bounds")
+ }
+
+ start := r.StartTime.UnwrapOr(time.Time{})
+ end := r.EndTime.UnwrapOr(time.Time{})
+
+ if start.After(end) {
+ return fmt.Errorf("v1 chan update range: " +
+ "start time after end time")
+ }
+
+ case gossipV2:
+ if hasTimeRange {
+ return fmt.Errorf("v2 chan update range must use " +
+ "blocks")
+ }
+
+ if !hasBlockRange {
+ return fmt.Errorf("v2 chan update range missing " +
+ "block range")
+ }
+
+ if !hasStartHeight || !hasEndHeight {
+ return fmt.Errorf("v2 chan update range " +
+ "missing block bounds")
+ }
+
+ start := r.StartHeight.UnwrapOr(0)
+ end := r.EndHeight.UnwrapOr(0)
+ if start > end {
+ return fmt.Errorf("v2 chan update range: " +
+ "start height after end height")
+ }
+
+ default:
+ return fmt.Errorf("unknown gossip version: %v", v)
+ }
+
+ return nil
+}
+
+// chanUpdateRangeErrIter returns an iterator that yields a single error.
+func chanUpdateRangeErrIter(err error) iter.Seq2[ChannelEdge, error] {
+ return func(yield func(ChannelEdge, error) bool) {
+ _ = yield(ChannelEdge{}, err)
+ }
+}
+
+// NodeUpdateRange describes a range for node updates. Only one of the time or
+// height ranges should be set depending on the gossip version.
+type NodeUpdateRange struct {
+ // StartTime is the inclusive lower time bound (v1 gossip only).
+ StartTime fn.Option[time.Time]
+
+ // EndTime is the exclusive upper time bound (v1 gossip only).
+ EndTime fn.Option[time.Time]
+
+ // StartHeight is the inclusive lower block-height bound (v2 gossip
+ // only).
+ StartHeight fn.Option[uint32]
+
+ // EndHeight is the exclusive upper block-height bound (v2 gossip
+ // only).
+ EndHeight fn.Option[uint32]
+}
+
+// validateForVersion checks that the range fields are consistent with the
+// given gossip version: v1 requires time bounds, v2 requires block-height
+// bounds, and mixing the two is rejected.
+func (r NodeUpdateRange) validateForVersion(v lnwire.GossipVersion) error {
+ var (
+ hasStartTime = r.StartTime.IsSome()
+ hasEndTime = r.EndTime.IsSome()
+
+ hasStartHeight = r.StartHeight.IsSome()
+ hasEndHeight = r.EndHeight.IsSome()
+
+ hasTimeRange = hasStartTime || hasEndTime
+ hasBlockRange = hasStartHeight || hasEndHeight
+ )
+
+ if hasTimeRange && hasBlockRange {
+ return fmt.Errorf("node update range has both " +
+ "time and block ranges")
+ }
+
+ switch v {
+ case gossipV1:
+ if hasBlockRange {
+ return fmt.Errorf("v1 node update range must use time")
+ }
+
+ if !hasTimeRange {
+ return fmt.Errorf("v1 node update range missing time")
+ }
+ if !hasStartTime || !hasEndTime {
+ return fmt.Errorf("v1 node update range missing " +
+ "time bounds")
+ }
+
+ start := r.StartTime.UnwrapOr(time.Time{})
+ end := r.EndTime.UnwrapOr(time.Time{})
+ if start.After(end) {
+ return fmt.Errorf("v1 node update range: start time " +
+ "after end time")
+ }
+
+ case gossipV2:
+ if hasTimeRange {
+ return fmt.Errorf("v2 node update range must use " +
+ "height")
+ }
+ if !hasBlockRange {
+ return fmt.Errorf("v2 node update range missing height")
+ }
+ if !hasStartHeight || !hasEndHeight {
+ return fmt.Errorf("v2 node update range missing " +
+ "height bounds")
+ }
+
+ start := r.StartHeight.UnwrapOr(0)
+ end := r.EndHeight.UnwrapOr(0)
+ if start > end {
+ return fmt.Errorf("v2 node update range: start " +
+ "height after end height")
+ }
+
+ default:
+ return fmt.Errorf("unknown gossip version: %d", v)
+ }
+
+ return nil
+}
+
// defaultIteratorConfig returns the default configuration.
func defaultIteratorConfig() *iterConfig {
return &iterConfig{
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.