graph/db: honor taproot feature bit in v1 funding script construction
What changed, and why it matters
This commit fixes a bug in the LND Lightning node where it could look up the wrong Bitcoin address (funding script) for certain private Taproot channels when rebuilding its chain filter after a restart. The wrong address was a legacy multisig address instead of the expected Taproot address. Because the node uses this filter to watch the blockchain for relevant transactions, using the wrong address could cause it to miss on-chain activity related to those channels. The commit makes the code check the channel's feature bits to decide which address type to reconstruct.
Apply the patch. After upgrade, nodes that had private taproot channels should verify that chain filters are rebuilt correctly; consider monitoring for any missed on-chain events on affected channels and, if necessary, force a rescan or filter refresh. Review whether any other read paths still call FundingPKScript() without loaded feature bits.
Security signals we found
Incorrect funding script reconstruction on read path
Chain filter (rescan/watch filter) could omit relevant taproot channel funding outputs
Mismatch between insertion path (makeFundingScript) and read path (FundingPKScript)
Private taproot channels affected because stored as v1 gossip with taproot feature bit
Potential missed on-chain events for affected channels after restart
Evidence from the diff
FundingPKScript() in graph/db/models/channel_edge_info.go previously ignored the SimpleTaprootChannelsOptionalStaging feature bit for v1 ChannelEdgeInfo objects and always produced a legacy P2WSH 2-of-2 multisig script. For private taproot channels stored as v1 gossip edges with the taproot bit set, this caused read paths such as ChannelView() to reconstruct an incorrect funding script. The fix checks the feature bit and uses input.GenTaprootFundingScript() with the stored bitcoin keys and MerkleRootHash when present. SQLStore.ChannelView() is updated to load channel feature bits and pass them into the reconstruction path. Tests are updated to assert the correct taproot script.
Changed components
graph/db/models/channel_edge_info.go: FundingPKScript()graph/db/sql_store.go: ChannelView()graph/db/graph_test.gograph/db/models/channel_edge_info_test.goInspect captured patch +77 / −22
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index 241f29c..acd548d 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -3902,9 +3902,9 @@ 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.
+// testChannelViewTaprootV1RoundTrip tests that a taproot channel persisted as a
+// v1 edge can be read back from ChannelView() with the correct taproot funding
+// script.
func testChannelViewTaprootV1RoundTrip(t *testing.T, v lnwire.GossipVersion) {
t.Parallel()
@@ -3952,14 +3952,12 @@ func testChannelViewTaprootV1RoundTrip(t *testing.T, v lnwire.GossipVersion) {
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(),
+ // The fix should make ChannelView reconstruct the taproot funding
+ // script for v1 channels that advertise the taproot staging bit.
+ expectedScript, _, err := input.GenTaprootFundingScript(
+ node1Pub, node2Pub, 0, fn.None[chainhash.Hash](),
)
require.NoError(t, err)
- expectedScript, err := input.WitnessScriptHash(witnessScript)
- require.NoError(t, err)
channelView, err := graph.ChannelView(ctx)
require.NoError(t, err)
diff --git a/graph/db/models/channel_edge_info.go b/graph/db/models/channel_edge_info.go
index aae7b91..a7e1107 100644
--- a/graph/db/models/channel_edge_info.go
+++ b/graph/db/models/channel_edge_info.go
@@ -304,6 +304,32 @@ func (c *ChannelEdgeInfo) FundingPKScript() ([]byte, error) {
return nil, err
}
+ if c.Features != nil && c.Features.HasFeature(
+ lnwire.SimpleTaprootChannelsOptionalStaging,
+ ) {
+
+ pubKey1, err := btcec.ParsePubKey(btc1Key[:])
+ if err != nil {
+ return nil, err
+ }
+ pubKey2, err := btcec.ParsePubKey(btc2Key[:])
+ if err != nil {
+ return nil, err
+ }
+
+ fundingScript, _, err := input.GenTaprootFundingScript(
+ pubKey1, pubKey2, 0, c.MerkleRootHash,
+ )
+ if err != nil {
+ return nil, fmt.Errorf(
+ "unable to make taproot pkscript: %w",
+ err,
+ )
+ }
+
+ return fundingScript, nil
+ }
+
witnessScript, err := input.GenMultiSigScript(
btc1Key[:], btc2Key[:],
)
diff --git a/graph/db/models/channel_edge_info_test.go b/graph/db/models/channel_edge_info_test.go
index 93a9b92..7102895 100644
--- a/graph/db/models/channel_edge_info_test.go
+++ b/graph/db/models/channel_edge_info_test.go
@@ -146,10 +146,9 @@ func TestFundingPKScriptV2(t *testing.T) {
})
}
-// 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) {
+// TestFundingPKScriptV1TaprootFeatureBit tests that a v1 channel edge carrying
+// the taproot staging bit reconstructs a taproot funding script.
+func TestFundingPKScriptV1TaprootFeatureBit(t *testing.T) {
t.Parallel()
privKey1, err := btcec.NewPrivateKey()
@@ -181,14 +180,12 @@ func TestFundingPKScriptV1TaprootFeatureBitBug(t *testing.T) {
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(),
+ // The fix should make FundingPKScript honor the feature bit and derive
+ // the taproot funding script directly from the stored bitcoin keys.
+ expectedScript, _, err := input.GenTaprootFundingScript(
+ pubKey1, pubKey2, 0, fn.None[chainhash.Hash](),
)
require.NoError(t, err)
- expectedScript, err := input.WitnessScriptHash(witnessScript)
- require.NoError(t, err)
require.Equal(t, expectedScript, pkScript)
}
diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go
index 0e39075..ff1170a 100644
--- a/graph/db/sql_store.go
+++ b/graph/db/sql_store.go
@@ -3054,7 +3054,8 @@ func (s *SQLStore) ChannelView(ctx context.Context,
switch v {
case gossipV1:
handleChannel := func(_ context.Context,
- channel sqlc.ListChannelsPaginatedRow) error {
+ channel sqlc.ListChannelsPaginatedRow,
+ chanFeats map[int64][]int) error {
key1, err := route.NewVertexFromBytes(
channel.BitcoinKey1,
@@ -3070,11 +3071,28 @@ func (s *SQLStore) ChannelView(ctx context.Context,
return err
}
+ // Private taproot channels are currently stored
+ // as simple v1 channels that only need the
+ // taproot staging bit to reconstruct the BIP86
+ // funding script. They do not carry a custom
+ // tapscript root on this path.
+ //
+ // TODO: Remove this v1 feature-bit workaround
+ // once private taproot channels have been
+ // migrated to v2 gossip objects.
+ feats := lnwire.EmptyFeatureVector()
+ bits := chanFeats[channel.ID]
+ for _, bit := range bits {
+ feats.Set(lnwire.FeatureBit(bit))
+ }
+
edge := &models.ChannelEdgeInfo{
Version: gossipV1,
BitcoinKey1Bytes: fn.Some(key1),
BitcoinKey2Bytes: fn.Some(key2),
+ Features: feats,
}
+
pkScript, err := edge.FundingPKScript()
if err != nil {
return err
@@ -3114,9 +3132,25 @@ func (s *SQLStore) ChannelView(ctx context.Context,
return row.ID
}
- return sqldb.ExecutePaginatedQuery(
+ collectID := func(
+ row sqlc.ListChannelsPaginatedRow) (int64,
+ error) {
+
+ return row.ID, nil
+ }
+
+ loadChannelFeatures := func(ctx context.Context,
+ chanIDs []int64) (map[int64][]int, error) {
+
+ return batchLoadChannelFeaturesHelper(
+ ctx, s.cfg.QueryCfg, db, chanIDs,
+ )
+ }
+
+ return sqldb.ExecuteCollectAndBatchWithSharedDataQuery(
ctx, s.cfg.QueryCfg, int64(-1), queryFunc,
- extractCursor, handleChannel,
+ extractCursor, collectID, loadChannelFeatures,
+ handleChannel,
)
case gossipV2:
Why this scored 58/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.