funding: reject public taproot opens before sending
What changed, and why it matters
This commit adds a safety check in LND's channel-opening code to prevent users from accidentally creating public (advertised) Taproot payment channels. The current Taproot channel implementation is only meant for private channels, so the patch now rejects such requests locally before any network message is sent. It also adds tests to confirm both sides of the channel-opening handshake reject public Taproot channels.
Treat as a hardening/defensive fix. Review whether the responder-side rejection is robust enough or whether a matching explicit guard should be added on the responder side, since the current diff only shows a generic 'internal error' wire response. No immediate incident response is indicated.
Security signals we found
New input-validation guard restricting a protocol feature combination
Test coverage added for both initiator and responder rejection paths
Change is defensive: blocks public Taproot channel opens that were previously allowed
Evidence from the diff
In funding/manager.go, handleInitFundingMsg now checks if the selected commitment type is Taproot and the Private flag is false. If so, it returns an error (‘taproot channel type for public channel’) without proceeding to fee estimation or sending OpenChannel. The accompanying tests verify the initiator path is blocked before wire transmission and that the responder path also rejects a forged/altered public OpenChannel message. The responder-side rejection appears to rely on an existing generic internal-error path rather than a new dedicated check in this diff.
Changed components
funding/manager.gofunding/manager_test.goLND channel funding workflowTaproot channel negotiationInspect captured patch +123 / −0
diff --git a/funding/manager.go b/funding/manager.go
index 2dcd4f1..66f6885 100644
--- a/funding/manager.go
+++ b/funding/manager.go
@@ -5018,6 +5018,16 @@ func (f *Manager) handleInitFundingMsg(msg *InitFundingMsg) {
}
}
+ // The current variant of taproot channels can only be used with
+ // unadvertised channels for now.
+ if commitType.IsTaproot() && !msg.Private {
+ err = fmt.Errorf("taproot channel type for public channel")
+ log.Error(err)
+ msg.Err <- err
+
+ return
+ }
+
// First, we'll query the fee estimator for a fee that should get the
// commitment transaction confirmed by the next few blocks (conf target
// of 3). We target the near blocks here to ensure that we'll be able
diff --git a/funding/manager_test.go b/funding/manager_test.go
index 0dd9f47..4bbed13 100644
--- a/funding/manager_test.go
+++ b/funding/manager_test.go
@@ -1009,6 +1009,8 @@ func assertFundingMsgSent(t *testing.T, msgChan chan lnwire.Message,
ok bool
)
switch msgType {
+ case "OpenChannel":
+ sentMsg, ok = msg.(*lnwire.OpenChannel)
case "AcceptChannel":
sentMsg, ok = msg.(*lnwire.AcceptChannel)
case "FundingCreated":
@@ -3968,6 +3970,117 @@ func TestFundingManagerPushAmountAtCapacity(t *testing.T) {
}
}
+// TestFundingManagerRejectPublicTaprootInitiator checks that a public taproot
+// channel request is rejected by the initiator before an OpenChannel message is
+// sent to the peer.
+func TestFundingManagerRejectPublicTaprootInitiator(t *testing.T) {
+ t.Parallel()
+
+ alice, bob := setupFundingManagers(t)
+ t.Cleanup(func() {
+ tearDownFundingManagers(t, alice, bob)
+ })
+
+ featureBits := []lnwire.FeatureBit{
+ lnwire.ExplicitChannelTypeOptional,
+ lnwire.SimpleTaprootChannelsOptionalFinal,
+ }
+ alice.localFeatures = featureBits
+ alice.remoteFeatures = featureBits
+ bob.localFeatures = featureBits
+ bob.remoteFeatures = featureBits
+
+ chanType := lnwire.ChannelType(*lnwire.NewRawFeatureVector(
+ lnwire.SimpleTaprootChannelsRequiredFinal,
+ ))
+
+ updateChan := make(chan *lnrpc.OpenStatusUpdate)
+ errChan := make(chan error, 1)
+ initReq := &InitFundingMsg{
+ Peer: bob,
+ TargetPubkey: bob.privKey.PubKey(),
+ ChainHash: *fundingNetParams.GenesisHash,
+ LocalFundingAmt: 500000,
+ Private: false,
+ ChannelType: &chanType,
+ Updates: updateChan,
+ Err: errChan,
+ }
+
+ alice.fundingMgr.InitFundingWorkflow(initReq)
+
+ select {
+ case err := <-errChan:
+ require.ErrorContains(
+ t, err, "taproot channel type for public channel",
+ )
+
+ case msg := <-bob.msgChan:
+ t.Fatalf("expected local error, got %T", msg)
+
+ case <-time.After(time.Second * 5):
+ t.Fatalf("timed out waiting for public taproot error")
+ }
+}
+
+// TestFundingManagerRejectPublicTaprootResponder checks that the responder
+// rejects a public taproot OpenChannel message.
+func TestFundingManagerRejectPublicTaprootResponder(t *testing.T) {
+ t.Parallel()
+
+ alice, bob := setupFundingManagers(t)
+ t.Cleanup(func() {
+ tearDownFundingManagers(t, alice, bob)
+ })
+
+ featureBits := []lnwire.FeatureBit{
+ lnwire.ExplicitChannelTypeOptional,
+ lnwire.SimpleTaprootChannelsOptionalFinal,
+ }
+ alice.localFeatures = featureBits
+ alice.remoteFeatures = featureBits
+ bob.localFeatures = featureBits
+ bob.remoteFeatures = featureBits
+
+ chanType := lnwire.ChannelType(*lnwire.NewRawFeatureVector(
+ lnwire.SimpleTaprootChannelsRequiredFinal,
+ ))
+
+ updateChan := make(chan *lnrpc.OpenStatusUpdate)
+ errChan := make(chan error, 1)
+ initReq := &InitFundingMsg{
+ Peer: bob,
+ TargetPubkey: bob.privKey.PubKey(),
+ ChainHash: *fundingNetParams.GenesisHash,
+ LocalFundingAmt: 500000,
+ Private: true,
+ ChannelType: &chanType,
+ Updates: updateChan,
+ Err: errChan,
+ }
+
+ alice.fundingMgr.InitFundingWorkflow(initReq)
+
+ msg := assertFundingMsgSent(t, alice.msgChan, "OpenChannel")
+ openChannelReq, ok := msg.(*lnwire.OpenChannel)
+ require.True(t, ok)
+
+ // Flip the captured wire message to public so the responder path is
+ // exercised without being blocked by the initiator-side guard.
+ openChannelReq.ChannelFlags = lnwire.FFAnnounceChannel
+ bob.fundingMgr.ProcessFundingMsg(openChannelReq, alice)
+
+ // The specific taproot/public failure is logged locally; the wire error
+ // carries the generic message used for non-whitelisted funding errors.
+ errMsg := assertFundingMsgSent(t, bob.msgChan, "Error")
+ err, ok := errMsg.(*lnwire.Error)
+ require.True(t, ok)
+ require.ErrorContains(
+ t, err, "funding failed due to internal error",
+ )
+ assertNumPendingReservations(t, bob, alicePubKey, 0)
+}
+
// TestFundingManagerMaxConfs ensures that we don't accept a funding proposal
// that proposes a MinAcceptDepth greater than the maximum number of
// confirmations we're willing to accept.
Why this scored 43/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.