graph/db: update SQLStore to read and write v2 channels
What changed, and why it matters
This commit extends LND's SQL graph database to store and retrieve a newer type of Lightning channel announcement (called 'v2' or 'taproot' channels). It adds support for new fields like funding scripts, Merkle root hashes, and a single signature, and it replaces hard-coded 'v1 only' checks with version-aware logic. There is no direct evidence in the commit that this fixes an active security bug; it reads as a feature-completion change to support a new protocol version.
Treat as a normal feature/compatability commit. Reviewers should verify that isKnownGossipVersion correctly enumerates only supported versions, that optional v2 fields are validated elsewhere before reaching the DB layer, and that the new signature/proof paths do not bypass existing auth-proof checks. No urgent security response is indicated by the diff alone.
Security signals we found
Input validation added: AddChannelEdge rejects unknown gossip versions before DB operations.
Version-specific handling replaces a hard-coded v1-only path, reducing the chance of version mismatch or data corruption when v2 announcements are received.
Optional fields are length-checked before conversion, which is defensive but not obviously fixing a known vulnerability.
No explicit security bug, CVE, or incident is described in the commit or supplied references.
Evidence from the diff
The patch updates graph/db/sql_store.go and graph/db/models/channel_edge_info.go so the SQL-backed channel graph can handle lnwire.GossipVersion2 channel announcements. Key changes: AddChannelEdge now validates edge.Version with isKnownGossipVersion and uses the edge’s actual version when checking for duplicates. insertChannel removes the ‘only V1 supported’ error, writes FundingPkScript/MerkleRootHash when present, and stores proof.Sig() for v2. buildEdgeInfoWithBatchData now switches on version to reconstruct either a V1 or V2 ChannelEdgeInfo, with v2 allowing optional Bitcoin keys and using NewV2ChannelAuthProof. A new WithMerkleRootHash EdgeModifier and two new fields (FundingScript, MerkleRootHash) are added to ChannelV2Fields.
Changed components
graph/db/sql_store.gograph/db/models/channel_edge_info.goLND channel graph SQL storeGossip v2 (taproot) channel announcement handlingInspect captured patch +147 / −58
diff --git a/graph/db/models/channel_edge_info.go b/graph/db/models/channel_edge_info.go
index 4855c4e..aae7b91 100644
--- a/graph/db/models/channel_edge_info.go
+++ b/graph/db/models/channel_edge_info.go
@@ -137,6 +137,13 @@ func WithFundingScript(script []byte) EdgeModifier {
}
}
+// WithMerkleRootHash sets the merkle root hash on the edge.
+func WithMerkleRootHash(hash chainhash.Hash) EdgeModifier {
+ return func(e *ChannelEdgeInfo) {
+ e.MerkleRootHash = fn.Some(hash)
+ }
+}
+
// ChannelV1Fields contains the fields that are specific to v1 channel
// announcements.
type ChannelV1Fields struct {
@@ -201,6 +208,14 @@ type ChannelV2Fields struct {
// BitcoinKey2Bytes is the raw public key of the second node.
BitcoinKey2Bytes fn.Option[route.Vertex]
+ // FundingScript is the funding output's pkScript. This is required for
+ // v2 channels when the bitcoin keys are not provided.
+ FundingScript fn.Option[[]byte]
+
+ // MerkleRootHash is an optional root hash of a Merkle tree that the
+ // funding output is committed to.
+ MerkleRootHash fn.Option[chainhash.Hash]
+
// ExtraSignedFields is a map of extra fields that are covered by the
// node announcement's signature that we have not explicitly parsed.
//
@@ -219,6 +234,8 @@ func NewV2Channel(chanID uint64, chainHash chainhash.Hash, node1,
NodeKey2Bytes: node2,
BitcoinKey1Bytes: v2Fields.BitcoinKey1Bytes,
BitcoinKey2Bytes: v2Fields.BitcoinKey2Bytes,
+ FundingScript: v2Fields.FundingScript,
+ MerkleRootHash: v2Fields.MerkleRootHash,
ChannelID: chanID,
ChainHash: chainHash,
Features: lnwire.EmptyFeatureVector(),
diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go
index e515b35..cbb3d14 100644
--- a/graph/db/sql_store.go
+++ b/graph/db/sql_store.go
@@ -703,6 +703,11 @@ func (s *SQLStore) NodeUpdatesInHorizon(startTime, endTime time.Time,
func (s *SQLStore) AddChannelEdge(ctx context.Context,
edge *models.ChannelEdgeInfo, opts ...batch.SchedulerOption) error {
+ if !isKnownGossipVersion(edge.Version) {
+ return fmt.Errorf("unsupported gossip version: %d",
+ edge.Version)
+ }
+
var alreadyExists bool
r := &batch.Request[SQLQueries]{
Opts: batch.NewSchedulerOptions(opts...),
@@ -720,7 +725,7 @@ func (s *SQLStore) AddChannelEdge(ctx context.Context,
_, err := tx.GetChannelBySCID(
ctx, sqlc.GetChannelBySCIDParams{
Scid: chanIDB,
- Version: int16(lnwire.GossipVersion1),
+ Version: int16(edge.Version),
},
)
if err == nil {
@@ -4259,26 +4264,16 @@ func marshalExtraOpaqueData(data []byte) (map[uint64][]byte, error) {
func insertChannel(ctx context.Context, db SQLQueries,
edge *models.ChannelEdgeInfo) error {
- v := lnwire.GossipVersion1
-
- // For now, we only support V1 channel edges in the SQL store.
- if edge.Version != v {
- return fmt.Errorf("only V1 channel edges supported, got V%d",
- edge.Version)
- }
+ v := edge.Version
// Make sure that at least a "shell" entry for each node is present in
// the nodes table.
- node1DBID, err := maybeCreateShellNode(
- ctx, db, v, edge.NodeKey1Bytes,
- )
+ node1DBID, err := maybeCreateShellNode(ctx, db, v, edge.NodeKey1Bytes)
if err != nil {
return fmt.Errorf("unable to create shell node: %w", err)
}
- node2DBID, err := maybeCreateShellNode(
- ctx, db, v, edge.NodeKey2Bytes,
- )
+ node2DBID, err := maybeCreateShellNode(ctx, db, v, edge.NodeKey2Bytes)
if err != nil {
return fmt.Errorf("unable to create shell node: %w", err)
}
@@ -4302,6 +4297,12 @@ func insertChannel(ctx context.Context, db SQLQueries,
edge.BitcoinKey2Bytes.WhenSome(func(vertex route.Vertex) {
createParams.BitcoinKey2 = vertex[:]
})
+ edge.FundingScript.WhenSome(func(script []byte) {
+ createParams.FundingPkScript = script
+ })
+ edge.MerkleRootHash.WhenSome(func(hash chainhash.Hash) {
+ createParams.MerkleRootHash = hash[:]
+ })
if edge.AuthProof != nil {
proof := edge.AuthProof
@@ -4310,6 +4311,7 @@ func insertChannel(ctx context.Context, db SQLQueries,
createParams.Node2Signature = proof.NodeSig2()
createParams.Bitcoin1Signature = proof.BitcoinSig1()
createParams.Bitcoin2Signature = proof.BitcoinSig2()
+ createParams.Signature = proof.Sig()
}
// Insert the new channel record.
@@ -4333,10 +4335,13 @@ func insertChannel(ctx context.Context, db SQLQueries,
}
// Finally, insert any extra TLV fields in the channel announcement.
- extra, err := marshalExtraOpaqueData(edge.ExtraOpaqueData)
- if err != nil {
- return fmt.Errorf("unable to marshal extra opaque data: %w",
- err)
+ extra := edge.ExtraSignedFields
+ if v == lnwire.GossipVersion1 {
+ extra, err = marshalExtraOpaqueData(edge.ExtraOpaqueData)
+ if err != nil {
+ return fmt.Errorf("unable to marshal extra opaque "+
+ "data: %w", err)
+ }
}
for tlvType, value := range extra {
@@ -4448,9 +4453,9 @@ func buildEdgeInfoWithBatchData(chain chainhash.Hash,
dbChan sqlc.GraphChannel, node1, node2 route.Vertex,
batchData *batchChannelData) (*models.ChannelEdgeInfo, error) {
- if dbChan.Version != int16(lnwire.GossipVersion1) {
- return nil, fmt.Errorf("unsupported channel version: %d",
- dbChan.Version)
+ v := lnwire.GossipVersion(dbChan.Version)
+ if !isKnownGossipVersion(v) {
+ return nil, fmt.Errorf("unknown channel version: %d", v)
}
// Use pre-loaded features and extras types.
@@ -4474,45 +4479,50 @@ func buildEdgeInfoWithBatchData(chain chainhash.Hash,
return nil, err
}
- recs, err := lnwire.CustomRecords(extras).Serialize()
- if err != nil {
- return nil, fmt.Errorf("unable to serialize extra signed "+
- "fields: %w", err)
- }
- if recs == nil {
- recs = make([]byte, 0)
- }
+ // Build the appropriate channel based on version.
+ var channel *models.ChannelEdgeInfo
+ switch v {
+ case lnwire.GossipVersion1:
+ // For v1, serialize extras into ExtraOpaqueData.
+ recs, err := lnwire.CustomRecords(extras).Serialize()
+ if err != nil {
+ return nil, fmt.Errorf("unable to serialize extra "+
+ "signed fields: %w", err)
+ }
+ if recs == nil {
+ recs = make([]byte, 0)
+ }
- btcKey1, err := route.NewVertexFromBytes(dbChan.BitcoinKey1)
- if err != nil {
- return nil, err
- }
- btcKey2, err := route.NewVertexFromBytes(dbChan.BitcoinKey2)
- if err != nil {
- return nil, err
- }
+ // Bitcoin keys are required for v1.
+ btcKey1, err := route.NewVertexFromBytes(dbChan.BitcoinKey1)
+ if err != nil {
+ return nil, err
+ }
+ btcKey2, err := route.NewVertexFromBytes(dbChan.BitcoinKey2)
+ if err != nil {
+ return nil, err
+ }
- channel, err := models.NewV1Channel(
- byteOrder.Uint64(dbChan.Scid), chain, node1, node2,
- &models.ChannelV1Fields{
- BitcoinKey1Bytes: btcKey1,
- BitcoinKey2Bytes: btcKey2,
- ExtraOpaqueData: recs,
- },
- models.WithChannelPoint(*op),
- models.WithCapacity(btcutil.Amount(dbChan.Capacity.Int64)),
- models.WithFeatures(fv.RawFeatureVector),
- )
- if err != nil {
- return nil, err
- }
+ channel, err = models.NewV1Channel(
+ byteOrder.Uint64(dbChan.Scid), chain, node1, node2,
+ &models.ChannelV1Fields{
+ BitcoinKey1Bytes: btcKey1,
+ BitcoinKey2Bytes: btcKey2,
+ ExtraOpaqueData: recs,
+ },
+ models.WithChannelPoint(*op),
+ models.WithCapacity(
+ btcutil.Amount(dbChan.Capacity.Int64),
+ ),
+ models.WithFeatures(fv.RawFeatureVector),
+ )
+ if err != nil {
+ return nil, err
+ }
- // We always set all the signatures at the same time, so we can
- // safely check if one signature is present to determine if we have the
- // rest of the signatures for the auth proof.
- if len(dbChan.Bitcoin1Signature) > 0 {
- // For v1 channels, we have four signatures.
- if dbChan.Version == int16(lnwire.GossipVersion1) {
+ // For v1 channels, attach the auth proof if all four
+ // signatures are present.
+ if len(dbChan.Bitcoin1Signature) > 0 {
channel.AuthProof = models.NewV1ChannelAuthProof(
dbChan.Node1Signature,
dbChan.Node2Signature,
@@ -4520,7 +4530,69 @@ func buildEdgeInfoWithBatchData(chain chainhash.Hash,
dbChan.Bitcoin2Signature,
)
}
- // TODO(elle): Add v2 support when needed.
+
+ case lnwire.GossipVersion2:
+ v2Fields := &models.ChannelV2Fields{
+ ExtraSignedFields: extras,
+ }
+
+ // For v2, bitcoin keys are optional.
+ if len(dbChan.BitcoinKey1) > 0 {
+ btcKey1, err := route.NewVertexFromBytes(
+ dbChan.BitcoinKey1,
+ )
+ if err != nil {
+ return nil, err
+ }
+ v2Fields.BitcoinKey1Bytes = fn.Some(btcKey1)
+ }
+ if len(dbChan.BitcoinKey2) > 0 {
+ btcKey2, err := route.NewVertexFromBytes(
+ dbChan.BitcoinKey2,
+ )
+ if err != nil {
+ return nil, err
+ }
+ v2Fields.BitcoinKey2Bytes = fn.Some(btcKey2)
+ }
+
+ // Parse funding script if present.
+ if len(dbChan.FundingPkScript) > 0 {
+ v2Fields.FundingScript = fn.Some(dbChan.FundingPkScript)
+ }
+
+ // Parse merkle root hash if present.
+ if len(dbChan.MerkleRootHash) > 0 {
+ var hash chainhash.Hash
+ copy(hash[:], dbChan.MerkleRootHash)
+ v2Fields.MerkleRootHash = fn.Some(hash)
+ }
+
+ opts := []models.EdgeModifier{
+ models.WithChannelPoint(*op),
+ models.WithCapacity(btcutil.Amount(
+ dbChan.Capacity.Int64,
+ )),
+ models.WithFeatures(fv.RawFeatureVector),
+ }
+
+ // For v2 channels, attach the auth proof if the signature is
+ // present.
+ if len(dbChan.Signature) > 0 {
+ proof := models.NewV2ChannelAuthProof(dbChan.Signature)
+ opts = append(opts, models.WithChanProof(proof))
+ }
+
+ channel, err = models.NewV2Channel(
+ byteOrder.Uint64(dbChan.Scid), chain, node1, node2,
+ v2Fields, opts...,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ default:
+ return nil, fmt.Errorf("unsupported channel version: %d", v)
}
return channel, nil
Why this scored 30/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.