Skip to content

funding: no fallback when taproot negotiated for public channel #10819

@calvinrzachman

Description

@calvinrzachman

Pre-Submission Checklist

  • I have searched the existing issues and believe this is a new bug.
  • I am not asking a question about how to use lnd, but reporting a bug.

LND Version

v0.21.0-beta.rc1

Bug Details & Steps to Reproduce

When --protocol.simple-taproot-chans is enabled, opening a public channel
without specifying a commitment type fails with "taproot channel type for
public channel".

implicitNegotiateCommitmentType
(funding/commitment_type_negotiation.go:464-472) checks taproot before
anchors in its priority order:

// Taproot channels are checked before anchors intentionally: when both
// peers support taproot, we prefer the newer channel type.
if hasFeatures(local, remote, lnwire.SimpleTaprootChannelsOptionalFinal) {
    chanType := lnwire.ChannelType(*lnwire.NewRawFeatureVector(
        lnwire.SimpleTaprootChannelsRequiredFinal,
    ))
    return &chanType, lnwallet.CommitmentTypeSimpleTaprootFinal
}

This function has no visibility into whether the channel will be public or
private. The funding manager enforces the public/private constraint later
(funding/manager.go:1658-1666):

case commitType.IsTaproot() && public:
    err = fmt.Errorf("taproot channel type for public channel")

The negotiation selects a channel type that the funding manager then rejects.
There is no fallback or re-negotiation path.

Steps to reproduce:

  1. Run two lnd v0.21 nodes with --protocol.simple-taproot-chans enabled.
  2. Open a channel: lncli openchannel --node_key=<peer> --local_amt=1000000
    (no --private flag, no explicit commitment type).
  3. The funding flow fails: "funding failed due to internal error" (which wraps
    "taproot channel type for public channel").

lnd's own integration tests don't catch this because every taproot channel
test explicitly passes Private: true (with // TODO(roasbeef): lift after G175 comments noting the gossip limitation). The default public channel path
is not tested with two taproot-capable peers.

Expected Behavior

When a public channel is requested (the default), implicit negotiation should
fall back to the next best supported type (anchors) rather than selecting
taproot and failing. A channel open that doesn't request a specific commitment
type should succeed regardless of which protocol features are enabled.

Impact

This affects any node that enables --protocol.simple-taproot-chans and
then opens a public channel to another node with the same flag enabled.
The flag is opt-in, so vanilla lnd deployments are not affected. However,
any user or integration test that enables taproot channel support will hit
this when opening public channels without specifying a commitment type.

In practice this breaks every litd deployment that opens public BTC channels
between two litd nodes, because litd enables --protocol.simple-taproot-chans
for taproot-assets overlay channels.

The workaround for openchannel is to pass --private or explicitly request
a non-taproot commitment type (e.g., --commitment_type=anchors). However,
lncli batchopenchannel does not expose --commitment_type, so there is no
workaround for batch opens — the only option is to exclude litd peers from the
batch and open those channels individually with an explicit type.

This was confirmed in real-world testing by a developer using litd's
frontend-regtest environment: batch-opening public channels from a v0.21 litd
node to a mix of litd and lnd v0.19 peers fails because the litd↔litd channel
negotiates taproot, which then also triggers the rpcacceptor bit 80 gap
(lnd #10763, merged to master but not yet in a release tag). Even with that
fix included, the implicit-negotiation issue remains — litd↔litd public
channel opens will still fail without an explicit commitment type override.

Root Cause

implicitNegotiateCommitmentType selects the "best" commitment type based
purely on feature bit support, without considering whether the channel will be
public or private. The public/private constraint is checked later by the
funding manager, after the type has already been negotiated and communicated
to the peer.

Enabling --protocol.simple-taproot-chans makes taproot the implicit default
for all channels, but taproot channels can't be public yet (the gossip protocol
doesn't support announced taproot channels). The implicit negotiation should
account for this constraint.

Suggested Fix

Pass the channel flags into the negotiation function so it can skip taproot
when the channel is public:

func implicitNegotiateCommitmentType(local, remote *lnwire.FeatureVector,
    public bool) (*lnwire.ChannelType, lnwallet.CommitmentType) {

    if !public && hasFeatures(local, remote,
        lnwire.SimpleTaprootChannelsOptionalFinal) {
        // ... select taproot
    }

    // Fall through to anchors for public channels.
    if hasFeatures(local, remote, lnwire.AnchorsZeroFeeHtlcTxOptional) {
        // ...
    }
}

This preserves the taproot preference for private channels while falling back
to anchors for public channels until gossip support is ready.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions