graph/db: add v2 block-height path for ChanUpdatesInHorizon
What changed, and why it matters
This commit adds a new database query path for Lightning Network gossip version 2, allowing channel updates to be fetched by blockchain block height range instead of by timestamp. It is a feature implementation that fills in previously unimplemented functionality, not a security fix or vulnerability patch.
No security action required. Review as normal feature code during standard QA.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces GetChannelsByPolicyBlockRange SQL query and a corresponding Go implementation chanUpdatesInHorizonV2, mirroring the existing v1 time-based ChanUpdatesInHorizon path. It adds cursor pagination by (max_block_height, channel_id), helper functions extractMaxBlockHeight and buildChannelEdgeFromRow, and extends extractChannelPolicies to handle the new row type. The prior v2 path returned an error (‘not yet implemented’); this commit wires it up.
Changed components
graph/db/sql_store.gosqldb/sqlc/graph.sql.gosqldb/sqlc/querier.gosqldb/sqlc/queries/graph.sqlgraph/db/graph_test.goInspect captured patch +781 / −16
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index e197c1a..ae99121 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -3311,6 +3311,215 @@ func TestChanUpdatesInHorizonExclusiveEnd(t *testing.T) {
}
}
+// TestChanUpdatesInHorizonV2 tests that ChanUpdatesInHorizon works correctly
+// for v2 gossip using block-height-based ranges with [start, end) semantics.
+func TestChanUpdatesInHorizonV2(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,
+ )
+
+ node1 := createTestVertex(t, lnwire.GossipVersion2)
+ node2 := createTestVertex(t, lnwire.GossipVersion2)
+ require.NoError(t, graph.AddNode(ctx, node1))
+ require.NoError(t, graph.AddNode(ctx, node2))
+
+ // Query before any channels exist — should return empty.
+ iter := graph.ChanUpdatesInHorizon(
+ ctx, ChanUpdateRange{
+ StartHeight: fn.Some(uint32(0)),
+ EndHeight: fn.Some(uint32(9999)),
+ },
+ )
+ channels, err := fn.CollectErr(iter)
+ require.NoError(t, err)
+ require.Empty(t, channels)
+
+ // Create 10 v2 channels with policy block heights at
+ // 100, 110, 120, ..., 190.
+ const numChans = 10
+ const startHeight uint32 = 100
+ const heightStep uint32 = 10
+
+ for i := 0; i < numChans; i++ {
+ height := startHeight + uint32(i)*heightStep
+
+ channel, chanID := createEdge(
+ lnwire.GossipVersion2, uint32(i*10), 0, 0, 0,
+ node1, node2,
+ )
+ require.NoError(t, graph.AddChannelEdge(ctx, channel))
+
+ edge1 := newEdgePolicy(
+ lnwire.GossipVersion2, chanID.ToUint64(), 0, true,
+ )
+ edge1.LastBlockHeight = height
+ edge1.ToNode = node2.PubKeyBytes
+ edge1.SigBytes = testSig.Serialize()
+ require.NoError(t, graph.UpdateEdgePolicy(ctx, edge1))
+
+ edge2 := newEdgePolicy(
+ lnwire.GossipVersion2, chanID.ToUint64(), 0, false,
+ )
+ edge2.LastBlockHeight = height
+ edge2.ToNode = node1.PubKeyBytes
+ edge2.SigBytes = testSig.Serialize()
+ require.NoError(t, graph.UpdateEdgePolicy(ctx, edge2))
+ }
+
+ endHeight := startHeight + uint32(numChans)*heightStep
+
+ tests := []struct {
+ name string
+ start uint32
+ end uint32
+ want int
+ }{
+ {
+ name: "below range",
+ start: 0,
+ end: 50,
+ want: 0,
+ },
+ {
+ name: "above range",
+ start: 500,
+ end: 600,
+ want: 0,
+ },
+ {
+ name: "start height is inclusive",
+ start: startHeight,
+ end: startHeight + 1,
+ want: 1,
+ },
+ {
+ // End is exclusive: channel at exactly
+ // endHeight-10 (=190) should NOT be included
+ // when end=190.
+ name: "end height is exclusive",
+ start: startHeight,
+ end: endHeight - heightStep,
+ want: numChans - 1,
+ },
+ {
+ name: "one past end includes last",
+ start: startHeight,
+ end: endHeight - heightStep + 1,
+ want: numChans,
+ },
+ {
+ name: "full range",
+ start: startHeight,
+ end: endHeight,
+ want: numChans,
+ },
+ {
+ name: "skip first",
+ start: startHeight + heightStep,
+ end: endHeight,
+ want: numChans - 1,
+ },
+ {
+ // Heights [120, 170) = channels at
+ // 120, 130, 140, 150, 160 = 5 channels.
+ name: "middle slice",
+ start: 120,
+ end: 170,
+ want: 5,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ iter := graph.ChanUpdatesInHorizon(
+ ctx, ChanUpdateRange{
+ 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)
+ })
+ }
+
+ // Test with asymmetric policy block heights: one policy inside
+ // the range, the other outside. The SQL query uses OR across the
+ // two policies, so the channel should still be returned if
+ // either policy is in range.
+ t.Run("asymmetric policy heights", func(t *testing.T) {
+ channel, chanID := createEdge(
+ lnwire.GossipVersion2, 500, 0, 0, 0,
+ node1, node2,
+ )
+ require.NoError(t, graph.AddChannelEdge(ctx, channel))
+
+ // Policy 1 at height 300 (inside range).
+ edge1 := newEdgePolicy(
+ lnwire.GossipVersion2,
+ chanID.ToUint64(), 0, true,
+ )
+ edge1.LastBlockHeight = 300
+ edge1.ToNode = node2.PubKeyBytes
+ edge1.SigBytes = testSig.Serialize()
+ require.NoError(t, graph.UpdateEdgePolicy(ctx, edge1))
+
+ // Policy 2 at height 900 (outside range).
+ edge2 := newEdgePolicy(
+ lnwire.GossipVersion2,
+ chanID.ToUint64(), 0, false,
+ )
+ edge2.LastBlockHeight = 900
+ edge2.ToNode = node1.PubKeyBytes
+ edge2.SigBytes = testSig.Serialize()
+ require.NoError(t, graph.UpdateEdgePolicy(ctx, edge2))
+
+ // Query [250, 350) — only policy 1 is in range, but the
+ // channel should still be returned.
+ iter := graph.ChanUpdatesInHorizon(
+ ctx, ChanUpdateRange{
+ StartHeight: fn.Some(uint32(250)),
+ EndHeight: fn.Some(uint32(350)),
+ },
+ )
+ results, err := fn.CollectErr(iter)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+
+ // Query [850, 950) — only policy 2 is in range, channel
+ // should still be returned.
+ iter = graph.ChanUpdatesInHorizon(
+ ctx, ChanUpdateRange{
+ StartHeight: fn.Some(uint32(850)),
+ EndHeight: fn.Some(uint32(950)),
+ },
+ )
+ results, err = fn.CollectErr(iter)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+
+ // Query [400, 500) — neither policy is in range.
+ iter = graph.ChanUpdatesInHorizon(
+ ctx, ChanUpdateRange{
+ StartHeight: fn.Some(uint32(400)),
+ EndHeight: fn.Some(uint32(500)),
+ },
+ )
+ results, err = fn.CollectErr(iter)
+ require.NoError(t, err)
+ require.Empty(t, results)
+ })
+}
+
// 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/sql_store.go b/graph/db/sql_store.go
index 008d32a..e448523 100644
--- a/graph/db/sql_store.go
+++ b/graph/db/sql_store.go
@@ -108,6 +108,7 @@ type SQLQueries interface {
ListChannelsPaginated(ctx context.Context, arg sqlc.ListChannelsPaginatedParams) ([]sqlc.ListChannelsPaginatedRow, error)
ListChannelsPaginatedV2(ctx context.Context, arg sqlc.ListChannelsPaginatedV2Params) ([]sqlc.ListChannelsPaginatedV2Row, error)
GetChannelsByPolicyLastUpdateRange(ctx context.Context, arg sqlc.GetChannelsByPolicyLastUpdateRangeParams) ([]sqlc.GetChannelsByPolicyLastUpdateRangeRow, error)
+ GetChannelsByPolicyBlockRange(ctx context.Context, arg sqlc.GetChannelsByPolicyBlockRangeParams) ([]sqlc.GetChannelsByPolicyBlockRangeRow, error)
GetChannelByOutpointWithPolicies(ctx context.Context, arg sqlc.GetChannelByOutpointWithPoliciesParams) (sqlc.GetChannelByOutpointWithPoliciesRow, error)
GetPublicV1ChannelsBySCID(ctx context.Context, arg sqlc.GetPublicV1ChannelsBySCIDParams) ([]sqlc.GraphChannel, error)
GetPublicV2ChannelsBySCID(ctx context.Context, arg sqlc.GetPublicV2ChannelsBySCIDParams) ([]sqlc.GraphChannel, error)
@@ -1227,26 +1228,28 @@ func extractMaxUpdateTime(
}
}
-// buildChannelFromRow constructs a ChannelEdge from a database row.
-// This includes building the nodes, channel info, and policies.
-func (s *SQLStore) buildChannelFromRow(ctx context.Context, db SQLQueries,
- row sqlc.GetChannelsByPolicyLastUpdateRangeRow) (ChannelEdge, error) {
+// buildChannelEdgeFromRow constructs a ChannelEdge from the common fields
+// shared by both the v1 time-range and v2 block-height-range query rows.
+// The policyRow parameter is passed to extractChannelPolicies which
+// type-switches on the concrete sqlc row type.
+func (s *SQLStore) buildChannelEdgeFromRow(ctx context.Context,
+ db SQLQueries, n1, n2 sqlc.GraphNode, ch sqlc.GraphChannel,
+ policyRow any) (ChannelEdge, error) {
- node1, err := buildNode(ctx, s.cfg.QueryCfg, db, row.GraphNode)
+ node1, err := buildNode(ctx, s.cfg.QueryCfg, db, n1)
if err != nil {
return ChannelEdge{}, fmt.Errorf("unable to build node1: %w",
err)
}
- node2, err := buildNode(ctx, s.cfg.QueryCfg, db, row.GraphNode_2)
+ node2, err := buildNode(ctx, s.cfg.QueryCfg, db, n2)
if err != nil {
return ChannelEdge{}, fmt.Errorf("unable to build node2: %w",
err)
}
channel, err := getAndBuildEdgeInfo(
- ctx, s.cfg, db,
- row.GraphChannel, node1.PubKeyBytes,
+ ctx, s.cfg, db, ch, node1.PubKeyBytes,
node2.PubKeyBytes,
)
if err != nil {
@@ -1254,7 +1257,7 @@ func (s *SQLStore) buildChannelFromRow(ctx context.Context, db SQLQueries,
"channel info: %w", err)
}
- dbPol1, dbPol2, err := extractChannelPolicies(row)
+ dbPol1, dbPol2, err := extractChannelPolicies(policyRow)
if err != nil {
return ChannelEdge{}, fmt.Errorf("unable to extract "+
"channel policies: %w", err)
@@ -1278,6 +1281,26 @@ func (s *SQLStore) buildChannelFromRow(ctx context.Context, db SQLQueries,
}, nil
}
+// extractMaxBlockHeight returns the maximum of the two policy block heights.
+// This is used for pagination cursor tracking in v2 gossip queries.
+func extractMaxBlockHeight(
+ row sqlc.GetChannelsByPolicyBlockRangeRow) int64 {
+
+ switch {
+ case row.Policy1BlockHeight.Valid &&
+ row.Policy2BlockHeight.Valid:
+
+ return max(row.Policy1BlockHeight.Int64,
+ row.Policy2BlockHeight.Int64)
+ case row.Policy1BlockHeight.Valid:
+ return row.Policy1BlockHeight.Int64
+ case row.Policy2BlockHeight.Valid:
+ return row.Policy2BlockHeight.Int64
+ default:
+ return 0
+ }
+}
+
// updateChanCacheBatch updates the channel cache with multiple edges at once.
// This method acquires the cache lock only once for the entire batch.
func (s *SQLStore) updateChanCacheBatch(v lnwire.GossipVersion,
@@ -1320,11 +1343,7 @@ func (s *SQLStore) ChanUpdatesInHorizon(ctx context.Context,
return s.chanUpdatesInHorizonV1(ctx, r, cfg)
case gossipV2:
- err := fmt.Errorf("v2 chan updates in horizon not yet " +
- "implemented")
- return func(yield func(ChannelEdge, error) bool) {
- _ = yield(ChannelEdge{}, err)
- }
+ return s.chanUpdatesInHorizonV2(ctx, r, cfg)
default:
err := fmt.Errorf("unknown gossip version: %v", v)
@@ -1431,8 +1450,12 @@ func (s *SQLStore) chanUpdatesInHorizonV1(ctx context.Context,
continue
}
- chanEdge, err := s.buildChannelFromRow(
- ctx, db, row,
+ chanEdge, err := s.buildChannelEdgeFromRow(
+ ctx, db,
+ row.GraphNode,
+ row.GraphNode_2,
+ row.GraphChannel,
+ row,
)
if err != nil {
return err
@@ -1495,6 +1518,179 @@ func (s *SQLStore) chanUpdatesInHorizonV1(ctx context.Context,
}
}
+// chanUpdatesInHorizonV2 implements the v2 block-height-based channel horizon
+// query.
+func (s *SQLStore) chanUpdatesInHorizonV2(ctx context.Context,
+ r ChanUpdateRange,
+ cfg *iterConfig) iter.Seq2[ChannelEdge, error] {
+
+ startHeight := int64(r.StartHeight.UnwrapOr(0))
+ endHeight := int64(r.EndHeight.UnwrapOr(0))
+ batchSize := cfg.chanUpdateIterBatchSize
+
+ return func(yield func(ChannelEdge, error) bool) {
+ var (
+ edgesSeen = make(map[uint64]struct{})
+ edgesToCache = make(map[uint64]ChannelEdge)
+ hits int
+ total int
+ lastBlockHeight sql.NullInt64
+ lastID sql.NullInt64
+ hasMore = true
+ )
+
+ // queryChannels fetches the next page of v2 channels in
+ // the block-height range.
+ queryChannels := func(
+ db SQLQueries,
+ ) ([]sqlc.GetChannelsByPolicyBlockRangeRow, error) {
+
+ return db.GetChannelsByPolicyBlockRange(
+ ctx,
+ sqlc.GetChannelsByPolicyBlockRangeParams{
+ Version: int16(gossipV2),
+ StartHeight: sqldb.SQLInt64(
+ startHeight,
+ ),
+ EndHeight: sqldb.SQLInt64(
+ endHeight,
+ ),
+ LastBlockHeight: lastBlockHeight,
+ LastID: lastID,
+ MaxResults: sql.NullInt32{
+ Int32: int32(batchSize),
+ Valid: true,
+ },
+ },
+ )
+ }
+
+ // processRow handles a single channel row: updates
+ // pagination cursors, checks the seen set and cache, and
+ // builds the channel edge if needed.
+ processRow := func(ctx context.Context, db SQLQueries,
+ row sqlc.GetChannelsByPolicyBlockRangeRow,
+ batch *[]ChannelEdge) error {
+
+ lastBlockHeight = sql.NullInt64{
+ Int64: extractMaxBlockHeight(row),
+ Valid: true,
+ }
+ lastID = sql.NullInt64{
+ Int64: row.GraphChannel.ID,
+ Valid: true,
+ }
+
+ chanIDInt := byteOrder.Uint64(
+ row.GraphChannel.Scid,
+ )
+ if _, ok := edgesSeen[chanIDInt]; ok {
+ return nil
+ }
+
+ // Check cache (we already hold shared read
+ // lock).
+ channel, ok := s.chanCache.get(
+ gossipV2, chanIDInt,
+ )
+ if ok {
+ hits++
+ total++
+ edgesSeen[chanIDInt] = struct{}{}
+ *batch = append(*batch, channel)
+
+ return nil
+ }
+
+ chanEdge, err := s.buildChannelEdgeFromRow(
+ ctx, db, row.GraphNode,
+ row.GraphNode_2,
+ row.GraphChannel, row,
+ )
+ if err != nil {
+ return err
+ }
+
+ edgesSeen[chanIDInt] = struct{}{}
+ edgesToCache[chanIDInt] = chanEdge
+ *batch = append(*batch, chanEdge)
+ total++
+
+ return nil
+ }
+
+ for hasMore {
+ var batch []ChannelEdge
+
+ s.cacheMu.RLock()
+
+ err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(),
+ func(db SQLQueries) error {
+ rows, err := queryChannels(db)
+ if err != nil {
+ return err
+ }
+
+ hasMore = len(rows) == batchSize
+
+ for _, row := range rows {
+ err := processRow(
+ ctx, db, row, &batch,
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }, func() {
+ batch = nil
+ edgesSeen = make(
+ map[uint64]struct{},
+ )
+ edgesToCache = make(
+ map[uint64]ChannelEdge,
+ )
+ },
+ )
+
+ s.cacheMu.RUnlock()
+
+ if err != nil {
+ log.Errorf("ChanUpdatesInHorizon(v2) "+
+ "batch error: %v", err)
+
+ yield(ChannelEdge{}, err)
+
+ return
+ }
+
+ for _, edge := range batch {
+ if !yield(edge, nil) {
+ return
+ }
+ }
+
+ s.updateChanCacheBatch(gossipV2, edgesToCache)
+ edgesToCache = make(map[uint64]ChannelEdge)
+
+ if len(batch) == 0 {
+ break
+ }
+ }
+
+ if total > 0 {
+ log.Debugf("ChanUpdatesInHorizon(v2) hit "+
+ "percentage: %.2f (%d/%d)",
+ float64(hits)*100/float64(total), hits,
+ total)
+ } else {
+ log.Debugf("ChanUpdatesInHorizon(v2) returned " +
+ "no edges in horizon")
+ }
+ }
+}
+
// ForEachNodeCached is similar to forEachNode, but it returns DirectedChannel
// data to the call-back. If withAddrs is true, then the call-back will also be
// provided with the addresses associated with the node. The address retrieval
@@ -5603,6 +5799,54 @@ func extractChannelPolicies(row any) (*sqlc.GraphChannelPolicy,
return policy1, policy2, nil
+ case sqlc.GetChannelsByPolicyBlockRangeRow:
+ if r.Policy1ID.Valid {
+ policy1 = &sqlc.GraphChannelPolicy{
+ ID: r.Policy1ID.Int64,
+ Version: r.Policy1Version.Int16,
+ ChannelID: r.GraphChannel.ID,
+ NodeID: r.Policy1NodeID.Int64,
+ Timelock: r.Policy1Timelock.Int32,
+ FeePpm: r.Policy1FeePpm.Int64,
+ BaseFeeMsat: r.Policy1BaseFeeMsat.Int64,
+ MinHtlcMsat: r.Policy1MinHtlcMsat.Int64,
+ MaxHtlcMsat: r.Policy1MaxHtlcMsat,
+ LastUpdate: r.Policy1LastUpdate,
+ InboundBaseFeeMsat: r.Policy1InboundBaseFeeMsat,
+ InboundFeeRateMilliMsat: r.Policy1InboundFeeRateMilliMsat,
+ Disabled: r.Policy1Disabled,
+ MessageFlags: r.Policy1MessageFlags,
+ ChannelFlags: r.Policy1ChannelFlags,
+ Signature: r.Policy1Signature,
+ BlockHeight: r.Policy1BlockHeight,
+ DisableFlags: r.Policy1DisableFlags,
+ }
+ }
+ if r.Policy2ID.Valid {
+ policy2 = &sqlc.GraphChannelPolicy{
+ ID: r.Policy2ID.Int64,
+ Version: r.Policy2Version.Int16,
+ ChannelID: r.GraphChannel.ID,
+ NodeID: r.Policy2NodeID.Int64,
+ Timelock: r.Policy2Timelock.Int32,
+ FeePpm: r.Policy2FeePpm.Int64,
+ BaseFeeMsat: r.Policy2BaseFeeMsat.Int64,
+ MinHtlcMsat: r.Policy2MinHtlcMsat.Int64,
+ MaxHtlcMsat: r.Policy2MaxHtlcMsat,
+ LastUpdate: r.Policy2LastUpdate,
+ InboundBaseFeeMsat: r.Policy2InboundBaseFeeMsat,
+ InboundFeeRateMilliMsat: r.Policy2InboundFeeRateMilliMsat,
+ Disabled: r.Policy2Disabled,
+ MessageFlags: r.Policy2MessageFlags,
+ ChannelFlags: r.Policy2ChannelFlags,
+ Signature: r.Policy2Signature,
+ BlockHeight: r.Policy2BlockHeight,
+ DisableFlags: r.Policy2DisableFlags,
+ }
+ }
+
+ return policy1, policy2, nil
+
case sqlc.ListChannelsForNodeIDsRow:
if r.Policy1ID.Valid {
policy1 = &sqlc.GraphChannelPolicy{
diff --git a/sqldb/sqlc/graph.sql.go b/sqldb/sqlc/graph.sql.go
index 6ba4113..465310c 100644
--- a/sqldb/sqlc/graph.sql.go
+++ b/sqldb/sqlc/graph.sql.go
@@ -1173,6 +1173,235 @@ func (q *Queries) GetChannelsByOutpoints(ctx context.Context, outpoints []string
return items, nil
}
+const getChannelsByPolicyBlockRange = `-- name: GetChannelsByPolicyBlockRange :many
+SELECT
+ c.id, c.version, c.scid, c.node_id_1, c.node_id_2, c.outpoint, c.capacity, c.bitcoin_key_1, c.bitcoin_key_2, c.node_1_signature, c.node_2_signature, c.bitcoin_1_signature, c.bitcoin_2_signature, c.signature, c.funding_pk_script, c.merkle_root_hash,
+ n1.id, n1.version, n1.pub_key, n1.alias, n1.last_update, n1.color, n1.signature, n1.block_height,
+ n2.id, n2.version, n2.pub_key, n2.alias, n2.last_update, n2.color, n2.signature, n2.block_height,
+
+ -- Policy 1 (node_id_1)
+ cp1.id AS policy1_id,
+ cp1.node_id AS policy1_node_id,
+ cp1.version AS policy1_version,
+ cp1.timelock AS policy1_timelock,
+ cp1.fee_ppm AS policy1_fee_ppm,
+ cp1.base_fee_msat AS policy1_base_fee_msat,
+ cp1.min_htlc_msat AS policy1_min_htlc_msat,
+ cp1.max_htlc_msat AS policy1_max_htlc_msat,
+ cp1.last_update AS policy1_last_update,
+ cp1.disabled AS policy1_disabled,
+ cp1.inbound_base_fee_msat AS policy1_inbound_base_fee_msat,
+ cp1.inbound_fee_rate_milli_msat AS policy1_inbound_fee_rate_milli_msat,
+ cp1.message_flags AS policy1_message_flags,
+ cp1.channel_flags AS policy1_channel_flags,
+ cp1.signature AS policy1_signature,
+ cp1.block_height AS policy1_block_height,
+ cp1.disable_flags AS policy1_disable_flags,
+
+ -- Policy 2 (node_id_2)
+ cp2.id AS policy2_id,
+ cp2.node_id AS policy2_node_id,
+ cp2.version AS policy2_version,
+ cp2.timelock AS policy2_timelock,
+ cp2.fee_ppm AS policy2_fee_ppm,
+ cp2.base_fee_msat AS policy2_base_fee_msat,
+ cp2.min_htlc_msat AS policy2_min_htlc_msat,
+ cp2.max_htlc_msat AS policy2_max_htlc_msat,
+ cp2.last_update AS policy2_last_update,
+ cp2.disabled AS policy2_disabled,
+ cp2.inbound_base_fee_msat AS policy2_inbound_base_fee_msat,
+ cp2.inbound_fee_rate_milli_msat AS policy2_inbound_fee_rate_milli_msat,
+ cp2.message_flags AS policy2_message_flags,
+ cp2.channel_flags AS policy2_channel_flags,
+ cp2.signature AS policy2_signature,
+ cp2.block_height AS policy2_block_height,
+ cp2.disable_flags AS policy2_disable_flags
+
+FROM graph_channels c
+ JOIN graph_nodes n1 ON c.node_id_1 = n1.id
+ JOIN graph_nodes n2 ON c.node_id_2 = n2.id
+ LEFT JOIN graph_channel_policies cp1
+ ON cp1.channel_id = c.id AND cp1.node_id = c.node_id_1 AND cp1.version = c.version
+ LEFT JOIN graph_channel_policies cp2
+ ON cp2.channel_id = c.id AND cp2.node_id = c.node_id_2 AND cp2.version = c.version
+WHERE c.version = $1
+ AND (
+ (cp1.block_height >= $2 AND cp1.block_height < $3)
+ OR
+ (cp2.block_height >= $2 AND cp2.block_height < $3)
+ )
+ -- Pagination using compound cursor (max_block_height, id).
+ -- We use COALESCE with -1 as sentinel since block heights are always positive.
+ AND (
+ (CASE
+ WHEN COALESCE(cp1.block_height, 0) >= COALESCE(cp2.block_height, 0)
+ THEN COALESCE(cp1.block_height, 0)
+ ELSE COALESCE(cp2.block_height, 0)
+ END > COALESCE($4, -1))
+ OR
+ (CASE
+ WHEN COALESCE(cp1.block_height, 0) >= COALESCE(cp2.block_height, 0)
+ THEN COALESCE(cp1.block_height, 0)
+ ELSE COALESCE(cp2.block_height, 0)
+ END = COALESCE($4, -1)
+ AND c.id > COALESCE($5, -1))
+ )
+ORDER BY
+ CASE
+ WHEN COALESCE(cp1.block_height, 0) >= COALESCE(cp2.block_height, 0)
+ THEN COALESCE(cp1.block_height, 0)
+ ELSE COALESCE(cp2.block_height, 0)
+ END ASC,
+ c.id ASC
+LIMIT COALESCE($6, 999999999)
+`
+
+type GetChannelsByPolicyBlockRangeParams struct {
+ Version int16
+ StartHeight sql.NullInt64
+ EndHeight sql.NullInt64
+ LastBlockHeight sql.NullInt64
+ LastID sql.NullInt64
+ MaxResults interface{}
+}
+
+type GetChannelsByPolicyBlockRangeRow struct {
+ GraphChannel GraphChannel
+ GraphNode GraphNode
+ GraphNode_2 GraphNode
+ Policy1ID sql.NullInt64
+ Policy1NodeID sql.NullInt64
+ Policy1Version sql.NullInt16
+ Policy1Timelock sql.NullInt32
+ Policy1FeePpm sql.NullInt64
+ Policy1BaseFeeMsat sql.NullInt64
+ Policy1MinHtlcMsat sql.NullInt64
+ Policy1MaxHtlcMsat sql.NullInt64
+ Policy1LastUpdate sql.NullInt64
+ Policy1Disabled sql.NullBool
+ Policy1InboundBaseFeeMsat sql.NullInt64
+ Policy1InboundFeeRateMilliMsat sql.NullInt64
+ Policy1MessageFlags sql.NullInt16
+ Policy1ChannelFlags sql.NullInt16
+ Policy1Signature []byte
+ Policy1BlockHeight sql.NullInt64
+ Policy1DisableFlags sql.NullInt16
+ Policy2ID sql.NullInt64
+ Policy2NodeID sql.NullInt64
+ Policy2Version sql.NullInt16
+ Policy2Timelock sql.NullInt32
+ Policy2FeePpm sql.NullInt64
+ Policy2BaseFeeMsat sql.NullInt64
+ Policy2MinHtlcMsat sql.NullInt64
+ Policy2MaxHtlcMsat sql.NullInt64
+ Policy2LastUpdate sql.NullInt64
+ Policy2Disabled sql.NullBool
+ Policy2InboundBaseFeeMsat sql.NullInt64
+ Policy2InboundFeeRateMilliMsat sql.NullInt64
+ Policy2MessageFlags sql.NullInt16
+ Policy2ChannelFlags sql.NullInt16
+ Policy2Signature []byte
+ Policy2BlockHeight sql.NullInt64
+ Policy2DisableFlags sql.NullInt16
+}
+
+func (q *Queries) GetChannelsByPolicyBlockRange(ctx context.Context, arg GetChannelsByPolicyBlockRangeParams) ([]GetChannelsByPolicyBlockRangeRow, error) {
+ rows, err := q.db.QueryContext(ctx, getChannelsByPolicyBlockRange,
+ arg.Version,
+ arg.StartHeight,
+ arg.EndHeight,
+ arg.LastBlockHeight,
+ arg.LastID,
+ arg.MaxResults,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetChannelsByPolicyBlockRangeRow
+ for rows.Next() {
+ var i GetChannelsByPolicyBlockRangeRow
+ if err := rows.Scan(
+ &i.GraphChannel.ID,
+ &i.GraphChannel.Version,
+ &i.GraphChannel.Scid,
+ &i.GraphChannel.NodeID1,
+ &i.GraphChannel.NodeID2,
+ &i.GraphChannel.Outpoint,
+ &i.GraphChannel.Capacity,
+ &i.GraphChannel.BitcoinKey1,
+ &i.GraphChannel.BitcoinKey2,
+ &i.GraphChannel.Node1Signature,
+ &i.GraphChannel.Node2Signature,
+ &i.GraphChannel.Bitcoin1Signature,
+ &i.GraphChannel.Bitcoin2Signature,
+ &i.GraphChannel.Signature,
+ &i.GraphChannel.FundingPkScript,
+ &i.GraphChannel.MerkleRootHash,
+ &i.GraphNode.ID,
+ &i.GraphNode.Version,
+ &i.GraphNode.PubKey,
+ &i.GraphNode.Alias,
+ &i.GraphNode.LastUpdate,
+ &i.GraphNode.Color,
+ &i.GraphNode.Signature,
+ &i.GraphNode.BlockHeight,
+ &i.GraphNode_2.ID,
+ &i.GraphNode_2.Version,
+ &i.GraphNode_2.PubKey,
+ &i.GraphNode_2.Alias,
+ &i.GraphNode_2.LastUpdate,
+ &i.GraphNode_2.Color,
+ &i.GraphNode_2.Signature,
+ &i.GraphNode_2.BlockHeight,
+ &i.Policy1ID,
+ &i.Policy1NodeID,
+ &i.Policy1Version,
+ &i.Policy1Timelock,
+ &i.Policy1FeePpm,
+ &i.Policy1BaseFeeMsat,
+ &i.Policy1MinHtlcMsat,
+ &i.Policy1MaxHtlcMsat,
+ &i.Policy1LastUpdate,
+ &i.Policy1Disabled,
+ &i.Policy1InboundBaseFeeMsat,
+ &i.Policy1InboundFeeRateMilliMsat,
+ &i.Policy1MessageFlags,
+ &i.Policy1ChannelFlags,
+ &i.Policy1Signature,
+ &i.Policy1BlockHeight,
+ &i.Policy1DisableFlags,
+ &i.Policy2ID,
+ &i.Policy2NodeID,
+ &i.Policy2Version,
+ &i.Policy2Timelock,
+ &i.Policy2FeePpm,
+ &i.Policy2BaseFeeMsat,
+ &i.Policy2MinHtlcMsat,
+ &i.Policy2MaxHtlcMsat,
+ &i.Policy2LastUpdate,
+ &i.Policy2Disabled,
+ &i.Policy2InboundBaseFeeMsat,
+ &i.Policy2InboundFeeRateMilliMsat,
+ &i.Policy2MessageFlags,
+ &i.Policy2ChannelFlags,
+ &i.Policy2Signature,
+ &i.Policy2BlockHeight,
+ &i.Policy2DisableFlags,
+ ); 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 getChannelsByPolicyLastUpdateRange = `-- name: GetChannelsByPolicyLastUpdateRange :many
SELECT
c.id, c.version, c.scid, c.node_id_1, c.node_id_2, c.outpoint, c.capacity, c.bitcoin_key_1, c.bitcoin_key_2, c.node_1_signature, c.node_2_signature, c.bitcoin_1_signature, c.bitcoin_2_signature, c.signature, c.funding_pk_script, c.merkle_root_hash,
diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go
index a2359ee..afe77ef 100644
--- a/sqldb/sqlc/querier.go
+++ b/sqldb/sqlc/querier.go
@@ -103,6 +103,7 @@ type Querier interface {
GetChannelPolicyExtraTypesBatch(ctx context.Context, policyIds []int64) ([]GetChannelPolicyExtraTypesBatchRow, error)
GetChannelsByIDs(ctx context.Context, ids []int64) ([]GetChannelsByIDsRow, error)
GetChannelsByOutpoints(ctx context.Context, outpoints []string) ([]GetChannelsByOutpointsRow, error)
+ GetChannelsByPolicyBlockRange(ctx context.Context, arg GetChannelsByPolicyBlockRangeParams) ([]GetChannelsByPolicyBlockRangeRow, error)
GetChannelsByPolicyLastUpdateRange(ctx context.Context, arg GetChannelsByPolicyLastUpdateRangeParams) ([]GetChannelsByPolicyLastUpdateRangeRow, error)
GetChannelsBySCIDRange(ctx context.Context, arg GetChannelsBySCIDRangeParams) ([]GetChannelsBySCIDRangeRow, error)
GetChannelsBySCIDWithPolicies(ctx context.Context, arg GetChannelsBySCIDWithPoliciesParams) ([]GetChannelsBySCIDWithPoliciesRow, error)
diff --git a/sqldb/sqlc/queries/graph.sql b/sqldb/sqlc/queries/graph.sql
index b045b42..6ceeffa 100644
--- a/sqldb/sqlc/queries/graph.sql
+++ b/sqldb/sqlc/queries/graph.sql
@@ -611,6 +611,88 @@ ORDER BY
c.id ASC
LIMIT COALESCE(sqlc.narg('max_results'), 999999999);
+-- name: GetChannelsByPolicyBlockRange :many
+SELECT
+ sqlc.embed(c),
+ sqlc.embed(n1),
+ sqlc.embed(n2),
+
+ -- Policy 1 (node_id_1)
+ cp1.id AS policy1_id,
+ cp1.node_id AS policy1_node_id,
+ cp1.version AS policy1_version,
+ cp1.timelock AS policy1_timelock,
+ cp1.fee_ppm AS policy1_fee_ppm,
+ cp1.base_fee_msat AS policy1_base_fee_msat,
+ cp1.min_htlc_msat AS policy1_min_htlc_msat,
+ cp1.max_htlc_msat AS policy1_max_htlc_msat,
+ cp1.last_update AS policy1_last_update,
+ cp1.disabled AS policy1_disabled,
+ cp1.inbound_base_fee_msat AS policy1_inbound_base_fee_msat,
+ cp1.inbound_fee_rate_milli_msat AS policy1_inbound_fee_rate_milli_msat,
+ cp1.message_flags AS policy1_message_flags,
+ cp1.channel_flags AS policy1_channel_flags,
+ cp1.signature AS policy1_signature,
+ cp1.block_height AS policy1_block_height,
+ cp1.disable_flags AS policy1_disable_flags,
+
+ -- Policy 2 (node_id_2)
+ cp2.id AS policy2_id,
+ cp2.node_id AS policy2_node_id,
+ cp2.version AS policy2_version,
+ cp2.timelock AS policy2_timelock,
+ cp2.fee_ppm AS policy2_fee_ppm,
+ cp2.base_fee_msat AS policy2_base_fee_msat,
+ cp2.min_htlc_msat AS policy2_min_htlc_msat,
+ cp2.max_htlc_msat AS policy2_max_htlc_msat,
+ cp2.last_update AS policy2_last_update,
+ cp2.disabled AS policy2_disabled,
+ cp2.inbound_base_fee_msat AS policy2_inbound_base_fee_msat,
+ cp2.inbound_fee_rate_milli_msat AS policy2_inbound_fee_rate_milli_msat,
+ cp2.message_flags AS policy2_message_flags,
+ cp2.channel_flags AS policy2_channel_flags,
+ cp2.signature AS policy2_signature,
+ cp2.block_height AS policy2_block_height,
+ cp2.disable_flags AS policy2_disable_flags
+
+FROM graph_channels c
+ JOIN graph_nodes n1 ON c.node_id_1 = n1.id
+ JOIN graph_nodes n2 ON c.node_id_2 = n2.id
+ LEFT JOIN graph_channel_policies cp1
+ ON cp1.channel_id = c.id AND cp1.node_id = c.node_id_1 AND cp1.version = c.version
+ LEFT JOIN graph_channel_policies cp2
+ ON cp2.channel_id = c.id AND cp2.node_id = c.node_id_2 AND cp2.version = c.version
+WHERE c.version = @version
+ AND (
+ (cp1.block_height >= @start_height AND cp1.block_height < @end_height)
+ OR
+ (cp2.block_height >= @start_height AND cp2.block_height < @end_height)
+ )
+ -- Pagination using compound cursor (max_block_height, id).
+ -- We use COALESCE with -1 as sentinel since block heights are always positive.
+ AND (
+ (CASE
+ WHEN COALESCE(cp1.block_height, 0) >= COALESCE(cp2.block_height, 0)
+ THEN COALESCE(cp1.block_height, 0)
+ ELSE COALESCE(cp2.block_height, 0)
+ END > COALESCE(sqlc.narg('last_block_height'), -1))
+ OR
+ (CASE
+ WHEN COALESCE(cp1.block_height, 0) >= COALESCE(cp2.block_height, 0)
+ THEN COALESCE(cp1.block_height, 0)
+ ELSE COALESCE(cp2.block_height, 0)
+ END = COALESCE(sqlc.narg('last_block_height'), -1)
+ AND c.id > COALESCE(sqlc.narg('last_id'), -1))
+ )
+ORDER BY
+ CASE
+ WHEN COALESCE(cp1.block_height, 0) >= COALESCE(cp2.block_height, 0)
+ THEN COALESCE(cp1.block_height, 0)
+ ELSE COALESCE(cp2.block_height, 0)
+ END ASC,
+ c.id ASC
+LIMIT COALESCE(sqlc.narg('max_results'), 999999999);
+
-- name: GetChannelByOutpointWithPolicies :one
SELECT
sqlc.embed(c),
Why this scored 14/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.