graph/db: add v2 channel announcement fields to ChannelEdgeInfo
What changed, and why it matters
This commit adds new data fields and code to support version 2 (Taproot) Lightning channels in LND's graph database. It does not fix a bug or close a security hole; it is a feature addition that lets the software understand and store newer channel announcement formats, including optional Merkle tree commitments and MuSig2 aggregated public keys. There is no indication in the commit or supplied references that this change addresses a security vulnerability.
No immediate security action required. Review the new MuSig2/taproot key aggregation logic during normal code review to ensure correct handling of optional Bitcoin keys and Merkle root tweaks, but treat this as a feature commit rather than a security patch.
Security signals we found
No security-relevant signal in commit title or message
No CVE, advisory, or security-fix wording present
No validation or hardening changes observed
New cryptographic code added (MuSig2 key aggregation, taproot tweaks) but appears to be feature implementation, not a fix
Evidence from the diff
The patch extends graph/db/models.ChannelEdgeInfo with v2 channel announcement support: MerkleRootHash (fn.Option[chainhash.Hash]), ExtraSignedFields (map[uint64][]byte), a ChannelV2Fields struct, a NewV2Channel constructor, and GossipVersion2 handling in FundingPKScript using musig2.AggregateKeys with either BIP86 or taproot tweaks. It also adds unit tests for the new FundingPKScript paths. No existing behavior is removed or hardened; the change is additive and preparatory for Taproot channel gossip.
Changed components
graph/db/models/channel_edge_info.gograph/db/models/channel_edge_info_test.goInspect captured patch +274 / −0
diff --git a/graph/db/models/channel_edge_info.go b/graph/db/models/channel_edge_info.go
index 3e4a065..4855c4e 100644
--- a/graph/db/models/channel_edge_info.go
+++ b/graph/db/models/channel_edge_info.go
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
@@ -40,9 +41,15 @@ type ChannelEdgeInfo struct {
NodeKey2Bytes route.Vertex
// BitcoinKey1Bytes is the raw public key of the first node.
+ //
+ // NOTE: this must be set for v1 channels but is optional for v2 and
+ // beyond.
BitcoinKey1Bytes fn.Option[route.Vertex]
// BitcoinKey2Bytes is the raw public key of the second node.
+ //
+ // NOTE: this must be set for v1 channels but is optional for v2 and
+ // beyond.
BitcoinKey2Bytes fn.Option[route.Vertex]
// Features is the list of protocol features supported by this channel
@@ -68,13 +75,28 @@ type ChannelEdgeInfo struct {
// the edge object is loaded from the database.
FundingScript fn.Option[[]byte]
+ // MerkleRootHash is an optional root hash of a Merkle tree that the
+ // funding output is committed to. This is then used to compute the
+ // final funding output script.
+ //
+ // NOTE: only used for version 2 channels and beyond.
+ MerkleRootHash fn.Option[chainhash.Hash]
+
// ExtraOpaqueData is the set of data that was appended to this
// message, some of which we may not actually know how to iterate or
// parse. By holding onto this data, we ensure that we're able to
// properly validate the set of signatures that cover these new fields,
// and ensure we're able to make upgrades to the network in a forwards
// compatible manner.
+ //
+ // NOTE: only used for version 1 channels.
ExtraOpaqueData []byte
+
+ // ExtraSignedFields is a map of extra fields that are covered by the
+ // node announcement's signature that we have not explicitly parsed.
+ //
+ // NOTE: This is only used for version 2 node announcements and beyond.
+ ExtraSignedFields map[uint64][]byte
}
// EdgeModifier is a functional option that modifies a ChannelEdgeInfo.
@@ -170,6 +192,53 @@ func NewV1Channel(chanID uint64, chainHash chainhash.Hash, node1,
return edge, nil
}
+// ChannelV2Fields contains the fields that are specific to v2 channel
+// announcements.
+type ChannelV2Fields struct {
+ // BitcoinKey1Bytes is the raw public key of the first node.
+ BitcoinKey1Bytes fn.Option[route.Vertex]
+
+ // BitcoinKey2Bytes is the raw public key of the second node.
+ BitcoinKey2Bytes fn.Option[route.Vertex]
+
+ // ExtraSignedFields is a map of extra fields that are covered by the
+ // node announcement's signature that we have not explicitly parsed.
+ //
+ // NOTE: This is only used for version 2 node announcements and beyond.
+ ExtraSignedFields map[uint64][]byte
+}
+
+// NewV2Channel creates a new ChannelEdgeInfo for a v2 channel announcement.
+func NewV2Channel(chanID uint64, chainHash chainhash.Hash, node1,
+ node2 route.Vertex, v2Fields *ChannelV2Fields,
+ opts ...EdgeModifier) (*ChannelEdgeInfo, error) {
+
+ edge := &ChannelEdgeInfo{
+ Version: lnwire.GossipVersion2,
+ NodeKey1Bytes: node1,
+ NodeKey2Bytes: node2,
+ BitcoinKey1Bytes: v2Fields.BitcoinKey1Bytes,
+ BitcoinKey2Bytes: v2Fields.BitcoinKey2Bytes,
+ ChannelID: chanID,
+ ChainHash: chainHash,
+ Features: lnwire.EmptyFeatureVector(),
+ ExtraSignedFields: v2Fields.ExtraSignedFields,
+ }
+
+ for _, opt := range opts {
+ opt(edge)
+ }
+
+ // Validate some fields after the options have been applied.
+ if edge.AuthProof != nil && edge.AuthProof.Version != edge.Version {
+ return nil, fmt.Errorf("channel auth proof version %d does "+
+ "not match channel version %d", edge.AuthProof.Version,
+ edge.Version)
+ }
+
+ return edge, nil
+}
+
// NodeKey1 is the identity public key of the "first" node that was involved in
// the creation of this channel. A node is considered "first" if the
// lexicographical ordering the its serialized public key is "smaller" than
@@ -227,6 +296,64 @@ func (c *ChannelEdgeInfo) FundingPKScript() ([]byte, error) {
return input.WitnessScriptHash(witnessScript)
+ case lnwire.GossipVersion2:
+ var (
+ pubKey1 *btcec.PublicKey
+ pubKey2 *btcec.PublicKey
+ err error
+ )
+ c.BitcoinKey1Bytes.WhenSome(func(key route.Vertex) {
+ pubKey1, err = btcec.ParsePubKey(key[:])
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ c.BitcoinKey2Bytes.WhenSome(func(key route.Vertex) {
+ pubKey2, err = btcec.ParsePubKey(key[:])
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // If both bitcoin keys are not present in the announcement,
+ // then we should previously have stored the funding script
+ // found on-chain.
+ if pubKey1 == nil || pubKey2 == nil {
+ return c.FundingScript.UnwrapOrErr(fmt.Errorf(
+ "expected a funding pk script since no " +
+ "bitcoin keys were provided",
+ ))
+ }
+
+ // By default, the tweak is empty which results in a BIP86
+ // output. If we have a merkle root, we'll use that as the
+ // tweak.
+ muSig2Opt := musig2.WithBIP86KeyTweak()
+ c.MerkleRootHash.WhenSome(func(hash chainhash.Hash) {
+ muSig2Opt = musig2.WithTaprootKeyTweak(hash[:])
+ })
+
+ // Compute the output key.
+ combinedKey, _, _, err := musig2.AggregateKeys(
+ []*btcec.PublicKey{pubKey1, pubKey2}, true, muSig2Opt,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // Now that we have the combined key, we can create a taproot
+ // pkScript from this, and then make the txout given the amount.
+ fundingScript, err := input.PayToTaprootScript(
+ combinedKey.FinalKey,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("unable to make taproot "+
+ "pkscript: %w", err)
+ }
+
+ return fundingScript, nil
+
default:
return nil, fmt.Errorf("unsupported channel version: %d",
c.Version)
diff --git a/graph/db/models/channel_edge_info_test.go b/graph/db/models/channel_edge_info_test.go
new file mode 100644
index 0000000..464ca21
--- /dev/null
+++ b/graph/db/models/channel_edge_info_test.go
@@ -0,0 +1,147 @@
+package models
+
+import (
+ "testing"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/lightningnetwork/lnd/input"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/routing/route"
+ "github.com/stretchr/testify/require"
+)
+
+// TestFundingPKScriptV2 tests the FundingPKScript method for v2 channels
+// which uses MuSig2 key aggregation.
+func TestFundingPKScriptV2(t *testing.T) {
+ t.Parallel()
+
+ // Generate two test keys for bitcoin keys.
+ privKey1, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ pubKey1 := privKey1.PubKey()
+
+ privKey2, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ pubKey2 := privKey2.PubKey()
+
+ // Convert to route.Vertex format.
+ var btcKey1, btcKey2 route.Vertex
+ copy(btcKey1[:], pubKey1.SerializeCompressed())
+ copy(btcKey2[:], pubKey2.SerializeCompressed())
+
+ // Create a test merkle root.
+ var merkleRoot chainhash.Hash
+ copy(merkleRoot[:], []byte("test-merkle-root-hash-32-bytes!"))
+
+ t.Run("v2 with btc keys, no merkle root (BIP86)", func(t *testing.T) {
+ t.Parallel()
+
+ edge := &ChannelEdgeInfo{
+ Version: lnwire.GossipVersion2,
+ BitcoinKey1Bytes: fn.Some(btcKey1),
+ BitcoinKey2Bytes: fn.Some(btcKey2),
+ }
+
+ pkScript, err := edge.FundingPKScript()
+ require.NoError(t, err)
+ require.NotEmpty(t, pkScript)
+
+ // Verify it's a valid taproot script (OP_1 <32-byte-key>).
+ require.Len(t, pkScript, 34)
+ require.Equal(t, byte(0x51), pkScript[0]) // OP_1
+
+ // Manually compute expected script using BIP86 tweak.
+ combinedKey, _, _, err := musig2.AggregateKeys(
+ []*btcec.PublicKey{pubKey1, pubKey2}, true,
+ musig2.WithBIP86KeyTweak(),
+ )
+ require.NoError(t, err)
+
+ expectedScript, err := input.PayToTaprootScript(
+ combinedKey.FinalKey,
+ )
+ require.NoError(t, err)
+ require.Equal(t, expectedScript, pkScript)
+ })
+
+ t.Run("v2 with bitcoin keys and merkle root", func(t *testing.T) {
+ t.Parallel()
+
+ edge := &ChannelEdgeInfo{
+ Version: lnwire.GossipVersion2,
+ BitcoinKey1Bytes: fn.Some(btcKey1),
+ BitcoinKey2Bytes: fn.Some(btcKey2),
+ MerkleRootHash: fn.Some(merkleRoot),
+ }
+
+ pkScript, err := edge.FundingPKScript()
+ require.NoError(t, err)
+ require.NotEmpty(t, pkScript)
+
+ // Verify it's a valid taproot script.
+ require.Len(t, pkScript, 34)
+ require.Equal(t, byte(0x51), pkScript[0]) // OP_1
+
+ // Manually compute expected script with taproot tweak.
+ combinedKey, _, _, err := musig2.AggregateKeys(
+ []*btcec.PublicKey{pubKey1, pubKey2}, true,
+ musig2.WithTaprootKeyTweak(merkleRoot[:]),
+ )
+ require.NoError(t, err)
+
+ expectedScript, err := input.PayToTaprootScript(
+ combinedKey.FinalKey,
+ )
+ require.NoError(t, err)
+ require.Equal(t, expectedScript, pkScript)
+ })
+
+ t.Run("v2 no btc keys returns stored script", func(t *testing.T) {
+ t.Parallel()
+
+ storedScript := []byte{0x51, 0x20} // OP_1 + push 32
+ storedScript = append(storedScript, make([]byte, 32)...)
+
+ edge := &ChannelEdgeInfo{
+ Version: lnwire.GossipVersion2,
+ FundingScript: fn.Some(storedScript),
+ }
+
+ pkScript, err := edge.FundingPKScript()
+ require.NoError(t, err)
+ require.Equal(t, storedScript, pkScript)
+ })
+
+ t.Run("v2 no btc keys and no stored script errors", func(t *testing.T) {
+ t.Parallel()
+
+ edge := &ChannelEdgeInfo{
+ Version: lnwire.GossipVersion2,
+ }
+
+ _, err := edge.FundingPKScript()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "expected a funding pk script")
+ })
+
+ t.Run("v2 one btc key returns stored script", func(t *testing.T) {
+ t.Parallel()
+
+ storedScript := []byte{0x51, 0x20}
+ storedScript = append(storedScript, make([]byte, 32)...)
+
+ // Only key1 set, key2 missing.
+ edge := &ChannelEdgeInfo{
+ Version: lnwire.GossipVersion2,
+ BitcoinKey1Bytes: fn.Some(btcKey1),
+ FundingScript: fn.Some(storedScript),
+ }
+
+ pkScript, err := edge.FundingPKScript()
+ require.NoError(t, err)
+ require.Equal(t, storedScript, pkScript)
+ })
+}
Why this scored 12/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.