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
This commit is contained in:
Elle Mouton committed 2026-02-13 07:21:22 +02:00
1 parent 25bbb39030
commit 6842252bf8
2 files changed
+274

No files matched your search

+127
View File
@@ -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)
+147
View File
@@ -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)
})
}