From 1e3b0747d9b640e817b02a2062b73313f8da15e6 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 21 Sep 2026 18:20:23 -0300 Subject: [PATCH 1/8] lnwire: add a funding error for the deprecated channel type In this commit, we add ErrChanTypeDeprecated so that a peer whose channel type we reject learns the actual reason. failFundingFlow only forwards the text of whitelisted error types and flattens everything else into a generic "funding failed due to internal error", which tells the remote nothing. The string is kept as terse as the ones around it. What the remote needs to know is that the type isn't acceptable; which type to use instead is our local policy, and spelling it out on the wire would only add another fingerprint for no gain. --- lnwire/error.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lnwire/error.go b/lnwire/error.go index b5105ebd8b8..18cc129717d 100644 --- a/lnwire/error.go +++ b/lnwire/error.go @@ -31,6 +31,13 @@ const ( // FundingOpen request which doesn't specify an explicit channel type, // as mandated by BOLT-02. ErrChanTypeRequired FundingError = 3 + + // ErrChanTypeDeprecated is returned by a remote peer that receives a + // FundingOpen request for the legacy commitment type, which it no + // longer opens. It is kept terse on purpose: which type the peer + // should use instead is our local policy, not something the remote can + // act on. + ErrChanTypeDeprecated FundingError = 4 ) // String returns a human readable version of the target FundingError. @@ -42,6 +49,8 @@ func (e FundingError) String() string { return "channel too large" case ErrChanTypeRequired: return "channel type required" + case ErrChanTypeDeprecated: + return "channel type deprecated" default: return "unknown error" } From 98a0d345a2301629c73c473450864aeeee5d646e Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 21 Sep 2026 18:20:24 -0300 Subject: [PATCH 2/8] lncfg: deprecate the protocol.legacy.committweak option In this commit, we turn the dev build only committweak option into a no-op. It stopped the node from signalling option_static_remotekey, which left the legacy commitment type as the only one it could negotiate. Since we no longer open channels of that type, such a node would be unable to negotiate any commitment type at all. The field is kept so that configs which still set it continue to parse. --- lncfg/protocol_legacy_on.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lncfg/protocol_legacy_on.go b/lncfg/protocol_legacy_on.go index 9b8fd4ebdf7..89aa1a2f964 100644 --- a/lncfg/protocol_legacy_on.go +++ b/lncfg/protocol_legacy_on.go @@ -14,11 +14,11 @@ type LegacyProtocol struct { // route won't use the new modern onion framing. LegacyOnionFormat bool `long:"onion" description:"force node to not advertise the new modern TLV onion format"` - // CommitmentTweak guards if we should use the old legacy commitment - // protocol, or the newer variant that doesn't have a tweak for the - // remote party's output in the commitment. If set to true, then we - // won't signal StaticRemoteKeyOptional. - CommitmentTweak bool `long:"committweak" description:"force node to not advertise the new commitment format"` + // CommitmentTweak is deprecated and no longer has any effect. The + // legacy commitment type is refused for new channels, so a node that + // stopped signalling StaticRemoteKeyOptional could no longer negotiate + // any commitment type at all. + CommitmentTweak bool `long:"committweak" hidden:"true" description:"deprecated: the legacy commitment format can no longer be used for new channels"` } // LegacyOnion returns true if the old legacy onion format should be used when @@ -29,7 +29,10 @@ func (l *LegacyProtocol) LegacyOnion() bool { } // NoStaticRemoteKey returns true if the old commitment format with a tweaked -// remote key should be used for new funded channels. +// remote key should be used for new funded channels. The legacy commitment +// type can no longer be used for new channels, so this always returns false. +// The CommitmentTweak field is only kept around so that configs which still +// set it continue to parse. func (l *LegacyProtocol) NoStaticRemoteKey() bool { - return l.CommitmentTweak + return false } From 6ea22314c3ad5cc246471e93eb1e6f4856451867 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 21 Sep 2026 18:20:25 -0300 Subject: [PATCH 3/8] funding: never open a legacy channel In this commit, we stop opening and accepting new channels using the legacy commitment type, which was removed from the spec in 2024. Its tweaked to_remote output is why funds in such a channel cannot be recovered unilaterally after data loss. Channels that already use it are untouched: they keep working and can be operated, force closed and cooperatively closed exactly as before. An empty channel_type asks for precisely this type, and that branch performed no feature check whatsoever, since the type predates feature bits entirely. Any peer could obtain a legacy channel from us no matter what either side signalled, which is what TestFundingManagerRejectLegacyChanType now covers. Implicit negotiation keeps falling back to the static remote key type, and only fails once there is nothing left below it. The funding manager's own test nodes signalled no features at all, which means they were negotiating legacy channels among themselves. They now default to static remote key. --- funding/commitment_type_negotiation.go | 55 ++++++++++++---- funding/commitment_type_negotiation_test.go | 39 +++++++----- funding/manager_test.go | 70 +++++++++++++++++++++ 3 files changed, 137 insertions(+), 27 deletions(-) diff --git a/funding/commitment_type_negotiation.go b/funding/commitment_type_negotiation.go index ebdb8251ccb..e1cf8f34e10 100644 --- a/funding/commitment_type_negotiation.go +++ b/funding/commitment_type_negotiation.go @@ -13,6 +13,20 @@ var ( // peer of the channel does not support it. errUnsupportedChannelType = errors.New("requested channel type " + "not supported") + + // ErrDeprecatedChanType is returned when settling on the legacy + // commitment type is the only option left, either because the caller of + // our own RPC asked for it or because automatic selection would have + // fallen back to it. We keep operating the legacy channels we already + // have, but no longer open new ones. + // + // Unlike lnwire.ErrChanTypeDeprecated, which we send to a peer whose + // proposal we reject, this never goes on the wire. The audience is our + // own operator, who can act on the answer, so it spells out what to use + // instead. + ErrDeprecatedChanType = errors.New("the legacy commitment type is " + + "deprecated, new channels must use the static remote key " + + "commitment type or later") ) // negotiateCommitmentType determines the commitment type of a newly opened @@ -20,8 +34,12 @@ var ( // commitment features supported by both peers. Otherwise, a default type is // selected from those features. // -// The returned ChannelType is always non-nil and is always signaled on the -// wire. An error is only returned if desiredChanType is not supported. +// The legacy commitment type is never selected, whether it was requested +// explicitly or would only have been reached by falling back. +// +// On success, the returned ChannelType is non-nil and is signaled on the wire. +// An error is returned if the requested type is unsupported or deprecated, or +// if no supported default type can be selected. func negotiateCommitmentType(desiredChanType *lnwire.ChannelType, local, remote *lnwire.FeatureVector) (*lnwire.ChannelType, lnwallet.CommitmentType, error) { @@ -38,7 +56,12 @@ func negotiateCommitmentType(desiredChanType *lnwire.ChannelType, local, // No specific channel type was requested. Select a default type based // on locally-known feature compatibility. This default is then sent // explicitly over the wire. - defaultChanType, commitType := selectDefaultChannelType(local, remote) + defaultChanType, commitType, err := selectDefaultChannelType( + local, remote, + ) + if err != nil { + return nil, 0, err + } return defaultChanType, commitType, nil } @@ -413,9 +436,14 @@ func explicitNegotiateCommitmentType(channelType lnwire.ChannelType, local, return lnwallet.CommitmentTypeSimpleTaprootOverlay, nil - // No features, use legacy commitment type. + // An empty channel type asks for the legacy commitment type, which was + // removed from the spec in 2024 and which we refuse outright, not by + // configuration. Note that this branch performs no feature check of its + // own, since the legacy type predates feature bits entirely: any peer + // sending an empty channel_type TLV used to get a legacy channel out of + // us no matter what either side signalled. case channelFeatures.IsEmpty(): - return lnwallet.CommitmentTypeLegacy, nil + return 0, lnwire.ErrChanTypeDeprecated default: return 0, errUnsupportedChannelType @@ -427,11 +455,14 @@ func explicitNegotiateCommitmentType(channelType lnwire.ChannelType, local, // Taproot channels must be requested explicitly, so that defaults stay on // channel types usable for both public and private channels. // +// An error is returned if there is no mutually supported type above the legacy +// one, which we no longer open. +// // TODO(yy): Revisit taproot channel selection once public taproot channel // announcements are supported. func selectDefaultChannelType(local, remote *lnwire.FeatureVector) (*lnwire.ChannelType, - lnwallet.CommitmentType) { + lnwallet.CommitmentType, error) { // If both peers are signalling support for anchor commitments with // zero-fee HTLC transactions, we'll use this type. @@ -441,7 +472,8 @@ func selectDefaultChannelType(local, lnwire.StaticRemoteKeyRequired, )) - return &chanType, lnwallet.CommitmentTypeAnchorsZeroFeeHtlcTx + return &chanType, lnwallet.CommitmentTypeAnchorsZeroFeeHtlcTx, + nil } // Since we don't want to support the "legacy" anchor type, we will fall @@ -455,12 +487,13 @@ func selectDefaultChannelType(local, lnwire.StaticRemoteKeyRequired, )) - return &chanType, lnwallet.CommitmentTypeTweakless + return &chanType, lnwallet.CommitmentTypeTweakless, nil } - // Otherwise we'll fall back to the legacy type. - chanType := lnwire.ChannelType(*lnwire.NewRawFeatureVector()) - return &chanType, lnwallet.CommitmentTypeLegacy + // Without a mutually supported type above it, the only one left to fall + // back on is the legacy type, which we never open. Either side failing + // to signal static remote key is enough to end up here. + return nil, 0, ErrDeprecatedChanType } // hasFeatures determines whether a set of features is supported by both the set diff --git a/funding/commitment_type_negotiation_test.go b/funding/commitment_type_negotiation_test.go index 6326a4df50b..a5d5740c2ed 100644 --- a/funding/commitment_type_negotiation_test.go +++ b/funding/commitment_type_negotiation_test.go @@ -227,7 +227,10 @@ func TestCommitmentTypeNegotiation(t *testing.T) { expectsErr: nil, }, { - name: "explicit legacy", + // An empty channel type asks for the legacy commitment + // type, which we no longer open. Note that this used to + // be accepted with no feature check at all. + name: "explicit legacy rejected", channelFeatures: lnwire.NewRawFeatureVector(), localFeatures: lnwire.NewRawFeatureVector( lnwire.StaticRemoteKeyRequired, @@ -237,11 +240,7 @@ func TestCommitmentTypeNegotiation(t *testing.T) { lnwire.StaticRemoteKeyOptional, lnwire.AnchorsZeroFeeHtlcTxOptional, ), - expectsCommitType: lnwallet.CommitmentTypeLegacy, - expectsChanType: (*lnwire.ChannelType)( - lnwire.NewRawFeatureVector(), - ), - expectsErr: nil, + expectsErr: lnwire.ErrChanTypeDeprecated, }, // No desired channel type is set, so we expect the default // selection to return the corresponding chan type feature bits, @@ -285,18 +284,18 @@ func TestCommitmentTypeNegotiation(t *testing.T) { expectsErr: nil, }, { - name: "default legacy", + // Without mutual support for static remote key there is + // nothing left to fall back on but the legacy type. + // Note that the test runs both peer orderings, so it + // covers either side being the one that lacks it. + name: "default legacy rejected", channelFeatures: nil, localFeatures: lnwire.NewRawFeatureVector(), remoteFeatures: lnwire.NewRawFeatureVector( lnwire.StaticRemoteKeyOptional, lnwire.AnchorsZeroFeeHtlcTxOptional, ), - expectsCommitType: lnwallet.CommitmentTypeLegacy, - expectsChanType: (*lnwire.ChannelType)( - lnwire.NewRawFeatureVector(), - ), - expectsErr: nil, + expectsErr: ErrDeprecatedChanType, }, // Test cases for final taproot channels with explicit @@ -443,15 +442,19 @@ func TestCommitmentTypeNegotiation(t *testing.T) { name: "default ignores staging taproot without anchors", channelFeatures: nil, localFeatures: lnwire.NewRawFeatureVector( + lnwire.StaticRemoteKeyOptional, lnwire.SimpleTaprootChannelsOptionalFinal, lnwire.SimpleTaprootChannelsOptionalStaging, ), remoteFeatures: lnwire.NewRawFeatureVector( + lnwire.StaticRemoteKeyOptional, lnwire.SimpleTaprootChannelsOptionalStaging, ), - expectsCommitType: lnwallet.CommitmentTypeLegacy, + expectsCommitType: lnwallet.CommitmentTypeTweakless, expectsChanType: (*lnwire.ChannelType)( - lnwire.NewRawFeatureVector(), + lnwire.NewRawFeatureVector( + lnwire.StaticRemoteKeyRequired, + ), ), expectsErr: nil, }, @@ -460,14 +463,18 @@ func TestCommitmentTypeNegotiation(t *testing.T) { name: "default ignores final taproot without anchors", channelFeatures: nil, localFeatures: lnwire.NewRawFeatureVector( + lnwire.StaticRemoteKeyOptional, lnwire.SimpleTaprootChannelsOptionalFinal, ), remoteFeatures: lnwire.NewRawFeatureVector( + lnwire.StaticRemoteKeyOptional, lnwire.SimpleTaprootChannelsOptionalFinal, ), - expectsCommitType: lnwallet.CommitmentTypeLegacy, + expectsCommitType: lnwallet.CommitmentTypeTweakless, expectsChanType: (*lnwire.ChannelType)( - lnwire.NewRawFeatureVector(), + lnwire.NewRawFeatureVector( + lnwire.StaticRemoteKeyRequired, + ), ), expectsErr: nil, }, diff --git a/funding/manager_test.go b/funding/manager_test.go index 68825935488..3bee02d5ca5 100644 --- a/funding/manager_test.go +++ b/funding/manager_test.go @@ -674,6 +674,17 @@ func createTestFundingManager(t *testing.T, privKey *btcec.PrivateKey, shutdownChannel: shutdownChan, reportScidChan: reportScidChan, addr: addr, + + // Default both sides to static remote key. Without any + // features at all these nodes used to negotiate the legacy + // commitment type, which we no longer open. Tests that care + // about a specific type overwrite these. + localFeatures: []lnwire.FeatureBit{ + lnwire.StaticRemoteKeyOptional, + }, + remoteFeatures: []lnwire.FeatureBit{ + lnwire.StaticRemoteKeyOptional, + }, } f.cfg.NotifyWhenOnline = func(peer [33]byte, @@ -5324,6 +5335,65 @@ func TestFundingManagerRejectMissingChanType(t *testing.T) { assertNumPendingReservations(t, bob, alicePubKey, 0) } +// TestFundingManagerRejectLegacyChanType verifies that the fundee rejects an +// OpenChannel message carrying an empty ChannelType, which asks for the legacy +// commitment type. +// +// This branch used to perform no feature check whatsoever, since the legacy +// type predates feature bits entirely, so any peer could obtain a legacy +// channel from us no matter what either side signalled. +func TestFundingManagerRejectLegacyChanType(t *testing.T) { + t.Parallel() + + alice, bob := setupFundingManagers(t) + t.Cleanup(func() { + tearDownFundingManagers(t, alice, bob) + }) + + // Both peers support better than the legacy type, which is exactly the + // case that used to slip through. + featureBits := []lnwire.FeatureBit{ + lnwire.StaticRemoteKeyOptional, + lnwire.AnchorsZeroFeeHtlcTxOptional, + } + alice.localFeatures = featureBits + alice.remoteFeatures = featureBits + bob.localFeatures = featureBits + bob.remoteFeatures = featureBits + + emptyChanType := (*lnwire.ChannelType)(lnwire.NewRawFeatureVector()) + + openChannelReq := &lnwire.OpenChannel{ + ChainHash: *fundingNetParams.GenesisHash, + PendingChannelID: [32]byte{0x01}, + FundingAmount: btcutil.Amount(10000000), + PushAmount: 0, + DustLimit: btcutil.Amount(546), + MaxValueInFlight: lnwire.MilliSatoshi(100000000), + ChannelReserve: btcutil.Amount(10000), + HtlcMinimum: lnwire.MilliSatoshi(1000), + FeePerKiloWeight: 15000, + CsvDelay: 144, + MaxAcceptedHTLCs: 483, + FundingKey: alice.privKey.PubKey(), + RevocationPoint: alice.privKey.PubKey(), + PaymentPoint: alice.privKey.PubKey(), + DelayedPaymentPoint: alice.privKey.PubKey(), + HtlcPoint: alice.privKey.PubKey(), + FirstCommitmentPoint: alice.privKey.PubKey(), + ChannelType: emptyChanType, + } + bob.fundingMgr.ProcessFundingMsg(openChannelReq, alice) + + // Bob should reject the OpenChannel message instead of echoing the + // empty channel type back in an AcceptChannel. + errMsg := assertFundingMsgSent(t, bob.msgChan, "Error") + err, ok := errMsg.(*lnwire.Error) + require.True(t, ok) + require.Equal(t, lnwire.ErrChanTypeDeprecated.Error(), string(err.Data)) + assertNumPendingReservations(t, bob, alicePubKey, 0) +} + // TestFundingManagerAcceptChanType verifies that the fundee accepts an // OpenChannel message that includes the ChannelType field and echoes it back // in AcceptChannel, even when neither peer advertises the explicit channel From a03747961b0b97ca12bae39452383116cf4ad5ec Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 21 Sep 2026 18:20:25 -0300 Subject: [PATCH 4/8] rpcserver: reject the legacy commitment type in OpenChannel In this commit, we turn down a request for the legacy commitment type before we touch the wallet or the peer, so the caller gets a clear error instead of one from deep inside the funding flow. The LEGACY enum value itself has to stay: CommitmentType is also the reporting type for ListChannels, ClosedChannels, PendingChannels and the channel acceptor, and channels we already have still report it. --- rpcserver.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rpcserver.go b/rpcserver.go index 4a8efc0c245..ebfffb32485 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -2318,9 +2318,11 @@ func (r *rpcServer) parseOpenChannelReq(in *lnrpc.OpenChannelRequest, return nil, fmt.Errorf("use anchors for zero-conf") } + // The legacy commitment type is no longer opened at all. Reject it here + // so the caller gets a clear error before we touch the wallet or the + // peer, rather than one from deep inside the funding flow. case lnrpc.CommitmentType_LEGACY: - channelType = new(lnwire.ChannelType) - *channelType = lnwire.ChannelType(*lnwire.NewRawFeatureVector()) + return nil, funding.ErrDeprecatedChanType case lnrpc.CommitmentType_STATIC_REMOTE_KEY: channelType = new(lnwire.ChannelType) From 03d8b09dca48caf5a6361d05c40c46672853d35e Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 21 Sep 2026 18:20:25 -0300 Subject: [PATCH 5/8] itest: stop creating legacy channels In this commit, we move the two tests that still opened legacy channels over to static remote key. Both produce the same commitment shape and the same fees, so no assertion moves. The watchtower case opted into legacy deliberately. The remote signer one did not: neither of its nodes runs with committweak, so both support static remote key, and the channel only came out legacy because an empty channel_type was accepted without any check. That is the bug this PR fixes, sitting in our own suite. The legacy node arguments and the unreferenced CfgLegacy go away with them, since committweak no longer does anything. --- itest/lnd_funding_test.go | 22 ++++++++++++++++++++++ itest/lnd_remote_signer_test.go | 2 +- itest/lnd_watchtower_test.go | 2 +- lntest/node/config.go | 4 ---- lntest/utils.go | 2 -- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/itest/lnd_funding_test.go b/itest/lnd_funding_test.go index cf7dad22508..8560d627824 100644 --- a/itest/lnd_funding_test.go +++ b/itest/lnd_funding_test.go @@ -37,6 +37,10 @@ var basicFundingTestCases = []*lntest.TestCase{ Name: "basic flow simple taproot final", TestFunc: testBasicChannelFundingSimpleTaprootFinal, }, + { + Name: "legacy chan type rejected", + TestFunc: testLegacyChanTypeRejected, + }, } // allFundingTypes defines the channel types to test for the basic funding @@ -48,6 +52,24 @@ var allFundingTypes = []lnrpc.CommitmentType{ lnrpc.CommitmentType_SIMPLE_TAPROOT_FINAL, } +// testLegacyChanTypeRejected asserts that lnd refuses to open a channel using +// the legacy commitment type, which was removed from the spec in 2024. +func testLegacyChanTypeRejected(ht *lntest.HarnessTest) { + carol := ht.NewNodeWithCoins("Carol", nil) + dave := ht.NewNodeWithCoins("Dave", nil) + ht.EnsureConnected(carol, dave) + + // The RPC server turns the request down before the funding flow even + // starts, so no channel type ever reaches Dave. + ht.OpenChannelAssertErr( + carol, dave, lntest.OpenChannelParams{ + Amt: funding.MaxBtcFundingAmount, + CommitmentType: lnrpc.CommitmentType_LEGACY, + }, + funding.ErrDeprecatedChanType, + ) +} + // testBasicChannelFundingStaticRemote performs a test exercising expected // behavior from a basic funding workflow. The test creates a new channel // between Carol and Dave, with Carol using the static remote key commitment diff --git a/itest/lnd_remote_signer_test.go b/itest/lnd_remote_signer_test.go index ca674b7443d..6982cf20f17 100644 --- a/itest/lnd_remote_signer_test.go +++ b/itest/lnd_remote_signer_test.go @@ -614,7 +614,7 @@ func psbtTestCase(ht *lntest.HarnessTest, fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) { runPsbtChanFundingWithNodes( tt, carol, wo, false, - lnrpc.CommitmentType_LEGACY, + lnrpc.CommitmentType_STATIC_REMOTE_KEY, ) runSignPsbtSegWitV0P2WKH(tt, wo) runSignPsbtSegWitV1KeySpendBip86(tt, wo) diff --git a/itest/lnd_watchtower_test.go b/itest/lnd_watchtower_test.go index 303ded46bf0..b19fb208710 100644 --- a/itest/lnd_watchtower_test.go +++ b/itest/lnd_watchtower_test.go @@ -336,7 +336,7 @@ func testTowerClientSessionDeletion(ht *lntest.HarnessTest) { // Carol's behalf sweeping her funds without a reward. func testRevokedCloseRetributionAltruistWatchtower(ht *lntest.HarnessTest) { for _, commitType := range []lnrpc.CommitmentType{ - lnrpc.CommitmentType_LEGACY, + lnrpc.CommitmentType_STATIC_REMOTE_KEY, lnrpc.CommitmentType_ANCHORS, lnrpc.CommitmentType_SIMPLE_TAPROOT, lnrpc.CommitmentType_SIMPLE_TAPROOT_FINAL, diff --git a/lntest/node/config.go b/lntest/node/config.go index bc9edb0049d..bfb6f155317 100644 --- a/lntest/node/config.go +++ b/lntest/node/config.go @@ -47,10 +47,6 @@ var ( "btcdexec", "", "full path to btcd binary", ) - // CfgLegacy specifies the config used to create a node that uses the - // legacy channel format. - CfgLegacy = []string{"--protocol.legacy.committweak"} - // CfgStaticRemoteKey specifies the config used to create a node that // uses the static remote key feature. CfgStaticRemoteKey = []string{} diff --git a/lntest/utils.go b/lntest/utils.go index f2b5b1ccd93..1e1ead71619 100644 --- a/lntest/utils.go +++ b/lntest/utils.go @@ -158,8 +158,6 @@ func CommitTypeHasAnchors(commitType lnrpc.CommitmentType) bool { // commitment type. func NodeArgsForCommitType(commitType lnrpc.CommitmentType) []string { switch commitType { - case lnrpc.CommitmentType_LEGACY: - return []string{"--protocol.legacy.committweak"} case lnrpc.CommitmentType_STATIC_REMOTE_KEY: return []string{} case lnrpc.CommitmentType_ANCHORS: From 76c18acbd63bf2868be7663f15d4d30f9471ac70 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 21 Sep 2026 18:20:25 -0300 Subject: [PATCH 6/8] docs: add 0.20.5 release notes for the legacy commitment type In this commit, we spell out that existing legacy channels are not affected and can still be operated and closed, since that is the question an operator reading these notes will have. Only opening new ones is refused. --- docs/release-notes/release-notes-0.20.5.md | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.5.md b/docs/release-notes/release-notes-0.20.5.md index 0e114efcb8f..71457ad57a1 100644 --- a/docs/release-notes/release-notes-0.20.5.md +++ b/docs/release-notes/release-notes-0.20.5.md @@ -56,10 +56,37 @@ ## Breaking Changes +* lnd [no longer opens or + accepts](https://github.com/lightningnetwork/lnd/pull/11212) new channels + using the legacy commitment type, which was + [removed](https://github.com/lightning/bolts/commit/91f4bd2383cc2fc7a0a43b697e209f9eb9f5183c) + from the spec in 2024. Its tweaked `to_remote` output is why funds in such a + channel cannot be recovered unilaterally after data loss: recovery needs the + peer to supply the relevant commitment point. + + Note that an empty `channel_type` in `open_channel` asks for exactly this + type, and used to be accepted without any feature check at all, so a peer + could obtain a legacy channel from us no matter what either side signalled. + New channels now fall back to the static remote key commitment type instead. + + Channels that already use the legacy type are **not** affected. They keep + working and can be operated, force closed and cooperatively closed as before. + Only opening new ones is refused. + + `OpenChannel` now rejects `commitment_type` `LEGACY`. The enum value itself + remains, since it is also how existing channels are reported by + `ListChannels`, `ClosedChannels`, `PendingChannels` and the channel acceptor. + ## Performance Improvements ## Deprecations +* The dev build only `protocol.legacy.committweak` option is + [deprecated](https://github.com/lightningnetwork/lnd/pull/11212) and no longer + has any effect. It stopped the node from signalling + `option_static_remotekey`, which now leaves no commitment type left to + negotiate at all. + # Technical and Architectural Updates ## BOLT Spec Updates @@ -87,3 +114,4 @@ * elsirion * Gijs van Dam * Yong Yu +* Ziggie From a5556e0525c2a105778d7e515faf161f535dcd93 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 21 Sep 2026 18:20:25 -0300 Subject: [PATCH 7/8] docs: add 0.21.4 release notes for the legacy commitment type In this commit, we add the same entries as the 0.20.5 notes, kept in a commit of their own so that each backport carries only the release notes of the branch it lands on. --- docs/release-notes/release-notes-0.21.4.md | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/release-notes/release-notes-0.21.4.md b/docs/release-notes/release-notes-0.21.4.md index 4471a14de41..0b09e9de07e 100644 --- a/docs/release-notes/release-notes-0.21.4.md +++ b/docs/release-notes/release-notes-0.21.4.md @@ -68,10 +68,37 @@ ## Breaking Changes +* lnd [no longer opens or + accepts](https://github.com/lightningnetwork/lnd/pull/11212) new channels + using the legacy commitment type, which was + [removed](https://github.com/lightning/bolts/commit/91f4bd2383cc2fc7a0a43b697e209f9eb9f5183c) + from the spec in 2024. Its tweaked `to_remote` output is why funds in such a + channel cannot be recovered unilaterally after data loss: recovery needs the + peer to supply the relevant commitment point. + + Note that an empty `channel_type` in `open_channel` asks for exactly this + type, and used to be accepted without any feature check at all, so a peer + could obtain a legacy channel from us no matter what either side signalled. + New channels now fall back to the static remote key commitment type instead. + + Channels that already use the legacy type are **not** affected. They keep + working and can be operated, force closed and cooperatively closed as before. + Only opening new ones is refused. + + `OpenChannel` now rejects `commitment_type` `LEGACY`. The enum value itself + remains, since it is also how existing channels are reported by + `ListChannels`, `ClosedChannels`, `PendingChannels` and the channel acceptor. + ## Performance Improvements ## Deprecations +* The dev build only `protocol.legacy.committweak` option is + [deprecated](https://github.com/lightningnetwork/lnd/pull/11212) and no longer + has any effect. It stopped the node from signalling + `option_static_remotekey`, which now leaves no commitment type left to + negotiate at all. + ### ⚠️ **Warning:** Deprecated fields in `lnrpc.Hop` will be removed in release version **0.22** ### ⚠️ **Warning:** The deprecated fee rate option `--sat_per_byte` will be removed in release version **0.22** From 9b634bfd0929ecc0f0e0ddc0b6aa85a5234a06b6 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 22 Sep 2026 08:13:56 -0300 Subject: [PATCH 8/8] lnrpc: document that the legacy commitment type is rejected on input In this commit, we document the restriction on the API itself, so callers can discover it without reading the release notes. The LEGACY enum value notes that it is only ever reported for channels that already exist, and the commitment_type fields of OpenChannelRequest and BatchOpenChannel note that it is turned down as an input. Worth spelling out on those fields that an empty channel type on the wire asks for the legacy type as well, so there is no way to request it at all. The generated code carries no change beyond the comments. --- lnrpc/lightning.pb.go | 12 ++++++++++++ lnrpc/lightning.proto | 12 ++++++++++++ lnrpc/lightning.swagger.json | 6 +++--- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index 346f45f5a2f..8b8ff3c0bac 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -216,6 +216,10 @@ const ( // //A channel using the legacy commitment format having tweaked to_remote //keys. + // + //This value is only reported for channels that already exist. It is + //rejected as an input when opening a channel, since lnd no longer opens + //or accepts channels of this type. CommitmentType_LEGACY CommitmentType = 1 // //A channel that uses the modern commitment format where the key in the @@ -7433,6 +7437,10 @@ type BatchOpenChannel struct { //The commitment type to request. If UNKNOWN, lnd selects a default from //both peers' supported features; the selected type is always sent //explicitly. + // + //LEGACY is rejected: lnd no longer opens or accepts channels of that + //type. Note that an empty channel type on the wire requests it too, so + //there is no way to ask for it at all. CommitmentType CommitmentType `protobuf:"varint,9,opt,name=commitment_type,json=commitmentType,proto3,enum=lnrpc.CommitmentType" json:"commitment_type,omitempty"` // //The maximum amount of coins in millisatoshi that can be pending within @@ -7777,6 +7785,10 @@ type OpenChannelRequest struct { //The commitment type to request. If UNKNOWN, lnd selects a default from //both peers' supported features; the selected type is always sent //explicitly. + // + //LEGACY is rejected: lnd no longer opens or accepts channels of that + //type. Note that an empty channel type on the wire requests it too, so + //there is no way to ask for it at all. CommitmentType CommitmentType `protobuf:"varint,18,opt,name=commitment_type,json=commitmentType,proto3,enum=lnrpc.CommitmentType" json:"commitment_type,omitempty"` // //If this is true, then a zero-conf channel open will be attempted. diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index 4ab530e2205..a90c837e2d3 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -1325,6 +1325,10 @@ enum CommitmentType { /* A channel using the legacy commitment format having tweaked to_remote keys. + + This value is only reported for channels that already exist. It is + rejected as an input when opening a channel, since lnd no longer opens + or accepts channels of this type. */ LEGACY = 1; @@ -2240,6 +2244,10 @@ message BatchOpenChannel { The commitment type to request. If UNKNOWN, lnd selects a default from both peers' supported features; the selected type is always sent explicitly. + + LEGACY is rejected: lnd no longer opens or accepts channels of that + type. Note that an empty channel type on the wire requests it too, so + there is no way to ask for it at all. */ CommitmentType commitment_type = 9; @@ -2414,6 +2422,10 @@ message OpenChannelRequest { The commitment type to request. If UNKNOWN, lnd selects a default from both peers' supported features; the selected type is always sent explicitly. + + LEGACY is rejected: lnd no longer opens or accepts channels of that + type. Note that an empty channel type on the wire requests it too, so + there is no way to ask for it at all. */ CommitmentType commitment_type = 18; diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index d70d375a89a..e3f8bc11948 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -3571,7 +3571,7 @@ }, "commitment_type": { "$ref": "#/definitions/lnrpcCommitmentType", - "description": "The commitment type to request. If UNKNOWN, lnd selects a default from\nboth peers' supported features; the selected type is always sent\nexplicitly." + "description": "The commitment type to request. If UNKNOWN, lnd selects a default from\nboth peers' supported features; the selected type is always sent\nexplicitly.\n\nLEGACY is rejected: lnd no longer opens or accepts channels of that\ntype. Note that an empty channel type on the wire requests it too, so\nthere is no way to ask for it at all." }, "remote_max_value_in_flight_msat": { "type": "string", @@ -4812,7 +4812,7 @@ "SIMPLE_TAPROOT_OVERLAY" ], "default": "UNKNOWN_COMMITMENT_TYPE", - "description": " - UNKNOWN_COMMITMENT_TYPE: Returned when the commitment type isn't known or unavailable.\n - LEGACY: A channel using the legacy commitment format having tweaked to_remote\nkeys.\n - STATIC_REMOTE_KEY: A channel that uses the modern commitment format where the key in the\noutput of the remote party does not change each state. This makes back\nup and recovery easier as when the channel is closed, the funds go\ndirectly to that key.\n - ANCHORS: A channel that uses a commitment format that has anchor outputs on the\ncommitments, allowing fee bumping after a force close transaction has\nbeen broadcast.\n - SCRIPT_ENFORCED_LEASE: A channel that uses a commitment type that builds upon the anchors\ncommitment format, but in addition requires a CLTV clause to spend outputs\npaying to the channel initiator. This is intended for use on leased channels\nto guarantee that the channel initiator has no incentives to close a leased\nchannel before its maturity date.\n - TAPROOT: The production taproot channel type that uses musig2 for the funding\noutput and the new tapscript features, with final scripts and feature\nbits 80/81. This is the recommended taproot variant; new integrations\nshould select this enum value.\n - SIMPLE_TAPROOT_FINAL: Deprecated alias for TAPROOT, preserved so existing clients that select\nthe production taproot channel type by its historic name continue to\ncompile and serialize against the same wire value.\n - SIMPLE_TAPROOT: A legacy taproot channel type that uses musig2 for the funding output and\nthe new tapscript features, but with development scripts and the staging\nfeature bits. Retained for compatibility with peers that have not upgraded\nto TAPROOT; new integrations should prefer TAPROOT.\n - SIMPLE_TAPROOT_OVERLAY: Identical to the SIMPLE_TAPROOT channel type, but with extra functionality.\nThis channel type also commits to additional meta data in the tapscript\nleaves for the scripts in a channel." + "description": " - UNKNOWN_COMMITMENT_TYPE: Returned when the commitment type isn't known or unavailable.\n - LEGACY: A channel using the legacy commitment format having tweaked to_remote\nkeys.\n\nThis value is only reported for channels that already exist. It is\nrejected as an input when opening a channel, since lnd no longer opens\nor accepts channels of this type.\n - STATIC_REMOTE_KEY: A channel that uses the modern commitment format where the key in the\noutput of the remote party does not change each state. This makes back\nup and recovery easier as when the channel is closed, the funds go\ndirectly to that key.\n - ANCHORS: A channel that uses a commitment format that has anchor outputs on the\ncommitments, allowing fee bumping after a force close transaction has\nbeen broadcast.\n - SCRIPT_ENFORCED_LEASE: A channel that uses a commitment type that builds upon the anchors\ncommitment format, but in addition requires a CLTV clause to spend outputs\npaying to the channel initiator. This is intended for use on leased channels\nto guarantee that the channel initiator has no incentives to close a leased\nchannel before its maturity date.\n - TAPROOT: The production taproot channel type that uses musig2 for the funding\noutput and the new tapscript features, with final scripts and feature\nbits 80/81. This is the recommended taproot variant; new integrations\nshould select this enum value.\n - SIMPLE_TAPROOT_FINAL: Deprecated alias for TAPROOT, preserved so existing clients that select\nthe production taproot channel type by its historic name continue to\ncompile and serialize against the same wire value.\n - SIMPLE_TAPROOT: A legacy taproot channel type that uses musig2 for the funding output and\nthe new tapscript features, but with development scripts and the staging\nfeature bits. Retained for compatibility with peers that have not upgraded\nto TAPROOT; new integrations should prefer TAPROOT.\n - SIMPLE_TAPROOT_OVERLAY: Identical to the SIMPLE_TAPROOT channel type, but with extra functionality.\nThis channel type also commits to additional meta data in the tapscript\nleaves for the scripts in a channel." }, "lnrpcConnectPeerRequest": { "type": "object", @@ -6608,7 +6608,7 @@ }, "commitment_type": { "$ref": "#/definitions/lnrpcCommitmentType", - "description": "The commitment type to request. If UNKNOWN, lnd selects a default from\nboth peers' supported features; the selected type is always sent\nexplicitly." + "description": "The commitment type to request. If UNKNOWN, lnd selects a default from\nboth peers' supported features; the selected type is always sent\nexplicitly.\n\nLEGACY is rejected: lnd no longer opens or accepts channels of that\ntype. Note that an empty channel type on the wire requests it too, so\nthere is no way to ask for it at all." }, "zero_conf": { "type": "boolean",