From 6842252bf8a57d22dbba2ae6690349fa497938ce Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 19 Nov 2025 16:17:42 +0200 Subject: [PATCH] graph/db: add v2 channel announcement fields to ChannelEdgeInfo This commit extends ChannelEdgeInfo to support v2 (taproot) channel announcements by adding: - MerkleRootHash for the optional Merkle tree commitment - ExtraSignedFields for additional signed TLV fields - ChannelV2Fields struct to encapsulate v2-specific optional fields - NewV2Channel constructor for creating v2 channel edges - FundingPKScript handling for MuSig2 key aggregation with optional taproot tweaks --- graph/db/models/channel_edge_info.go | 127 +++++++++++++++++++ graph/db/models/channel_edge_info_test.go | 147 ++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 graph/db/models/channel_edge_info_test.go diff --git a/graph/db/models/channel_edge_info.go b/graph/db/models/channel_edge_info.go index 3e4a06587..4855c4ee4 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 000000000..464ca212d --- /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) + }) +}