graph/db: add tests to document v1 taproot funding script bug
What changed, and why it matters
This commit only adds automated tests that demonstrate an existing bug in LND's handling of private taproot channels. The bug causes the wallet to watch for the wrong Bitcoin address when monitoring the blockchain for channel funding transactions. Because the commit itself does not change production code, it cannot be exploited; rather, it documents a real bug that the next commit is intended to fix. If left unfixed, the bug could prevent LND from correctly detecting on-chain activity for some taproot channels.
Review and apply the follow-up commit that fixes FundingPKScript() and ChannelView() to honor the SimpleTaprootChannelsRequiredStaging feature bit. Until then, operators relying on private taproot channels should be aware that chain-watch filters may use incorrect scripts after restart.
Security signals we found
Incorrect funding script reconstruction for taproot channels in graph DB read paths
Chain watch filter may miss or misidentify on-chain funding outputs for private taproot channels
Gossiper insertion path correctly honors taproot bit, creating an inconsistency between write-time validation and read-time reconstruction
Commit is test-only; the actual vulnerability is in the pre-existing production code being documented
Evidence from the diff
The commit introduces two failing tests in graph/db/graph_test.go and graph/db/models/channel_edge_info_test.go. They show that for v1 gossip channel edges carrying the SimpleTaprootChannelsRequiredStaging feature bit, FundingPKScript() and ChannelView() reconstruct a legacy 2-of-2 P2WSH multisig script instead of the correct taproot funding script. This affects chain-watch filter reconstruction on restart. The commit message explicitly states the bug has existed since private taproot channels were introduced and that the next commit will fix it.
Changed components
graph/db/models.ChannelEdgeInfo.FundingPKScriptgraph/db.ChannelViewchain watch filter reconstruction on restartprivate taproot channels serialized as v1 gossip edgesInspect captured patch +118 / −0
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index 46ac8d0..241f29c 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -23,6 +23,7 @@ import (
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
+ "github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/lntest/wait"
"github.com/lightningnetwork/lnd/lnwire"
@@ -209,6 +210,10 @@ var versionedTests = []versionedTest{
name: "channel view",
test: testChannelView,
},
+ {
+ name: "channel view taproot v1 round trip",
+ test: testChannelViewTaprootV1RoundTrip,
+ },
}
// TestVersionedDBs runs various tests against both v1 and v2 versioned
@@ -3897,6 +3902,72 @@ func testChannelView(t *testing.T, v lnwire.GossipVersion) {
assertChanViewEqual(t, channelView, edgePoints)
}
+// testChannelViewTaprootV1RoundTripBug documents the current bug: a taproot
+// channel persisted as a v1 edge is read back from ChannelView() with a legacy
+// P2WSH funding script. The next commit fixes this behavior.
+func testChannelViewTaprootV1RoundTrip(t *testing.T, v lnwire.GossipVersion) {
+ t.Parallel()
+
+ if v != lnwire.GossipVersion1 {
+ t.Skip("only relevant for v1 taproot workaround channels")
+ }
+
+ ctx := t.Context()
+ graph := NewVersionedGraph(MakeTestGraph(t), v)
+
+ node1 := createTestVertex(t, v)
+ require.NoError(t, graph.AddNode(ctx, node1))
+ node2 := createTestVertex(t, v)
+ require.NoError(t, graph.AddNode(ctx, node2))
+
+ node1Pub, err := node1.PubKey()
+ require.NoError(t, err)
+ node2Pub, err := node2.PubKey()
+ require.NoError(t, err)
+
+ node1Vertex := route.NewVertex(node1Pub)
+ node2Vertex := route.NewVertex(node2Pub)
+ outpoint := wire.OutPoint{
+ Hash: rev,
+ Index: 1,
+ }
+
+ // Persist a synthetic v1 channel that advertises the taproot staging
+ // bit. This reproduces the serialization path exercised by older graph
+ // entries.
+ edgeInfo, err := models.NewV1Channel(
+ 1, *chaincfg.MainNetParams.GenesisHash,
+ node1Vertex, node2Vertex,
+ &models.ChannelV1Fields{
+ BitcoinKey1Bytes: node1Vertex,
+ BitcoinKey2Bytes: node2Vertex,
+ ExtraOpaqueData: make([]byte, 0),
+ },
+ models.WithChannelPoint(outpoint),
+ models.WithCapacity(9000),
+ models.WithFeatures(lnwire.NewRawFeatureVector(
+ lnwire.SimpleTaprootChannelsRequiredStaging,
+ )),
+ )
+ require.NoError(t, err)
+ require.NoError(t, graph.AddChannelEdge(ctx, edgeInfo))
+
+ // The current buggy behavior reconstructs the legacy 2-of-2 witness
+ // script hash when ChannelView reads the edge back out of the database.
+ witnessScript, err := input.GenMultiSigScript(
+ node1Pub.SerializeCompressed(), node2Pub.SerializeCompressed(),
+ )
+ require.NoError(t, err)
+ expectedScript, err := input.WitnessScriptHash(witnessScript)
+ require.NoError(t, err)
+
+ channelView, err := graph.ChannelView(ctx)
+ require.NoError(t, err)
+ require.Len(t, channelView, 1)
+ require.Equal(t, expectedScript, channelView[0].FundingPkScript)
+ require.Equal(t, outpoint, channelView[0].OutPoint)
+}
+
// testIncompleteChannelPolicies tests that a channel that only has a policy
// specified on one end is properly returned in ForEachChannel calls from
// both sides.
diff --git a/graph/db/models/channel_edge_info_test.go b/graph/db/models/channel_edge_info_test.go
index 464ca21..93a9b92 100644
--- a/graph/db/models/channel_edge_info_test.go
+++ b/graph/db/models/channel_edge_info_test.go
@@ -145,3 +145,50 @@ func TestFundingPKScriptV2(t *testing.T) {
require.Equal(t, storedScript, pkScript)
})
}
+
+// TestFundingPKScriptV1TaprootFeatureBitBug documents the current bug: a v1
+// channel edge carrying the taproot staging bit still reconstructs a legacy
+// P2WSH funding script. The next commit fixes this behavior.
+func TestFundingPKScriptV1TaprootFeatureBitBug(t *testing.T) {
+ t.Parallel()
+
+ privKey1, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ pubKey1 := privKey1.PubKey()
+
+ privKey2, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ pubKey2 := privKey2.PubKey()
+
+ var btcKey1, btcKey2 route.Vertex
+ copy(btcKey1[:], pubKey1.SerializeCompressed())
+ copy(btcKey2[:], pubKey2.SerializeCompressed())
+
+ // Build a v1 edge that only has the legacy bitcoin keys populated, but
+ // does advertise the taproot staging bit in its feature vector.
+ edge := &ChannelEdgeInfo{
+ Version: lnwire.GossipVersion1,
+ BitcoinKey1Bytes: fn.Some(btcKey1),
+ BitcoinKey2Bytes: fn.Some(btcKey2),
+ Features: lnwire.NewFeatureVector(
+ lnwire.NewRawFeatureVector(
+ lnwire.SimpleTaprootChannelsRequiredStaging,
+ ),
+ lnwire.Features,
+ ),
+ }
+
+ pkScript, err := edge.FundingPKScript()
+ require.NoError(t, err)
+
+ // The pre-fix behavior ignores the feature bit here and reconstructs
+ // the legacy P2WSH funding script from the stored multisig keys.
+ witnessScript, err := input.GenMultiSigScript(
+ pubKey1.SerializeCompressed(), pubKey2.SerializeCompressed(),
+ )
+ require.NoError(t, err)
+ expectedScript, err := input.WitnessScriptHash(witnessScript)
+ require.NoError(t, err)
+
+ require.Equal(t, expectedScript, pkScript)
+}
Why this scored 62/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.